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,411 @@
1
+ """Istanbul/NYC coverage plugin.
2
+
3
+ Istanbul (via NYC) is a JavaScript code coverage tool.
4
+ https://istanbul.js.org/
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from lucidscan.core.logging import get_logger
18
+ from lucidscan.core.models import (
19
+ ScanContext,
20
+ Severity,
21
+ ToolDomain,
22
+ UnifiedIssue,
23
+ )
24
+ from lucidscan.plugins.coverage.base import (
25
+ CoveragePlugin,
26
+ CoverageResult,
27
+ FileCoverage,
28
+ )
29
+
30
+ LOGGER = get_logger(__name__)
31
+
32
+
33
+ class IstanbulPlugin(CoveragePlugin):
34
+ """Istanbul/NYC coverage plugin for JavaScript/TypeScript coverage analysis."""
35
+
36
+ def __init__(self, project_root: Optional[Path] = None):
37
+ """Initialize IstanbulPlugin.
38
+
39
+ Args:
40
+ project_root: Optional project root for finding NYC installation.
41
+ """
42
+ self._project_root = project_root
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ """Plugin identifier."""
47
+ return "istanbul"
48
+
49
+ @property
50
+ def languages(self) -> List[str]:
51
+ """Supported languages."""
52
+ return ["javascript", "typescript"]
53
+
54
+ def get_version(self) -> str:
55
+ """Get NYC version.
56
+
57
+ Returns:
58
+ Version string or 'unknown' if unable to determine.
59
+ """
60
+ try:
61
+ binary = self.ensure_binary()
62
+ result = subprocess.run(
63
+ [str(binary), "--version"],
64
+ capture_output=True,
65
+ text=True,
66
+ encoding="utf-8",
67
+ errors="replace",
68
+ )
69
+ # Output is just the version number like "15.1.0"
70
+ if result.returncode == 0:
71
+ return result.stdout.strip()
72
+ except Exception:
73
+ pass
74
+ return "unknown"
75
+
76
+ def ensure_binary(self) -> Path:
77
+ """Ensure NYC is available.
78
+
79
+ Checks for NYC in:
80
+ 1. Project's node_modules/.bin/nyc
81
+ 2. System PATH (globally installed)
82
+
83
+ Returns:
84
+ Path to NYC binary.
85
+
86
+ Raises:
87
+ FileNotFoundError: If NYC is not installed.
88
+ """
89
+ # Check project node_modules first
90
+ if self._project_root:
91
+ node_nyc = self._project_root / "node_modules" / ".bin" / "nyc"
92
+ if node_nyc.exists():
93
+ return node_nyc
94
+
95
+ # Check system PATH
96
+ nyc_path = shutil.which("nyc")
97
+ if nyc_path:
98
+ return Path(nyc_path)
99
+
100
+ raise FileNotFoundError(
101
+ "NYC (Istanbul) is not installed. Install it with:\n"
102
+ " npm install nyc --save-dev\n"
103
+ " OR\n"
104
+ " npm install -g nyc"
105
+ )
106
+
107
+ def measure_coverage(
108
+ self,
109
+ context: ScanContext,
110
+ threshold: float = 80.0,
111
+ run_tests: bool = True,
112
+ ) -> CoverageResult:
113
+ """Run coverage analysis on the specified paths.
114
+
115
+ Args:
116
+ context: Scan context with paths and configuration.
117
+ threshold: Coverage percentage threshold (default 80%).
118
+ run_tests: Whether to run tests if no existing coverage data exists.
119
+
120
+ Returns:
121
+ CoverageResult with coverage statistics and issues if below threshold.
122
+ """
123
+ try:
124
+ binary = self.ensure_binary()
125
+ except FileNotFoundError as e:
126
+ LOGGER.warning(str(e))
127
+ return CoverageResult(threshold=threshold)
128
+
129
+ # Always run tests fresh when run_tests=True to ensure accurate coverage
130
+ if run_tests:
131
+ LOGGER.info("Running tests with coverage...")
132
+ if not self._run_tests_with_coverage(binary, context):
133
+ LOGGER.warning("Failed to run tests with coverage")
134
+ return CoverageResult(threshold=threshold)
135
+
136
+ # Generate JSON report from coverage data
137
+ result = self._generate_and_parse_report(binary, context, threshold)
138
+
139
+ return result
140
+
141
+ def _run_tests_with_coverage(
142
+ self,
143
+ binary: Path,
144
+ context: ScanContext,
145
+ ) -> bool:
146
+ """Run tests with NYC coverage.
147
+
148
+ Args:
149
+ binary: Path to NYC binary.
150
+ context: Scan context.
151
+
152
+ Returns:
153
+ True if tests ran successfully.
154
+ """
155
+ # Check for jest or npm test
156
+ jest_path = None
157
+ if self._project_root:
158
+ node_jest = self._project_root / "node_modules" / ".bin" / "jest"
159
+ if node_jest.exists():
160
+ jest_path = node_jest
161
+
162
+ if not jest_path:
163
+ jest_which = shutil.which("jest")
164
+ if jest_which:
165
+ jest_path = Path(jest_which)
166
+
167
+ if jest_path:
168
+ # Run nyc jest
169
+ cmd = [str(binary), str(jest_path), "--passWithNoTests"]
170
+ else:
171
+ # Fall back to npm test
172
+ cmd = [str(binary), "npm", "test"]
173
+
174
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
175
+
176
+ try:
177
+ subprocess.run(
178
+ cmd,
179
+ capture_output=True,
180
+ text=True,
181
+ encoding="utf-8",
182
+ errors="replace",
183
+ cwd=str(context.project_root),
184
+ )
185
+ return True
186
+ except Exception as e:
187
+ LOGGER.error(f"Failed to run tests with coverage: {e}")
188
+ return False
189
+
190
+ def _generate_and_parse_report(
191
+ self,
192
+ binary: Path,
193
+ context: ScanContext,
194
+ threshold: float,
195
+ ) -> CoverageResult:
196
+ """Generate JSON report and parse it.
197
+
198
+ Args:
199
+ binary: Path to NYC binary.
200
+ context: Scan context.
201
+ threshold: Coverage percentage threshold.
202
+
203
+ Returns:
204
+ CoverageResult with parsed data.
205
+ """
206
+ with tempfile.TemporaryDirectory() as tmpdir:
207
+ report_dir = Path(tmpdir)
208
+
209
+ cmd = [
210
+ str(binary),
211
+ "report",
212
+ "--reporter=json-summary",
213
+ f"--report-dir={report_dir}",
214
+ ]
215
+
216
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
217
+
218
+ try:
219
+ result = subprocess.run(
220
+ cmd,
221
+ capture_output=True,
222
+ text=True,
223
+ encoding="utf-8",
224
+ errors="replace",
225
+ cwd=str(context.project_root),
226
+ )
227
+
228
+ if result.returncode != 0:
229
+ LOGGER.warning(f"NYC report failed: {result.stderr}")
230
+ return CoverageResult(threshold=threshold)
231
+
232
+ except Exception as e:
233
+ LOGGER.error(f"Failed to generate coverage report: {e}")
234
+ return CoverageResult(threshold=threshold)
235
+
236
+ # Parse JSON report
237
+ report_file = report_dir / "coverage-summary.json"
238
+ if report_file.exists():
239
+ return self._parse_json_report(report_file, context.project_root, threshold)
240
+ else:
241
+ LOGGER.warning("Coverage JSON report not generated")
242
+ return CoverageResult(threshold=threshold)
243
+
244
+ def _parse_json_report(
245
+ self,
246
+ report_file: Path,
247
+ project_root: Path,
248
+ threshold: float,
249
+ ) -> CoverageResult:
250
+ """Parse Istanbul JSON summary report.
251
+
252
+ Args:
253
+ report_file: Path to JSON report file.
254
+ project_root: Project root directory.
255
+ threshold: Coverage percentage threshold.
256
+
257
+ Returns:
258
+ CoverageResult with parsed data.
259
+ """
260
+ try:
261
+ with open(report_file) as f:
262
+ report = json.load(f)
263
+ except Exception as e:
264
+ LOGGER.error(f"Failed to parse Istanbul JSON report: {e}")
265
+ return CoverageResult(threshold=threshold)
266
+
267
+ # Get total statistics
268
+ total = report.get("total", {})
269
+ lines = total.get("lines", {})
270
+ statements = total.get("statements", {})
271
+ branches = total.get("branches", {})
272
+ functions = total.get("functions", {})
273
+
274
+ # Calculate overall coverage (use lines as primary metric)
275
+ total_lines = lines.get("total", 0)
276
+ covered_lines = lines.get("covered", 0)
277
+ percent_covered = lines.get("pct", 0.0)
278
+
279
+ result = CoverageResult(
280
+ total_lines=total_lines,
281
+ covered_lines=covered_lines,
282
+ missing_lines=total_lines - covered_lines,
283
+ excluded_lines=0,
284
+ threshold=threshold,
285
+ )
286
+
287
+ # Parse per-file coverage (all keys except "total")
288
+ for file_path, file_data in report.items():
289
+ if file_path == "total":
290
+ continue
291
+
292
+ file_lines = file_data.get("lines", {})
293
+ file_total = file_lines.get("total", 0)
294
+ file_covered = file_lines.get("covered", 0)
295
+
296
+ file_coverage = FileCoverage(
297
+ file_path=project_root / file_path,
298
+ total_lines=file_total,
299
+ covered_lines=file_covered,
300
+ missing_lines=[], # Istanbul doesn't provide specific line numbers in summary
301
+ excluded_lines=0,
302
+ )
303
+ result.files[file_path] = file_coverage
304
+
305
+ # Generate issue if below threshold
306
+ if percent_covered < threshold:
307
+ issue = self._create_coverage_issue(
308
+ percent_covered,
309
+ threshold,
310
+ total_lines,
311
+ covered_lines,
312
+ total_lines - covered_lines,
313
+ statements,
314
+ branches,
315
+ functions,
316
+ )
317
+ result.issues.append(issue)
318
+
319
+ LOGGER.info(
320
+ f"Coverage: {percent_covered:.1f}% ({covered_lines}/{total_lines} lines) "
321
+ f"- threshold: {threshold}%"
322
+ )
323
+
324
+ return result
325
+
326
+ def _create_coverage_issue(
327
+ self,
328
+ percentage: float,
329
+ threshold: float,
330
+ total_lines: int,
331
+ covered_lines: int,
332
+ missing_lines: int,
333
+ statements: Dict[str, Any],
334
+ branches: Dict[str, Any],
335
+ functions: Dict[str, Any],
336
+ ) -> UnifiedIssue:
337
+ """Create a UnifiedIssue for coverage below threshold.
338
+
339
+ Args:
340
+ percentage: Actual coverage percentage.
341
+ threshold: Required coverage threshold.
342
+ total_lines: Total number of lines.
343
+ covered_lines: Number of covered lines.
344
+ missing_lines: Number of missing lines.
345
+ statements: Statement coverage data.
346
+ branches: Branch coverage data.
347
+ functions: Function coverage data.
348
+
349
+ Returns:
350
+ UnifiedIssue for coverage failure.
351
+ """
352
+ # Determine severity based on how far below threshold
353
+ if percentage < 50:
354
+ severity = Severity.HIGH
355
+ elif percentage < threshold - 10:
356
+ severity = Severity.MEDIUM
357
+ else:
358
+ severity = Severity.LOW
359
+
360
+ # Generate deterministic ID
361
+ issue_id = self._generate_issue_id(percentage, threshold)
362
+
363
+ gap = threshold - percentage
364
+
365
+ return UnifiedIssue(
366
+ id=issue_id,
367
+ domain=ToolDomain.COVERAGE,
368
+ source_tool="istanbul",
369
+ severity=severity,
370
+ rule_id="coverage_below_threshold",
371
+ title=f"Coverage {percentage:.1f}% is below threshold {threshold}%",
372
+ description=(
373
+ f"Project coverage is {percentage:.1f}%, which is {gap:.1f}% below "
374
+ f"the required threshold of {threshold}%. "
375
+ f"Lines: {covered_lines}/{total_lines} ({percentage:.1f}%), "
376
+ f"Statements: {statements.get('covered', 0)}/{statements.get('total', 0)} ({statements.get('pct', 0):.1f}%), "
377
+ f"Branches: {branches.get('covered', 0)}/{branches.get('total', 0)} ({branches.get('pct', 0):.1f}%), "
378
+ f"Functions: {functions.get('covered', 0)}/{functions.get('total', 0)} ({functions.get('pct', 0):.1f}%)"
379
+ ),
380
+ recommendation=f"Add tests to cover at least {gap:.1f}% more of the codebase.",
381
+ file_path=None, # Project-level issue
382
+ line_start=None,
383
+ line_end=None,
384
+ fixable=False,
385
+ metadata={
386
+ "coverage_percentage": round(percentage, 2),
387
+ "threshold": threshold,
388
+ "total_lines": total_lines,
389
+ "covered_lines": covered_lines,
390
+ "missing_lines": missing_lines,
391
+ "gap_percentage": round(gap, 2),
392
+ "statements": statements,
393
+ "branches": branches,
394
+ "functions": functions,
395
+ },
396
+ )
397
+
398
+ def _generate_issue_id(self, percentage: float, threshold: float) -> str:
399
+ """Generate deterministic issue ID.
400
+
401
+ Args:
402
+ percentage: Coverage percentage.
403
+ threshold: Coverage threshold.
404
+
405
+ Returns:
406
+ Unique issue ID.
407
+ """
408
+ # ID based on rounded percentage and threshold for stability
409
+ content = f"istanbul:{round(percentage)}:{threshold}"
410
+ hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
411
+ return f"istanbul-{hash_val}"
@@ -0,0 +1,107 @@
1
+ """Plugin discovery via Python entry points.
2
+
3
+ Supports discovering different plugin types:
4
+ - Scanner plugins: lucidscan.scanners
5
+ - Enricher plugins: lucidscan.enrichers (future)
6
+ - Reporter plugins: lucidscan.reporters (future)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import entry_points
12
+ from typing import Dict, List, Type, TypeVar
13
+
14
+ from lucidscan.core.logging import get_logger
15
+
16
+ LOGGER = get_logger(__name__)
17
+
18
+ # Entry point group names for different plugin types
19
+ SCANNER_ENTRY_POINT_GROUP = "lucidscan.scanners"
20
+ ENRICHER_ENTRY_POINT_GROUP = "lucidscan.enrichers"
21
+ REPORTER_ENTRY_POINT_GROUP = "lucidscan.reporters"
22
+
23
+ # New plugin groups for v0.2+ quality pipeline
24
+ LINTER_ENTRY_POINT_GROUP = "lucidscan.linters"
25
+ TYPE_CHECKER_ENTRY_POINT_GROUP = "lucidscan.type_checkers"
26
+ TEST_RUNNER_ENTRY_POINT_GROUP = "lucidscan.test_runners"
27
+ COVERAGE_ENTRY_POINT_GROUP = "lucidscan.coverage"
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ def discover_plugins(group: str, base_class: Type[T] | None = None) -> Dict[str, Type[T]]:
33
+ """Discover all installed plugins for a given entry point group.
34
+
35
+ Plugins register themselves in their pyproject.toml:
36
+
37
+ [project.entry-points."lucidscan.scanners"]
38
+ trivy = "lucidscan.scanners.trivy:TrivyScanner"
39
+
40
+ Args:
41
+ group: Entry point group name (e.g., 'lucidscan.scanners').
42
+ base_class: Optional base class to validate plugins against.
43
+
44
+ Returns:
45
+ Dictionary mapping plugin names to plugin classes.
46
+ """
47
+ plugins: Dict[str, Type[T]] = {}
48
+
49
+ try:
50
+ eps = entry_points(group=group)
51
+ except TypeError:
52
+ # Python 3.9 compatibility
53
+ all_eps = entry_points()
54
+ eps = getattr(all_eps, group, []) # type: ignore[assignment]
55
+
56
+ for ep in eps:
57
+ try:
58
+ plugin_class = ep.load()
59
+ if base_class is not None and not issubclass(plugin_class, base_class):
60
+ LOGGER.warning(
61
+ f"Plugin '{ep.name}' does not inherit from {base_class.__name__}, skipping"
62
+ )
63
+ continue
64
+ plugins[ep.name] = plugin_class
65
+ LOGGER.debug(f"Discovered plugin: {ep.name} (group: {group})")
66
+ except Exception as e:
67
+ LOGGER.warning(f"Failed to load plugin '{ep.name}': {e}")
68
+
69
+ return plugins
70
+
71
+
72
+ def get_plugin(
73
+ group: str,
74
+ name: str,
75
+ base_class: Type[T] | None = None,
76
+ **kwargs,
77
+ ) -> T | None:
78
+ """Get an instantiated plugin by name.
79
+
80
+ Args:
81
+ group: Entry point group name.
82
+ name: Plugin name (e.g., 'trivy').
83
+ base_class: Optional base class to validate against.
84
+ **kwargs: Additional arguments to pass to the plugin constructor.
85
+ Common kwargs include:
86
+ - project_root: Path to project root for tool installation.
87
+
88
+ Returns:
89
+ Instantiated plugin or None if not found.
90
+ """
91
+ plugins = discover_plugins(group, base_class)
92
+ plugin_class = plugins.get(name)
93
+ if plugin_class:
94
+ return plugin_class(**kwargs)
95
+ return None
96
+
97
+
98
+ def list_available_plugins(group: str) -> List[str]:
99
+ """List names of all available plugins in a group.
100
+
101
+ Args:
102
+ group: Entry point group name.
103
+
104
+ Returns:
105
+ List of plugin names.
106
+ """
107
+ return list(discover_plugins(group).keys())
@@ -0,0 +1,61 @@
1
+ """Enricher plugins for lucidscan post-processing.
2
+
3
+ Enricher plugins process scan results after scanner execution,
4
+ adding context, metadata, or performing transformations like
5
+ deduplication or AI-powered explanations.
6
+
7
+ Plugins are discovered via Python entry points (lucidscan.enrichers group).
8
+
9
+ Example registration in pyproject.toml:
10
+ [project.entry-points."lucidscan.enrichers"]
11
+ dedup = "lucidscan_dedup:DedupEnricher"
12
+ epss = "lucidscan_epss:EPSSEnricher"
13
+ """
14
+
15
+ from typing import Dict, List, Optional, Type
16
+
17
+ from lucidscan.plugins.enrichers.base import EnricherPlugin
18
+ from lucidscan.plugins.discovery import (
19
+ ENRICHER_ENTRY_POINT_GROUP,
20
+ discover_plugins,
21
+ get_plugin,
22
+ list_available_plugins as _list_plugins,
23
+ )
24
+
25
+
26
+ def discover_enricher_plugins() -> Dict[str, Type[EnricherPlugin]]:
27
+ """Discover all installed enricher plugins via entry points.
28
+
29
+ Returns:
30
+ Dictionary mapping plugin names to plugin classes.
31
+ """
32
+ return discover_plugins(ENRICHER_ENTRY_POINT_GROUP, EnricherPlugin)
33
+
34
+
35
+ def get_enricher_plugin(name: str) -> Optional[EnricherPlugin]:
36
+ """Get an instantiated enricher plugin by name.
37
+
38
+ Args:
39
+ name: Plugin name (e.g., 'dedup', 'epss').
40
+
41
+ Returns:
42
+ Instantiated EnricherPlugin or None if not found.
43
+ """
44
+ return get_plugin(ENRICHER_ENTRY_POINT_GROUP, name, EnricherPlugin)
45
+
46
+
47
+ def list_available_enrichers() -> List[str]:
48
+ """List names of all available enricher plugins.
49
+
50
+ Returns:
51
+ List of enricher plugin names.
52
+ """
53
+ return _list_plugins(ENRICHER_ENTRY_POINT_GROUP)
54
+
55
+
56
+ __all__ = [
57
+ "EnricherPlugin",
58
+ "discover_enricher_plugins",
59
+ "get_enricher_plugin",
60
+ "list_available_enrichers",
61
+ ]
@@ -0,0 +1,63 @@
1
+ """Base class for enricher plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, List
7
+
8
+ if TYPE_CHECKING:
9
+ from lucidscan.core.models import ScanContext, UnifiedIssue
10
+
11
+
12
+ class EnricherPlugin(ABC):
13
+ """Base class for all enricher plugins.
14
+
15
+ Enricher plugins process issues after scanning, adding additional
16
+ context, metadata, or performing transformations. They run sequentially
17
+ in the configured order, with each enricher receiving the output of
18
+ the previous one.
19
+
20
+ Example enrichers:
21
+ - Deduplication: Remove duplicate issues across scanners
22
+ - EPSS scoring: Add exploit prediction scores
23
+ - KEV tagging: Mark known exploited vulnerabilities
24
+ - AI explanation: Add LLM-generated explanations
25
+
26
+ Enricher constraints:
27
+ - Enrichers MUST NOT modify severity levels set by scanners
28
+ - Enrichers MUST NOT affect exit codes (that's the CLI's responsibility)
29
+ - Enrichers MAY filter, augment, or reorder issues
30
+ - Enrichers SHOULD preserve scanner_metadata from original issues
31
+ """
32
+
33
+ @property
34
+ @abstractmethod
35
+ def name(self) -> str:
36
+ """Enricher identifier (e.g., 'dedup', 'epss', 'kev').
37
+
38
+ This name is used for:
39
+ - Plugin discovery via entry points
40
+ - Configuration in .lucidscan.yml
41
+ - Logging and error messages
42
+ """
43
+
44
+ @abstractmethod
45
+ def enrich(
46
+ self,
47
+ issues: List["UnifiedIssue"],
48
+ context: "ScanContext",
49
+ ) -> List["UnifiedIssue"]:
50
+ """Process and enrich issues.
51
+
52
+ Args:
53
+ issues: List of issues from scanner execution (or previous enricher).
54
+ context: Scan context with project info and configuration.
55
+
56
+ Returns:
57
+ Enriched list of issues. May be modified, filtered, augmented,
58
+ or returned unchanged.
59
+
60
+ Raises:
61
+ Any exception raised will be logged and the enricher skipped,
62
+ with the pipeline continuing with unenriched issues.
63
+ """
@@ -0,0 +1,26 @@
1
+ """Linter plugins for lucidscan.
2
+
3
+ This module provides linter integrations for the quality pipeline.
4
+ Linters are discovered via the lucidscan.linters entry point group.
5
+ """
6
+
7
+ from lucidscan.plugins.linters.base import LinterPlugin
8
+ from lucidscan.plugins.discovery import (
9
+ discover_plugins,
10
+ LINTER_ENTRY_POINT_GROUP,
11
+ )
12
+
13
+
14
+ def discover_linter_plugins():
15
+ """Discover all installed linter plugins.
16
+
17
+ Returns:
18
+ Dictionary mapping plugin names to plugin classes.
19
+ """
20
+ return discover_plugins(LINTER_ENTRY_POINT_GROUP, LinterPlugin)
21
+
22
+
23
+ __all__ = [
24
+ "LinterPlugin",
25
+ "discover_linter_plugins",
26
+ ]