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,125 @@
|
|
|
1
|
+
"""Base class for linter plugins.
|
|
2
|
+
|
|
3
|
+
All linter plugins inherit from LinterPlugin and implement the lint() method.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from lucidscan.core.models import ScanContext, UnifiedIssue, ToolDomain
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FixResult:
|
|
18
|
+
"""Result of applying automatic fixes."""
|
|
19
|
+
|
|
20
|
+
files_modified: int = 0
|
|
21
|
+
issues_fixed: int = 0
|
|
22
|
+
issues_remaining: int = 0
|
|
23
|
+
details: List[str] = None # type: ignore
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
if self.details is None:
|
|
27
|
+
self.details = []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LinterPlugin(ABC):
|
|
31
|
+
"""Abstract base class for linter plugins.
|
|
32
|
+
|
|
33
|
+
Linter plugins provide code linting functionality for the quality pipeline.
|
|
34
|
+
Each plugin wraps a specific linting tool (Ruff, ESLint, etc.).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_root: Optional[Path] = None, **kwargs) -> None:
|
|
38
|
+
"""Initialize the linter plugin.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_root: Optional project root for tool installation.
|
|
42
|
+
**kwargs: Additional arguments for subclasses.
|
|
43
|
+
"""
|
|
44
|
+
self._project_root = project_root
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
"""Unique plugin identifier (e.g., 'ruff', 'eslint').
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Plugin name string.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def languages(self) -> List[str]:
|
|
58
|
+
"""Languages this linter supports.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of language names (e.g., ['python'], ['javascript', 'typescript']).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def domain(self) -> ToolDomain:
|
|
66
|
+
"""Tool domain (always LINTING for linters).
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ToolDomain.LINTING
|
|
70
|
+
"""
|
|
71
|
+
return ToolDomain.LINTING
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def supports_fix(self) -> bool:
|
|
75
|
+
"""Whether this linter supports auto-fix mode.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if the linter can automatically fix issues.
|
|
79
|
+
"""
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def get_version(self) -> str:
|
|
84
|
+
"""Get the version of the underlying linting tool.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Version string.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def ensure_binary(self) -> Path:
|
|
92
|
+
"""Ensure the linting tool is installed.
|
|
93
|
+
|
|
94
|
+
Downloads or installs the tool if not present.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Path to the tool binary.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def lint(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
102
|
+
"""Run linting on the specified paths.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
context: Scan context with paths and configuration.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of UnifiedIssue objects for each linting violation.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def fix(self, context: ScanContext) -> FixResult:
|
|
112
|
+
"""Apply automatic fixes for linting issues.
|
|
113
|
+
|
|
114
|
+
Override this method if the linter supports auto-fix.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
context: Scan context with paths and configuration.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
FixResult with statistics about fixes applied.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
NotImplementedError: If the linter doesn't support auto-fix.
|
|
124
|
+
"""
|
|
125
|
+
raise NotImplementedError(f"{self.name} does not support auto-fix")
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Biome linter plugin.
|
|
2
|
+
|
|
3
|
+
Biome is a fast linter and formatter for JavaScript, TypeScript, and more.
|
|
4
|
+
https://biomejs.dev/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from lucidscan.bootstrap.paths import LucidscanPaths
|
|
18
|
+
from lucidscan.bootstrap.versions import get_tool_version
|
|
19
|
+
from lucidscan.core.logging import get_logger
|
|
20
|
+
from lucidscan.core.models import (
|
|
21
|
+
ScanContext,
|
|
22
|
+
Severity,
|
|
23
|
+
ToolDomain,
|
|
24
|
+
UnifiedIssue,
|
|
25
|
+
)
|
|
26
|
+
from lucidscan.core.subprocess_runner import run_with_streaming
|
|
27
|
+
from lucidscan.plugins.linters.base import FixResult, LinterPlugin
|
|
28
|
+
|
|
29
|
+
LOGGER = get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
# Default version from pyproject.toml [tool.lucidscan.tools]
|
|
32
|
+
DEFAULT_VERSION = get_tool_version("biome")
|
|
33
|
+
|
|
34
|
+
# Biome severity mapping
|
|
35
|
+
SEVERITY_MAP = {
|
|
36
|
+
"error": Severity.HIGH,
|
|
37
|
+
"warning": Severity.MEDIUM,
|
|
38
|
+
"info": Severity.LOW,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BiomeLinter(LinterPlugin):
|
|
43
|
+
"""Biome linter plugin for JavaScript/TypeScript code analysis."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
version: str = DEFAULT_VERSION,
|
|
48
|
+
project_root: Optional[Path] = None,
|
|
49
|
+
):
|
|
50
|
+
"""Initialize BiomeLinter.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
version: Biome version to use.
|
|
54
|
+
project_root: Optional project root for tool installation.
|
|
55
|
+
"""
|
|
56
|
+
self._version = version
|
|
57
|
+
if project_root:
|
|
58
|
+
self._paths = LucidscanPaths.for_project(project_root)
|
|
59
|
+
self._project_root = project_root
|
|
60
|
+
else:
|
|
61
|
+
self._paths = LucidscanPaths.default()
|
|
62
|
+
self._project_root = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
"""Plugin identifier."""
|
|
67
|
+
return "biome"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def languages(self) -> List[str]:
|
|
71
|
+
"""Supported languages."""
|
|
72
|
+
return ["javascript", "typescript", "json"]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def supports_fix(self) -> bool:
|
|
76
|
+
"""Biome supports auto-fix."""
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def get_version(self) -> str:
|
|
80
|
+
"""Get Biome version."""
|
|
81
|
+
return self._version
|
|
82
|
+
|
|
83
|
+
def ensure_binary(self) -> Path:
|
|
84
|
+
"""Ensure Biome binary is available.
|
|
85
|
+
|
|
86
|
+
Downloads from GitHub releases if not present.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Path to Biome binary.
|
|
90
|
+
"""
|
|
91
|
+
# Check project node_modules first
|
|
92
|
+
if self._project_root:
|
|
93
|
+
node_biome = self._project_root / "node_modules" / ".bin" / "biome"
|
|
94
|
+
if node_biome.exists():
|
|
95
|
+
return node_biome
|
|
96
|
+
|
|
97
|
+
# Check system PATH
|
|
98
|
+
biome_path = shutil.which("biome")
|
|
99
|
+
if biome_path:
|
|
100
|
+
return Path(biome_path)
|
|
101
|
+
|
|
102
|
+
# Download binary
|
|
103
|
+
binary_dir = self._paths.plugin_bin_dir(self.name, self._version)
|
|
104
|
+
binary_name = "biome.exe" if platform.system() == "Windows" else "biome"
|
|
105
|
+
binary_path = binary_dir / binary_name
|
|
106
|
+
|
|
107
|
+
if binary_path.exists():
|
|
108
|
+
return binary_path
|
|
109
|
+
|
|
110
|
+
LOGGER.info(f"Downloading Biome {self._version}...")
|
|
111
|
+
binary_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
archive_path = self._download_release(binary_dir)
|
|
114
|
+
self._extract_binary(archive_path, binary_dir, binary_name)
|
|
115
|
+
|
|
116
|
+
# Make executable on Unix
|
|
117
|
+
if platform.system() != "Windows":
|
|
118
|
+
binary_path.chmod(0o755)
|
|
119
|
+
|
|
120
|
+
# Clean up archive
|
|
121
|
+
archive_path.unlink(missing_ok=True)
|
|
122
|
+
|
|
123
|
+
LOGGER.info(f"Biome {self._version} installed to {binary_dir}")
|
|
124
|
+
return binary_path
|
|
125
|
+
|
|
126
|
+
def lint(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
127
|
+
"""Run Biome linting.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
context: Scan context with paths and configuration.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of linting issues.
|
|
134
|
+
"""
|
|
135
|
+
binary = self.ensure_binary()
|
|
136
|
+
|
|
137
|
+
# Build command
|
|
138
|
+
cmd = [
|
|
139
|
+
str(binary),
|
|
140
|
+
"lint",
|
|
141
|
+
"--reporter", "json",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
# Add paths to check
|
|
145
|
+
if context.paths:
|
|
146
|
+
paths = [str(p) for p in context.paths]
|
|
147
|
+
else:
|
|
148
|
+
src_dir = context.project_root / "src"
|
|
149
|
+
if src_dir.exists():
|
|
150
|
+
paths = [str(src_dir)]
|
|
151
|
+
else:
|
|
152
|
+
paths = ["."]
|
|
153
|
+
|
|
154
|
+
cmd.extend(paths)
|
|
155
|
+
|
|
156
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
result = run_with_streaming(
|
|
160
|
+
cmd=cmd,
|
|
161
|
+
cwd=context.project_root,
|
|
162
|
+
tool_name="biome",
|
|
163
|
+
stream_handler=context.stream_handler,
|
|
164
|
+
timeout=120,
|
|
165
|
+
)
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
LOGGER.warning("Biome lint timed out after 120 seconds")
|
|
168
|
+
return []
|
|
169
|
+
except Exception as e:
|
|
170
|
+
LOGGER.error(f"Failed to run Biome: {e}")
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
# Parse output
|
|
174
|
+
issues = self._parse_output(result.stdout, context.project_root)
|
|
175
|
+
|
|
176
|
+
LOGGER.info(f"Biome found {len(issues)} issues")
|
|
177
|
+
return issues
|
|
178
|
+
|
|
179
|
+
def fix(self, context: ScanContext) -> FixResult:
|
|
180
|
+
"""Apply Biome auto-fixes.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
context: Scan context with paths and configuration.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
FixResult with statistics.
|
|
187
|
+
"""
|
|
188
|
+
binary = self.ensure_binary()
|
|
189
|
+
|
|
190
|
+
# Run without fix to count issues first
|
|
191
|
+
pre_issues = self.lint(context)
|
|
192
|
+
|
|
193
|
+
# Build fix command - Biome uses 'check --apply' for fixes
|
|
194
|
+
cmd = [
|
|
195
|
+
str(binary),
|
|
196
|
+
"check",
|
|
197
|
+
"--apply",
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
if context.paths:
|
|
201
|
+
paths = [str(p) for p in context.paths]
|
|
202
|
+
else:
|
|
203
|
+
src_dir = context.project_root / "src"
|
|
204
|
+
if src_dir.exists():
|
|
205
|
+
paths = [str(src_dir)]
|
|
206
|
+
else:
|
|
207
|
+
paths = ["."]
|
|
208
|
+
|
|
209
|
+
cmd.extend(paths)
|
|
210
|
+
|
|
211
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
run_with_streaming(
|
|
215
|
+
cmd=cmd,
|
|
216
|
+
cwd=context.project_root,
|
|
217
|
+
tool_name="biome-fix",
|
|
218
|
+
stream_handler=context.stream_handler,
|
|
219
|
+
timeout=120,
|
|
220
|
+
)
|
|
221
|
+
except subprocess.TimeoutExpired:
|
|
222
|
+
LOGGER.warning("Biome fix timed out after 120 seconds")
|
|
223
|
+
return FixResult()
|
|
224
|
+
except Exception as e:
|
|
225
|
+
LOGGER.error(f"Failed to run Biome fix: {e}")
|
|
226
|
+
return FixResult()
|
|
227
|
+
|
|
228
|
+
# Run lint again to get remaining issues
|
|
229
|
+
post_issues = self.lint(context)
|
|
230
|
+
|
|
231
|
+
# Calculate stats
|
|
232
|
+
files_modified = len(set(
|
|
233
|
+
str(issue.file_path)
|
|
234
|
+
for issue in pre_issues
|
|
235
|
+
if issue not in post_issues
|
|
236
|
+
))
|
|
237
|
+
|
|
238
|
+
return FixResult(
|
|
239
|
+
files_modified=files_modified,
|
|
240
|
+
issues_fixed=len(pre_issues) - len(post_issues),
|
|
241
|
+
issues_remaining=len(post_issues),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _download_release(self, target_dir: Path) -> Path:
|
|
245
|
+
"""Download Biome release archive.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
target_dir: Directory to download to.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Path to downloaded archive.
|
|
252
|
+
"""
|
|
253
|
+
import urllib.request
|
|
254
|
+
|
|
255
|
+
system = platform.system().lower()
|
|
256
|
+
machine = platform.machine().lower()
|
|
257
|
+
|
|
258
|
+
# Map platform names for Biome releases
|
|
259
|
+
if system == "darwin":
|
|
260
|
+
platform_name = "darwin"
|
|
261
|
+
elif system == "linux":
|
|
262
|
+
platform_name = "linux"
|
|
263
|
+
elif system == "windows":
|
|
264
|
+
platform_name = "win32"
|
|
265
|
+
else:
|
|
266
|
+
platform_name = system
|
|
267
|
+
|
|
268
|
+
# Map architecture
|
|
269
|
+
if machine in ("x86_64", "amd64"):
|
|
270
|
+
arch = "x64"
|
|
271
|
+
elif machine in ("arm64", "aarch64"):
|
|
272
|
+
arch = "arm64"
|
|
273
|
+
else:
|
|
274
|
+
arch = machine
|
|
275
|
+
|
|
276
|
+
# Build download URL
|
|
277
|
+
# Biome releases: biome-darwin-arm64, biome-linux-x64, etc.
|
|
278
|
+
binary_name = f"biome-{platform_name}-{arch}"
|
|
279
|
+
if system == "windows":
|
|
280
|
+
binary_name += ".exe"
|
|
281
|
+
|
|
282
|
+
# Biome 2.x changed the release URL format
|
|
283
|
+
# 1.x: https://github.com/biomejs/biome/releases/download/cli/v{version}/...
|
|
284
|
+
# 2.x: https://github.com/biomejs/biome/releases/download/@biomejs/biome@{version}/...
|
|
285
|
+
major_version = int(self._version.split(".")[0])
|
|
286
|
+
if major_version >= 2:
|
|
287
|
+
url = f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{self._version}/{binary_name}"
|
|
288
|
+
else:
|
|
289
|
+
url = f"https://github.com/biomejs/biome/releases/download/cli/v{self._version}/{binary_name}"
|
|
290
|
+
|
|
291
|
+
archive_path = target_dir / binary_name
|
|
292
|
+
|
|
293
|
+
LOGGER.debug(f"Downloading from {url}")
|
|
294
|
+
|
|
295
|
+
# Validate URL scheme and domain for security
|
|
296
|
+
if not url.startswith("https://github.com/"):
|
|
297
|
+
raise ValueError(f"Invalid download URL: {url}")
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
urllib.request.urlretrieve(url, archive_path) # nosec B310 nosemgrep
|
|
301
|
+
except Exception as e:
|
|
302
|
+
raise RuntimeError(f"Failed to download Biome: {e}") from e
|
|
303
|
+
|
|
304
|
+
return archive_path
|
|
305
|
+
|
|
306
|
+
def _extract_binary(self, archive_path: Path, target_dir: Path, binary_name: str) -> None:
|
|
307
|
+
"""Move/rename downloaded binary.
|
|
308
|
+
|
|
309
|
+
Biome releases are standalone binaries, not archives.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
archive_path: Path to downloaded binary.
|
|
313
|
+
target_dir: Directory to place binary.
|
|
314
|
+
binary_name: Target binary name.
|
|
315
|
+
"""
|
|
316
|
+
target_path = target_dir / binary_name
|
|
317
|
+
if archive_path != target_path:
|
|
318
|
+
archive_path.rename(target_path)
|
|
319
|
+
|
|
320
|
+
def _parse_output(self, output: str, project_root: Path) -> List[UnifiedIssue]:
|
|
321
|
+
"""Parse Biome JSON output.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
output: JSON output from Biome.
|
|
325
|
+
project_root: Project root directory.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of UnifiedIssue objects.
|
|
329
|
+
"""
|
|
330
|
+
if not output.strip():
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
data = json.loads(output)
|
|
335
|
+
except json.JSONDecodeError:
|
|
336
|
+
LOGGER.warning("Failed to parse Biome output as JSON")
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
issues = []
|
|
340
|
+
diagnostics = data.get("diagnostics", [])
|
|
341
|
+
|
|
342
|
+
for diagnostic in diagnostics:
|
|
343
|
+
issue = self._diagnostic_to_issue(diagnostic, project_root)
|
|
344
|
+
if issue:
|
|
345
|
+
issues.append(issue)
|
|
346
|
+
|
|
347
|
+
return issues
|
|
348
|
+
|
|
349
|
+
def _diagnostic_to_issue(
|
|
350
|
+
self,
|
|
351
|
+
diagnostic: Dict[str, Any],
|
|
352
|
+
project_root: Path,
|
|
353
|
+
) -> Optional[UnifiedIssue]:
|
|
354
|
+
"""Convert Biome diagnostic to UnifiedIssue.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
diagnostic: Biome diagnostic dict.
|
|
358
|
+
project_root: Project root directory.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
UnifiedIssue or None.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
severity_str = diagnostic.get("severity", "error")
|
|
365
|
+
message = diagnostic.get("message", "")
|
|
366
|
+
# Handle structured message format
|
|
367
|
+
if isinstance(message, list):
|
|
368
|
+
message = " ".join(
|
|
369
|
+
m.get("content", "") if isinstance(m, dict) else str(m)
|
|
370
|
+
for m in message
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
category = diagnostic.get("category", "")
|
|
374
|
+
location = diagnostic.get("location", {})
|
|
375
|
+
|
|
376
|
+
# Get file path from location
|
|
377
|
+
file_path_str = location.get("path", {}).get("file", "")
|
|
378
|
+
|
|
379
|
+
# Get position info
|
|
380
|
+
line_start = location.get("lineStart", 1)
|
|
381
|
+
line_end = location.get("lineEnd", line_start)
|
|
382
|
+
column_start = location.get("columnStart", 1)
|
|
383
|
+
|
|
384
|
+
# Get severity
|
|
385
|
+
severity = SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
|
|
386
|
+
|
|
387
|
+
# Build file path
|
|
388
|
+
file_path = Path(file_path_str) if file_path_str else Path("unknown")
|
|
389
|
+
if not file_path.is_absolute() and file_path_str:
|
|
390
|
+
file_path = project_root / file_path
|
|
391
|
+
|
|
392
|
+
# Generate deterministic ID
|
|
393
|
+
issue_id = self._generate_issue_id(
|
|
394
|
+
category, file_path_str, line_start, column_start, message
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Build title
|
|
398
|
+
title = f"[{category}] {message}" if category else message
|
|
399
|
+
|
|
400
|
+
# Get column end
|
|
401
|
+
column_end = location.get("columnEnd")
|
|
402
|
+
|
|
403
|
+
return UnifiedIssue(
|
|
404
|
+
id=issue_id,
|
|
405
|
+
domain=ToolDomain.LINTING,
|
|
406
|
+
source_tool="biome",
|
|
407
|
+
severity=severity,
|
|
408
|
+
rule_id=category or "unknown",
|
|
409
|
+
title=title,
|
|
410
|
+
description=message,
|
|
411
|
+
documentation_url=f"https://biomejs.dev/linter/rules/{category.lower().replace('/', '-')}" if category else None,
|
|
412
|
+
file_path=file_path,
|
|
413
|
+
line_start=line_start,
|
|
414
|
+
line_end=line_end,
|
|
415
|
+
column_start=column_start,
|
|
416
|
+
column_end=column_end,
|
|
417
|
+
fixable=diagnostic.get("fixable", False),
|
|
418
|
+
metadata={
|
|
419
|
+
"severity_raw": severity_str,
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
except Exception as e:
|
|
423
|
+
LOGGER.warning(f"Failed to parse Biome diagnostic: {e}")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
def _generate_issue_id(
|
|
427
|
+
self,
|
|
428
|
+
category: str,
|
|
429
|
+
file: str,
|
|
430
|
+
line: int,
|
|
431
|
+
column: int,
|
|
432
|
+
message: str,
|
|
433
|
+
) -> str:
|
|
434
|
+
"""Generate deterministic issue ID.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
category: Rule category.
|
|
438
|
+
file: File path.
|
|
439
|
+
line: Line number.
|
|
440
|
+
column: Column number.
|
|
441
|
+
message: Error message.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Unique issue ID.
|
|
445
|
+
"""
|
|
446
|
+
content = f"{category}:{file}:{line}:{column}:{message}"
|
|
447
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
448
|
+
return f"biome-{category}-{hash_val}" if category else f"biome-{hash_val}"
|