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,645 @@
1
+ """Configuration validation for lucidscan.
2
+
3
+ Validates core configuration keys and warns on unknown keys.
4
+ Plugin-specific options are passed through without validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from difflib import get_close_matches
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Set, Tuple
14
+
15
+ import yaml
16
+
17
+ from lucidscan.core.logging import get_logger
18
+
19
+ LOGGER = get_logger(__name__)
20
+
21
+
22
+ class ValidationSeverity(Enum):
23
+ """Severity level for validation issues."""
24
+
25
+ ERROR = "error" # Config will fail at runtime
26
+ WARNING = "warning" # Likely mistake but config usable
27
+
28
+
29
+ @dataclass
30
+ class ConfigValidationIssue:
31
+ """A validation issue for configuration with severity."""
32
+
33
+ message: str
34
+ source: str
35
+ severity: ValidationSeverity
36
+ key: Optional[str] = None
37
+ suggestion: Optional[str] = None
38
+
39
+
40
+ # Valid top-level keys (core config)
41
+ VALID_TOP_LEVEL_KEYS: Set[str] = {
42
+ "version",
43
+ "project",
44
+ "fail_on",
45
+ "ignore",
46
+ "output",
47
+ "scanners",
48
+ "enrichers",
49
+ "pipeline",
50
+ "ai",
51
+ }
52
+
53
+ # Valid keys under output section
54
+ VALID_OUTPUT_KEYS: Set[str] = {
55
+ "format",
56
+ }
57
+
58
+ # Valid keys under pipeline section
59
+ VALID_PIPELINE_KEYS: Set[str] = {
60
+ "enrichers",
61
+ "max_workers",
62
+ "linting",
63
+ "type_checking",
64
+ "security",
65
+ "testing",
66
+ "coverage",
67
+ }
68
+
69
+ # Valid keys under pipeline domain sections (linting, type_checking, testing, coverage)
70
+ VALID_PIPELINE_DOMAIN_KEYS: Set[str] = {
71
+ "enabled",
72
+ "tools",
73
+ }
74
+
75
+ # Valid keys under pipeline.coverage section
76
+ VALID_PIPELINE_COVERAGE_KEYS: Set[str] = {
77
+ "enabled",
78
+ "tools",
79
+ "threshold",
80
+ }
81
+
82
+ # Valid keys under pipeline.security section
83
+ VALID_PIPELINE_SECURITY_KEYS: Set[str] = {
84
+ "enabled",
85
+ "tools",
86
+ }
87
+
88
+ # Pipeline domains that require tools when enabled
89
+ PIPELINE_DOMAINS_REQUIRING_TOOLS: Set[str] = {
90
+ "linting",
91
+ "type_checking",
92
+ "testing",
93
+ "coverage",
94
+ }
95
+
96
+ # Valid keys under scanners.<domain> (framework-level, not plugin-specific)
97
+ VALID_SCANNER_DOMAIN_KEYS: Set[str] = {
98
+ "enabled",
99
+ "plugin",
100
+ # Everything else is plugin-specific and passed through
101
+ }
102
+
103
+ # Valid domain names
104
+ VALID_DOMAINS: Set[str] = {
105
+ "sca",
106
+ "sast",
107
+ "iac",
108
+ "container",
109
+ }
110
+
111
+ # Valid severity values
112
+ VALID_SEVERITIES: Set[str] = {
113
+ "critical",
114
+ "high",
115
+ "medium",
116
+ "low",
117
+ "info",
118
+ }
119
+
120
+ # Valid fail_on domains (for dict format)
121
+ VALID_FAIL_ON_DOMAINS: Set[str] = {
122
+ "linting",
123
+ "type_checking",
124
+ "security",
125
+ "testing",
126
+ "coverage",
127
+ }
128
+
129
+ # Valid fail_on values per domain type
130
+ VALID_FAIL_ON_VALUES: Dict[str, Set[str]] = {
131
+ "linting": {"error", "none"},
132
+ "type_checking": {"error", "none"},
133
+ "security": {"critical", "high", "medium", "low", "info", "none"},
134
+ "testing": {"any", "none"},
135
+ "coverage": {"any", "none"},
136
+ }
137
+
138
+ # Valid keys under ai section
139
+ VALID_AI_KEYS: Set[str] = {
140
+ "enabled",
141
+ "provider",
142
+ "model",
143
+ "api_key",
144
+ "send_code_snippets",
145
+ "base_url",
146
+ "temperature",
147
+ "max_tokens",
148
+ "timeout",
149
+ "cache_enabled",
150
+ "prompt_version",
151
+ }
152
+
153
+ # Valid AI providers
154
+ VALID_AI_PROVIDERS: Set[str] = {
155
+ "openai",
156
+ "anthropic",
157
+ "ollama",
158
+ }
159
+
160
+
161
+ @dataclass
162
+ class ConfigValidationWarning:
163
+ """A validation warning for configuration."""
164
+
165
+ message: str
166
+ source: str
167
+ key: Optional[str] = None
168
+ suggestion: Optional[str] = None
169
+
170
+
171
+ def validate_config(
172
+ data: Dict[str, Any],
173
+ source: str,
174
+ ) -> List[ConfigValidationWarning]:
175
+ """Validate configuration dictionary.
176
+
177
+ Warns on unknown core keys but allows plugin-specific options to pass through.
178
+ Does not raise exceptions - returns warnings instead.
179
+
180
+ Args:
181
+ data: Config dictionary to validate.
182
+ source: Source file path for warning messages.
183
+
184
+ Returns:
185
+ List of validation warnings.
186
+ """
187
+ warnings: List[ConfigValidationWarning] = []
188
+
189
+ # Runtime type check for defensive programming (data may not be dict at runtime)
190
+ if not isinstance(data, dict): # type: ignore[unreachable]
191
+ warnings.append(ConfigValidationWarning(
192
+ message=f"Config must be a mapping, got {type(data).__name__}",
193
+ source=source,
194
+ ))
195
+ return warnings # type: ignore[unreachable]
196
+
197
+ # Check top-level keys
198
+ for key in data.keys():
199
+ if key not in VALID_TOP_LEVEL_KEYS:
200
+ suggestion = _suggest_key(key, VALID_TOP_LEVEL_KEYS)
201
+ warning = ConfigValidationWarning(
202
+ message=f"Unknown top-level key '{key}'",
203
+ source=source,
204
+ key=key,
205
+ suggestion=suggestion,
206
+ )
207
+ warnings.append(warning)
208
+ _log_warning(warning)
209
+
210
+ # Validate fail_on (string or dict format)
211
+ fail_on = data.get("fail_on")
212
+ if fail_on is not None:
213
+ if isinstance(fail_on, str):
214
+ # Legacy string format - must be a valid severity
215
+ if fail_on.lower() not in VALID_SEVERITIES:
216
+ suggestion = _suggest_key(fail_on.lower(), VALID_SEVERITIES)
217
+ warning = ConfigValidationWarning(
218
+ message=f"Invalid severity '{fail_on}' for 'fail_on'",
219
+ source=source,
220
+ key="fail_on",
221
+ suggestion=suggestion,
222
+ )
223
+ warnings.append(warning)
224
+ _log_warning(warning)
225
+ elif isinstance(fail_on, dict):
226
+ # Dict format - validate each domain key and value
227
+ for domain, value in fail_on.items():
228
+ if domain not in VALID_FAIL_ON_DOMAINS:
229
+ suggestion = _suggest_key(domain, VALID_FAIL_ON_DOMAINS)
230
+ warning = ConfigValidationWarning(
231
+ message=f"Unknown domain '{domain}' in 'fail_on'",
232
+ source=source,
233
+ key=f"fail_on.{domain}",
234
+ suggestion=suggestion,
235
+ )
236
+ warnings.append(warning)
237
+ _log_warning(warning)
238
+ elif not isinstance(value, str):
239
+ warnings.append(ConfigValidationWarning(
240
+ message=f"'fail_on.{domain}' must be a string, got {type(value).__name__}",
241
+ source=source,
242
+ key=f"fail_on.{domain}",
243
+ ))
244
+ else:
245
+ valid_values = VALID_FAIL_ON_VALUES.get(domain, set())
246
+ if value.lower() not in valid_values:
247
+ warning = ConfigValidationWarning(
248
+ message=f"Invalid value '{value}' for 'fail_on.{domain}'. "
249
+ f"Valid values: {', '.join(sorted(valid_values))}",
250
+ source=source,
251
+ key=f"fail_on.{domain}",
252
+ )
253
+ warnings.append(warning)
254
+ _log_warning(warning)
255
+ else:
256
+ warnings.append(ConfigValidationWarning(
257
+ message=f"'fail_on' must be a string or mapping, got {type(fail_on).__name__}",
258
+ source=source,
259
+ key="fail_on",
260
+ ))
261
+
262
+ # Validate ignore
263
+ ignore = data.get("ignore")
264
+ if ignore is not None:
265
+ if not isinstance(ignore, list):
266
+ warnings.append(ConfigValidationWarning(
267
+ message=f"'ignore' must be a list, got {type(ignore).__name__}",
268
+ source=source,
269
+ key="ignore",
270
+ ))
271
+
272
+ # Validate output section
273
+ output = data.get("output")
274
+ if output is not None:
275
+ if not isinstance(output, dict):
276
+ warnings.append(ConfigValidationWarning(
277
+ message=f"'output' must be a mapping, got {type(output).__name__}",
278
+ source=source,
279
+ key="output",
280
+ ))
281
+ else:
282
+ for key in output.keys():
283
+ if key not in VALID_OUTPUT_KEYS:
284
+ suggestion = _suggest_key(key, VALID_OUTPUT_KEYS)
285
+ warning = ConfigValidationWarning(
286
+ message=f"Unknown key 'output.{key}'",
287
+ source=source,
288
+ key=f"output.{key}",
289
+ suggestion=suggestion,
290
+ )
291
+ warnings.append(warning)
292
+ _log_warning(warning)
293
+
294
+ # Validate scanners section
295
+ scanners = data.get("scanners")
296
+ if scanners is not None:
297
+ if not isinstance(scanners, dict):
298
+ warnings.append(ConfigValidationWarning(
299
+ message=f"'scanners' must be a mapping, got {type(scanners).__name__}",
300
+ source=source,
301
+ key="scanners",
302
+ ))
303
+ else:
304
+ for domain, domain_config in scanners.items():
305
+ # Warn on unknown domains (but allow them)
306
+ if domain not in VALID_DOMAINS:
307
+ suggestion = _suggest_key(domain, VALID_DOMAINS)
308
+ warning = ConfigValidationWarning(
309
+ message=f"Unknown scanner domain '{domain}'",
310
+ source=source,
311
+ key=f"scanners.{domain}",
312
+ suggestion=suggestion,
313
+ )
314
+ warnings.append(warning)
315
+ _log_warning(warning)
316
+
317
+ if isinstance(domain_config, dict):
318
+ # Validate enabled type
319
+ enabled = domain_config.get("enabled")
320
+ if enabled is not None and not isinstance(enabled, bool):
321
+ warnings.append(ConfigValidationWarning(
322
+ message=f"'scanners.{domain}.enabled' must be a boolean",
323
+ source=source,
324
+ key=f"scanners.{domain}.enabled",
325
+ ))
326
+
327
+ # Validate plugin type
328
+ plugin = domain_config.get("plugin")
329
+ if plugin is not None and not isinstance(plugin, str):
330
+ warnings.append(ConfigValidationWarning(
331
+ message=f"'scanners.{domain}.plugin' must be a string",
332
+ source=source,
333
+ key=f"scanners.{domain}.plugin",
334
+ ))
335
+
336
+ # Other keys are plugin-specific and not validated
337
+
338
+ # Validate pipeline section
339
+ pipeline = data.get("pipeline")
340
+ if pipeline is not None:
341
+ if not isinstance(pipeline, dict):
342
+ warnings.append(ConfigValidationWarning(
343
+ message=f"'pipeline' must be a mapping, got {type(pipeline).__name__}",
344
+ source=source,
345
+ key="pipeline",
346
+ ))
347
+ else:
348
+ for key in pipeline.keys():
349
+ if key not in VALID_PIPELINE_KEYS:
350
+ suggestion = _suggest_key(key, VALID_PIPELINE_KEYS)
351
+ warning = ConfigValidationWarning(
352
+ message=f"Unknown key 'pipeline.{key}'",
353
+ source=source,
354
+ key=f"pipeline.{key}",
355
+ suggestion=suggestion,
356
+ )
357
+ warnings.append(warning)
358
+ _log_warning(warning)
359
+
360
+ # Validate enrichers is a list
361
+ enrichers = pipeline.get("enrichers")
362
+ if enrichers is not None and not isinstance(enrichers, list):
363
+ warnings.append(ConfigValidationWarning(
364
+ message="'pipeline.enrichers' must be a list",
365
+ source=source,
366
+ key="pipeline.enrichers",
367
+ ))
368
+
369
+ # Validate max_workers is an integer
370
+ max_workers = pipeline.get("max_workers")
371
+ if max_workers is not None and not isinstance(max_workers, int):
372
+ warnings.append(ConfigValidationWarning(
373
+ message="'pipeline.max_workers' must be an integer",
374
+ source=source,
375
+ key="pipeline.max_workers",
376
+ ))
377
+
378
+ # Validate pipeline domain sections (linting, type_checking, testing, coverage)
379
+ for domain in PIPELINE_DOMAINS_REQUIRING_TOOLS:
380
+ domain_config = pipeline.get(domain)
381
+ if domain_config is not None and isinstance(domain_config, dict):
382
+ # Check if enabled (default True if not specified)
383
+ is_enabled = domain_config.get("enabled", True)
384
+
385
+ # Validate keys based on domain type
386
+ if domain == "coverage":
387
+ valid_keys = VALID_PIPELINE_COVERAGE_KEYS
388
+ else:
389
+ valid_keys = VALID_PIPELINE_DOMAIN_KEYS
390
+
391
+ for key in domain_config.keys():
392
+ if key not in valid_keys:
393
+ suggestion = _suggest_key(key, valid_keys)
394
+ warning = ConfigValidationWarning(
395
+ message=f"Unknown key 'pipeline.{domain}.{key}'",
396
+ source=source,
397
+ key=f"pipeline.{domain}.{key}",
398
+ suggestion=suggestion,
399
+ )
400
+ warnings.append(warning)
401
+ _log_warning(warning)
402
+
403
+ # Check tools is specified when enabled
404
+ tools = domain_config.get("tools")
405
+ if is_enabled and tools is None:
406
+ warnings.append(ConfigValidationWarning(
407
+ message=f"'pipeline.{domain}.tools' is required when {domain} is enabled",
408
+ source=source,
409
+ key=f"pipeline.{domain}.tools",
410
+ ))
411
+ elif tools is not None and not isinstance(tools, list):
412
+ warnings.append(ConfigValidationWarning(
413
+ message=f"'pipeline.{domain}.tools' must be a list",
414
+ source=source,
415
+ key=f"pipeline.{domain}.tools",
416
+ ))
417
+
418
+ # Validate threshold for coverage
419
+ if domain == "coverage":
420
+ threshold = domain_config.get("threshold")
421
+ if threshold is not None and not isinstance(threshold, (int, float)):
422
+ warnings.append(ConfigValidationWarning(
423
+ message="'pipeline.coverage.threshold' must be a number",
424
+ source=source,
425
+ key="pipeline.coverage.threshold",
426
+ ))
427
+
428
+ # Validate pipeline.security section
429
+ security_config = pipeline.get("security")
430
+ if security_config is not None and isinstance(security_config, dict):
431
+ for key in security_config.keys():
432
+ if key not in VALID_PIPELINE_SECURITY_KEYS:
433
+ suggestion = _suggest_key(key, VALID_PIPELINE_SECURITY_KEYS)
434
+ warning = ConfigValidationWarning(
435
+ message=f"Unknown key 'pipeline.security.{key}'",
436
+ source=source,
437
+ key=f"pipeline.security.{key}",
438
+ suggestion=suggestion,
439
+ )
440
+ warnings.append(warning)
441
+ _log_warning(warning)
442
+
443
+ # Validate tools is a list of dicts with name and domains
444
+ tools = security_config.get("tools")
445
+ if tools is not None:
446
+ if not isinstance(tools, list):
447
+ warnings.append(ConfigValidationWarning(
448
+ message="'pipeline.security.tools' must be a list",
449
+ source=source,
450
+ key="pipeline.security.tools",
451
+ ))
452
+ else:
453
+ for i, tool in enumerate(tools):
454
+ if isinstance(tool, dict):
455
+ if "name" not in tool:
456
+ warnings.append(ConfigValidationWarning(
457
+ message=f"'pipeline.security.tools[{i}]' must have a 'name' field",
458
+ source=source,
459
+ key=f"pipeline.security.tools[{i}].name",
460
+ ))
461
+
462
+ # Validate ai section
463
+ ai = data.get("ai")
464
+ if ai is not None:
465
+ if not isinstance(ai, dict):
466
+ warnings.append(ConfigValidationWarning(
467
+ message=f"'ai' must be a mapping, got {type(ai).__name__}",
468
+ source=source,
469
+ key="ai",
470
+ ))
471
+ else:
472
+ for key in ai.keys():
473
+ if key not in VALID_AI_KEYS:
474
+ suggestion = _suggest_key(key, VALID_AI_KEYS)
475
+ warning = ConfigValidationWarning(
476
+ message=f"Unknown key 'ai.{key}'",
477
+ source=source,
478
+ key=f"ai.{key}",
479
+ suggestion=suggestion,
480
+ )
481
+ warnings.append(warning)
482
+ _log_warning(warning)
483
+
484
+ # Validate enabled type
485
+ enabled = ai.get("enabled")
486
+ if enabled is not None and not isinstance(enabled, bool):
487
+ warnings.append(ConfigValidationWarning(
488
+ message="'ai.enabled' must be a boolean",
489
+ source=source,
490
+ key="ai.enabled",
491
+ ))
492
+
493
+ # Validate provider
494
+ provider = ai.get("provider")
495
+ if provider is not None:
496
+ if not isinstance(provider, str):
497
+ warnings.append(ConfigValidationWarning(
498
+ message="'ai.provider' must be a string",
499
+ source=source,
500
+ key="ai.provider",
501
+ ))
502
+ elif provider.lower() not in VALID_AI_PROVIDERS:
503
+ suggestion = _suggest_key(provider.lower(), VALID_AI_PROVIDERS)
504
+ warning = ConfigValidationWarning(
505
+ message=f"Unknown AI provider '{provider}'",
506
+ source=source,
507
+ key="ai.provider",
508
+ suggestion=suggestion,
509
+ )
510
+ warnings.append(warning)
511
+ _log_warning(warning)
512
+
513
+ # Validate send_code_snippets type
514
+ send_code = ai.get("send_code_snippets")
515
+ if send_code is not None and not isinstance(send_code, bool):
516
+ warnings.append(ConfigValidationWarning(
517
+ message="'ai.send_code_snippets' must be a boolean",
518
+ source=source,
519
+ key="ai.send_code_snippets",
520
+ ))
521
+
522
+ # Validate cache_enabled type
523
+ cache_enabled = ai.get("cache_enabled")
524
+ if cache_enabled is not None and not isinstance(cache_enabled, bool):
525
+ warnings.append(ConfigValidationWarning(
526
+ message="'ai.cache_enabled' must be a boolean",
527
+ source=source,
528
+ key="ai.cache_enabled",
529
+ ))
530
+
531
+ # Validate temperature is a number
532
+ temperature = ai.get("temperature")
533
+ if temperature is not None and not isinstance(temperature, (int, float)):
534
+ warnings.append(ConfigValidationWarning(
535
+ message="'ai.temperature' must be a number",
536
+ source=source,
537
+ key="ai.temperature",
538
+ ))
539
+
540
+ # Validate max_tokens is an integer
541
+ max_tokens = ai.get("max_tokens")
542
+ if max_tokens is not None and not isinstance(max_tokens, int):
543
+ warnings.append(ConfigValidationWarning(
544
+ message="'ai.max_tokens' must be an integer",
545
+ source=source,
546
+ key="ai.max_tokens",
547
+ ))
548
+
549
+ return warnings
550
+
551
+
552
+ def _suggest_key(invalid_key: str, valid_keys: Set[str]) -> Optional[str]:
553
+ """Suggest a valid key for a potential typo.
554
+
555
+ Args:
556
+ invalid_key: The invalid key entered.
557
+ valid_keys: Set of valid keys.
558
+
559
+ Returns:
560
+ Closest matching valid key, or None if no good match.
561
+ """
562
+ matches = get_close_matches(invalid_key, list(valid_keys), n=1, cutoff=0.6)
563
+ return matches[0] if matches else None
564
+
565
+
566
+ def _log_warning(warning: ConfigValidationWarning) -> None:
567
+ """Log a validation warning."""
568
+ msg = f"{warning.message} in {warning.source}"
569
+ if warning.suggestion:
570
+ msg += f" (did you mean '{warning.suggestion}'?)"
571
+ LOGGER.warning(msg)
572
+
573
+
574
+ def validate_config_file(config_path: Path) -> Tuple[bool, List[ConfigValidationIssue]]:
575
+ """Validate a configuration file from disk.
576
+
577
+ Checks file existence, YAML syntax, and configuration semantics.
578
+
579
+ Args:
580
+ config_path: Path to the configuration file.
581
+
582
+ Returns:
583
+ Tuple of (is_valid, issues) where is_valid is False if any errors exist.
584
+ """
585
+ issues: List[ConfigValidationIssue] = []
586
+ source = str(config_path)
587
+
588
+ # Check file exists
589
+ if not config_path.exists():
590
+ issues.append(ConfigValidationIssue(
591
+ message=f"Configuration file not found: {config_path}",
592
+ source=source,
593
+ severity=ValidationSeverity.ERROR,
594
+ ))
595
+ return False, issues
596
+
597
+ # Try to parse YAML
598
+ try:
599
+ with open(config_path, encoding="utf-8") as f:
600
+ data = yaml.safe_load(f)
601
+ except yaml.YAMLError as e:
602
+ # Extract line number from YAML error if available
603
+ error_msg = f"Invalid YAML syntax: {e}"
604
+ issues.append(ConfigValidationIssue(
605
+ message=error_msg,
606
+ source=source,
607
+ severity=ValidationSeverity.ERROR,
608
+ ))
609
+ return False, issues
610
+
611
+ # Empty file is valid but warn
612
+ if data is None:
613
+ issues.append(ConfigValidationIssue(
614
+ message="Configuration file is empty",
615
+ source=source,
616
+ severity=ValidationSeverity.WARNING,
617
+ ))
618
+ return True, issues
619
+
620
+ # Validate config structure
621
+ warnings = validate_config(data, source)
622
+
623
+ # Convert warnings to issues
624
+ # Type errors are ERROR severity, unknown keys are WARNING severity
625
+ for warning in warnings:
626
+ # Determine severity based on message content
627
+ # Type mismatches and invalid values are errors
628
+ is_error = any(phrase in warning.message for phrase in [
629
+ "must be a",
630
+ "Invalid severity",
631
+ "Invalid value",
632
+ "Config must be",
633
+ ])
634
+
635
+ issues.append(ConfigValidationIssue(
636
+ message=warning.message,
637
+ source=warning.source,
638
+ severity=ValidationSeverity.ERROR if is_error else ValidationSeverity.WARNING,
639
+ key=warning.key,
640
+ suggestion=warning.suggestion,
641
+ ))
642
+
643
+ # Valid if no errors
644
+ has_errors = any(issue.severity == ValidationSeverity.ERROR for issue in issues)
645
+ return not has_errors, issues
@@ -0,0 +1,3 @@
1
+ """Core domain models and infrastructure for lucidscan."""
2
+
3
+