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,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}"