lucidscan 0.5.12__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.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- lucidscan-0.5.12.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Base class for coverage plugins.
|
|
2
|
+
|
|
3
|
+
All coverage plugins inherit from CoveragePlugin and implement the measure_coverage() method.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
# Re-export TestStatistics for plugins
|
|
14
|
+
__all__ = ["CoveragePlugin", "CoverageResult", "FileCoverage", "TestStatistics"]
|
|
15
|
+
|
|
16
|
+
from lucidscan.core.models import ScanContext, UnifiedIssue, ToolDomain
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FileCoverage:
|
|
21
|
+
"""Coverage statistics for a single file."""
|
|
22
|
+
|
|
23
|
+
file_path: Path
|
|
24
|
+
total_lines: int = 0
|
|
25
|
+
covered_lines: int = 0
|
|
26
|
+
missing_lines: List[int] = field(default_factory=list)
|
|
27
|
+
excluded_lines: int = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def percentage(self) -> float:
|
|
31
|
+
"""Coverage percentage for this file."""
|
|
32
|
+
if self.total_lines == 0:
|
|
33
|
+
return 100.0
|
|
34
|
+
return (self.covered_lines / self.total_lines) * 100
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TestStatistics:
|
|
39
|
+
"""Test execution statistics."""
|
|
40
|
+
|
|
41
|
+
total: int = 0
|
|
42
|
+
passed: int = 0
|
|
43
|
+
failed: int = 0
|
|
44
|
+
skipped: int = 0
|
|
45
|
+
errors: int = 0
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def success(self) -> bool:
|
|
49
|
+
"""Whether all tests passed (no failures or errors)."""
|
|
50
|
+
return self.failed == 0 and self.errors == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class CoverageResult:
|
|
55
|
+
"""Result statistics from coverage analysis."""
|
|
56
|
+
|
|
57
|
+
total_lines: int = 0
|
|
58
|
+
covered_lines: int = 0
|
|
59
|
+
missing_lines: int = 0
|
|
60
|
+
excluded_lines: int = 0
|
|
61
|
+
threshold: float = 0.0
|
|
62
|
+
files: Dict[str, FileCoverage] = field(default_factory=dict)
|
|
63
|
+
issues: List[UnifiedIssue] = field(default_factory=list)
|
|
64
|
+
# Test statistics (populated when tests are run for coverage)
|
|
65
|
+
test_stats: Optional[TestStatistics] = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def percentage(self) -> float:
|
|
69
|
+
"""Overall coverage percentage."""
|
|
70
|
+
if self.total_lines == 0:
|
|
71
|
+
return 100.0
|
|
72
|
+
return (self.covered_lines / self.total_lines) * 100
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def passed(self) -> bool:
|
|
76
|
+
"""Whether coverage meets the threshold."""
|
|
77
|
+
return self.percentage >= self.threshold
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CoveragePlugin(ABC):
|
|
81
|
+
"""Abstract base class for coverage plugins.
|
|
82
|
+
|
|
83
|
+
Coverage plugins provide code coverage analysis functionality.
|
|
84
|
+
Each plugin wraps a specific coverage tool (coverage.py, Istanbul, etc.).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, project_root: Optional[Path] = None, **kwargs) -> None:
|
|
88
|
+
"""Initialize the coverage plugin.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project_root: Optional project root for tool installation.
|
|
92
|
+
**kwargs: Additional arguments for subclasses.
|
|
93
|
+
"""
|
|
94
|
+
self._project_root = project_root
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def name(self) -> str:
|
|
99
|
+
"""Unique plugin identifier (e.g., 'coverage_py', 'istanbul').
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Plugin name string.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def languages(self) -> List[str]:
|
|
108
|
+
"""Languages this coverage tool supports.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of language names (e.g., ['python'], ['javascript', 'typescript']).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def domain(self) -> ToolDomain:
|
|
116
|
+
"""Tool domain (always COVERAGE for coverage plugins).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
ToolDomain.COVERAGE
|
|
120
|
+
"""
|
|
121
|
+
return ToolDomain.COVERAGE
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def get_version(self) -> str:
|
|
125
|
+
"""Get the version of the underlying coverage tool.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Version string.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def ensure_binary(self) -> Path:
|
|
133
|
+
"""Ensure the coverage tool is installed.
|
|
134
|
+
|
|
135
|
+
Finds or installs the tool if not present.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Path to the tool binary.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
FileNotFoundError: If the tool cannot be found or installed.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def measure_coverage(
|
|
146
|
+
self,
|
|
147
|
+
context: ScanContext,
|
|
148
|
+
threshold: float = 80.0,
|
|
149
|
+
run_tests: bool = True,
|
|
150
|
+
) -> CoverageResult:
|
|
151
|
+
"""Run coverage analysis on the specified paths.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
context: Scan context with paths and configuration.
|
|
155
|
+
threshold: Coverage percentage threshold (default 80%).
|
|
156
|
+
run_tests: Whether to run tests if no existing coverage data exists.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
CoverageResult with coverage statistics and issues if below threshold.
|
|
160
|
+
"""
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""coverage.py coverage plugin.
|
|
2
|
+
|
|
3
|
+
coverage.py is a tool for measuring code coverage of Python programs.
|
|
4
|
+
https://coverage.readthedocs.io/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from lucidscan.core.logging import get_logger
|
|
19
|
+
from lucidscan.core.models import (
|
|
20
|
+
ScanContext,
|
|
21
|
+
Severity,
|
|
22
|
+
ToolDomain,
|
|
23
|
+
UnifiedIssue,
|
|
24
|
+
)
|
|
25
|
+
from lucidscan.core.subprocess_runner import run_with_streaming
|
|
26
|
+
from lucidscan.plugins.coverage.base import (
|
|
27
|
+
CoveragePlugin,
|
|
28
|
+
CoverageResult,
|
|
29
|
+
FileCoverage,
|
|
30
|
+
TestStatistics,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
LOGGER = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CoveragePyPlugin(CoveragePlugin):
|
|
37
|
+
"""coverage.py plugin for Python code coverage analysis."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
40
|
+
"""Initialize CoveragePyPlugin.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
project_root: Optional project root for finding coverage installation.
|
|
44
|
+
"""
|
|
45
|
+
self._project_root = project_root
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
"""Plugin identifier."""
|
|
50
|
+
return "coverage_py"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def languages(self) -> List[str]:
|
|
54
|
+
"""Supported languages."""
|
|
55
|
+
return ["python"]
|
|
56
|
+
|
|
57
|
+
def get_version(self) -> str:
|
|
58
|
+
"""Get coverage.py version.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Version string or 'unknown' if unable to determine.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
binary = self.ensure_binary()
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
[str(binary), "--version"],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
errors="replace",
|
|
71
|
+
)
|
|
72
|
+
# Output is like "Coverage.py, version 7.4.0 ..."
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
output = result.stdout.strip()
|
|
75
|
+
if "version" in output:
|
|
76
|
+
# Extract version number
|
|
77
|
+
parts = output.split("version")
|
|
78
|
+
if len(parts) >= 2:
|
|
79
|
+
version = parts[1].strip().split()[0]
|
|
80
|
+
return version.rstrip(",")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return "unknown"
|
|
84
|
+
|
|
85
|
+
def ensure_binary(self) -> Path:
|
|
86
|
+
"""Ensure coverage is available.
|
|
87
|
+
|
|
88
|
+
Checks for coverage in:
|
|
89
|
+
1. Project's .venv/bin/coverage
|
|
90
|
+
2. System PATH
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Path to coverage binary.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
FileNotFoundError: If coverage is not installed.
|
|
97
|
+
"""
|
|
98
|
+
# Check project venv first
|
|
99
|
+
if self._project_root:
|
|
100
|
+
venv_coverage = self._project_root / ".venv" / "bin" / "coverage"
|
|
101
|
+
if venv_coverage.exists():
|
|
102
|
+
return venv_coverage
|
|
103
|
+
|
|
104
|
+
# Check system PATH
|
|
105
|
+
coverage_path = shutil.which("coverage")
|
|
106
|
+
if coverage_path:
|
|
107
|
+
return Path(coverage_path)
|
|
108
|
+
|
|
109
|
+
raise FileNotFoundError(
|
|
110
|
+
"coverage is not installed. Install it with: pip install coverage"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def measure_coverage(
|
|
114
|
+
self,
|
|
115
|
+
context: ScanContext,
|
|
116
|
+
threshold: float = 80.0,
|
|
117
|
+
run_tests: bool = True,
|
|
118
|
+
) -> CoverageResult:
|
|
119
|
+
"""Run coverage analysis on the specified paths.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
context: Scan context with paths and configuration.
|
|
123
|
+
threshold: Coverage percentage threshold (default 80%).
|
|
124
|
+
run_tests: Whether to run tests if no existing coverage data exists.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
CoverageResult with coverage statistics and issues if below threshold.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
binary = self.ensure_binary()
|
|
131
|
+
except FileNotFoundError as e:
|
|
132
|
+
LOGGER.warning(str(e))
|
|
133
|
+
return CoverageResult(threshold=threshold)
|
|
134
|
+
|
|
135
|
+
test_stats: Optional[TestStatistics] = None
|
|
136
|
+
|
|
137
|
+
# Always run tests fresh when run_tests=True to ensure accurate coverage
|
|
138
|
+
if run_tests:
|
|
139
|
+
LOGGER.info("Running tests with coverage...")
|
|
140
|
+
success, test_stats = self._run_tests_with_coverage(binary, context)
|
|
141
|
+
if not success:
|
|
142
|
+
LOGGER.warning("Failed to run tests with coverage")
|
|
143
|
+
return CoverageResult(threshold=threshold)
|
|
144
|
+
|
|
145
|
+
# Generate JSON report from coverage data
|
|
146
|
+
result = self._generate_and_parse_report(binary, context, threshold)
|
|
147
|
+
result.test_stats = test_stats
|
|
148
|
+
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def _run_tests_with_coverage(
|
|
152
|
+
self,
|
|
153
|
+
binary: Path,
|
|
154
|
+
context: ScanContext,
|
|
155
|
+
) -> Tuple[bool, Optional[TestStatistics]]:
|
|
156
|
+
"""Run pytest with coverage measurement.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
binary: Path to coverage binary.
|
|
160
|
+
context: Scan context.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Tuple of (success, test_stats). Success is True if tests ran.
|
|
164
|
+
Test stats contain passed/failed/skipped/error counts.
|
|
165
|
+
"""
|
|
166
|
+
# Check for pytest
|
|
167
|
+
pytest_path = None
|
|
168
|
+
if self._project_root:
|
|
169
|
+
venv_pytest = self._project_root / ".venv" / "bin" / "pytest"
|
|
170
|
+
if venv_pytest.exists():
|
|
171
|
+
pytest_path = venv_pytest
|
|
172
|
+
|
|
173
|
+
if not pytest_path:
|
|
174
|
+
pytest_which = shutil.which("pytest")
|
|
175
|
+
if pytest_which:
|
|
176
|
+
pytest_path = Path(pytest_which)
|
|
177
|
+
|
|
178
|
+
if not pytest_path:
|
|
179
|
+
LOGGER.warning("pytest not found, cannot run tests for coverage")
|
|
180
|
+
return False, None
|
|
181
|
+
|
|
182
|
+
# Build command to run coverage with pytest
|
|
183
|
+
# Always run full test suite - coverage needs complete test runs to be meaningful
|
|
184
|
+
# (unlike linting which can work on individual changed files)
|
|
185
|
+
cmd = [
|
|
186
|
+
str(binary),
|
|
187
|
+
"run",
|
|
188
|
+
"-m",
|
|
189
|
+
"pytest",
|
|
190
|
+
"--tb=no",
|
|
191
|
+
"-q",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = run_with_streaming(
|
|
198
|
+
cmd=cmd,
|
|
199
|
+
cwd=context.project_root,
|
|
200
|
+
tool_name="coverage-run",
|
|
201
|
+
stream_handler=context.stream_handler,
|
|
202
|
+
timeout=600,
|
|
203
|
+
)
|
|
204
|
+
# Parse test statistics from pytest output
|
|
205
|
+
test_stats = self._parse_pytest_output(result.stdout + "\n" + result.stderr)
|
|
206
|
+
# Coverage run returns the pytest exit code
|
|
207
|
+
# We consider it successful even if some tests fail
|
|
208
|
+
return True, test_stats
|
|
209
|
+
except Exception as e:
|
|
210
|
+
LOGGER.error(f"Failed to run tests with coverage: {e}")
|
|
211
|
+
return False, None
|
|
212
|
+
|
|
213
|
+
def _parse_pytest_output(self, output: str) -> TestStatistics:
|
|
214
|
+
"""Parse pytest output to extract test statistics.
|
|
215
|
+
|
|
216
|
+
Parses pytest summary lines like:
|
|
217
|
+
- "9 passed in 0.12s"
|
|
218
|
+
- "1 failed, 2 passed in 0.15s"
|
|
219
|
+
- "3 passed, 1 skipped, 1 warning in 0.10s"
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
output: Combined stdout/stderr from pytest run.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
TestStatistics with parsed counts.
|
|
226
|
+
"""
|
|
227
|
+
stats = TestStatistics()
|
|
228
|
+
|
|
229
|
+
# Look for the summary line pattern
|
|
230
|
+
# Example patterns:
|
|
231
|
+
# "===== 1 failed, 2 passed in 0.15s ====="
|
|
232
|
+
# "9 passed in 0.12s"
|
|
233
|
+
# "1 passed, 1 skipped in 0.10s"
|
|
234
|
+
summary_pattern = r"(?:=+\s*)?(\d+\s+\w+(?:,\s*\d+\s+\w+)*)\s+in\s+[\d.]+s\s*(?:=+)?"
|
|
235
|
+
|
|
236
|
+
for line in output.split("\n"):
|
|
237
|
+
match = re.search(summary_pattern, line)
|
|
238
|
+
if match:
|
|
239
|
+
summary = match.group(1)
|
|
240
|
+
# Parse individual counts
|
|
241
|
+
passed_match = re.search(r"(\d+)\s+passed", summary)
|
|
242
|
+
failed_match = re.search(r"(\d+)\s+failed", summary)
|
|
243
|
+
skipped_match = re.search(r"(\d+)\s+skipped", summary)
|
|
244
|
+
error_match = re.search(r"(\d+)\s+error", summary)
|
|
245
|
+
|
|
246
|
+
if passed_match:
|
|
247
|
+
stats.passed = int(passed_match.group(1))
|
|
248
|
+
if failed_match:
|
|
249
|
+
stats.failed = int(failed_match.group(1))
|
|
250
|
+
if skipped_match:
|
|
251
|
+
stats.skipped = int(skipped_match.group(1))
|
|
252
|
+
if error_match:
|
|
253
|
+
stats.errors = int(error_match.group(1))
|
|
254
|
+
|
|
255
|
+
stats.total = stats.passed + stats.failed + stats.skipped + stats.errors
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
return stats
|
|
259
|
+
|
|
260
|
+
def _generate_and_parse_report(
|
|
261
|
+
self,
|
|
262
|
+
binary: Path,
|
|
263
|
+
context: ScanContext,
|
|
264
|
+
threshold: float,
|
|
265
|
+
) -> CoverageResult:
|
|
266
|
+
"""Generate JSON report and parse it.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
binary: Path to coverage binary.
|
|
270
|
+
context: Scan context.
|
|
271
|
+
threshold: Coverage percentage threshold.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
CoverageResult with parsed data.
|
|
275
|
+
"""
|
|
276
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
277
|
+
report_file = Path(tmpdir) / "coverage.json"
|
|
278
|
+
|
|
279
|
+
cmd = [
|
|
280
|
+
str(binary),
|
|
281
|
+
"json",
|
|
282
|
+
"-o",
|
|
283
|
+
str(report_file),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
result = run_with_streaming(
|
|
290
|
+
cmd=cmd,
|
|
291
|
+
cwd=context.project_root,
|
|
292
|
+
tool_name="coverage-json",
|
|
293
|
+
stream_handler=context.stream_handler,
|
|
294
|
+
timeout=60,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if result.returncode != 0:
|
|
298
|
+
LOGGER.warning(f"Coverage json failed: {result.stderr}")
|
|
299
|
+
return CoverageResult(threshold=threshold)
|
|
300
|
+
|
|
301
|
+
except Exception as e:
|
|
302
|
+
LOGGER.error(f"Failed to generate coverage report: {e}")
|
|
303
|
+
return CoverageResult(threshold=threshold)
|
|
304
|
+
|
|
305
|
+
# Parse JSON report
|
|
306
|
+
if report_file.exists():
|
|
307
|
+
return self._parse_json_report(report_file, context.project_root, threshold)
|
|
308
|
+
else:
|
|
309
|
+
LOGGER.warning("Coverage JSON report not generated")
|
|
310
|
+
return CoverageResult(threshold=threshold)
|
|
311
|
+
|
|
312
|
+
def _parse_json_report(
|
|
313
|
+
self,
|
|
314
|
+
report_file: Path,
|
|
315
|
+
project_root: Path,
|
|
316
|
+
threshold: float,
|
|
317
|
+
) -> CoverageResult:
|
|
318
|
+
"""Parse coverage.py JSON report.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
report_file: Path to JSON report file.
|
|
322
|
+
project_root: Project root directory.
|
|
323
|
+
threshold: Coverage percentage threshold.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
CoverageResult with parsed data.
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
with open(report_file) as f:
|
|
330
|
+
report = json.load(f)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
LOGGER.error(f"Failed to parse coverage JSON report: {e}")
|
|
333
|
+
return CoverageResult(threshold=threshold)
|
|
334
|
+
|
|
335
|
+
totals = report.get("totals", {})
|
|
336
|
+
files_data = report.get("files", {})
|
|
337
|
+
|
|
338
|
+
# Parse totals
|
|
339
|
+
total_lines = totals.get("num_statements", 0)
|
|
340
|
+
covered_lines = totals.get("covered_lines", 0)
|
|
341
|
+
missing_lines = totals.get("missing_lines", 0)
|
|
342
|
+
excluded_lines = totals.get("excluded_lines", 0)
|
|
343
|
+
percent_covered = totals.get("percent_covered", 0.0)
|
|
344
|
+
|
|
345
|
+
result = CoverageResult(
|
|
346
|
+
total_lines=total_lines,
|
|
347
|
+
covered_lines=covered_lines,
|
|
348
|
+
missing_lines=missing_lines,
|
|
349
|
+
excluded_lines=excluded_lines,
|
|
350
|
+
threshold=threshold,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Parse per-file coverage
|
|
354
|
+
for file_path, file_data in files_data.items():
|
|
355
|
+
summary = file_data.get("summary", {})
|
|
356
|
+
missing = file_data.get("missing_lines", [])
|
|
357
|
+
|
|
358
|
+
file_coverage = FileCoverage(
|
|
359
|
+
file_path=project_root / file_path,
|
|
360
|
+
total_lines=summary.get("num_statements", 0),
|
|
361
|
+
covered_lines=summary.get("covered_lines", 0),
|
|
362
|
+
missing_lines=missing,
|
|
363
|
+
excluded_lines=summary.get("excluded_lines", 0),
|
|
364
|
+
)
|
|
365
|
+
result.files[file_path] = file_coverage
|
|
366
|
+
|
|
367
|
+
# Generate issue if below threshold
|
|
368
|
+
if percent_covered < threshold:
|
|
369
|
+
issue = self._create_coverage_issue(
|
|
370
|
+
percent_covered, threshold, total_lines, covered_lines, missing_lines
|
|
371
|
+
)
|
|
372
|
+
result.issues.append(issue)
|
|
373
|
+
|
|
374
|
+
LOGGER.info(
|
|
375
|
+
f"Coverage: {percent_covered:.1f}% ({covered_lines}/{total_lines} lines) "
|
|
376
|
+
f"- threshold: {threshold}%"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
def _create_coverage_issue(
|
|
382
|
+
self,
|
|
383
|
+
percentage: float,
|
|
384
|
+
threshold: float,
|
|
385
|
+
total_lines: int,
|
|
386
|
+
covered_lines: int,
|
|
387
|
+
missing_lines: int,
|
|
388
|
+
) -> UnifiedIssue:
|
|
389
|
+
"""Create a UnifiedIssue for coverage below threshold.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
percentage: Actual coverage percentage.
|
|
393
|
+
threshold: Required coverage threshold.
|
|
394
|
+
total_lines: Total number of lines.
|
|
395
|
+
covered_lines: Number of covered lines.
|
|
396
|
+
missing_lines: Number of missing lines.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
UnifiedIssue for coverage failure.
|
|
400
|
+
"""
|
|
401
|
+
# Determine severity based on how far below threshold
|
|
402
|
+
if percentage < 50:
|
|
403
|
+
severity = Severity.HIGH
|
|
404
|
+
elif percentage < threshold - 10:
|
|
405
|
+
severity = Severity.MEDIUM
|
|
406
|
+
else:
|
|
407
|
+
severity = Severity.LOW
|
|
408
|
+
|
|
409
|
+
# Generate deterministic ID
|
|
410
|
+
issue_id = self._generate_issue_id(percentage, threshold)
|
|
411
|
+
|
|
412
|
+
gap = threshold - percentage
|
|
413
|
+
|
|
414
|
+
return UnifiedIssue(
|
|
415
|
+
id=issue_id,
|
|
416
|
+
domain=ToolDomain.COVERAGE,
|
|
417
|
+
source_tool="coverage.py",
|
|
418
|
+
severity=severity,
|
|
419
|
+
rule_id="coverage_below_threshold",
|
|
420
|
+
title=f"Coverage {percentage:.1f}% is below threshold {threshold}%",
|
|
421
|
+
description=(
|
|
422
|
+
f"Project coverage is {percentage:.1f}%, which is {gap:.1f}% below "
|
|
423
|
+
f"the required threshold of {threshold}%. "
|
|
424
|
+
f"{missing_lines} lines are not covered."
|
|
425
|
+
),
|
|
426
|
+
recommendation=f"Add tests to cover at least {gap:.1f}% more of the codebase.",
|
|
427
|
+
file_path=None, # Project-level issue
|
|
428
|
+
line_start=None,
|
|
429
|
+
line_end=None,
|
|
430
|
+
fixable=False,
|
|
431
|
+
metadata={
|
|
432
|
+
"coverage_percentage": round(percentage, 2),
|
|
433
|
+
"threshold": threshold,
|
|
434
|
+
"total_lines": total_lines,
|
|
435
|
+
"covered_lines": covered_lines,
|
|
436
|
+
"missing_lines": missing_lines,
|
|
437
|
+
"gap_percentage": round(gap, 2),
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _generate_issue_id(self, percentage: float, threshold: float) -> str:
|
|
442
|
+
"""Generate deterministic issue ID.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
percentage: Coverage percentage.
|
|
446
|
+
threshold: Coverage threshold.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Unique issue ID.
|
|
450
|
+
"""
|
|
451
|
+
# ID based on rounded percentage and threshold for stability
|
|
452
|
+
content = f"coverage:{round(percentage)}:{threshold}"
|
|
453
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
454
|
+
return f"coverage-{hash_val}"
|