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,313 @@
|
|
|
1
|
+
"""pyright type checker plugin.
|
|
2
|
+
|
|
3
|
+
pyright is a fast static type checker for Python.
|
|
4
|
+
https://github.com/microsoft/pyright
|
|
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.plugins.type_checkers.base import TypeCheckerPlugin
|
|
27
|
+
|
|
28
|
+
LOGGER = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# Default version from pyproject.toml [tool.lucidscan.tools]
|
|
31
|
+
DEFAULT_VERSION = get_tool_version("pyright")
|
|
32
|
+
|
|
33
|
+
# pyright severity mapping
|
|
34
|
+
SEVERITY_MAP = {
|
|
35
|
+
"error": Severity.HIGH,
|
|
36
|
+
"warning": Severity.MEDIUM,
|
|
37
|
+
"information": Severity.LOW,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PyrightChecker(TypeCheckerPlugin):
|
|
42
|
+
"""pyright type checker plugin for Python code analysis."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
version: str = DEFAULT_VERSION,
|
|
47
|
+
project_root: Optional[Path] = None,
|
|
48
|
+
):
|
|
49
|
+
"""Initialize PyrightChecker.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
version: pyright version to use.
|
|
53
|
+
project_root: Optional project root for tool installation.
|
|
54
|
+
"""
|
|
55
|
+
self._version = version
|
|
56
|
+
if project_root:
|
|
57
|
+
self._paths = LucidscanPaths.for_project(project_root)
|
|
58
|
+
self._project_root = project_root
|
|
59
|
+
else:
|
|
60
|
+
self._paths = LucidscanPaths.default()
|
|
61
|
+
self._project_root = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
"""Plugin identifier."""
|
|
66
|
+
return "pyright"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def languages(self) -> List[str]:
|
|
70
|
+
"""Supported languages."""
|
|
71
|
+
return ["python"]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def supports_strict_mode(self) -> bool:
|
|
75
|
+
"""pyright supports strict mode."""
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def get_version(self) -> str:
|
|
79
|
+
"""Get pyright version."""
|
|
80
|
+
return self._version
|
|
81
|
+
|
|
82
|
+
def ensure_binary(self) -> Path:
|
|
83
|
+
"""Ensure pyright is available.
|
|
84
|
+
|
|
85
|
+
Checks for pyright in:
|
|
86
|
+
1. Project's .venv/bin/pyright (pip installed pyright)
|
|
87
|
+
2. Project's node_modules/.bin/pyright
|
|
88
|
+
3. System PATH (npm or pip installed)
|
|
89
|
+
4. Downloads standalone binary if not found
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path to pyright binary.
|
|
93
|
+
"""
|
|
94
|
+
# Check project venv first (pip install pyright)
|
|
95
|
+
if self._project_root:
|
|
96
|
+
venv_pyright = self._project_root / ".venv" / "bin" / "pyright"
|
|
97
|
+
if venv_pyright.exists():
|
|
98
|
+
return venv_pyright
|
|
99
|
+
|
|
100
|
+
# Check project node_modules
|
|
101
|
+
if self._project_root:
|
|
102
|
+
node_pyright = self._project_root / "node_modules" / ".bin" / "pyright"
|
|
103
|
+
if node_pyright.exists():
|
|
104
|
+
return node_pyright
|
|
105
|
+
|
|
106
|
+
# Check system PATH (npm or pip installed)
|
|
107
|
+
pyright_path = shutil.which("pyright")
|
|
108
|
+
if pyright_path:
|
|
109
|
+
return Path(pyright_path)
|
|
110
|
+
|
|
111
|
+
# Download standalone binary
|
|
112
|
+
return self._download_binary()
|
|
113
|
+
|
|
114
|
+
def _download_binary(self) -> Path:
|
|
115
|
+
"""Download pyright standalone binary.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Path to downloaded binary.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
binary_dir = self._paths.plugin_bin_dir(self.name, self._version)
|
|
122
|
+
binary_name = "pyright.exe" if platform.system() == "Windows" else "pyright"
|
|
123
|
+
binary_path = binary_dir / binary_name
|
|
124
|
+
|
|
125
|
+
if binary_path.exists():
|
|
126
|
+
return binary_path
|
|
127
|
+
|
|
128
|
+
LOGGER.info(f"Downloading pyright {self._version}...")
|
|
129
|
+
binary_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
# pyright is distributed as npm package, but we can use pyright-python
|
|
132
|
+
# which provides a standalone binary
|
|
133
|
+
# For now, we'll check if pyright is available via pip
|
|
134
|
+
pip_pyright = shutil.which("pyright")
|
|
135
|
+
if pip_pyright:
|
|
136
|
+
return Path(pip_pyright)
|
|
137
|
+
|
|
138
|
+
# If not available, try to use npm to install it locally
|
|
139
|
+
# or raise an error with installation instructions
|
|
140
|
+
raise FileNotFoundError(
|
|
141
|
+
"pyright is not installed. Install it with:\n"
|
|
142
|
+
" npm install -g pyright\n"
|
|
143
|
+
" OR\n"
|
|
144
|
+
" pip install pyright"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def check(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
148
|
+
"""Run pyright type checking.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
context: Scan context with paths and configuration.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of type checking issues.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
binary = self.ensure_binary()
|
|
158
|
+
except FileNotFoundError as e:
|
|
159
|
+
LOGGER.warning(str(e))
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
# Build command
|
|
163
|
+
cmd = [
|
|
164
|
+
str(binary),
|
|
165
|
+
"--outputjson",
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# Add paths to check
|
|
169
|
+
paths = [str(p) for p in context.paths] if context.paths else ["."]
|
|
170
|
+
cmd.extend(paths)
|
|
171
|
+
|
|
172
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
result = subprocess.run(
|
|
176
|
+
cmd,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
encoding="utf-8",
|
|
180
|
+
errors="replace",
|
|
181
|
+
cwd=str(context.project_root),
|
|
182
|
+
timeout=180, # 3 minute timeout
|
|
183
|
+
)
|
|
184
|
+
except subprocess.TimeoutExpired:
|
|
185
|
+
LOGGER.warning("pyright timed out after 180 seconds")
|
|
186
|
+
return []
|
|
187
|
+
except Exception as e:
|
|
188
|
+
LOGGER.error(f"Failed to run pyright: {e}")
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
# Parse output
|
|
192
|
+
issues = self._parse_output(result.stdout, context.project_root)
|
|
193
|
+
|
|
194
|
+
LOGGER.info(f"pyright found {len(issues)} issues")
|
|
195
|
+
return issues
|
|
196
|
+
|
|
197
|
+
def _parse_output(self, output: str, project_root: Path) -> List[UnifiedIssue]:
|
|
198
|
+
"""Parse pyright JSON output.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
output: JSON output from pyright.
|
|
202
|
+
project_root: Project root directory.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of UnifiedIssue objects.
|
|
206
|
+
"""
|
|
207
|
+
if not output.strip():
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
data = json.loads(output)
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
LOGGER.warning("Failed to parse pyright output as JSON")
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
issues = []
|
|
217
|
+
diagnostics = data.get("generalDiagnostics", [])
|
|
218
|
+
|
|
219
|
+
for diagnostic in diagnostics:
|
|
220
|
+
issue = self._diagnostic_to_issue(diagnostic, project_root)
|
|
221
|
+
if issue:
|
|
222
|
+
issues.append(issue)
|
|
223
|
+
|
|
224
|
+
return issues
|
|
225
|
+
|
|
226
|
+
def _diagnostic_to_issue(
|
|
227
|
+
self,
|
|
228
|
+
diagnostic: Dict[str, Any],
|
|
229
|
+
project_root: Path,
|
|
230
|
+
) -> Optional[UnifiedIssue]:
|
|
231
|
+
"""Convert pyright diagnostic to UnifiedIssue.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
diagnostic: pyright diagnostic dict.
|
|
235
|
+
project_root: Project root directory.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
UnifiedIssue or None.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
severity_str = diagnostic.get("severity", "error")
|
|
242
|
+
message = diagnostic.get("message", "")
|
|
243
|
+
file = diagnostic.get("file", "")
|
|
244
|
+
rule = diagnostic.get("rule", "")
|
|
245
|
+
|
|
246
|
+
# Get range info
|
|
247
|
+
range_info = diagnostic.get("range", {})
|
|
248
|
+
start = range_info.get("start", {})
|
|
249
|
+
end = range_info.get("end", {})
|
|
250
|
+
|
|
251
|
+
line_start = start.get("line", 0) + 1 # pyright uses 0-based lines
|
|
252
|
+
line_end = end.get("line", 0) + 1
|
|
253
|
+
column = start.get("character", 0) + 1
|
|
254
|
+
|
|
255
|
+
# Get severity
|
|
256
|
+
severity = SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
|
|
257
|
+
|
|
258
|
+
# Build file path
|
|
259
|
+
file_path = Path(file)
|
|
260
|
+
if not file_path.is_absolute():
|
|
261
|
+
file_path = project_root / file_path
|
|
262
|
+
|
|
263
|
+
# Generate deterministic ID
|
|
264
|
+
issue_id = self._generate_issue_id(rule, file, line_start, column, message)
|
|
265
|
+
|
|
266
|
+
# Build title
|
|
267
|
+
title = f"[{rule}] {message}" if rule else message
|
|
268
|
+
|
|
269
|
+
return UnifiedIssue(
|
|
270
|
+
id=issue_id,
|
|
271
|
+
domain=ToolDomain.TYPE_CHECKING,
|
|
272
|
+
source_tool="pyright",
|
|
273
|
+
severity=severity,
|
|
274
|
+
rule_id=rule or "unknown",
|
|
275
|
+
title=title,
|
|
276
|
+
description=message,
|
|
277
|
+
documentation_url="https://github.com/microsoft/pyright/blob/main/docs/configuration.md",
|
|
278
|
+
file_path=file_path,
|
|
279
|
+
line_start=line_start,
|
|
280
|
+
line_end=line_end,
|
|
281
|
+
column_start=column,
|
|
282
|
+
fixable=False,
|
|
283
|
+
metadata={
|
|
284
|
+
"severity_raw": severity_str,
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
LOGGER.warning(f"Failed to parse pyright diagnostic: {e}")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _generate_issue_id(
|
|
292
|
+
self,
|
|
293
|
+
rule: str,
|
|
294
|
+
file: str,
|
|
295
|
+
line: int,
|
|
296
|
+
column: int,
|
|
297
|
+
message: str,
|
|
298
|
+
) -> str:
|
|
299
|
+
"""Generate deterministic issue ID.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
rule: Error rule/code.
|
|
303
|
+
file: File path.
|
|
304
|
+
line: Line number.
|
|
305
|
+
column: Column number.
|
|
306
|
+
message: Error message.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Unique issue ID.
|
|
310
|
+
"""
|
|
311
|
+
content = f"{rule}:{file}:{line}:{column}:{message}"
|
|
312
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
313
|
+
return f"pyright-{rule}-{hash_val}" if rule else f"pyright-{hash_val}"
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""TypeScript type checker plugin.
|
|
2
|
+
|
|
3
|
+
TypeScript uses the tsc compiler for type checking.
|
|
4
|
+
https://www.typescriptlang.org/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from lucidscan.core.logging import get_logger
|
|
17
|
+
from lucidscan.core.models import (
|
|
18
|
+
ScanContext,
|
|
19
|
+
Severity,
|
|
20
|
+
ToolDomain,
|
|
21
|
+
UnifiedIssue,
|
|
22
|
+
)
|
|
23
|
+
from lucidscan.plugins.type_checkers.base import TypeCheckerPlugin
|
|
24
|
+
|
|
25
|
+
LOGGER = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
# TypeScript error pattern: file(line,col): error TS1234: message
|
|
28
|
+
TSC_ERROR_PATTERN = re.compile(
|
|
29
|
+
r"^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TypeScriptChecker(TypeCheckerPlugin):
|
|
34
|
+
"""TypeScript type checker plugin using tsc."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
37
|
+
"""Initialize TypeScriptChecker.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
project_root: Optional project root for finding tsc installation.
|
|
41
|
+
"""
|
|
42
|
+
self._project_root = project_root
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
"""Plugin identifier."""
|
|
47
|
+
return "typescript"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def languages(self) -> List[str]:
|
|
51
|
+
"""Supported languages."""
|
|
52
|
+
return ["typescript"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def supports_strict_mode(self) -> bool:
|
|
56
|
+
"""TypeScript supports strict mode via tsconfig.json."""
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def get_version(self) -> str:
|
|
60
|
+
"""Get TypeScript version.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Version string or 'unknown' if unable to determine.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
binary = self.ensure_binary()
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
[str(binary), "--version"],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
errors="replace",
|
|
73
|
+
timeout=30,
|
|
74
|
+
)
|
|
75
|
+
# Output is like "Version 5.3.3"
|
|
76
|
+
if result.returncode == 0:
|
|
77
|
+
parts = result.stdout.strip().split()
|
|
78
|
+
if len(parts) >= 2:
|
|
79
|
+
return parts[1]
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return "unknown"
|
|
83
|
+
|
|
84
|
+
def ensure_binary(self) -> Path:
|
|
85
|
+
"""Ensure tsc is available.
|
|
86
|
+
|
|
87
|
+
Checks for tsc in:
|
|
88
|
+
1. Project's node_modules/.bin/tsc
|
|
89
|
+
2. System PATH (globally installed)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path to tsc binary.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
FileNotFoundError: If TypeScript is not installed.
|
|
96
|
+
"""
|
|
97
|
+
# Check project node_modules first
|
|
98
|
+
if self._project_root:
|
|
99
|
+
node_tsc = self._project_root / "node_modules" / ".bin" / "tsc"
|
|
100
|
+
if node_tsc.exists():
|
|
101
|
+
return node_tsc
|
|
102
|
+
|
|
103
|
+
# Check system PATH
|
|
104
|
+
tsc_path = shutil.which("tsc")
|
|
105
|
+
if tsc_path:
|
|
106
|
+
return Path(tsc_path)
|
|
107
|
+
|
|
108
|
+
raise FileNotFoundError(
|
|
109
|
+
"TypeScript is not installed. Install it with:\n"
|
|
110
|
+
" npm install typescript --save-dev\n"
|
|
111
|
+
" OR\n"
|
|
112
|
+
" npm install -g typescript"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def check(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
116
|
+
"""Run TypeScript type checking.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
context: Scan context with paths and configuration.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of type checking issues.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
binary = self.ensure_binary()
|
|
126
|
+
except FileNotFoundError as e:
|
|
127
|
+
LOGGER.warning(str(e))
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
# Check for tsconfig.json
|
|
131
|
+
tsconfig = context.project_root / "tsconfig.json"
|
|
132
|
+
if not tsconfig.exists():
|
|
133
|
+
LOGGER.warning(
|
|
134
|
+
f"No tsconfig.json found in {context.project_root}, skipping TypeScript checking"
|
|
135
|
+
)
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
# Build command
|
|
139
|
+
cmd = [
|
|
140
|
+
str(binary),
|
|
141
|
+
"--noEmit", # Don't emit compiled files
|
|
142
|
+
"--pretty", "false", # Plain output for parsing
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
cmd,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
errors="replace",
|
|
154
|
+
cwd=str(context.project_root),
|
|
155
|
+
timeout=180, # 3 minute timeout
|
|
156
|
+
)
|
|
157
|
+
except subprocess.TimeoutExpired:
|
|
158
|
+
LOGGER.warning("tsc timed out after 180 seconds")
|
|
159
|
+
return []
|
|
160
|
+
except Exception as e:
|
|
161
|
+
LOGGER.error(f"Failed to run tsc: {e}")
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
# Parse output (tsc outputs to stdout on success, stderr on error)
|
|
165
|
+
output = result.stdout or result.stderr
|
|
166
|
+
issues = self._parse_output(output, context.project_root)
|
|
167
|
+
|
|
168
|
+
LOGGER.info(f"TypeScript found {len(issues)} issues")
|
|
169
|
+
return issues
|
|
170
|
+
|
|
171
|
+
def _parse_output(self, output: str, project_root: Path) -> List[UnifiedIssue]:
|
|
172
|
+
"""Parse tsc output.
|
|
173
|
+
|
|
174
|
+
tsc outputs errors in format:
|
|
175
|
+
file(line,col): error TS1234: message
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
output: Output from tsc command.
|
|
179
|
+
project_root: Project root directory.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of UnifiedIssue objects.
|
|
183
|
+
"""
|
|
184
|
+
if not output.strip():
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
issues = []
|
|
188
|
+
for line in output.strip().split("\n"):
|
|
189
|
+
line = line.strip()
|
|
190
|
+
if not line:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
match = TSC_ERROR_PATTERN.match(line)
|
|
194
|
+
if match:
|
|
195
|
+
issue = self._match_to_issue(match, project_root)
|
|
196
|
+
if issue:
|
|
197
|
+
issues.append(issue)
|
|
198
|
+
else:
|
|
199
|
+
LOGGER.debug(f"Skipping non-matching line: {line}")
|
|
200
|
+
|
|
201
|
+
return issues
|
|
202
|
+
|
|
203
|
+
def _match_to_issue(
|
|
204
|
+
self,
|
|
205
|
+
match: re.Match,
|
|
206
|
+
project_root: Path,
|
|
207
|
+
) -> Optional[UnifiedIssue]:
|
|
208
|
+
"""Convert regex match to UnifiedIssue.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
match: Regex match from TSC_ERROR_PATTERN.
|
|
212
|
+
project_root: Project root directory.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
UnifiedIssue or None.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
file_path_str = match.group(1)
|
|
219
|
+
line = int(match.group(2))
|
|
220
|
+
column = int(match.group(3))
|
|
221
|
+
severity_str = match.group(4)
|
|
222
|
+
code = match.group(5)
|
|
223
|
+
message = match.group(6)
|
|
224
|
+
|
|
225
|
+
# TypeScript errors are always high severity
|
|
226
|
+
severity = Severity.HIGH if severity_str == "error" else Severity.MEDIUM
|
|
227
|
+
|
|
228
|
+
# Build file path
|
|
229
|
+
file_path = Path(file_path_str)
|
|
230
|
+
if not file_path.is_absolute():
|
|
231
|
+
file_path = project_root / file_path
|
|
232
|
+
|
|
233
|
+
# Generate deterministic ID
|
|
234
|
+
issue_id = self._generate_issue_id(code, file_path_str, line, column, message)
|
|
235
|
+
|
|
236
|
+
return UnifiedIssue(
|
|
237
|
+
id=issue_id,
|
|
238
|
+
domain=ToolDomain.TYPE_CHECKING,
|
|
239
|
+
source_tool="typescript",
|
|
240
|
+
severity=severity,
|
|
241
|
+
rule_id=code,
|
|
242
|
+
title=f"[{code}] {message}",
|
|
243
|
+
description=message,
|
|
244
|
+
documentation_url=f"https://typescript.tv/errors/#{code}",
|
|
245
|
+
file_path=file_path,
|
|
246
|
+
line_start=line,
|
|
247
|
+
line_end=line,
|
|
248
|
+
column_start=column,
|
|
249
|
+
fixable=False,
|
|
250
|
+
metadata={
|
|
251
|
+
"severity_raw": severity_str,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
LOGGER.warning(f"Failed to parse tsc error: {e}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def _generate_issue_id(
|
|
259
|
+
self,
|
|
260
|
+
code: str,
|
|
261
|
+
file: str,
|
|
262
|
+
line: int,
|
|
263
|
+
column: int,
|
|
264
|
+
message: str,
|
|
265
|
+
) -> str:
|
|
266
|
+
"""Generate deterministic issue ID.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
code: TypeScript error code (e.g., TS1234).
|
|
270
|
+
file: File path.
|
|
271
|
+
line: Line number.
|
|
272
|
+
column: Column number.
|
|
273
|
+
message: Error message.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Unique issue ID.
|
|
277
|
+
"""
|
|
278
|
+
content = f"{code}:{file}:{line}:{column}:{message}"
|
|
279
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
280
|
+
return f"ts-{code}-{hash_val}"
|