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.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- 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
|