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,307 @@
1
+ """Scan command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from argparse import Namespace
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from lucidscan.cli.commands import Command
11
+ from lucidscan.cli.config_bridge import ConfigBridge
12
+ from lucidscan.cli.exit_codes import (
13
+ EXIT_ISSUES_FOUND,
14
+ EXIT_SCANNER_ERROR,
15
+ EXIT_SUCCESS,
16
+ )
17
+ from lucidscan.config.ignore import load_ignore_patterns
18
+ from lucidscan.config.models import LucidScanConfig
19
+ from lucidscan.core.domain_runner import DomainRunner, check_severity_threshold
20
+ from lucidscan.core.git import get_changed_files
21
+ from lucidscan.core.logging import get_logger
22
+ from lucidscan.core.models import CoverageSummary, ScanContext, ScanResult, UnifiedIssue
23
+ from lucidscan.core.streaming import CLIStreamHandler, StreamHandler
24
+ from lucidscan.pipeline import PipelineConfig, PipelineExecutor
25
+ from lucidscan.plugins.reporters import get_reporter_plugin
26
+
27
+ LOGGER = get_logger(__name__)
28
+
29
+
30
+ class ScanCommand(Command):
31
+ """Executes security scanning."""
32
+
33
+ def __init__(self, version: str):
34
+ """Initialize ScanCommand.
35
+
36
+ Args:
37
+ version: Current lucidscan version string.
38
+ """
39
+ self._version = version
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """Command identifier."""
44
+ return "scan"
45
+
46
+ def execute(self, args: Namespace, config: LucidScanConfig | None = None) -> int:
47
+ """Execute the scan command.
48
+
49
+ Args:
50
+ args: Parsed command-line arguments.
51
+ config: Loaded configuration.
52
+
53
+ Returns:
54
+ Exit code based on scan results.
55
+ """
56
+ if config is None:
57
+ LOGGER.error("Configuration is required for scan command")
58
+ return EXIT_SCANNER_ERROR
59
+
60
+ try:
61
+ result = self._run_scan(args, config)
62
+
63
+ # Determine output format: CLI > config > default (json)
64
+ if args.format:
65
+ output_format = args.format
66
+ elif config.output.format:
67
+ output_format = config.output.format
68
+ else:
69
+ output_format = "json"
70
+
71
+ reporter = get_reporter_plugin(output_format)
72
+ if not reporter:
73
+ LOGGER.error(f"Reporter plugin '{output_format}' not found")
74
+ return EXIT_SCANNER_ERROR
75
+
76
+ # Write output to stdout
77
+ reporter.report(result, sys.stdout)
78
+
79
+ # Check severity threshold - CLI overrides config
80
+ # Use get_fail_on_threshold to handle both string and dict formats
81
+ threshold = args.fail_on if args.fail_on else config.get_fail_on_threshold("security")
82
+ if check_severity_threshold(result.issues, threshold):
83
+ return EXIT_ISSUES_FOUND
84
+
85
+ return EXIT_SUCCESS
86
+
87
+ except FileNotFoundError as e:
88
+ LOGGER.error(str(e))
89
+ raise
90
+ except Exception as e:
91
+ LOGGER.error(f"Scan failed: {e}")
92
+ raise
93
+
94
+ def _run_scan(
95
+ self, args: Namespace, config: LucidScanConfig
96
+ ) -> ScanResult:
97
+ """Execute the scan based on CLI arguments and config.
98
+
99
+ Uses PipelineExecutor to run the scan pipeline:
100
+ 1. Linting (if --linting or --all)
101
+ 2. Scanner execution (parallel by default)
102
+ 3. Enricher execution (sequential, in configured order)
103
+ 4. Result aggregation
104
+
105
+ Partial Scanning (default behavior):
106
+ - If --files is specified, scan only those files
107
+ - If --all-files is specified, scan entire project
108
+ - Otherwise, scan only changed files (uncommitted changes)
109
+
110
+ Args:
111
+ args: Parsed CLI arguments.
112
+ config: Loaded configuration.
113
+
114
+ Returns:
115
+ ScanResult containing all issues found.
116
+ """
117
+ project_root = Path(args.path).resolve()
118
+
119
+ if not project_root.exists():
120
+ raise FileNotFoundError(f"Path does not exist: {project_root}")
121
+
122
+ enabled_domains = ConfigBridge.get_enabled_domains(config, args)
123
+
124
+ # Load ignore patterns from .lucidscanignore and config
125
+ ignore_patterns = load_ignore_patterns(project_root, config.ignore)
126
+
127
+ # Determine which files to scan (partial scanning logic)
128
+ scan_paths = self._determine_scan_paths(args, project_root)
129
+
130
+ # Create stream handler if streaming is enabled
131
+ stream_handler: Optional[StreamHandler] = None
132
+ stream_enabled = getattr(args, "stream", False) or getattr(args, "verbose", False)
133
+ if stream_enabled:
134
+ stream_handler = CLIStreamHandler(
135
+ output=sys.stderr,
136
+ show_output=True,
137
+ use_rich=False, # Use plain output for better compatibility
138
+ )
139
+
140
+ # Build scan context
141
+ context = ScanContext(
142
+ project_root=project_root,
143
+ paths=scan_paths,
144
+ enabled_domains=enabled_domains,
145
+ config=config,
146
+ ignore_patterns=ignore_patterns,
147
+ stream_handler=stream_handler,
148
+ )
149
+
150
+ # Create domain runner for executing tool-based scans
151
+ runner = DomainRunner(project_root, config, log_level="info")
152
+
153
+ all_issues: List[UnifiedIssue] = []
154
+ pipeline_result: Optional[ScanResult] = None
155
+
156
+ # Run linting if requested
157
+ linting_enabled = getattr(args, "linting", False) or getattr(args, "all", False)
158
+ fix_enabled = getattr(args, "fix", False)
159
+
160
+ if linting_enabled:
161
+ all_issues.extend(runner.run_linting(context, fix_enabled))
162
+
163
+ # Run type checking if requested
164
+ type_checking_enabled = getattr(args, "type_checking", False) or getattr(
165
+ args, "all", False
166
+ )
167
+
168
+ if type_checking_enabled:
169
+ all_issues.extend(runner.run_type_checking(context))
170
+
171
+ # Run tests if requested
172
+ testing_enabled = getattr(args, "testing", False) or getattr(args, "all", False)
173
+
174
+ if testing_enabled:
175
+ all_issues.extend(runner.run_tests(context))
176
+
177
+ # Run coverage if requested
178
+ coverage_enabled = getattr(args, "coverage", False) or getattr(
179
+ args, "all", False
180
+ )
181
+
182
+ coverage_summary: Optional[CoverageSummary] = None
183
+ if coverage_enabled:
184
+ coverage_threshold = getattr(args, "coverage_threshold", None) or 80.0
185
+ # Don't re-run tests if they were already run in the testing domain
186
+ run_tests_for_coverage = not testing_enabled
187
+ all_issues.extend(
188
+ runner.run_coverage(context, coverage_threshold, run_tests_for_coverage)
189
+ )
190
+
191
+ # Build coverage summary from context.coverage_result
192
+ if context.coverage_result is not None:
193
+ cov = context.coverage_result
194
+ coverage_summary = CoverageSummary(
195
+ coverage_percentage=round(cov.percentage, 2),
196
+ threshold=cov.threshold,
197
+ total_lines=cov.total_lines,
198
+ covered_lines=cov.covered_lines,
199
+ missing_lines=cov.missing_lines,
200
+ passed=cov.passed,
201
+ )
202
+ # Add test stats if available
203
+ if cov.test_stats is not None:
204
+ ts = cov.test_stats
205
+ coverage_summary.tests_total = ts.total
206
+ coverage_summary.tests_passed = ts.passed
207
+ coverage_summary.tests_failed = ts.failed
208
+ coverage_summary.tests_skipped = ts.skipped
209
+ coverage_summary.tests_errors = ts.errors
210
+
211
+ # Run security scanning if any domains are enabled
212
+ if enabled_domains:
213
+ # Collect unique scanners needed based on config
214
+ needed_scanners: List[str] = []
215
+ for domain in enabled_domains:
216
+ scanner_name = config.get_plugin_for_domain(domain.value)
217
+ if scanner_name and scanner_name not in needed_scanners:
218
+ needed_scanners.append(scanner_name)
219
+ elif not scanner_name:
220
+ LOGGER.warning(
221
+ f"No scanner plugin configured for domain: {domain.value}"
222
+ )
223
+
224
+ if needed_scanners:
225
+ # Build pipeline configuration
226
+ pipeline_config = PipelineConfig(
227
+ sequential_scanners=getattr(args, "sequential", False),
228
+ max_workers=config.pipeline.max_workers,
229
+ enricher_order=config.pipeline.enrichers,
230
+ )
231
+
232
+ # Execute pipeline
233
+ executor = PipelineExecutor(
234
+ config=config,
235
+ pipeline_config=pipeline_config,
236
+ lucidscan_version=self._version,
237
+ )
238
+
239
+ pipeline_result = executor.execute(needed_scanners, context)
240
+ all_issues.extend(pipeline_result.issues)
241
+
242
+ # Build final result
243
+ result = ScanResult(issues=all_issues)
244
+ result.summary = result.compute_summary()
245
+ result.coverage_summary = coverage_summary
246
+
247
+ # Preserve metadata from pipeline execution
248
+ if pipeline_result and pipeline_result.metadata:
249
+ result.metadata = pipeline_result.metadata
250
+
251
+ return result
252
+
253
+ def _determine_scan_paths(
254
+ self, args: Namespace, project_root: Path
255
+ ) -> List[Path]:
256
+ """Determine which paths to scan based on CLI arguments.
257
+
258
+ Priority:
259
+ 1. --files: Scan only specified files
260
+ 2. --all-files: Scan entire project
261
+ 3. Default: Scan only changed files (uncommitted changes)
262
+
263
+ Args:
264
+ args: Parsed CLI arguments.
265
+ project_root: Project root directory.
266
+
267
+ Returns:
268
+ List of paths to scan.
269
+ """
270
+ # If --files is specified, use those files
271
+ files_arg = getattr(args, "files", None)
272
+ if files_arg:
273
+ paths = []
274
+ for file_path in files_arg:
275
+ path = Path(file_path)
276
+ if not path.is_absolute():
277
+ path = project_root / path
278
+ path = path.resolve()
279
+ if path.exists():
280
+ paths.append(path)
281
+ else:
282
+ LOGGER.warning(f"File not found: {file_path}")
283
+ if paths:
284
+ LOGGER.info(f"Scanning {len(paths)} specified file(s)")
285
+ return paths
286
+ # Fall through to full scan if no valid files
287
+ LOGGER.warning("No valid files specified, falling back to full scan")
288
+
289
+ # If --all-files is specified, scan entire project
290
+ all_files = getattr(args, "all_files", False)
291
+ if all_files:
292
+ LOGGER.info("Scanning entire project (--all-files)")
293
+ return [project_root]
294
+
295
+ # Default: scan only changed files
296
+ changed_files = get_changed_files(project_root)
297
+ if changed_files is not None and len(changed_files) > 0:
298
+ LOGGER.info(f"Scanning {len(changed_files)} changed file(s)")
299
+ return changed_files
300
+
301
+ # Fall back to full scan if no changed files or not a git repo
302
+ if changed_files is not None and len(changed_files) == 0:
303
+ LOGGER.info("No changed files detected, nothing to scan")
304
+ return [] # Return empty list - no files to scan
305
+ else:
306
+ LOGGER.info("Not a git repository, scanning entire project")
307
+ return [project_root]
@@ -0,0 +1,142 @@
1
+ """Serve command implementation.
2
+
3
+ Run LucidScan as an MCP server for AI agents or as a file watcher.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from argparse import Namespace
10
+ from pathlib import Path
11
+
12
+ from lucidscan.cli.commands import Command
13
+ from lucidscan.cli.exit_codes import EXIT_SUCCESS, EXIT_SCANNER_ERROR
14
+ from lucidscan.config import LucidScanConfig
15
+ from lucidscan.core.logging import get_logger
16
+
17
+ LOGGER = get_logger(__name__)
18
+
19
+
20
+ class ServeCommand(Command):
21
+ """Run LucidScan as a server for AI integration."""
22
+
23
+ def __init__(self, version: str):
24
+ """Initialize ServeCommand.
25
+
26
+ Args:
27
+ version: Current lucidscan version string.
28
+ """
29
+ self._version = version
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ """Command identifier."""
34
+ return "serve"
35
+
36
+ def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
37
+ """Execute the serve command.
38
+
39
+ Args:
40
+ args: Parsed command-line arguments.
41
+ config: LucidScan configuration.
42
+
43
+ Returns:
44
+ Exit code.
45
+ """
46
+ if config is None:
47
+ LOGGER.error("Configuration is required for serve command")
48
+ return EXIT_SCANNER_ERROR
49
+
50
+ project_root = Path(args.path).resolve()
51
+
52
+ if not project_root.is_dir():
53
+ LOGGER.error(f"Not a directory: {project_root}")
54
+ return EXIT_SCANNER_ERROR
55
+
56
+ # Determine mode
57
+ if args.mcp:
58
+ return self._run_mcp_server(args, config, project_root)
59
+ elif args.watch:
60
+ return self._run_file_watcher(args, config, project_root)
61
+ else:
62
+ # Default to MCP mode
63
+ return self._run_mcp_server(args, config, project_root)
64
+
65
+ def _run_mcp_server(
66
+ self,
67
+ args: Namespace,
68
+ config: LucidScanConfig,
69
+ project_root: Path,
70
+ ) -> int:
71
+ """Run LucidScan as an MCP server.
72
+
73
+ Args:
74
+ args: Parsed command-line arguments.
75
+ config: LucidScan configuration.
76
+ project_root: Project root directory.
77
+
78
+ Returns:
79
+ Exit code.
80
+ """
81
+ try:
82
+ from lucidscan.mcp.server import LucidScanMCPServer
83
+
84
+ LOGGER.info(f"Starting MCP server for {project_root}")
85
+ server = LucidScanMCPServer(project_root, config)
86
+ asyncio.run(server.run())
87
+ return EXIT_SUCCESS
88
+ except ImportError as e:
89
+ LOGGER.error(f"MCP dependencies not installed: {e}")
90
+ LOGGER.error("Install with: pip install lucidscan[mcp]")
91
+ return EXIT_SCANNER_ERROR
92
+ except Exception as e:
93
+ LOGGER.error(f"MCP server error: {e}")
94
+ return EXIT_SCANNER_ERROR
95
+
96
+ def _run_file_watcher(
97
+ self,
98
+ args: Namespace,
99
+ config: LucidScanConfig,
100
+ project_root: Path,
101
+ ) -> int:
102
+ """Run LucidScan in file watcher mode.
103
+
104
+ Args:
105
+ args: Parsed command-line arguments.
106
+ config: LucidScan configuration.
107
+ project_root: Project root directory.
108
+
109
+ Returns:
110
+ Exit code.
111
+ """
112
+ try:
113
+ from lucidscan.mcp.watcher import LucidScanFileWatcher
114
+
115
+ debounce_ms = getattr(args, "debounce", 1000)
116
+ LOGGER.info(f"Starting file watcher for {project_root}")
117
+ LOGGER.info(f"Debounce: {debounce_ms}ms")
118
+
119
+ watcher = LucidScanFileWatcher(
120
+ project_root=project_root,
121
+ config=config,
122
+ debounce_ms=debounce_ms,
123
+ )
124
+
125
+ # Set up result callback
126
+ def on_result(result):
127
+ """Print scan results to stdout."""
128
+ import json
129
+ print(json.dumps(result, indent=2))
130
+
131
+ watcher.on_result(on_result)
132
+ asyncio.run(watcher.start())
133
+ return EXIT_SUCCESS
134
+ except ImportError as e:
135
+ LOGGER.error(f"Watcher dependencies not installed: {e}")
136
+ return EXIT_SCANNER_ERROR
137
+ except KeyboardInterrupt:
138
+ LOGGER.info("File watcher stopped")
139
+ return EXIT_SUCCESS
140
+ except Exception as e:
141
+ LOGGER.error(f"File watcher error: {e}")
142
+ return EXIT_SCANNER_ERROR
@@ -0,0 +1,84 @@
1
+ """Status command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import Namespace
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from lucidscan.config.models import LucidScanConfig
10
+
11
+ from lucidscan.bootstrap.paths import get_lucidscan_home, LucidscanPaths
12
+ from lucidscan.bootstrap.platform import get_platform_info
13
+ from lucidscan.bootstrap.validation import validate_binary, ToolStatus
14
+ from lucidscan.cli.commands import Command
15
+ from lucidscan.cli.exit_codes import EXIT_SUCCESS
16
+ from lucidscan.plugins.scanners import discover_scanner_plugins
17
+
18
+
19
+ class StatusCommand(Command):
20
+ """Shows scanner plugin status and environment information."""
21
+
22
+ def __init__(self, version: str):
23
+ """Initialize StatusCommand.
24
+
25
+ Args:
26
+ version: Current lucidscan version string.
27
+ """
28
+ self._version = version
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ """Command identifier."""
33
+ return "status"
34
+
35
+ def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
36
+ """Execute the status command.
37
+
38
+ Displays lucidscan version, platform info, and scanner plugin status.
39
+
40
+ Args:
41
+ args: Parsed command-line arguments.
42
+ config: Optional LucidScan configuration (unused).
43
+
44
+ Returns:
45
+ Exit code (always 0 for status).
46
+ """
47
+ # Use current directory as project root
48
+ home = get_lucidscan_home()
49
+ paths = LucidscanPaths(home)
50
+ platform_info = get_platform_info()
51
+
52
+ print(f"lucidscan version: {self._version}")
53
+ print(f"Platform: {platform_info.os}-{platform_info.arch}")
54
+ print(f"Tool cache: {home}/bin/")
55
+ print()
56
+
57
+ # Discover plugins via entry points
58
+ print("Scanner plugins:")
59
+ plugins = discover_scanner_plugins()
60
+
61
+ if plugins:
62
+ for name, plugin_class in sorted(plugins.items()):
63
+ try:
64
+ plugin = plugin_class()
65
+ domains = ", ".join(d.value.upper() for d in plugin.domains)
66
+ binary_dir = paths.plugin_bin_dir(name, plugin.get_version())
67
+ binary_path = binary_dir / name
68
+
69
+ status = validate_binary(binary_path)
70
+ if status == ToolStatus.PRESENT:
71
+ status_str = f"v{plugin.get_version()} installed"
72
+ else:
73
+ status_str = f"v{plugin.get_version()} (not downloaded)"
74
+
75
+ print(f" {name}: {status_str} [{domains}]")
76
+ except Exception as e:
77
+ print(f" {name}: error loading plugin ({e})")
78
+ else:
79
+ print(" No plugins discovered.")
80
+
81
+ print()
82
+ print("Tools are downloaded to .lucidscan/ on first scan.")
83
+
84
+ return EXIT_SUCCESS
@@ -0,0 +1,105 @@
1
+ """Validate command implementation.
2
+
3
+ Validates lucidscan.yml configuration files and reports issues.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from argparse import Namespace
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from lucidscan.config.models import LucidScanConfig
14
+
15
+ from lucidscan.cli.commands import Command
16
+ from lucidscan.cli.exit_codes import EXIT_ISSUES_FOUND, EXIT_INVALID_USAGE, EXIT_SUCCESS
17
+ from lucidscan.config.loader import find_project_config
18
+ from lucidscan.config.validation import (
19
+ ConfigValidationIssue,
20
+ validate_config_file,
21
+ ValidationSeverity,
22
+ )
23
+
24
+
25
+ class ValidateCommand(Command):
26
+ """Validates lucidscan.yml configuration files."""
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ """Command identifier."""
31
+ return "validate"
32
+
33
+ def execute(self, args: Namespace, config: "LucidScanConfig | None" = None) -> int:
34
+ """Execute the validate command.
35
+
36
+ Validates a configuration file and reports errors/warnings.
37
+
38
+ Args:
39
+ args: Parsed command-line arguments.
40
+ config: Optional LucidScan configuration (unused).
41
+
42
+ Returns:
43
+ Exit code: 0 = valid, 1 = has errors, 3 = file not found.
44
+ """
45
+ # Determine config path
46
+ config_path = getattr(args, "config", None)
47
+ if config_path:
48
+ config_path = Path(config_path)
49
+ else:
50
+ # Find in current directory
51
+ config_path = find_project_config(Path.cwd())
52
+
53
+ if config_path is None:
54
+ print("No configuration file found.")
55
+ print("Looked for: .lucidscan.yml, .lucidscan.yaml, lucidscan.yml, lucidscan.yaml")
56
+ return EXIT_INVALID_USAGE
57
+
58
+ if not config_path.exists():
59
+ print(f"Configuration file not found: {config_path}")
60
+ return EXIT_INVALID_USAGE
61
+
62
+ print(f"Validating {config_path}...")
63
+
64
+ is_valid, issues = validate_config_file(config_path)
65
+
66
+ if not issues:
67
+ print("Configuration is valid.")
68
+ return EXIT_SUCCESS
69
+
70
+ # Group by severity
71
+ errors = [i for i in issues if i.severity == ValidationSeverity.ERROR]
72
+ warnings = [i for i in issues if i.severity == ValidationSeverity.WARNING]
73
+
74
+ # Print errors
75
+ if errors:
76
+ print(f"\nErrors ({len(errors)}):")
77
+ for issue in errors:
78
+ self._print_issue(issue)
79
+
80
+ # Print warnings
81
+ if warnings:
82
+ print(f"\nWarnings ({len(warnings)}):")
83
+ for issue in warnings:
84
+ self._print_issue(issue)
85
+
86
+ if errors:
87
+ print(f"\nConfiguration is invalid ({len(errors)} error(s)).")
88
+ return EXIT_ISSUES_FOUND
89
+ else:
90
+ print(f"\nConfiguration is valid with {len(warnings)} warning(s).")
91
+ return EXIT_SUCCESS
92
+
93
+ def _print_issue(self, issue: ConfigValidationIssue) -> None:
94
+ """Print a formatted issue.
95
+
96
+ Args:
97
+ issue: The validation issue to print.
98
+ """
99
+ location = ""
100
+ if issue.key:
101
+ location = f" [{issue.key}]"
102
+
103
+ print(f" - {issue.message}{location}")
104
+ if issue.suggestion:
105
+ print(f" Did you mean '{issue.suggestion}'?")