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,152 @@
1
+ """Bridge between CLI arguments and configuration models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any, Dict, List
7
+
8
+ from lucidscan.config.models import LucidScanConfig
9
+ from lucidscan.core.logging import get_logger
10
+ from lucidscan.core.models import ScanDomain
11
+
12
+ LOGGER = get_logger(__name__)
13
+
14
+
15
+ class ConfigBridge:
16
+ """Translates CLI arguments to configuration objects."""
17
+
18
+ @staticmethod
19
+ def args_to_overrides(args: argparse.Namespace) -> Dict[str, Any]:
20
+ """Convert CLI arguments to config override dict.
21
+
22
+ CLI arguments take precedence over config file values.
23
+
24
+ Args:
25
+ args: Parsed CLI arguments.
26
+
27
+ Returns:
28
+ Dictionary of config overrides.
29
+ """
30
+ overrides: Dict[str, Any] = {}
31
+
32
+ # Domain toggles - only set if explicitly provided on CLI
33
+ # Use getattr with defaults for subcommand compatibility
34
+ scanners: Dict[str, Dict[str, Any]] = {}
35
+ linters: Dict[str, Dict[str, Any]] = {}
36
+
37
+ all_domains = getattr(args, "all", False)
38
+ sca = getattr(args, "sca", False)
39
+ sast = getattr(args, "sast", False)
40
+ iac = getattr(args, "iac", False)
41
+ container = getattr(args, "container", False)
42
+ linting = getattr(args, "linting", False)
43
+ fix = getattr(args, "fix", False)
44
+ images = getattr(args, "images", None)
45
+
46
+ if all_domains:
47
+ # Enable all domains including linting
48
+ for domain in ["sca", "sast", "iac", "container"]:
49
+ scanners[domain] = {"enabled": True}
50
+ linters["ruff"] = {"enabled": True}
51
+ else:
52
+ if sca:
53
+ scanners["sca"] = {"enabled": True}
54
+ if sast:
55
+ scanners["sast"] = {"enabled": True}
56
+ if iac:
57
+ scanners["iac"] = {"enabled": True}
58
+ if container:
59
+ scanners["container"] = {"enabled": True}
60
+ if linting:
61
+ linters["ruff"] = {"enabled": True}
62
+
63
+ # Container images go into container scanner options
64
+ if images:
65
+ if "container" not in scanners:
66
+ scanners["container"] = {}
67
+ scanners["container"]["enabled"] = True
68
+ scanners["container"]["images"] = images
69
+
70
+ if scanners:
71
+ overrides["scanners"] = scanners
72
+
73
+ if linters:
74
+ overrides["linters"] = linters
75
+
76
+ # Fix mode for linting
77
+ if fix:
78
+ overrides["fix"] = True
79
+
80
+ # Fail-on threshold
81
+ fail_on = getattr(args, "fail_on", None)
82
+ if fail_on:
83
+ overrides["fail_on"] = fail_on
84
+
85
+ return overrides
86
+
87
+ @staticmethod
88
+ def get_enabled_domains(
89
+ config: LucidScanConfig,
90
+ args: argparse.Namespace,
91
+ ) -> List[ScanDomain]:
92
+ """Determine which scan domains are enabled.
93
+
94
+ If specific CLI flags (--sca, --sast, etc.) are provided, use those.
95
+ If --all is provided, use domains from config file.
96
+ If other domain flags (--test, --coverage, --lint, --type-check) are set,
97
+ return empty list (user wants only those specific domains, not security).
98
+ Otherwise, use domains enabled in config file.
99
+
100
+ Args:
101
+ config: Loaded configuration.
102
+ args: Parsed CLI arguments.
103
+
104
+ Returns:
105
+ List of enabled ScanDomain values.
106
+ """
107
+ # Use getattr for subcommand compatibility
108
+ sca = getattr(args, "sca", False)
109
+ sast = getattr(args, "sast", False)
110
+ iac = getattr(args, "iac", False)
111
+ container = getattr(args, "container", False)
112
+ all_domains = getattr(args, "all", False)
113
+
114
+ # Check if specific security domain flags were set
115
+ security_domains_set = any([sca, sast, iac, container])
116
+
117
+ if security_domains_set:
118
+ # Specific CLI flags take precedence
119
+ domains: List[ScanDomain] = []
120
+ if sca:
121
+ domains.append(ScanDomain.SCA)
122
+ if sast:
123
+ domains.append(ScanDomain.SAST)
124
+ if iac:
125
+ domains.append(ScanDomain.IAC)
126
+ if container:
127
+ domains.append(ScanDomain.CONTAINER)
128
+ return domains
129
+
130
+ # Check if user specified non-security domain flags (testing, coverage, etc.)
131
+ # If so, they don't want security scanning unless explicitly requested
132
+ linting = getattr(args, "linting", False)
133
+ type_checking = getattr(args, "type_checking", False)
134
+ testing = getattr(args, "testing", False)
135
+ coverage = getattr(args, "coverage", False)
136
+ non_security_domains_set = any([linting, type_checking, testing, coverage])
137
+
138
+ if non_security_domains_set and not all_domains:
139
+ # User explicitly requested non-security domains only
140
+ # Don't run security scanners unless explicitly requested
141
+ return []
142
+
143
+ # --all or no flags: use config file settings
144
+ # This respects what's actually configured in lucidscan.yml
145
+ enabled_domains: List[ScanDomain] = []
146
+ for domain_name in config.get_enabled_domains():
147
+ try:
148
+ enabled_domains.append(ScanDomain(domain_name))
149
+ except ValueError:
150
+ LOGGER.warning(f"Unknown domain in config: {domain_name}")
151
+
152
+ return enabled_domains
@@ -0,0 +1,17 @@
1
+ """Exit codes for lucidscan CLI.
2
+
3
+ Exit codes follow Section 14 of the specification:
4
+ - 0: Success (no issues found or below threshold)
5
+ - 1: Issues found at or above severity threshold
6
+ - 2: Scanner error
7
+ - 3: Invalid usage (bad arguments, missing config)
8
+ - 4: Bootstrap failure (binary download failed)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ EXIT_SUCCESS = 0
14
+ EXIT_ISSUES_FOUND = 1
15
+ EXIT_SCANNER_ERROR = 2
16
+ EXIT_INVALID_USAGE = 3
17
+ EXIT_BOOTSTRAP_FAILURE = 4
@@ -0,0 +1,284 @@
1
+ """CLI runner orchestration.
2
+
3
+ This module handles command dispatch and execution for the lucidscan CLI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Iterable, Optional
10
+
11
+ from importlib.metadata import version, PackageNotFoundError
12
+
13
+ from lucidscan.cli.arguments import build_parser
14
+ from lucidscan.cli.config_bridge import ConfigBridge
15
+ from lucidscan.cli.exit_codes import (
16
+ EXIT_INVALID_USAGE,
17
+ EXIT_SCANNER_ERROR,
18
+ EXIT_SUCCESS,
19
+ )
20
+ from lucidscan.cli.commands.status import StatusCommand
21
+ from lucidscan.cli.commands.scan import ScanCommand
22
+ from lucidscan.cli.commands.help import HelpCommand
23
+ from lucidscan.config import load_config
24
+ from lucidscan.config.loader import ConfigError
25
+ from lucidscan.core.logging import configure_logging, get_logger
26
+
27
+ LOGGER = get_logger(__name__)
28
+
29
+
30
+ def get_version() -> str:
31
+ """Get lucidscan version.
32
+
33
+ Returns:
34
+ Version string from package metadata or fallback.
35
+ """
36
+ try:
37
+ return version("lucidscan")
38
+ except PackageNotFoundError:
39
+ # Fallback for editable installs that have not yet built metadata.
40
+ from lucidscan import __version__
41
+ return __version__
42
+
43
+
44
+ class CLIRunner:
45
+ """Orchestrates CLI execution with subcommand dispatch."""
46
+
47
+ def __init__(self) -> None:
48
+ """Initialize CLIRunner with parser and commands."""
49
+ self.parser = build_parser()
50
+ self._version = get_version()
51
+ self.status_cmd = StatusCommand(version=self._version)
52
+ self.scan_cmd = ScanCommand(version=self._version)
53
+ self.help_cmd = HelpCommand(version=self._version)
54
+ # InitCommand and AutoconfigureCommand will be imported lazily when needed
55
+ self._init_cmd = None
56
+ self._autoconfigure_cmd = None
57
+
58
+ @property
59
+ def init_cmd(self):
60
+ """Lazy-load InitCommand to avoid import errors during development."""
61
+ if self._init_cmd is None:
62
+ try:
63
+ from lucidscan.cli.commands.init import InitCommand
64
+ self._init_cmd = InitCommand(version=self._version)
65
+ except ImportError:
66
+ self._init_cmd = None
67
+ return self._init_cmd
68
+
69
+ @property
70
+ def autoconfigure_cmd(self):
71
+ """Lazy-load AutoconfigureCommand to avoid import errors during development."""
72
+ if self._autoconfigure_cmd is None:
73
+ try:
74
+ from lucidscan.cli.commands.autoconfigure import AutoconfigureCommand
75
+ self._autoconfigure_cmd = AutoconfigureCommand()
76
+ except ImportError:
77
+ self._autoconfigure_cmd = None
78
+ return self._autoconfigure_cmd
79
+
80
+ def run(self, argv: Optional[Iterable[str]] = None) -> int:
81
+ """Run the CLI.
82
+
83
+ Args:
84
+ argv: Command-line arguments (defaults to sys.argv).
85
+
86
+ Returns:
87
+ Exit code.
88
+ """
89
+ # Handle --help specially to return 0
90
+ if argv is not None:
91
+ argv_list = list(argv)
92
+ if "--help" in argv_list or "-h" in argv_list:
93
+ self.parser.print_help()
94
+ return EXIT_SUCCESS
95
+ else:
96
+ argv_list = None
97
+
98
+ args = self.parser.parse_args(argv_list)
99
+
100
+ # Configure logging as early as possible
101
+ configure_logging(
102
+ debug=args.debug,
103
+ verbose=args.verbose,
104
+ quiet=args.quiet,
105
+ )
106
+
107
+ # Handle --version
108
+ if args.version:
109
+ print(self._version)
110
+ return EXIT_SUCCESS
111
+
112
+ # Dispatch to appropriate command handler
113
+ command = getattr(args, "command", None)
114
+
115
+ if command == "init":
116
+ return self._handle_init(args)
117
+ elif command == "autoconfigure":
118
+ return self._handle_autoconfigure(args)
119
+ elif command == "scan":
120
+ return self._handle_scan(args)
121
+ elif command == "status":
122
+ return self._handle_status(args)
123
+ elif command == "serve":
124
+ return self._handle_serve(args)
125
+ elif command == "help":
126
+ return self._handle_help(args)
127
+ elif command == "validate":
128
+ return self._handle_validate(args)
129
+ else:
130
+ # No command specified - show help
131
+ self.parser.print_help()
132
+ return EXIT_SUCCESS
133
+
134
+ def _handle_init(self, args) -> int:
135
+ """Handle the init command (configure AI tools).
136
+
137
+ Args:
138
+ args: Parsed command-line arguments.
139
+
140
+ Returns:
141
+ Exit code.
142
+ """
143
+ if self.init_cmd is None:
144
+ LOGGER.error("Init command not available. This feature is in development.")
145
+ return EXIT_INVALID_USAGE
146
+
147
+ return self.init_cmd.execute(args)
148
+
149
+ def _handle_autoconfigure(self, args) -> int:
150
+ """Handle the autoconfigure command (generate lucidscan.yml).
151
+
152
+ Args:
153
+ args: Parsed command-line arguments.
154
+
155
+ Returns:
156
+ Exit code.
157
+ """
158
+ if self.autoconfigure_cmd is None:
159
+ LOGGER.error("Autoconfigure command not available. This feature is in development.")
160
+ return EXIT_INVALID_USAGE
161
+
162
+ return self.autoconfigure_cmd.execute(args)
163
+
164
+ def _handle_scan(self, args) -> int:
165
+ """Handle the scan command.
166
+
167
+ Args:
168
+ args: Parsed command-line arguments.
169
+
170
+ Returns:
171
+ Exit code.
172
+ """
173
+ # Load configuration
174
+ project_root = Path(args.path).resolve()
175
+ cli_overrides = ConfigBridge.args_to_overrides(args)
176
+
177
+ try:
178
+ config = load_config(
179
+ project_root=project_root,
180
+ cli_config_path=getattr(args, "config", None),
181
+ cli_overrides=cli_overrides,
182
+ )
183
+ except ConfigError as e:
184
+ LOGGER.error(str(e))
185
+ return EXIT_INVALID_USAGE
186
+
187
+ # Check if any domains are enabled
188
+ cli_scan_requested = any([
189
+ getattr(args, "sca", False),
190
+ getattr(args, "container", False),
191
+ getattr(args, "iac", False),
192
+ getattr(args, "sast", False),
193
+ getattr(args, "linting", False),
194
+ getattr(args, "type_checking", False),
195
+ getattr(args, "testing", False),
196
+ getattr(args, "coverage", False),
197
+ getattr(args, "all", False),
198
+ ])
199
+
200
+ config_has_enabled_domains = bool(config.get_enabled_domains())
201
+
202
+ if cli_scan_requested or config_has_enabled_domains:
203
+ try:
204
+ return self.scan_cmd.execute(args, config)
205
+ except FileNotFoundError:
206
+ return EXIT_INVALID_USAGE
207
+ except Exception as e:
208
+ if args.debug:
209
+ import traceback
210
+ traceback.print_exc()
211
+ LOGGER.error(f"Scan failed: {e}")
212
+ return EXIT_SCANNER_ERROR
213
+
214
+ # No scanners selected - show scan help
215
+ print("No scan domains selected. Use --sca, --sast, --iac, --linting, --type-checking, or --all.")
216
+ print("\nRun 'lucidscan scan --help' for more options.")
217
+ return EXIT_SUCCESS
218
+
219
+ def _handle_status(self, args) -> int:
220
+ """Handle the status command.
221
+
222
+ Args:
223
+ args: Parsed command-line arguments.
224
+
225
+ Returns:
226
+ Exit code.
227
+ """
228
+ return self.status_cmd.execute(args)
229
+
230
+ def _handle_serve(self, args) -> int:
231
+ """Handle the serve command.
232
+
233
+ Args:
234
+ args: Parsed command-line arguments.
235
+
236
+ Returns:
237
+ Exit code.
238
+ """
239
+ # Load configuration
240
+ project_root = Path(args.path).resolve()
241
+ cli_overrides = ConfigBridge.args_to_overrides(args)
242
+
243
+ try:
244
+ config = load_config(
245
+ project_root=project_root,
246
+ cli_config_path=getattr(args, "config", None),
247
+ cli_overrides=cli_overrides,
248
+ )
249
+ except ConfigError as e:
250
+ LOGGER.error(str(e))
251
+ return EXIT_INVALID_USAGE
252
+
253
+ try:
254
+ from lucidscan.cli.commands.serve import ServeCommand
255
+ serve_cmd = ServeCommand(version=self._version)
256
+ return serve_cmd.execute(args, config)
257
+ except ImportError as e:
258
+ LOGGER.error(f"Serve command not available: {e}")
259
+ return EXIT_INVALID_USAGE
260
+
261
+ def _handle_help(self, args) -> int:
262
+ """Handle the help command.
263
+
264
+ Args:
265
+ args: Parsed command-line arguments.
266
+
267
+ Returns:
268
+ Exit code.
269
+ """
270
+ return self.help_cmd.execute(args)
271
+
272
+ def _handle_validate(self, args) -> int:
273
+ """Handle the validate command.
274
+
275
+ Args:
276
+ args: Parsed command-line arguments.
277
+
278
+ Returns:
279
+ Exit code.
280
+ """
281
+ from lucidscan.cli.commands.validate import ValidateCommand
282
+
283
+ validate_cmd = ValidateCommand()
284
+ return validate_cmd.execute(args)
@@ -0,0 +1,29 @@
1
+ """Configuration module for lucidscan.
2
+
3
+ Provides configuration file loading, parsing, and validation with support for:
4
+ - Project-level config (.lucidscan.yml)
5
+ - Global config (~/.lucidscan/config/config.yml)
6
+ - Environment variable expansion
7
+ - Plugin-specific configuration passthrough
8
+ """
9
+
10
+ from lucidscan.config.models import (
11
+ LucidScanConfig,
12
+ OutputConfig,
13
+ ScannerDomainConfig,
14
+ DEFAULT_PLUGINS,
15
+ )
16
+ from lucidscan.config.loader import load_config, find_project_config, find_global_config
17
+ from lucidscan.config.validation import validate_config, ConfigValidationWarning
18
+
19
+ __all__ = [
20
+ "LucidScanConfig",
21
+ "OutputConfig",
22
+ "ScannerDomainConfig",
23
+ "DEFAULT_PLUGINS",
24
+ "load_config",
25
+ "find_project_config",
26
+ "find_global_config",
27
+ "validate_config",
28
+ "ConfigValidationWarning",
29
+ ]
@@ -0,0 +1,178 @@
1
+ """Gitignore-style pattern parsing and matching.
2
+
3
+ Handles loading and matching of ignore patterns from:
4
+ - .lucidscanignore file (gitignore syntax)
5
+ - config.ignore list (gitignore syntax)
6
+
7
+ Uses pathspec library for full gitignore compliance including:
8
+ - ** recursive globbing
9
+ - ! negation patterns
10
+ - # comments
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, List, Optional
17
+
18
+ import pathspec
19
+
20
+ from lucidscan.core.logging import get_logger
21
+
22
+ if TYPE_CHECKING:
23
+ pass
24
+
25
+ LOGGER = get_logger(__name__)
26
+
27
+ LUCIDSCANIGNORE_NAMES = [".lucidscanignore"]
28
+
29
+
30
+ class IgnorePatterns:
31
+ """Manages ignore patterns from multiple sources."""
32
+
33
+ def __init__(
34
+ self,
35
+ patterns: List[str],
36
+ source: str = "config",
37
+ ) -> None:
38
+ """Initialize with a list of gitignore-style patterns.
39
+
40
+ Args:
41
+ patterns: List of gitignore-style patterns.
42
+ source: Source description for logging.
43
+ """
44
+ self._source = source
45
+ self._raw_patterns = patterns
46
+
47
+ # Filter out empty lines and comments for the PathSpec
48
+ clean_patterns = [
49
+ p for p in patterns if p.strip() and not p.strip().startswith("#")
50
+ ]
51
+
52
+ self._spec = pathspec.PathSpec.from_lines(
53
+ pathspec.patterns.GitWildMatchPattern,
54
+ clean_patterns,
55
+ )
56
+
57
+ if clean_patterns:
58
+ LOGGER.debug(f"Loaded {len(clean_patterns)} ignore patterns from {source}")
59
+
60
+ def matches(self, path: Path, root: Path) -> bool:
61
+ """Check if a path matches any ignore pattern.
62
+
63
+ Args:
64
+ path: Path to check (absolute or relative).
65
+ root: Project root for relative path calculation.
66
+
67
+ Returns:
68
+ True if path should be ignored, False otherwise.
69
+ """
70
+ try:
71
+ rel_path = path.relative_to(root)
72
+ except ValueError:
73
+ rel_path = path
74
+
75
+ # pathspec expects forward-slash paths
76
+ rel_str = str(rel_path).replace("\\", "/")
77
+ return self._spec.match_file(rel_str)
78
+
79
+ def get_exclude_patterns(self) -> List[str]:
80
+ """Get patterns suitable for scanner --exclude flags.
81
+
82
+ Returns clean patterns without comments, suitable for
83
+ passing to scanner CLIs.
84
+ """
85
+ return [
86
+ p.strip()
87
+ for p in self._raw_patterns
88
+ if p.strip() and not p.strip().startswith("#")
89
+ ]
90
+
91
+ @classmethod
92
+ def from_file(cls, file_path: Path) -> Optional["IgnorePatterns"]:
93
+ """Load patterns from a file.
94
+
95
+ Args:
96
+ file_path: Path to ignore file.
97
+
98
+ Returns:
99
+ IgnorePatterns instance, or None if file doesn't exist.
100
+ """
101
+ if not file_path.exists():
102
+ return None
103
+
104
+ try:
105
+ content = file_path.read_text(encoding="utf-8")
106
+ patterns = content.splitlines()
107
+ return cls(patterns, source=str(file_path))
108
+ except Exception as e:
109
+ LOGGER.warning(f"Failed to load ignore file {file_path}: {e}")
110
+ return None
111
+
112
+ @classmethod
113
+ def merge(cls, *pattern_sets: Optional["IgnorePatterns"]) -> "IgnorePatterns":
114
+ """Merge multiple IgnorePatterns instances.
115
+
116
+ Args:
117
+ pattern_sets: IgnorePatterns instances to merge.
118
+
119
+ Returns:
120
+ New IgnorePatterns with combined patterns.
121
+ """
122
+ all_patterns: List[str] = []
123
+ sources: List[str] = []
124
+
125
+ for ps in pattern_sets:
126
+ if ps is not None:
127
+ all_patterns.extend(ps._raw_patterns)
128
+ sources.append(ps._source)
129
+
130
+ return cls(all_patterns, source="+".join(sources) if sources else "empty")
131
+
132
+
133
+ def find_lucidscanignore(project_root: Path) -> Optional[Path]:
134
+ """Find .lucidscanignore file in project root.
135
+
136
+ Args:
137
+ project_root: Project root directory.
138
+
139
+ Returns:
140
+ Path to ignore file if found, None otherwise.
141
+ """
142
+ for name in LUCIDSCANIGNORE_NAMES:
143
+ ignore_path = project_root / name
144
+ if ignore_path.exists():
145
+ return ignore_path
146
+ return None
147
+
148
+
149
+ def load_ignore_patterns(
150
+ project_root: Path,
151
+ config_patterns: List[str],
152
+ ) -> IgnorePatterns:
153
+ """Load and merge ignore patterns from all sources.
154
+
155
+ Loads patterns from:
156
+ 1. .lucidscanignore file (if present)
157
+ 2. config.ignore list from .lucidscan.yml
158
+
159
+ Args:
160
+ project_root: Project root directory.
161
+ config_patterns: Patterns from config.ignore.
162
+
163
+ Returns:
164
+ Merged IgnorePatterns instance.
165
+ """
166
+ # Load from .lucidscanignore
167
+ ignore_file = find_lucidscanignore(project_root)
168
+ file_patterns = IgnorePatterns.from_file(ignore_file) if ignore_file else None
169
+
170
+ # Load from config
171
+ config_ignore = (
172
+ IgnorePatterns(config_patterns, source="config.ignore")
173
+ if config_patterns
174
+ else None
175
+ )
176
+
177
+ # Merge (file patterns first, then config)
178
+ return IgnorePatterns.merge(file_patterns, config_ignore)