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
lucidscan/mcp/tools.py ADDED
@@ -0,0 +1,1049 @@
1
+ """MCP tool executor for LucidScan operations.
2
+
3
+ Executes LucidScan scan operations and formats results for AI agents.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import functools
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Coroutine, Dict, List, Optional
13
+
14
+ from lucidscan.config import LucidScanConfig
15
+ from lucidscan.core.domain_runner import (
16
+ DomainRunner,
17
+ detect_language,
18
+ get_domains_for_language,
19
+ )
20
+ from lucidscan.core.git import get_changed_files
21
+ from lucidscan.core.logging import get_logger
22
+ from lucidscan.core.models import DomainType, ScanContext, ScanDomain, ToolDomain, UnifiedIssue
23
+ from lucidscan.core.streaming import (
24
+ CLIStreamHandler,
25
+ MCPStreamHandler,
26
+ StreamEvent,
27
+ StreamHandler,
28
+ )
29
+ from lucidscan.mcp.formatter import InstructionFormatter
30
+
31
+ LOGGER = get_logger(__name__)
32
+
33
+
34
+ class MCPToolExecutor:
35
+ """Executes LucidScan operations for MCP tools."""
36
+
37
+ # Map string domain names to the appropriate enum
38
+ # ScanDomain for scanner plugins, ToolDomain for other tools
39
+ # Use canonical names only - no synonyms
40
+ DOMAIN_MAP: Dict[str, DomainType] = {
41
+ "linting": ToolDomain.LINTING,
42
+ "type_checking": ToolDomain.TYPE_CHECKING,
43
+ "sast": ScanDomain.SAST,
44
+ "sca": ScanDomain.SCA,
45
+ "iac": ScanDomain.IAC,
46
+ "container": ScanDomain.CONTAINER,
47
+ "testing": ToolDomain.TESTING,
48
+ "coverage": ToolDomain.COVERAGE,
49
+ }
50
+
51
+ def __init__(self, project_root: Path, config: LucidScanConfig):
52
+ """Initialize MCPToolExecutor.
53
+
54
+ Args:
55
+ project_root: Project root directory.
56
+ config: LucidScan configuration.
57
+ """
58
+ self.project_root = project_root
59
+ self.config = config
60
+ self.instruction_formatter = InstructionFormatter()
61
+ self._issue_cache: Dict[str, UnifiedIssue] = {}
62
+ self._tools_bootstrapped = False
63
+ # Use DomainRunner with debug logging for MCP (less verbose)
64
+ self._runner = DomainRunner(project_root, config, log_level="debug")
65
+
66
+ def _bootstrap_security_tools(self, security_domains: List[ScanDomain]) -> None:
67
+ """Ensure security tool binaries are available.
68
+
69
+ Downloads tools if not already present. Called before first scan
70
+ to ensure tools are ready before async scan operations begin.
71
+
72
+ Args:
73
+ security_domains: List of security domains that need to be bootstrapped.
74
+ """
75
+ if self._tools_bootstrapped:
76
+ return
77
+
78
+ from lucidscan.plugins.scanners import get_scanner_plugin
79
+
80
+ # Get unique scanners needed based on requested security domains only
81
+ scanners_to_bootstrap: set[str] = set()
82
+ for domain in security_domains:
83
+ plugin_name = self.config.get_plugin_for_domain(domain.value)
84
+ if plugin_name:
85
+ scanners_to_bootstrap.add(plugin_name)
86
+
87
+ for scanner_name in scanners_to_bootstrap:
88
+ try:
89
+ LOGGER.info(f"Bootstrapping {scanner_name}...")
90
+ scanner = get_scanner_plugin(scanner_name, project_root=self.project_root)
91
+ if scanner:
92
+ scanner.ensure_binary()
93
+ LOGGER.debug(f"{scanner_name} ready")
94
+ except Exception as e:
95
+ LOGGER.error(f"Failed to bootstrap {scanner_name}: {e}")
96
+
97
+ self._tools_bootstrapped = True
98
+
99
+ async def scan(
100
+ self,
101
+ domains: List[str],
102
+ files: Optional[List[str]] = None,
103
+ all_files: bool = False,
104
+ fix: bool = False,
105
+ on_progress: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None,
106
+ ) -> Dict[str, Any]:
107
+ """Execute scan and return AI-formatted results.
108
+
109
+ Default behavior: Scans only changed files (uncommitted changes).
110
+ - If `files` is provided, scan only those specific files
111
+ - If `all_files` is True, scan entire project
112
+ - Otherwise, scan only changed files (git diff)
113
+
114
+ Args:
115
+ domains: List of domain names to scan (e.g., ["linting", "security"]).
116
+ files: Optional list of specific files to scan.
117
+ all_files: If True, scan entire project instead of just changed files.
118
+ fix: Whether to apply auto-fixes (linting only).
119
+ on_progress: Optional async callback for progress events (MCP notifications).
120
+
121
+ Returns:
122
+ Structured scan result with AI instructions.
123
+ """
124
+ # Convert domain strings to ToolDomain enums
125
+ enabled_domains = self._parse_domains(domains)
126
+
127
+ # Bootstrap security tools if needed (before async operations)
128
+ security_domains = [d for d in enabled_domains if isinstance(d, ScanDomain)]
129
+ if security_domains and not self._tools_bootstrapped:
130
+ if on_progress:
131
+ await on_progress({
132
+ "tool": "lucidscan",
133
+ "content": "Downloading security tools...",
134
+ "progress": 0,
135
+ "total": None,
136
+ })
137
+ loop = asyncio.get_event_loop()
138
+ await loop.run_in_executor(None, self._bootstrap_security_tools, security_domains)
139
+
140
+ # Create stream handler for progress output
141
+ stream_handler: Optional[StreamHandler] = None
142
+
143
+ if on_progress:
144
+ # Use MCP stream handler for async notifications
145
+ async def on_event(event: StreamEvent) -> None:
146
+ event_dict = {
147
+ "tool": event.tool_name,
148
+ "type": event.stream_type.value,
149
+ "content": event.content,
150
+ }
151
+ await on_progress(event_dict)
152
+
153
+ stream_handler = MCPStreamHandler(on_event=on_event)
154
+ else:
155
+ # Default: write progress to stderr
156
+ stream_handler = CLIStreamHandler(
157
+ output=sys.stderr,
158
+ show_output=True,
159
+ use_rich=False,
160
+ )
161
+
162
+ # Build context with stream handler and partial scanning logic
163
+ context = self._build_context(enabled_domains, files, all_files, stream_handler)
164
+
165
+ # Run scans in parallel for different domains
166
+ all_issues: List[UnifiedIssue] = []
167
+
168
+ # Build list of tasks with their domain names for progress tracking
169
+ tasks_with_names: List[tuple[str, Coroutine]] = []
170
+ if ToolDomain.LINTING in enabled_domains:
171
+ tasks_with_names.append(("linting", self._run_linting(context, fix)))
172
+ if ToolDomain.TYPE_CHECKING in enabled_domains:
173
+ tasks_with_names.append(("type_checking", self._run_type_checking(context)))
174
+ if ScanDomain.SAST in enabled_domains:
175
+ tasks_with_names.append(("sast", self._run_security(context, ScanDomain.SAST)))
176
+ if ScanDomain.SCA in enabled_domains:
177
+ tasks_with_names.append(("sca", self._run_security(context, ScanDomain.SCA)))
178
+ if ScanDomain.IAC in enabled_domains:
179
+ tasks_with_names.append(("iac", self._run_security(context, ScanDomain.IAC)))
180
+ if ScanDomain.CONTAINER in enabled_domains:
181
+ tasks_with_names.append(("container", self._run_security(context, ScanDomain.CONTAINER)))
182
+
183
+ # Check if both testing and coverage are enabled
184
+ testing_enabled = ToolDomain.TESTING in enabled_domains
185
+ coverage_enabled = ToolDomain.COVERAGE in enabled_domains
186
+
187
+ if testing_enabled and not coverage_enabled:
188
+ # Only testing enabled - run tests standalone
189
+ tasks_with_names.append(("testing", self._run_testing(context)))
190
+ elif coverage_enabled:
191
+ # Coverage enabled - it will run tests with instrumentation
192
+ # Skip standalone testing to avoid running tests twice
193
+ # Coverage result includes test stats
194
+ tasks_with_names.append(("coverage", self._run_coverage(context, run_tests=True)))
195
+
196
+ total_domains = len(tasks_with_names)
197
+
198
+ if tasks_with_names:
199
+ # Send initial progress notification
200
+ if on_progress and total_domains > 0:
201
+ domain_names = [name for name, _ in tasks_with_names]
202
+ await on_progress({
203
+ "tool": "lucidscan",
204
+ "content": f"Scanning {total_domains} domain(s): {', '.join(domain_names)}",
205
+ "progress": 0,
206
+ "total": total_domains,
207
+ })
208
+
209
+ # Wrap each task to report progress on completion
210
+ completed_count = 0
211
+
212
+ async def run_with_progress(
213
+ domain_name: str, coro: Coroutine
214
+ ) -> List[UnifiedIssue]:
215
+ nonlocal completed_count
216
+ try:
217
+ if on_progress:
218
+ await on_progress({
219
+ "tool": domain_name,
220
+ "content": "started",
221
+ "progress": completed_count,
222
+ "total": total_domains,
223
+ })
224
+ result = await coro
225
+ completed_count += 1
226
+ if on_progress:
227
+ await on_progress({
228
+ "tool": domain_name,
229
+ "content": "completed",
230
+ "progress": completed_count,
231
+ "total": total_domains,
232
+ })
233
+ return result if result is not None else []
234
+ except Exception as e:
235
+ completed_count += 1
236
+ if on_progress:
237
+ await on_progress({
238
+ "tool": domain_name,
239
+ "content": f"failed: {e}",
240
+ "progress": completed_count,
241
+ "total": total_domains,
242
+ })
243
+ raise
244
+
245
+ # Run all tasks with progress tracking
246
+ tasks = [run_with_progress(name, coro) for name, coro in tasks_with_names]
247
+ results = await asyncio.gather(*tasks, return_exceptions=True)
248
+
249
+ for result in results:
250
+ if isinstance(result, BaseException):
251
+ LOGGER.warning(f"Scan task failed: {result}")
252
+ elif result is not None:
253
+ all_issues.extend(result)
254
+
255
+ # Cache issues for later reference
256
+ for issue in all_issues:
257
+ self._issue_cache[issue.id] = issue
258
+
259
+ # Build list of checked domain names for the formatter
260
+ checked_domain_names: List[str] = []
261
+ for domain in enabled_domains:
262
+ checked_domain_names.append(domain.value)
263
+
264
+ # Format as AI instructions with domain status
265
+ formatted_result = self.instruction_formatter.format_scan_result(
266
+ all_issues, checked_domains=checked_domain_names
267
+ )
268
+
269
+ # Add coverage summary if coverage was run
270
+ if context.coverage_result is not None:
271
+ cov = context.coverage_result
272
+ coverage_summary: Dict[str, Any] = {
273
+ "coverage_percentage": round(cov.percentage, 2),
274
+ "threshold": cov.threshold,
275
+ "total_lines": cov.total_lines,
276
+ "covered_lines": cov.covered_lines,
277
+ "missing_lines": cov.missing_lines,
278
+ "passed": cov.passed,
279
+ }
280
+ # Add test statistics if available
281
+ if cov.test_stats is not None:
282
+ ts = cov.test_stats
283
+ coverage_summary["tests"] = {
284
+ "total": ts.total,
285
+ "passed": ts.passed,
286
+ "failed": ts.failed,
287
+ "skipped": ts.skipped,
288
+ "errors": ts.errors,
289
+ "success": ts.success,
290
+ }
291
+ formatted_result["coverage_summary"] = coverage_summary
292
+
293
+ return formatted_result
294
+
295
+ async def check_file(self, file_path: str) -> Dict[str, Any]:
296
+ """Check a single file.
297
+
298
+ Args:
299
+ file_path: Path to the file (relative to project root).
300
+
301
+ Returns:
302
+ Structured scan result for the file.
303
+ """
304
+ path = self.project_root / file_path
305
+ if not path.exists():
306
+ return {"error": f"File not found: {file_path}"}
307
+
308
+ # Detect language and run appropriate checks
309
+ language = detect_language(path)
310
+ domains = get_domains_for_language(language)
311
+
312
+ return await self.scan(domains, files=[file_path])
313
+
314
+ async def get_fix_instructions(self, issue_id: str) -> Dict[str, Any]:
315
+ """Get detailed fix instructions for an issue.
316
+
317
+ Args:
318
+ issue_id: The issue identifier.
319
+
320
+ Returns:
321
+ Detailed fix instructions.
322
+ """
323
+ issue = self._issue_cache.get(issue_id)
324
+ if not issue:
325
+ return {"error": f"Issue not found: {issue_id}"}
326
+
327
+ return self.instruction_formatter.format_single_issue(issue, detailed=True)
328
+
329
+ async def apply_fix(self, issue_id: str) -> Dict[str, Any]:
330
+ """Apply auto-fix for an issue.
331
+
332
+ Args:
333
+ issue_id: The issue identifier to fix.
334
+
335
+ Returns:
336
+ Result of the fix operation.
337
+ """
338
+ issue = self._issue_cache.get(issue_id)
339
+ if not issue:
340
+ return {"error": f"Issue not found: {issue_id}"}
341
+
342
+ # Only linting issues are auto-fixable
343
+ if issue.domain != ToolDomain.LINTING:
344
+ return {
345
+ "error": "Only linting issues support auto-fix",
346
+ "issue_type": issue.domain.value if issue.domain else "unknown",
347
+ }
348
+
349
+ # Run linter in fix mode for the specific file
350
+ if not issue.file_path:
351
+ return {"error": "Issue has no file path for fixing"}
352
+
353
+ try:
354
+ # Create stream handler for progress output (writes to stderr)
355
+ stream_handler = CLIStreamHandler(
356
+ output=sys.stderr,
357
+ show_output=True,
358
+ use_rich=False,
359
+ )
360
+ context = self._build_context(
361
+ [ToolDomain.LINTING],
362
+ files=[str(issue.file_path)],
363
+ stream_handler=stream_handler,
364
+ )
365
+ await self._run_linting(context, fix=True)
366
+ return {
367
+ "success": True,
368
+ "message": f"Applied fix for {issue_id}",
369
+ "file": str(issue.file_path),
370
+ }
371
+ except Exception as e:
372
+ return {"error": f"Failed to apply fix: {e}"}
373
+
374
+ async def get_status(self) -> Dict[str, Any]:
375
+ """Get current LucidScan status and configuration.
376
+
377
+ Returns:
378
+ Status information.
379
+ """
380
+ from lucidscan.plugins.scanners import discover_scanner_plugins
381
+ from lucidscan.plugins.linters import discover_linter_plugins
382
+ from lucidscan.plugins.type_checkers import discover_type_checker_plugins
383
+
384
+ scanners = discover_scanner_plugins()
385
+ linters = discover_linter_plugins()
386
+ type_checkers = discover_type_checker_plugins()
387
+
388
+ return {
389
+ "project_root": str(self.project_root),
390
+ "available_tools": {
391
+ "scanners": list(scanners.keys()),
392
+ "linters": list(linters.keys()),
393
+ "type_checkers": list(type_checkers.keys()),
394
+ },
395
+ "enabled_domains": self.config.get_enabled_domains(),
396
+ "cached_issues": len(self._issue_cache),
397
+ }
398
+
399
+ async def get_help(self) -> Dict[str, Any]:
400
+ """Get LucidScan documentation.
401
+
402
+ Returns:
403
+ Documentation content in markdown format.
404
+ """
405
+ from lucidscan.cli.commands.help import get_help_content
406
+
407
+ content = get_help_content()
408
+ return {
409
+ "documentation": content,
410
+ "format": "markdown",
411
+ }
412
+
413
+ async def validate_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
414
+ """Validate a configuration file.
415
+
416
+ Args:
417
+ config_path: Optional path to config file (relative to project root).
418
+ If not provided, searches for lucidscan.yml in project root.
419
+
420
+ Returns:
421
+ Structured validation result with valid flag, errors, and warnings.
422
+ """
423
+ from lucidscan.config.loader import find_project_config
424
+ from lucidscan.config.validation import validate_config_file, ValidationSeverity
425
+
426
+ # Determine config path
427
+ path: Optional[Path]
428
+ if config_path:
429
+ path = self.project_root / config_path
430
+ else:
431
+ path = find_project_config(self.project_root)
432
+
433
+ if path is None:
434
+ return {
435
+ "valid": False,
436
+ "error": "No configuration file found in project root",
437
+ "searched_for": [
438
+ ".lucidscan.yml",
439
+ ".lucidscan.yaml",
440
+ "lucidscan.yml",
441
+ "lucidscan.yaml",
442
+ ],
443
+ "errors": [],
444
+ "warnings": [],
445
+ }
446
+
447
+ if not path.exists():
448
+ return {
449
+ "valid": False,
450
+ "error": f"Configuration file not found: {path}",
451
+ "errors": [],
452
+ "warnings": [],
453
+ }
454
+
455
+ is_valid, issues = validate_config_file(path)
456
+
457
+ errors = []
458
+ warnings = []
459
+
460
+ for issue in issues:
461
+ issue_dict: Dict[str, Any] = {
462
+ "message": issue.message,
463
+ "key": issue.key,
464
+ }
465
+ if issue.suggestion:
466
+ issue_dict["suggestion"] = issue.suggestion
467
+
468
+ if issue.severity == ValidationSeverity.ERROR:
469
+ errors.append(issue_dict)
470
+ else:
471
+ warnings.append(issue_dict)
472
+
473
+ return {
474
+ "valid": is_valid,
475
+ "config_path": str(path),
476
+ "errors": errors,
477
+ "warnings": warnings,
478
+ }
479
+
480
+ async def autoconfigure(self) -> Dict[str, Any]:
481
+ """Get instructions for auto-configuring LucidScan.
482
+
483
+ Returns guidance for AI to analyze the codebase, ask the user
484
+ important configuration questions, and generate an appropriate
485
+ lucidscan.yml configuration file.
486
+
487
+ Returns:
488
+ Instructions and guidance for configuration generation.
489
+ """
490
+ return {
491
+ "instructions": (
492
+ "Analyze the codebase, ask 1-2 quick questions if needed, "
493
+ "then generate lucidscan.yml with smart defaults."
494
+ ),
495
+ "analysis_steps": [
496
+ {
497
+ "step": 1,
498
+ "action": "Detect languages and package managers",
499
+ "files_to_check": [
500
+ "package.json",
501
+ "pyproject.toml",
502
+ "setup.py",
503
+ "requirements.txt",
504
+ "Cargo.toml",
505
+ "go.mod",
506
+ "pom.xml",
507
+ "build.gradle",
508
+ ],
509
+ "what_to_look_for": (
510
+ "Presence of these files indicates the primary language(s). "
511
+ "package.json = JavaScript/TypeScript, "
512
+ "pyproject.toml/setup.py/requirements.txt = Python, "
513
+ "Cargo.toml = Rust, go.mod = Go, pom.xml/build.gradle = Java"
514
+ ),
515
+ },
516
+ {
517
+ "step": 2,
518
+ "action": "Detect existing linting/type checking tools",
519
+ "files_to_check": [
520
+ ".eslintrc",
521
+ ".eslintrc.js",
522
+ ".eslintrc.json",
523
+ "eslint.config.js",
524
+ "biome.json",
525
+ "ruff.toml",
526
+ "pyproject.toml (look for [tool.ruff] section)",
527
+ ".flake8",
528
+ "tsconfig.json",
529
+ "mypy.ini",
530
+ "pyproject.toml (look for [tool.mypy] section)",
531
+ "pyrightconfig.json",
532
+ ],
533
+ "what_to_look_for": (
534
+ "Existing tool configurations to preserve. "
535
+ "If a tool is already configured, use it rather than replacing. "
536
+ "For Python: ruff or flake8 for linting, mypy or pyright for types. "
537
+ "For JS/TS: eslint or biome for linting, tsconfig.json for TypeScript."
538
+ ),
539
+ },
540
+ {
541
+ "step": 3,
542
+ "action": "Detect test frameworks and coverage",
543
+ "files_to_check": [
544
+ "pytest.ini",
545
+ "pyproject.toml (look for [tool.pytest] and [tool.coverage] sections)",
546
+ "conftest.py",
547
+ ".coveragerc",
548
+ "jest.config.js",
549
+ "jest.config.ts",
550
+ "karma.conf.js",
551
+ "playwright.config.ts",
552
+ ".nycrc",
553
+ ".nycrc.json",
554
+ ],
555
+ "what_to_look_for": (
556
+ "Test framework configurations and existing coverage settings. "
557
+ "Check if there's an existing coverage threshold defined. "
558
+ "pytest = Python tests, jest = JS/TS tests, "
559
+ "karma = Angular tests, playwright = E2E tests"
560
+ ),
561
+ },
562
+ {
563
+ "step": 4,
564
+ "action": "Ask user 1-2 quick questions based on detection",
565
+ "guidance": (
566
+ "If tests detected: ask coverage threshold (suggest 80%). "
567
+ "If large legacy codebase: ask strict vs gradual mode. "
568
+ "Otherwise, use smart defaults and skip questions."
569
+ ),
570
+ },
571
+ {
572
+ "step": 5,
573
+ "action": "Read LucidScan documentation",
574
+ "tool_to_call": "get_help()",
575
+ "what_to_extract": (
576
+ "Read the 'Configuration Reference (lucidscan.yml)' section "
577
+ "to understand the full configuration format, available tools, "
578
+ "and valid options for each domain."
579
+ ),
580
+ },
581
+ {
582
+ "step": 6,
583
+ "action": "Generate lucidscan.yml",
584
+ "output_file": "lucidscan.yml",
585
+ "template_guidance": (
586
+ "Based on detected languages/tools AND user answers, create a configuration "
587
+ "that enables appropriate domains. Include: version, project metadata, "
588
+ "pipeline configuration with detected tools, fail_on thresholds, "
589
+ "coverage threshold, and ignore patterns."
590
+ ),
591
+ },
592
+ {
593
+ "step": 7,
594
+ "action": "Validate the generated configuration",
595
+ "tool_to_call": "validate_config()",
596
+ "what_to_do": (
597
+ "After writing lucidscan.yml, call validate_config() to verify "
598
+ "the configuration is valid. If there are errors, fix them before "
599
+ "proceeding. Warnings can be addressed but are not blocking."
600
+ ),
601
+ "on_error": (
602
+ "If validation returns errors, edit lucidscan.yml to fix the issues "
603
+ "and call validate_config() again until it passes."
604
+ ),
605
+ },
606
+ {
607
+ "step": 8,
608
+ "action": "Inform user about tool installation and next steps",
609
+ "guidance": (
610
+ "After generating the config, tell the user: "
611
+ "1) Which tools need to be installed (security tools are auto-downloaded), "
612
+ "2) Run 'lucidscan init --claude-code' or '--cursor' for AI integration, "
613
+ "3) Run 'lucidscan scan --all' to verify the configuration works, "
614
+ "4) IMPORTANT: Restart Claude Code or Cursor for the configuration to take effect."
615
+ ),
616
+ },
617
+ ],
618
+ "questions_to_ask": {
619
+ "description": (
620
+ "Ask 1-3 quick questions based on codebase. Use smart defaults for the rest."
621
+ ),
622
+ "conditional_questions": [
623
+ {
624
+ "id": "coverage_threshold",
625
+ "ask_when": "Tests detected (pytest.ini, jest.config.*, conftest.py, etc.)",
626
+ "question": "What coverage threshold? (80% recommended, or lower for legacy code)",
627
+ "default": 80,
628
+ "skip_if": "No tests detected - disable coverage, inform user they can enable later",
629
+ },
630
+ {
631
+ "id": "strictness",
632
+ "ask_when": "Large existing codebase with no lucidscan.yml",
633
+ "question": "Strict mode (fail on issues) or gradual adoption (report only)?",
634
+ "options": {
635
+ "strict": "fail_on errors - recommended for new/clean projects",
636
+ "gradual": "report only - recommended for legacy codebases to avoid blocking work",
637
+ },
638
+ "how_to_detect": (
639
+ "If you see many existing linting/type errors when analyzing, "
640
+ "suggest gradual mode. Otherwise, default to strict."
641
+ ),
642
+ },
643
+ {
644
+ "id": "pre_commit_hook",
645
+ "ask_when": "Git repository detected (.git directory exists)",
646
+ "question": "Run LucidScan before every commit? (creates pre-commit hook)",
647
+ "default": True,
648
+ "if_yes": {
649
+ "action": "Create .git/hooks/pre-commit script",
650
+ "script_content": """#!/bin/sh
651
+ # LucidScan pre-commit hook
652
+ # Runs quality checks before allowing commit
653
+
654
+ echo "Running LucidScan checks..."
655
+ lucidscan scan --all
656
+
657
+ if [ $? -ne 0 ]; then
658
+ echo ""
659
+ echo "LucidScan found issues. Fix them before committing."
660
+ echo "To skip this check, use: git commit --no-verify"
661
+ exit 1
662
+ fi
663
+ """,
664
+ "make_executable": "chmod +x .git/hooks/pre-commit",
665
+ },
666
+ },
667
+ ],
668
+ "always_use_defaults": {
669
+ "security": "Always enable security scanning (trivy + opengrep). Fail on 'high' severity.",
670
+ "testing": "Enable if tests detected. Always fail on test failures.",
671
+ "linting": "Enable with detected tool. Use strictness setting for fail_on.",
672
+ "type_checking": "Enable if tool detected. Use strictness setting for fail_on.",
673
+ },
674
+ },
675
+ "common_pitfalls": [
676
+ "Always add '**/.venv/**' and '**/node_modules/**' to ignore list",
677
+ "For legacy codebases: start with fail_on: none, fix issues gradually",
678
+ "Check current coverage with 'pytest --cov' before setting threshold",
679
+ ],
680
+ "tool_recommendations": {
681
+ "python": {
682
+ "linter": "ruff (recommended, fast and comprehensive) or flake8",
683
+ "type_checker": "mypy (recommended, widely used) or pyright",
684
+ "test_runner": "pytest (standard choice)",
685
+ "coverage": "coverage.py (via pytest-cov)",
686
+ },
687
+ "javascript_typescript": {
688
+ "linter": "eslint (most popular) or biome (faster, newer)",
689
+ "type_checker": "typescript (tsc) - enabled via tsconfig.json",
690
+ "test_runner": "jest (most common), karma (Angular), or playwright (E2E)",
691
+ "coverage": "istanbul/nyc (usually included with jest)",
692
+ },
693
+ "java": {
694
+ "linter": "checkstyle",
695
+ "test_runner": "junit (via maven/gradle)",
696
+ },
697
+ },
698
+ "security_tools": {
699
+ "always_recommended": [
700
+ "trivy (for SCA - dependency vulnerability scanning)",
701
+ "opengrep (for SAST - code pattern security analysis)",
702
+ ],
703
+ "optional": [
704
+ "checkov (for IaC scanning - Terraform, Kubernetes, CloudFormation)",
705
+ ],
706
+ "note": "Security tools are downloaded automatically - no manual installation needed.",
707
+ },
708
+ "example_config": {
709
+ "description": "Example configurations with common settings",
710
+ "python_with_coverage": """version: 1
711
+
712
+ project:
713
+ name: my-python-project
714
+ languages: [python]
715
+
716
+ pipeline:
717
+ linting:
718
+ enabled: true
719
+ tools: [ruff]
720
+ type_checking:
721
+ enabled: true
722
+ tools: [mypy]
723
+ security:
724
+ enabled: true
725
+ tools:
726
+ - name: trivy
727
+ domains: [sca]
728
+ - name: opengrep
729
+ domains: [sast]
730
+ testing:
731
+ enabled: true
732
+ tools: [pytest]
733
+ coverage:
734
+ enabled: true
735
+ tools: [coverage_py]
736
+ threshold: 80
737
+
738
+ fail_on:
739
+ linting: error
740
+ type_checking: error
741
+ security: high
742
+ testing: any
743
+ coverage: any
744
+
745
+ ignore:
746
+ - "**/.venv/**"
747
+ - "**/__pycache__/**"
748
+ - "**/dist/**"
749
+ - "**/build/**"
750
+ - "**/.git/**"
751
+ """,
752
+ "typescript_with_coverage": """version: 1
753
+
754
+ project:
755
+ name: my-typescript-project
756
+ languages: [typescript]
757
+
758
+ pipeline:
759
+ linting:
760
+ enabled: true
761
+ tools: [eslint]
762
+ type_checking:
763
+ enabled: true
764
+ tools: [typescript]
765
+ security:
766
+ enabled: true
767
+ tools:
768
+ - name: trivy
769
+ domains: [sca]
770
+ - name: opengrep
771
+ domains: [sast]
772
+ testing:
773
+ enabled: true
774
+ tools: [jest]
775
+ coverage:
776
+ enabled: true
777
+ tools: [istanbul]
778
+ threshold: 80
779
+
780
+ fail_on:
781
+ linting: error
782
+ type_checking: error
783
+ security: high
784
+ testing: any
785
+ coverage: any
786
+
787
+ ignore:
788
+ - "**/node_modules/**"
789
+ - "**/dist/**"
790
+ - "**/build/**"
791
+ - "**/coverage/**"
792
+ - "**/.git/**"
793
+ """,
794
+ "gradual_adoption": """# Configuration for gradual adoption (legacy codebase)
795
+ version: 1
796
+
797
+ project:
798
+ name: legacy-project
799
+ languages: [python]
800
+
801
+ pipeline:
802
+ linting:
803
+ enabled: true
804
+ tools: [ruff]
805
+ type_checking:
806
+ enabled: true
807
+ tools: [mypy]
808
+ security:
809
+ enabled: true
810
+ tools:
811
+ - name: trivy
812
+ domains: [sca]
813
+ - name: opengrep
814
+ domains: [sast]
815
+ testing:
816
+ enabled: true
817
+ tools: [pytest]
818
+ coverage:
819
+ enabled: false # Enable later when tests are added
820
+
821
+ # Relaxed thresholds for gradual adoption
822
+ fail_on:
823
+ linting: none # Report only, don't fail
824
+ type_checking: none # Report only, don't fail
825
+ security: critical # Only fail on critical issues
826
+ testing: any
827
+
828
+ ignore:
829
+ - "**/.venv/**"
830
+ - "**/__pycache__/**"
831
+ """,
832
+ },
833
+ "post_config_steps": [
834
+ "Run 'lucidscan init --claude-code' or 'lucidscan init --cursor' to set up AI tool integration",
835
+ "Install required linting/testing tools via package manager (security tools auto-download)",
836
+ "Run 'lucidscan scan --all' to test the configuration and see initial results",
837
+ "If many issues appear, consider starting with relaxed thresholds (see gradual_adoption example)",
838
+ "IMPORTANT: Restart Claude Code or Cursor for the new configuration to take effect",
839
+ ],
840
+ }
841
+
842
+ def _parse_domains(self, domains: List[str]) -> List[DomainType]:
843
+ """Parse domain strings to domain enums.
844
+
845
+ When "all" is specified, returns domains based on what's configured
846
+ in lucidscan.yml. If no config exists, uses sensible defaults.
847
+
848
+ Args:
849
+ domains: List of domain names.
850
+
851
+ Returns:
852
+ List of domain enums (ToolDomain or ScanDomain).
853
+ """
854
+ if "all" in domains:
855
+ result: List[DomainType] = []
856
+
857
+ # Include tool domains based on pipeline config
858
+ # If explicitly configured, respect the enabled flag
859
+ # If not configured (None), enable by default for "all"
860
+ if self.config.pipeline.linting is None or self.config.pipeline.linting.enabled:
861
+ result.append(ToolDomain.LINTING)
862
+ if self.config.pipeline.type_checking is None or self.config.pipeline.type_checking.enabled:
863
+ result.append(ToolDomain.TYPE_CHECKING)
864
+ if self.config.pipeline.testing and self.config.pipeline.testing.enabled:
865
+ result.append(ToolDomain.TESTING)
866
+ if self.config.pipeline.coverage and self.config.pipeline.coverage.enabled:
867
+ result.append(ToolDomain.COVERAGE)
868
+
869
+ # Include security domains based on config (both legacy and pipeline)
870
+ security_domains = self.config.get_enabled_domains()
871
+ if security_domains:
872
+ for domain_str in security_domains:
873
+ try:
874
+ result.append(ScanDomain(domain_str))
875
+ except ValueError:
876
+ LOGGER.warning(f"Unknown security domain in config: {domain_str}")
877
+ else:
878
+ # No security config - use defaults (SCA and SAST)
879
+ result.append(ScanDomain.SCA)
880
+ result.append(ScanDomain.SAST)
881
+
882
+ return result
883
+
884
+ result = []
885
+ for domain in domains:
886
+ domain_lower = domain.lower()
887
+ if domain_lower in self.DOMAIN_MAP:
888
+ result.append(self.DOMAIN_MAP[domain_lower])
889
+ else:
890
+ LOGGER.warning(f"Unknown domain: {domain}")
891
+
892
+ return result
893
+
894
+ def _build_context(
895
+ self,
896
+ domains: List[DomainType],
897
+ files: Optional[List[str]] = None,
898
+ all_files: bool = False,
899
+ stream_handler: Optional[StreamHandler] = None,
900
+ ) -> ScanContext:
901
+ """Build scan context with partial scanning support.
902
+
903
+ Priority:
904
+ 1. If `files` is provided, scan only those specific files
905
+ 2. If `all_files` is True, scan entire project
906
+ 3. Otherwise, scan only changed files (uncommitted changes)
907
+
908
+ Args:
909
+ domains: Enabled domains.
910
+ files: Optional specific files to scan.
911
+ all_files: If True, scan entire project.
912
+ stream_handler: Optional handler for streaming output.
913
+
914
+ Returns:
915
+ ScanContext instance.
916
+ """
917
+ # Determine which paths to scan
918
+ paths: List[Path]
919
+
920
+ if files:
921
+ # Explicit files specified - use those
922
+ paths = []
923
+ for f in files:
924
+ file_path = self.project_root / f
925
+ if file_path.exists():
926
+ paths.append(file_path)
927
+ else:
928
+ LOGGER.warning(f"File not found: {f}")
929
+ if paths:
930
+ LOGGER.info(f"Scanning {len(paths)} specified file(s)")
931
+ else:
932
+ LOGGER.warning("No valid files specified, falling back to full scan")
933
+ paths = [self.project_root]
934
+ elif all_files:
935
+ # Explicit full scan requested
936
+ LOGGER.info("Scanning entire project (all_files=true)")
937
+ paths = [self.project_root]
938
+ else:
939
+ # Default: scan only changed files
940
+ changed_files = get_changed_files(self.project_root)
941
+ if changed_files is not None and len(changed_files) > 0:
942
+ LOGGER.info(f"Scanning {len(changed_files)} changed file(s)")
943
+ paths = changed_files
944
+ elif changed_files is not None and len(changed_files) == 0:
945
+ LOGGER.info("No changed files detected, nothing to scan")
946
+ paths = [] # Return empty list - no files to scan
947
+ else:
948
+ # Not a git repo or git command failed
949
+ LOGGER.info("Not a git repository, scanning entire project")
950
+ paths = [self.project_root]
951
+
952
+ return ScanContext(
953
+ project_root=self.project_root,
954
+ paths=paths,
955
+ enabled_domains=domains,
956
+ config=self.config,
957
+ stream_handler=stream_handler,
958
+ )
959
+
960
+ async def _run_linting(
961
+ self,
962
+ context: ScanContext,
963
+ fix: bool = False,
964
+ ) -> List[UnifiedIssue]:
965
+ """Run linting checks asynchronously.
966
+
967
+ Args:
968
+ context: Scan context.
969
+ fix: Whether to apply fixes.
970
+
971
+ Returns:
972
+ List of linting issues.
973
+ """
974
+ loop = asyncio.get_event_loop()
975
+ return await loop.run_in_executor(
976
+ None, self._runner.run_linting, context, fix
977
+ )
978
+
979
+ async def _run_type_checking(self, context: ScanContext) -> List[UnifiedIssue]:
980
+ """Run type checking asynchronously.
981
+
982
+ Args:
983
+ context: Scan context.
984
+
985
+ Returns:
986
+ List of type checking issues.
987
+ """
988
+ loop = asyncio.get_event_loop()
989
+ return await loop.run_in_executor(
990
+ None, self._runner.run_type_checking, context
991
+ )
992
+
993
+ async def _run_testing(self, context: ScanContext) -> List[UnifiedIssue]:
994
+ """Run test suite asynchronously.
995
+
996
+ Args:
997
+ context: Scan context.
998
+
999
+ Returns:
1000
+ List of test failure issues.
1001
+ """
1002
+ loop = asyncio.get_event_loop()
1003
+ return await loop.run_in_executor(
1004
+ None, self._runner.run_tests, context
1005
+ )
1006
+
1007
+ async def _run_coverage(
1008
+ self,
1009
+ context: ScanContext,
1010
+ run_tests: bool = True,
1011
+ ) -> List[UnifiedIssue]:
1012
+ """Run coverage analysis asynchronously.
1013
+
1014
+ Args:
1015
+ context: Scan context.
1016
+ run_tests: Whether to run tests for coverage measurement.
1017
+
1018
+ Returns:
1019
+ List of coverage issues.
1020
+ """
1021
+ loop = asyncio.get_event_loop()
1022
+ # Use functools.partial to pass run_tests parameter
1023
+ run_coverage_fn = functools.partial(
1024
+ self._runner.run_coverage,
1025
+ context,
1026
+ run_tests=run_tests,
1027
+ )
1028
+ issues = await loop.run_in_executor(None, run_coverage_fn)
1029
+ # Coverage result is stored in context.coverage_result by DomainRunner
1030
+ return issues
1031
+
1032
+ async def _run_security(
1033
+ self,
1034
+ context: ScanContext,
1035
+ domain: ScanDomain,
1036
+ ) -> List[UnifiedIssue]:
1037
+ """Run security scanner asynchronously.
1038
+
1039
+ Args:
1040
+ context: Scan context.
1041
+ domain: Scanner domain (SAST, SCA, IAC, CONTAINER).
1042
+
1043
+ Returns:
1044
+ List of security issues.
1045
+ """
1046
+ loop = asyncio.get_event_loop()
1047
+ return await loop.run_in_executor(
1048
+ None, self._runner.run_security, context, domain
1049
+ )