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,381 @@
|
|
|
1
|
+
"""Jest test runner plugin.
|
|
2
|
+
|
|
3
|
+
Jest is a delightful JavaScript Testing Framework.
|
|
4
|
+
https://jestjs.io/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from lucidscan.core.logging import get_logger
|
|
18
|
+
from lucidscan.core.models import (
|
|
19
|
+
ScanContext,
|
|
20
|
+
Severity,
|
|
21
|
+
ToolDomain,
|
|
22
|
+
UnifiedIssue,
|
|
23
|
+
)
|
|
24
|
+
from lucidscan.plugins.test_runners.base import TestRunnerPlugin, TestResult
|
|
25
|
+
|
|
26
|
+
LOGGER = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JestRunner(TestRunnerPlugin):
|
|
30
|
+
"""Jest test runner plugin for JavaScript/TypeScript test execution."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
33
|
+
"""Initialize JestRunner.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
project_root: Optional project root for finding Jest installation.
|
|
37
|
+
"""
|
|
38
|
+
self._project_root = project_root
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
"""Plugin identifier."""
|
|
43
|
+
return "jest"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def languages(self) -> List[str]:
|
|
47
|
+
"""Supported languages."""
|
|
48
|
+
return ["javascript", "typescript"]
|
|
49
|
+
|
|
50
|
+
def get_version(self) -> str:
|
|
51
|
+
"""Get Jest version.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Version string or 'unknown' if unable to determine.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
binary = self.ensure_binary()
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
[str(binary), "--version"],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
timeout=30,
|
|
65
|
+
)
|
|
66
|
+
# Output is just the version number like "29.7.0"
|
|
67
|
+
if result.returncode == 0:
|
|
68
|
+
return result.stdout.strip()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
def ensure_binary(self) -> Path:
|
|
74
|
+
"""Ensure Jest is available.
|
|
75
|
+
|
|
76
|
+
Checks for Jest in:
|
|
77
|
+
1. Project's node_modules/.bin/jest
|
|
78
|
+
2. System PATH (globally installed)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to Jest binary.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
FileNotFoundError: If Jest is not installed.
|
|
85
|
+
"""
|
|
86
|
+
# Check project node_modules first
|
|
87
|
+
if self._project_root:
|
|
88
|
+
node_jest = self._project_root / "node_modules" / ".bin" / "jest"
|
|
89
|
+
if node_jest.exists():
|
|
90
|
+
return node_jest
|
|
91
|
+
|
|
92
|
+
# Check system PATH
|
|
93
|
+
jest_path = shutil.which("jest")
|
|
94
|
+
if jest_path:
|
|
95
|
+
return Path(jest_path)
|
|
96
|
+
|
|
97
|
+
raise FileNotFoundError(
|
|
98
|
+
"Jest is not installed. Install it with:\n"
|
|
99
|
+
" npm install jest --save-dev\n"
|
|
100
|
+
" OR\n"
|
|
101
|
+
" npm install -g jest"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def run_tests(self, context: ScanContext) -> TestResult:
|
|
105
|
+
"""Run Jest on the specified paths.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
context: Scan context with paths and configuration.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
TestResult with test statistics and issues for failures.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
binary = self.ensure_binary()
|
|
115
|
+
except FileNotFoundError as e:
|
|
116
|
+
LOGGER.warning(str(e))
|
|
117
|
+
return TestResult()
|
|
118
|
+
|
|
119
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
120
|
+
report_file = Path(tmpdir) / "jest-results.json"
|
|
121
|
+
|
|
122
|
+
cmd = [
|
|
123
|
+
str(binary),
|
|
124
|
+
"--json",
|
|
125
|
+
f"--outputFile={report_file}",
|
|
126
|
+
"--passWithNoTests", # Don't fail if no tests found
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
# Add paths to test if specified
|
|
130
|
+
if context.paths:
|
|
131
|
+
paths = [str(p) for p in context.paths]
|
|
132
|
+
cmd.extend(paths)
|
|
133
|
+
|
|
134
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
result = subprocess.run(
|
|
138
|
+
cmd,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
errors="replace",
|
|
143
|
+
cwd=str(context.project_root),
|
|
144
|
+
timeout=600, # 10 minute timeout for test runs
|
|
145
|
+
)
|
|
146
|
+
except subprocess.TimeoutExpired:
|
|
147
|
+
LOGGER.warning("Jest timed out after 600 seconds")
|
|
148
|
+
return TestResult()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
LOGGER.error(f"Failed to run Jest: {e}")
|
|
151
|
+
return TestResult()
|
|
152
|
+
|
|
153
|
+
# Parse JSON report
|
|
154
|
+
if report_file.exists():
|
|
155
|
+
return self._parse_json_report(report_file, context.project_root)
|
|
156
|
+
else:
|
|
157
|
+
# Jest might output JSON to stdout if no outputFile
|
|
158
|
+
return self._parse_json_output(result.stdout, context.project_root)
|
|
159
|
+
|
|
160
|
+
def _parse_json_report(
|
|
161
|
+
self,
|
|
162
|
+
report_file: Path,
|
|
163
|
+
project_root: Path,
|
|
164
|
+
) -> TestResult:
|
|
165
|
+
"""Parse Jest JSON report file.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
report_file: Path to JSON report file.
|
|
169
|
+
project_root: Project root directory.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
TestResult with parsed data.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
with open(report_file) as f:
|
|
176
|
+
report = json.load(f)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
LOGGER.error(f"Failed to parse Jest JSON report: {e}")
|
|
179
|
+
return TestResult()
|
|
180
|
+
|
|
181
|
+
return self._process_report(report, project_root)
|
|
182
|
+
|
|
183
|
+
def _parse_json_output(
|
|
184
|
+
self,
|
|
185
|
+
output: str,
|
|
186
|
+
project_root: Path,
|
|
187
|
+
) -> TestResult:
|
|
188
|
+
"""Parse Jest JSON output from stdout.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
output: JSON output from Jest.
|
|
192
|
+
project_root: Project root directory.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
TestResult with parsed data.
|
|
196
|
+
"""
|
|
197
|
+
if not output.strip():
|
|
198
|
+
return TestResult()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
report = json.loads(output)
|
|
202
|
+
except json.JSONDecodeError as e:
|
|
203
|
+
LOGGER.warning(f"Failed to parse Jest JSON output: {e}")
|
|
204
|
+
return TestResult()
|
|
205
|
+
|
|
206
|
+
return self._process_report(report, project_root)
|
|
207
|
+
|
|
208
|
+
def _process_report(
|
|
209
|
+
self,
|
|
210
|
+
report: Dict[str, Any],
|
|
211
|
+
project_root: Path,
|
|
212
|
+
) -> TestResult:
|
|
213
|
+
"""Process Jest JSON report.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
report: Parsed JSON report.
|
|
217
|
+
project_root: Project root directory.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
TestResult with processed data.
|
|
221
|
+
"""
|
|
222
|
+
# Extract summary statistics
|
|
223
|
+
num_passed = report.get("numPassedTests", 0)
|
|
224
|
+
num_failed = report.get("numFailedTests", 0)
|
|
225
|
+
num_pending = report.get("numPendingTests", 0)
|
|
226
|
+
num_todo = report.get("numTodoTests", 0)
|
|
227
|
+
|
|
228
|
+
# Calculate duration from individual test results
|
|
229
|
+
test_results = report.get("testResults", [])
|
|
230
|
+
duration_ms = 0
|
|
231
|
+
for test_result in test_results:
|
|
232
|
+
duration_ms += test_result.get("endTime", 0) - test_result.get("startTime", 0)
|
|
233
|
+
|
|
234
|
+
result = TestResult(
|
|
235
|
+
passed=num_passed,
|
|
236
|
+
failed=num_failed,
|
|
237
|
+
skipped=num_pending + num_todo,
|
|
238
|
+
errors=0, # Jest doesn't distinguish errors from failures
|
|
239
|
+
duration_ms=duration_ms,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Convert failures to issues
|
|
243
|
+
for test_file in test_results:
|
|
244
|
+
status = test_file.get("status", "")
|
|
245
|
+
if status == "failed":
|
|
246
|
+
for assertion in test_file.get("assertionResults", []):
|
|
247
|
+
if assertion.get("status") == "failed":
|
|
248
|
+
issue = self._assertion_to_issue(
|
|
249
|
+
assertion, test_file, project_root
|
|
250
|
+
)
|
|
251
|
+
if issue:
|
|
252
|
+
result.issues.append(issue)
|
|
253
|
+
|
|
254
|
+
LOGGER.info(
|
|
255
|
+
f"Jest: {result.passed} passed, {result.failed} failed, "
|
|
256
|
+
f"{result.skipped} skipped"
|
|
257
|
+
)
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def _assertion_to_issue(
|
|
261
|
+
self,
|
|
262
|
+
assertion: Dict[str, Any],
|
|
263
|
+
test_file: Dict[str, Any],
|
|
264
|
+
project_root: Path,
|
|
265
|
+
) -> Optional[UnifiedIssue]:
|
|
266
|
+
"""Convert Jest assertion failure to UnifiedIssue.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
assertion: Assertion result dict.
|
|
270
|
+
test_file: Test file result dict.
|
|
271
|
+
project_root: Project root directory.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
UnifiedIssue or None.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
# Get test information
|
|
278
|
+
full_name = assertion.get("fullName", "")
|
|
279
|
+
title_parts = assertion.get("ancestorTitles", [])
|
|
280
|
+
test_name = assertion.get("title", "")
|
|
281
|
+
failure_messages = assertion.get("failureMessages", [])
|
|
282
|
+
location = assertion.get("location", {})
|
|
283
|
+
|
|
284
|
+
# Build file path
|
|
285
|
+
file_name = test_file.get("name", "")
|
|
286
|
+
file_path = Path(file_name)
|
|
287
|
+
if not file_path.is_absolute():
|
|
288
|
+
file_path = project_root / file_path
|
|
289
|
+
|
|
290
|
+
# Get line number if available
|
|
291
|
+
line_number = location.get("line") if location else None
|
|
292
|
+
|
|
293
|
+
# Build test name with ancestors
|
|
294
|
+
if title_parts:
|
|
295
|
+
display_name = " > ".join(title_parts + [test_name])
|
|
296
|
+
else:
|
|
297
|
+
display_name = test_name
|
|
298
|
+
|
|
299
|
+
# Get failure message
|
|
300
|
+
message = failure_messages[0] if failure_messages else "Test failed"
|
|
301
|
+
# Extract assertion from message
|
|
302
|
+
assertion_text = self._extract_assertion(message)
|
|
303
|
+
|
|
304
|
+
# Generate deterministic ID
|
|
305
|
+
issue_id = self._generate_issue_id(full_name, assertion_text)
|
|
306
|
+
|
|
307
|
+
# Build title
|
|
308
|
+
title = f"{display_name}: {assertion_text}" if assertion_text else f"{display_name} failed"
|
|
309
|
+
|
|
310
|
+
return UnifiedIssue(
|
|
311
|
+
id=issue_id,
|
|
312
|
+
domain=ToolDomain.TESTING,
|
|
313
|
+
source_tool="jest",
|
|
314
|
+
severity=Severity.HIGH,
|
|
315
|
+
rule_id="failed",
|
|
316
|
+
title=title,
|
|
317
|
+
description=message,
|
|
318
|
+
file_path=file_path,
|
|
319
|
+
line_start=line_number,
|
|
320
|
+
line_end=line_number,
|
|
321
|
+
fixable=False,
|
|
322
|
+
metadata={
|
|
323
|
+
"full_name": full_name,
|
|
324
|
+
"test_name": test_name,
|
|
325
|
+
"ancestor_titles": title_parts,
|
|
326
|
+
"failure_messages": failure_messages,
|
|
327
|
+
"assertion": assertion_text,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
LOGGER.warning(f"Failed to parse Jest assertion failure: {e}")
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
def _extract_assertion(self, message: str) -> str:
|
|
335
|
+
"""Extract assertion from Jest failure message.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
message: Failure message from Jest.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Extracted assertion or truncated message.
|
|
342
|
+
"""
|
|
343
|
+
if not message:
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
lines = message.strip().split("\n")
|
|
347
|
+
|
|
348
|
+
# Look for expect/received patterns
|
|
349
|
+
for line in lines:
|
|
350
|
+
line = line.strip()
|
|
351
|
+
if line.startswith("expect("):
|
|
352
|
+
return line[:100]
|
|
353
|
+
if line.startswith("Expected:"):
|
|
354
|
+
# Find the corresponding Received line
|
|
355
|
+
idx = lines.index(line) if line in lines else -1
|
|
356
|
+
if idx >= 0 and idx + 1 < len(lines):
|
|
357
|
+
received = lines[idx + 1].strip()
|
|
358
|
+
return f"{line} {received}"[:100]
|
|
359
|
+
return line[:100]
|
|
360
|
+
|
|
361
|
+
# Return first meaningful line
|
|
362
|
+
for line in lines:
|
|
363
|
+
line = line.strip()
|
|
364
|
+
if line and not line.startswith("at ") and len(line) > 5:
|
|
365
|
+
return line[:100]
|
|
366
|
+
|
|
367
|
+
return message[:100]
|
|
368
|
+
|
|
369
|
+
def _generate_issue_id(self, full_name: str, assertion: str) -> str:
|
|
370
|
+
"""Generate deterministic issue ID.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
full_name: Full test name with ancestors.
|
|
374
|
+
assertion: Assertion message.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Unique issue ID.
|
|
378
|
+
"""
|
|
379
|
+
content = f"{full_name}:{assertion}"
|
|
380
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
381
|
+
return f"jest-{hash_val}"
|