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,481 @@
1
+ """Karma test runner plugin.
2
+
3
+ Karma is a test runner for JavaScript that works with Jasmine,
4
+ commonly used with Angular applications.
5
+ https://karma-runner.github.io/
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
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.plugins.test_runners.base import TestRunnerPlugin, TestResult
26
+
27
+ LOGGER = get_logger(__name__)
28
+
29
+
30
+ class KarmaRunner(TestRunnerPlugin):
31
+ """Karma test runner plugin for Angular/JavaScript test execution."""
32
+
33
+ def __init__(self, project_root: Optional[Path] = None):
34
+ """Initialize KarmaRunner.
35
+
36
+ Args:
37
+ project_root: Optional project root for finding Karma installation.
38
+ """
39
+ self._project_root = project_root
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """Plugin identifier."""
44
+ return "karma"
45
+
46
+ @property
47
+ def languages(self) -> List[str]:
48
+ """Supported languages."""
49
+ return ["javascript", "typescript"]
50
+
51
+ def get_version(self) -> str:
52
+ """Get Karma version.
53
+
54
+ Returns:
55
+ Version string or 'unknown' if unable to determine.
56
+ """
57
+ try:
58
+ binary = self.ensure_binary()
59
+ result = subprocess.run(
60
+ [str(binary), "--version"],
61
+ capture_output=True,
62
+ text=True,
63
+ encoding="utf-8",
64
+ errors="replace",
65
+ timeout=30,
66
+ )
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 Karma is available.
75
+
76
+ Checks for Karma in:
77
+ 1. Project's node_modules/.bin/karma
78
+ 2. System PATH (globally installed)
79
+
80
+ Returns:
81
+ Path to Karma binary.
82
+
83
+ Raises:
84
+ FileNotFoundError: If Karma is not installed.
85
+ """
86
+ # Check project node_modules first
87
+ if self._project_root:
88
+ node_karma = self._project_root / "node_modules" / ".bin" / "karma"
89
+ if node_karma.exists():
90
+ return node_karma
91
+
92
+ # Check system PATH
93
+ karma_path = shutil.which("karma")
94
+ if karma_path:
95
+ return Path(karma_path)
96
+
97
+ raise FileNotFoundError(
98
+ "Karma is not installed. Install it with:\n"
99
+ " npm install karma --save-dev\n"
100
+ " OR\n"
101
+ " npm install -g karma-cli"
102
+ )
103
+
104
+ def run_tests(self, context: ScanContext) -> TestResult:
105
+ """Run Karma 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) / "karma-results.json"
121
+
122
+ # Find karma config file
123
+ karma_config = self._find_karma_config(context.project_root)
124
+
125
+ cmd = [
126
+ str(binary),
127
+ "start",
128
+ "--single-run",
129
+ "--no-auto-watch",
130
+ ]
131
+
132
+ if karma_config:
133
+ cmd.append(str(karma_config))
134
+
135
+ # Set environment variable for JSON reporter output
136
+ env = {
137
+ "KARMA_JSON_REPORTER_OUTPUT": str(report_file),
138
+ }
139
+
140
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
141
+
142
+ try:
143
+ import os
144
+ full_env = os.environ.copy()
145
+ full_env.update(env)
146
+
147
+ result = subprocess.run(
148
+ cmd,
149
+ capture_output=True,
150
+ text=True,
151
+ encoding="utf-8",
152
+ errors="replace",
153
+ cwd=str(context.project_root),
154
+ timeout=600, # 10 minute timeout for test runs
155
+ env=full_env,
156
+ )
157
+ except subprocess.TimeoutExpired:
158
+ LOGGER.warning("Karma timed out after 600 seconds")
159
+ return TestResult()
160
+ except Exception as e:
161
+ LOGGER.error(f"Failed to run Karma: {e}")
162
+ return TestResult()
163
+
164
+ # Parse JSON report if karma-json-reporter was used
165
+ if report_file.exists():
166
+ return self._parse_json_report(report_file, context.project_root)
167
+
168
+ # Fallback: parse stdout for basic results
169
+ return self._parse_stdout(result.stdout, result.stderr, context.project_root)
170
+
171
+ def _find_karma_config(self, project_root: Path) -> Optional[Path]:
172
+ """Find Karma configuration file.
173
+
174
+ Args:
175
+ project_root: Project root directory.
176
+
177
+ Returns:
178
+ Path to karma config or None.
179
+ """
180
+ config_names = [
181
+ "karma.conf.js",
182
+ "karma.conf.ts",
183
+ "karma.config.js",
184
+ "karma.config.ts",
185
+ ]
186
+ for name in config_names:
187
+ config_path = project_root / name
188
+ if config_path.exists():
189
+ return config_path
190
+ return None
191
+
192
+ def _parse_json_report(
193
+ self,
194
+ report_file: Path,
195
+ project_root: Path,
196
+ ) -> TestResult:
197
+ """Parse Karma JSON reporter output.
198
+
199
+ Args:
200
+ report_file: Path to JSON report file.
201
+ project_root: Project root directory.
202
+
203
+ Returns:
204
+ TestResult with parsed data.
205
+ """
206
+ try:
207
+ with open(report_file) as f:
208
+ report = json.load(f)
209
+ except Exception as e:
210
+ LOGGER.error(f"Failed to parse Karma JSON report: {e}")
211
+ return TestResult()
212
+
213
+ return self._process_report(report, project_root)
214
+
215
+ def _process_report(
216
+ self,
217
+ report: Dict[str, Any],
218
+ project_root: Path,
219
+ ) -> TestResult:
220
+ """Process Karma JSON report.
221
+
222
+ Args:
223
+ report: Parsed JSON report.
224
+ project_root: Project root directory.
225
+
226
+ Returns:
227
+ TestResult with processed data.
228
+ """
229
+ summary = report.get("summary", {})
230
+ num_passed = summary.get("success", 0)
231
+ num_failed = summary.get("failed", 0)
232
+ num_skipped = summary.get("skipped", 0)
233
+ num_errors = summary.get("error", 0)
234
+
235
+ # Get duration if available
236
+ duration_ms = 0
237
+ if "totalTime" in summary:
238
+ duration_ms = int(summary["totalTime"])
239
+
240
+ result = TestResult(
241
+ passed=num_passed,
242
+ failed=num_failed,
243
+ skipped=num_skipped,
244
+ errors=num_errors,
245
+ duration_ms=duration_ms,
246
+ )
247
+
248
+ # Process browsers results
249
+ browsers = report.get("browsers", {})
250
+ for browser_name, browser_results in browsers.items():
251
+ for test_result in browser_results.get("results", []):
252
+ if not test_result.get("success", True):
253
+ issue = self._test_to_issue(test_result, project_root)
254
+ if issue:
255
+ result.issues.append(issue)
256
+
257
+ LOGGER.info(
258
+ f"Karma: {result.passed} passed, {result.failed} failed, "
259
+ f"{result.skipped} skipped, {result.errors} errors"
260
+ )
261
+ return result
262
+
263
+ def _parse_stdout(
264
+ self,
265
+ stdout: str,
266
+ stderr: str,
267
+ project_root: Path,
268
+ ) -> TestResult:
269
+ """Parse Karma output from stdout/stderr.
270
+
271
+ This is a fallback when JSON reporter is not available.
272
+
273
+ Args:
274
+ stdout: Standard output from Karma.
275
+ stderr: Standard error from Karma.
276
+ project_root: Project root directory.
277
+
278
+ Returns:
279
+ TestResult with parsed data.
280
+ """
281
+ result = TestResult()
282
+
283
+ # Look for summary line like "Executed 42 of 42 SUCCESS"
284
+ # or "Executed 42 of 42 (1 FAILED)"
285
+ import re
286
+
287
+ # Pattern for success: "Executed X of Y SUCCESS"
288
+ success_pattern = r"Executed (\d+) of (\d+).*SUCCESS"
289
+ # Pattern for failures: "Executed X of Y (Z FAILED)"
290
+ failure_pattern = r"Executed (\d+) of (\d+).*\((\d+) FAILED\)"
291
+ # Pattern for skipped: "Executed X of Y (Z skipped)"
292
+ skipped_pattern = r"\((\d+) skipped\)"
293
+
294
+ output = stdout + stderr
295
+
296
+ success_match = re.search(success_pattern, output)
297
+ failure_match = re.search(failure_pattern, output)
298
+ skipped_match = re.search(skipped_pattern, output)
299
+
300
+ if failure_match:
301
+ executed = int(failure_match.group(1))
302
+ failed = int(failure_match.group(3))
303
+ result.failed = failed
304
+ result.passed = executed - failed
305
+ elif success_match:
306
+ executed = int(success_match.group(1))
307
+ result.passed = executed
308
+
309
+ if skipped_match:
310
+ result.skipped = int(skipped_match.group(1))
311
+
312
+ # Parse individual failure messages
313
+ # Pattern: "FAILED: Suite Name Test Name"
314
+ failure_lines = re.findall(r"FAILED[:\s]+(.+)", output)
315
+ for failure in failure_lines:
316
+ issue = self._failure_line_to_issue(failure, project_root)
317
+ if issue:
318
+ result.issues.append(issue)
319
+
320
+ LOGGER.info(
321
+ f"Karma: {result.passed} passed, {result.failed} failed, "
322
+ f"{result.skipped} skipped"
323
+ )
324
+ return result
325
+
326
+ def _test_to_issue(
327
+ self,
328
+ test_result: Dict[str, Any],
329
+ project_root: Path,
330
+ ) -> Optional[UnifiedIssue]:
331
+ """Convert Karma test failure to UnifiedIssue.
332
+
333
+ Args:
334
+ test_result: Test result dict from Karma JSON.
335
+ project_root: Project root directory.
336
+
337
+ Returns:
338
+ UnifiedIssue or None.
339
+ """
340
+ try:
341
+ suite = test_result.get("suite", [])
342
+ description_name = test_result.get("description", "")
343
+ log = test_result.get("log", [])
344
+
345
+ # Build full test name
346
+ full_name = " > ".join(suite + [description_name])
347
+
348
+ # Get failure message
349
+ message = log[0] if log else "Test failed"
350
+
351
+ # Try to extract file path and line from error stack
352
+ file_path, line_number = self._extract_location(message, project_root)
353
+
354
+ # Generate deterministic ID
355
+ issue_id = self._generate_issue_id(full_name, message)
356
+
357
+ return UnifiedIssue(
358
+ id=issue_id,
359
+ domain=ToolDomain.TESTING,
360
+ source_tool="karma",
361
+ severity=Severity.HIGH,
362
+ rule_id="failed",
363
+ title=f"{full_name}: {self._truncate(message, 80)}",
364
+ description=message,
365
+ file_path=file_path,
366
+ line_start=line_number,
367
+ line_end=line_number,
368
+ fixable=False,
369
+ metadata={
370
+ "full_name": full_name,
371
+ "suite": suite,
372
+ "description": description_name,
373
+ "log": log,
374
+ },
375
+ )
376
+ except Exception as e:
377
+ LOGGER.warning(f"Failed to parse Karma test failure: {e}")
378
+ return None
379
+
380
+ def _failure_line_to_issue(
381
+ self,
382
+ failure: str,
383
+ project_root: Path,
384
+ ) -> Optional[UnifiedIssue]:
385
+ """Convert a failure line to UnifiedIssue.
386
+
387
+ Args:
388
+ failure: Failure description string.
389
+ project_root: Project root directory.
390
+
391
+ Returns:
392
+ UnifiedIssue or None.
393
+ """
394
+ try:
395
+ issue_id = self._generate_issue_id(failure, "")
396
+
397
+ return UnifiedIssue(
398
+ id=issue_id,
399
+ domain=ToolDomain.TESTING,
400
+ source_tool="karma",
401
+ severity=Severity.HIGH,
402
+ rule_id="failed",
403
+ title=self._truncate(failure, 100),
404
+ description=failure,
405
+ file_path=None,
406
+ line_start=None,
407
+ line_end=None,
408
+ fixable=False,
409
+ metadata={
410
+ "failure_line": failure,
411
+ },
412
+ )
413
+ except Exception as e:
414
+ LOGGER.warning(f"Failed to parse Karma failure line: {e}")
415
+ return None
416
+
417
+ def _extract_location(
418
+ self,
419
+ message: str,
420
+ project_root: Path,
421
+ ) -> tuple[Optional[Path], Optional[int]]:
422
+ """Extract file path and line number from error message.
423
+
424
+ Args:
425
+ message: Error message with potential stack trace.
426
+ project_root: Project root directory.
427
+
428
+ Returns:
429
+ Tuple of (file_path, line_number) or (None, None).
430
+ """
431
+ import re
432
+
433
+ # Look for patterns like "at Context.<anonymous> (src/app/foo.spec.ts:42:15)"
434
+ # or "src/app/foo.spec.ts:42:15"
435
+ patterns = [
436
+ r"\(([^)]+\.(?:spec|test)\.ts):(\d+):\d+\)",
437
+ r"([^\s]+\.(?:spec|test)\.ts):(\d+):\d+",
438
+ r"\(([^)]+\.ts):(\d+):\d+\)",
439
+ r"([^\s]+\.ts):(\d+):\d+",
440
+ ]
441
+
442
+ for pattern in patterns:
443
+ match = re.search(pattern, message)
444
+ if match:
445
+ file_str = match.group(1)
446
+ line_num = int(match.group(2))
447
+ file_path = Path(file_str)
448
+ if not file_path.is_absolute():
449
+ file_path = project_root / file_path
450
+ return file_path, line_num
451
+
452
+ return None, None
453
+
454
+ def _truncate(self, text: str, max_length: int) -> str:
455
+ """Truncate text to max length.
456
+
457
+ Args:
458
+ text: Text to truncate.
459
+ max_length: Maximum length.
460
+
461
+ Returns:
462
+ Truncated text.
463
+ """
464
+ text = text.replace("\n", " ").strip()
465
+ if len(text) <= max_length:
466
+ return text
467
+ return text[:max_length - 3] + "..."
468
+
469
+ def _generate_issue_id(self, full_name: str, message: str) -> str:
470
+ """Generate deterministic issue ID.
471
+
472
+ Args:
473
+ full_name: Full test name.
474
+ message: Failure message.
475
+
476
+ Returns:
477
+ Unique issue ID.
478
+ """
479
+ content = f"{full_name}:{message[:100]}"
480
+ hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
481
+ return f"karma-{hash_val}"