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.
Files changed (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. 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}"