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