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,463 @@
1
+ """Shared domain runner for executing scanner plugins.
2
+
3
+ This module provides shared functionality for running scanner plugins
4
+ across both CLI and MCP interfaces.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Type
11
+
12
+ from lucidscan.config import LucidScanConfig
13
+ from lucidscan.core.logging import get_logger
14
+ from lucidscan.core.models import ScanContext, ScanDomain, UnifiedIssue
15
+
16
+ LOGGER = get_logger(__name__)
17
+
18
+ # Plugin to supported languages mapping
19
+ PLUGIN_LANGUAGES: Dict[str, List[str]] = {
20
+ # Linters
21
+ "ruff": ["python"],
22
+ "eslint": ["javascript", "typescript"],
23
+ "biome": ["javascript", "typescript"],
24
+ "checkstyle": ["java"],
25
+ # Type checkers
26
+ "mypy": ["python"],
27
+ "pyright": ["python"],
28
+ "typescript": ["typescript"],
29
+ # Test runners
30
+ "pytest": ["python"],
31
+ "jest": ["javascript", "typescript"],
32
+ "karma": ["javascript", "typescript"],
33
+ "playwright": ["javascript", "typescript"],
34
+ # Coverage
35
+ "coverage_py": ["python"],
36
+ "istanbul": ["javascript", "typescript"],
37
+ }
38
+
39
+ # File extension to language mapping
40
+ EXTENSION_LANGUAGE: Dict[str, str] = {
41
+ ".py": "python",
42
+ ".pyi": "python",
43
+ ".js": "javascript",
44
+ ".jsx": "javascript",
45
+ ".ts": "typescript",
46
+ ".tsx": "typescript",
47
+ ".java": "java",
48
+ ".go": "go",
49
+ ".rs": "rust",
50
+ ".rb": "ruby",
51
+ ".tf": "terraform",
52
+ ".yaml": "yaml",
53
+ ".yml": "yaml",
54
+ ".json": "json",
55
+ }
56
+
57
+
58
+ def filter_plugins_by_language(
59
+ plugins: Dict[str, Type[Any]],
60
+ project_languages: List[str],
61
+ ) -> Dict[str, Type[Any]]:
62
+ """Filter plugins to only those supporting the project's languages.
63
+
64
+ Args:
65
+ plugins: Dict of plugin_name -> plugin_class.
66
+ project_languages: List of languages from project config.
67
+
68
+ Returns:
69
+ Filtered dict of plugins that support at least one project language.
70
+ """
71
+ if not project_languages:
72
+ return plugins
73
+
74
+ filtered = {}
75
+ for name, cls in plugins.items():
76
+ supported_langs = PLUGIN_LANGUAGES.get(name, [])
77
+ # Include plugin if it supports any of the project languages
78
+ # or if the plugin has no language restrictions
79
+ if not supported_langs or any(
80
+ lang.lower() in [sl.lower() for sl in supported_langs]
81
+ for lang in project_languages
82
+ ):
83
+ filtered[name] = cls
84
+
85
+ return filtered
86
+
87
+
88
+ def filter_plugins_by_config(
89
+ plugins: Dict[str, Type[Any]],
90
+ config: LucidScanConfig,
91
+ domain: str,
92
+ ) -> Dict[str, Type[Any]]:
93
+ """Filter plugins based on configuration.
94
+
95
+ First tries to filter by explicitly configured tools. If none are
96
+ configured, falls back to language-based filtering.
97
+
98
+ Args:
99
+ plugins: Dict of plugin_name -> plugin_class.
100
+ config: LucidScan configuration.
101
+ domain: Domain name (linting, type_checking, testing, coverage).
102
+
103
+ Returns:
104
+ Filtered dict of plugins.
105
+ """
106
+ configured_tools = config.pipeline.get_enabled_tool_names(domain)
107
+ if configured_tools:
108
+ return {
109
+ name: cls for name, cls in plugins.items()
110
+ if name in configured_tools
111
+ }
112
+ return filter_plugins_by_language(plugins, config.project.languages)
113
+
114
+
115
+ def filter_scanners_by_config(
116
+ scanners: Dict[str, Type[Any]],
117
+ config: LucidScanConfig,
118
+ domain: str,
119
+ ) -> Dict[str, Type[Any]]:
120
+ """Filter scanner plugins based on configuration for a specific domain.
121
+
122
+ Args:
123
+ scanners: Dict of scanner_name -> scanner_class.
124
+ config: LucidScan configuration.
125
+ domain: Scanner domain (sast, sca, iac, container).
126
+
127
+ Returns:
128
+ Filtered dict of scanners.
129
+ """
130
+ configured_plugin = config.get_plugin_for_domain(domain)
131
+ if configured_plugin:
132
+ return {
133
+ name: cls for name, cls in scanners.items()
134
+ if name == configured_plugin
135
+ }
136
+ return scanners
137
+
138
+
139
+ def detect_language(path: Path) -> str:
140
+ """Detect language from file extension.
141
+
142
+ Args:
143
+ path: File path.
144
+
145
+ Returns:
146
+ Language name or "unknown".
147
+ """
148
+ suffix = path.suffix.lower()
149
+ return EXTENSION_LANGUAGE.get(suffix, "unknown")
150
+
151
+
152
+ def get_domains_for_language(language: str) -> List[str]:
153
+ """Get appropriate domains for a language.
154
+
155
+ Args:
156
+ language: Language name.
157
+
158
+ Returns:
159
+ List of domain names.
160
+ """
161
+ # Default domains for most languages - use specific security domains
162
+ # "sast" for static analysis, "sca" for dependency scanning
163
+ domains = ["linting", "sast", "sca"]
164
+
165
+ if language == "python":
166
+ domains.extend(["type_checking", "testing", "coverage"])
167
+ elif language in ("javascript", "typescript"):
168
+ domains.extend(["type_checking", "testing", "coverage"])
169
+ elif language == "terraform":
170
+ domains = ["iac"]
171
+ elif language in ("yaml", "json"):
172
+ domains = ["iac", "sast"]
173
+
174
+ return domains
175
+
176
+
177
+ class DomainRunner:
178
+ """Executes plugin-based domain scans.
179
+
180
+ Provides a unified interface for running linting, type checking,
181
+ testing, coverage, and security scans across both CLI and MCP.
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ project_root: Path,
187
+ config: LucidScanConfig,
188
+ log_level: str = "info",
189
+ ):
190
+ """Initialize DomainRunner.
191
+
192
+ Args:
193
+ project_root: Project root directory.
194
+ config: LucidScan configuration.
195
+ log_level: Logging level for plugin execution ("info" or "debug").
196
+ """
197
+ self.project_root = project_root
198
+ self.config = config
199
+ self._log_level = log_level
200
+
201
+ def _log(self, level: str, message: str) -> None:
202
+ """Log a message at the configured level."""
203
+ if level == "info" and self._log_level == "info":
204
+ LOGGER.info(message)
205
+ else:
206
+ LOGGER.debug(message)
207
+
208
+ def run_linting(
209
+ self,
210
+ context: ScanContext,
211
+ fix: bool = False,
212
+ ) -> List[UnifiedIssue]:
213
+ """Run linting checks.
214
+
215
+ Args:
216
+ context: Scan context.
217
+ fix: Whether to apply automatic fixes.
218
+
219
+ Returns:
220
+ List of linting issues.
221
+ """
222
+ from lucidscan.plugins.linters import discover_linter_plugins
223
+
224
+ issues: List[UnifiedIssue] = []
225
+ linters = discover_linter_plugins()
226
+
227
+ if not linters:
228
+ LOGGER.warning("No linter plugins found")
229
+ return issues
230
+
231
+ linters = filter_plugins_by_config(linters, self.config, "linting")
232
+
233
+ for name, plugin_class in linters.items():
234
+ try:
235
+ self._log("info", f"Running linter: {name}")
236
+ plugin = plugin_class(project_root=self.project_root)
237
+
238
+ if fix and plugin.supports_fix:
239
+ fix_result = plugin.fix(context)
240
+ self._log(
241
+ "info",
242
+ f"{name}: Fixed {fix_result.issues_fixed} issues, "
243
+ f"{fix_result.issues_remaining} remaining"
244
+ )
245
+ # Run again to get remaining issues
246
+ issues.extend(plugin.lint(context))
247
+ else:
248
+ issues.extend(plugin.lint(context))
249
+
250
+ except Exception as e:
251
+ LOGGER.error(f"Linter {name} failed: {e}")
252
+
253
+ return issues
254
+
255
+ def run_type_checking(self, context: ScanContext) -> List[UnifiedIssue]:
256
+ """Run type checking.
257
+
258
+ Args:
259
+ context: Scan context.
260
+
261
+ Returns:
262
+ List of type checking issues.
263
+ """
264
+ from lucidscan.plugins.type_checkers import discover_type_checker_plugins
265
+
266
+ issues: List[UnifiedIssue] = []
267
+ checkers = discover_type_checker_plugins()
268
+
269
+ if not checkers:
270
+ LOGGER.warning("No type checker plugins found")
271
+ return issues
272
+
273
+ checkers = filter_plugins_by_config(checkers, self.config, "type_checking")
274
+
275
+ for name, plugin_class in checkers.items():
276
+ try:
277
+ self._log("info", f"Running type checker: {name}")
278
+ plugin = plugin_class(project_root=self.project_root)
279
+ issues.extend(plugin.check(context))
280
+
281
+ except Exception as e:
282
+ LOGGER.error(f"Type checker {name} failed: {e}")
283
+
284
+ return issues
285
+
286
+ def run_tests(self, context: ScanContext) -> List[UnifiedIssue]:
287
+ """Run test suite.
288
+
289
+ Args:
290
+ context: Scan context.
291
+
292
+ Returns:
293
+ List of test failure issues.
294
+ """
295
+ from lucidscan.plugins.test_runners import discover_test_runner_plugins
296
+
297
+ issues: List[UnifiedIssue] = []
298
+ runners = discover_test_runner_plugins()
299
+
300
+ if not runners:
301
+ LOGGER.warning("No test runner plugins found")
302
+ return issues
303
+
304
+ runners = filter_plugins_by_config(runners, self.config, "testing")
305
+
306
+ for name, plugin_class in runners.items():
307
+ try:
308
+ self._log("info", f"Running test runner: {name}")
309
+ plugin = plugin_class(project_root=self.project_root)
310
+ result = plugin.run_tests(context)
311
+
312
+ self._log(
313
+ "info",
314
+ f"{name}: {result.passed} passed, {result.failed} failed, "
315
+ f"{result.skipped} skipped, {result.errors} errors"
316
+ )
317
+
318
+ issues.extend(result.issues)
319
+
320
+ except FileNotFoundError:
321
+ LOGGER.debug(f"Test runner {name} not available")
322
+ except Exception as e:
323
+ LOGGER.error(f"Test runner {name} failed: {e}")
324
+
325
+ return issues
326
+
327
+ def run_coverage(
328
+ self,
329
+ context: ScanContext,
330
+ threshold: float = 80.0,
331
+ run_tests: bool = True,
332
+ ) -> List[UnifiedIssue]:
333
+ """Run coverage analysis.
334
+
335
+ Args:
336
+ context: Scan context.
337
+ threshold: Coverage percentage threshold.
338
+ run_tests: Whether to run tests for coverage.
339
+
340
+ Returns:
341
+ List of coverage issues.
342
+ """
343
+ from lucidscan.plugins.coverage import discover_coverage_plugins
344
+
345
+ issues: List[UnifiedIssue] = []
346
+ plugins = discover_coverage_plugins()
347
+
348
+ if not plugins:
349
+ LOGGER.warning("No coverage plugins found")
350
+ return issues
351
+
352
+ plugins = filter_plugins_by_config(plugins, self.config, "coverage")
353
+
354
+ for name, plugin_class in plugins.items():
355
+ try:
356
+ self._log("info", f"Running coverage: {name}")
357
+ plugin = plugin_class(project_root=self.project_root)
358
+ result = plugin.measure_coverage(
359
+ context, threshold=threshold, run_tests=run_tests
360
+ )
361
+
362
+ status = "PASSED" if result.passed else "FAILED"
363
+
364
+ # Build log message with test stats if available
365
+ log_parts = [
366
+ f"{name}: {result.percentage:.1f}%",
367
+ f"({result.covered_lines}/{result.total_lines} lines)",
368
+ f"- threshold: {threshold}%",
369
+ f"- {status}",
370
+ ]
371
+ if result.test_stats:
372
+ ts = result.test_stats
373
+ log_parts.append(
374
+ f"| Tests: {ts.total} total, {ts.passed} passed, "
375
+ f"{ts.failed} failed, {ts.skipped} skipped"
376
+ )
377
+
378
+ self._log("info", " ".join(log_parts))
379
+
380
+ # Store the coverage result in context for MCP to access
381
+ context.coverage_result = result
382
+
383
+ issues.extend(result.issues)
384
+
385
+ except FileNotFoundError:
386
+ LOGGER.debug(f"Coverage plugin {name} not available")
387
+ except Exception as e:
388
+ LOGGER.error(f"Coverage plugin {name} failed: {e}")
389
+
390
+ return issues
391
+
392
+ def run_security(
393
+ self,
394
+ context: ScanContext,
395
+ domain: ScanDomain,
396
+ ) -> List[UnifiedIssue]:
397
+ """Run security scanner for a specific domain.
398
+
399
+ Args:
400
+ context: Scan context.
401
+ domain: Scanner domain (SAST, SCA, IAC, CONTAINER).
402
+
403
+ Returns:
404
+ List of security issues.
405
+ """
406
+ from lucidscan.plugins.scanners import discover_scanner_plugins
407
+
408
+ issues: List[UnifiedIssue] = []
409
+ scanners = discover_scanner_plugins()
410
+
411
+ if not scanners:
412
+ LOGGER.warning("No scanner plugins found")
413
+ return issues
414
+
415
+ # Filter by configured plugin for this domain
416
+ domain_str = domain.value.lower()
417
+ scanners = filter_scanners_by_config(scanners, self.config, domain_str)
418
+
419
+ for name, scanner_class in scanners.items():
420
+ try:
421
+ scanner = scanner_class(project_root=self.project_root)
422
+ if domain in scanner.domains:
423
+ self._log("info", f"Running {domain_str} scanner: {name}")
424
+ result = scanner.scan(context)
425
+ issues.extend(result)
426
+
427
+ except Exception as e:
428
+ LOGGER.error(f"Scanner {name} failed: {e}")
429
+
430
+ return issues
431
+
432
+
433
+ def check_severity_threshold(
434
+ issues: List[UnifiedIssue],
435
+ threshold: Optional[str],
436
+ ) -> bool:
437
+ """Check if any issues meet or exceed the severity threshold.
438
+
439
+ Args:
440
+ issues: List of issues to check.
441
+ threshold: Severity threshold ('critical', 'high', 'medium', 'low').
442
+
443
+ Returns:
444
+ True if issues at or above threshold exist, False otherwise.
445
+ """
446
+ if not threshold or not issues:
447
+ return False
448
+
449
+ threshold_order = {
450
+ "critical": 0,
451
+ "high": 1,
452
+ "medium": 2,
453
+ "low": 3,
454
+ }
455
+
456
+ threshold_level = threshold_order.get(threshold.lower(), 99)
457
+
458
+ for issue in issues:
459
+ issue_level = threshold_order.get(issue.severity.value, 99)
460
+ if issue_level <= threshold_level:
461
+ return True
462
+
463
+ return False
lucidscan/core/git.py ADDED
@@ -0,0 +1,174 @@
1
+ """Git utilities for detecting changed files.
2
+
3
+ Provides functionality to detect uncommitted changes in a git repository
4
+ for partial/incremental scanning.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ from lucidscan.core.logging import get_logger
14
+
15
+ LOGGER = get_logger(__name__)
16
+
17
+
18
+ def is_git_repo(path: Path) -> bool:
19
+ """Check if the given path is inside a git repository.
20
+
21
+ Args:
22
+ path: Path to check.
23
+
24
+ Returns:
25
+ True if inside a git repository, False otherwise.
26
+ """
27
+ try:
28
+ result = subprocess.run(
29
+ ["git", "rev-parse", "--git-dir"],
30
+ cwd=path,
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=5,
34
+ )
35
+ return result.returncode == 0
36
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
37
+ return False
38
+
39
+
40
+ def get_git_root(path: Path) -> Optional[Path]:
41
+ """Get the root directory of the git repository.
42
+
43
+ Args:
44
+ path: Path inside the repository.
45
+
46
+ Returns:
47
+ Path to git root, or None if not a git repository.
48
+ """
49
+ try:
50
+ result = subprocess.run(
51
+ ["git", "rev-parse", "--show-toplevel"],
52
+ cwd=path,
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=5,
56
+ )
57
+ if result.returncode == 0:
58
+ return Path(result.stdout.strip())
59
+ return None
60
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
61
+ return None
62
+
63
+
64
+ def get_changed_files(
65
+ project_root: Path,
66
+ include_untracked: bool = True,
67
+ include_staged: bool = True,
68
+ include_unstaged: bool = True,
69
+ ) -> Optional[List[Path]]:
70
+ """Get list of changed files in the git repository.
71
+
72
+ Returns files that have uncommitted changes (staged, unstaged, or untracked).
73
+
74
+ Args:
75
+ project_root: Root directory of the project.
76
+ include_untracked: Include untracked files.
77
+ include_staged: Include staged (added to index) files.
78
+ include_unstaged: Include unstaged modifications.
79
+
80
+ Returns:
81
+ List of changed file paths (absolute), or None if not a git repo
82
+ or git command fails.
83
+ """
84
+ if not is_git_repo(project_root):
85
+ LOGGER.debug(f"Not a git repository: {project_root}")
86
+ return None
87
+
88
+ changed_files: set[Path] = set()
89
+
90
+ try:
91
+ # Get staged files (files added to index)
92
+ if include_staged:
93
+ result = subprocess.run(
94
+ ["git", "diff", "--cached", "--name-only"],
95
+ cwd=project_root,
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=30,
99
+ )
100
+ if result.returncode == 0:
101
+ for line in result.stdout.strip().split("\n"):
102
+ if line:
103
+ file_path = project_root / line
104
+ if file_path.exists():
105
+ changed_files.add(file_path)
106
+
107
+ # Get unstaged modifications (modified but not staged)
108
+ if include_unstaged:
109
+ result = subprocess.run(
110
+ ["git", "diff", "--name-only"],
111
+ cwd=project_root,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=30,
115
+ )
116
+ if result.returncode == 0:
117
+ for line in result.stdout.strip().split("\n"):
118
+ if line:
119
+ file_path = project_root / line
120
+ if file_path.exists():
121
+ changed_files.add(file_path)
122
+
123
+ # Get untracked files
124
+ if include_untracked:
125
+ result = subprocess.run(
126
+ ["git", "ls-files", "--others", "--exclude-standard"],
127
+ cwd=project_root,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=30,
131
+ )
132
+ if result.returncode == 0:
133
+ for line in result.stdout.strip().split("\n"):
134
+ if line:
135
+ file_path = project_root / line
136
+ if file_path.exists():
137
+ changed_files.add(file_path)
138
+
139
+ LOGGER.debug(f"Found {len(changed_files)} changed files in {project_root}")
140
+ return sorted(changed_files)
141
+
142
+ except subprocess.TimeoutExpired:
143
+ LOGGER.warning("Git command timed out, falling back to full scan")
144
+ return None
145
+ except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
146
+ LOGGER.warning(f"Git command failed: {e}, falling back to full scan")
147
+ return None
148
+
149
+
150
+ def filter_files_by_extension(
151
+ files: List[Path],
152
+ extensions: Optional[List[str]] = None,
153
+ ) -> List[Path]:
154
+ """Filter files by extension.
155
+
156
+ Args:
157
+ files: List of file paths.
158
+ extensions: List of extensions to include (e.g., [".py", ".js"]).
159
+ If None, returns all files.
160
+
161
+ Returns:
162
+ Filtered list of files.
163
+ """
164
+ if extensions is None:
165
+ return files
166
+
167
+ # Normalize extensions to include the dot
168
+ normalized_extensions = set()
169
+ for ext in extensions:
170
+ if not ext.startswith("."):
171
+ ext = f".{ext}"
172
+ normalized_extensions.add(ext.lower())
173
+
174
+ return [f for f in files if f.suffix.lower() in normalized_extensions]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+
7
+ def configure_logging(*, debug: bool = False, verbose: bool = False, quiet: bool = False) -> None:
8
+ """Configure root logging level based on CLI flags.
9
+
10
+ Precedence:
11
+ - quiet → ERROR
12
+ - debug → DEBUG
13
+ - verbose → INFO
14
+ - default → WARNING
15
+ """
16
+
17
+ if quiet:
18
+ level = logging.ERROR
19
+ elif debug:
20
+ level = logging.DEBUG
21
+ elif verbose:
22
+ level = logging.INFO
23
+ else:
24
+ level = logging.WARNING
25
+
26
+ logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
27
+
28
+
29
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
30
+ """Return a module-level logger."""
31
+
32
+ return logging.getLogger(name if name is not None else __name__)
33
+
34
+