Coverage for src / domain / validation / config.py: 64%
182 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
1"""Configuration dataclasses for mala.yaml validation configuration.
3This module provides the data structures for the language-agnostic configuration
4system. Users define their validation commands in mala.yaml, which is parsed into
5these frozen dataclasses.
7These dataclasses represent the deserialized configuration. They are immutable
8(frozen) to ensure configuration cannot be accidentally modified after loading.
10Key types:
11- CommandConfig: A single command with optional timeout
12- YamlCoverageConfig: Coverage settings (named to avoid collision with spec.CoverageConfig)
13- CommandsConfig: All validation commands (setup, test, lint, format, typecheck, e2e)
14- ValidationConfig: Top-level configuration with preset, commands, coverage, patterns
15- PromptValidationCommands: Validation commands formatted for prompt templates
16"""
18from __future__ import annotations
20from dataclasses import dataclass, field
21from typing import cast
24class ConfigError(Exception):
25 """Base exception for configuration errors.
27 Raised when mala.yaml has invalid content, missing required fields,
28 or other configuration problems.
29 """
31 pass
34class PresetNotFoundError(ConfigError):
35 """Raised when a referenced preset does not exist.
37 Example:
38 >>> raise PresetNotFoundError("unknown-preset", ["python-uv", "go", "rust"])
39 PresetNotFoundError: Unknown preset 'unknown-preset'. Available presets: python-uv, go, rust
40 """
42 def __init__(self, preset_name: str, available: list[str] | None = None) -> None:
43 self.preset_name = preset_name
44 self.available = available or []
45 if self.available:
46 available_str = ", ".join(sorted(self.available))
47 message = (
48 f"Unknown preset '{preset_name}'. Available presets: {available_str}"
49 )
50 else:
51 message = f"Unknown preset '{preset_name}'"
52 super().__init__(message)
55@dataclass(frozen=True)
56class CommandConfig:
57 """Configuration for a single validation command.
59 Commands can be specified in two forms in mala.yaml:
60 - String shorthand: "uv run pytest"
61 - Object form: {command: "uv run pytest", timeout: 300}
63 The factory method `from_value` handles both forms.
65 Attributes:
66 command: The shell command string to execute.
67 timeout: Optional timeout in seconds. None means use system default.
68 """
70 command: str
71 timeout: int | None = None
73 @classmethod
74 def from_value(cls, value: str | dict[str, object]) -> CommandConfig:
75 """Create CommandConfig from YAML value (string or dict).
77 Args:
78 value: Either a command string or a dict with 'command' and
79 optional 'timeout' keys.
81 Returns:
82 CommandConfig instance.
84 Raises:
85 ConfigError: If value is neither string nor valid dict.
87 Examples:
88 >>> CommandConfig.from_value("uv run pytest")
89 CommandConfig(command='uv run pytest', timeout=None)
91 >>> CommandConfig.from_value({"command": "pytest", "timeout": 60})
92 CommandConfig(command='pytest', timeout=60)
93 """
94 if isinstance(value, str):
95 if not value:
96 raise ConfigError(
97 "Command cannot be empty string. Use null to disable."
98 )
99 return cls(command=value)
101 if isinstance(value, dict):
102 command = value.get("command")
103 if not isinstance(command, str):
104 raise ConfigError("Command object must have a 'command' string field")
105 if not command:
106 raise ConfigError(
107 "Command cannot be empty string. Use null to disable."
108 )
110 timeout = value.get("timeout")
111 if timeout is not None:
112 # Reject booleans explicitly (bool is subclass of int)
113 if isinstance(timeout, bool) or not isinstance(timeout, int):
114 raise ConfigError(
115 f"Command timeout must be an integer, got {type(timeout).__name__}"
116 )
118 return cls(command=command, timeout=cast("int | None", timeout))
120 raise ConfigError(
121 f"Command must be a string or object, got {type(value).__name__}"
122 )
125@dataclass(frozen=True)
126class YamlCoverageConfig:
127 """Coverage configuration from mala.yaml.
129 Named YamlCoverageConfig to avoid collision with the existing CoverageConfig
130 in spec.py which is used by the validation runner.
132 When the coverage section is present in mala.yaml, all required fields
133 (format, file, threshold) must be specified. The coverage section can be
134 omitted entirely to disable coverage, or set to null.
136 Attributes:
137 command: Optional separate command to run tests with coverage.
138 If omitted, uses the test command from commands section.
139 format: Coverage report format. MVP supports only "xml" (Cobertura).
140 file: Path to coverage report file, relative to repo root.
141 threshold: Minimum coverage percentage (0-100).
142 timeout: Optional timeout in seconds for the coverage command.
143 """
145 format: str
146 file: str
147 threshold: float
148 command: str | None = None
149 timeout: int | None = None
151 def __post_init__(self) -> None:
152 """Validate coverage configuration after initialization."""
153 # Validate format
154 supported_formats = ("xml",)
155 if self.format not in supported_formats:
156 raise ConfigError(
157 f"Unsupported coverage format '{self.format}'. "
158 f"Supported formats: {', '.join(supported_formats)}"
159 )
161 # Validate threshold range
162 if not 0 <= self.threshold <= 100:
163 raise ConfigError(
164 f"Coverage threshold must be between 0 and 100, got {self.threshold}"
165 )
167 # Validate file is not empty
168 if not self.file:
169 raise ConfigError("Coverage file path cannot be empty")
171 @classmethod
172 def from_dict(cls, data: dict[str, object]) -> YamlCoverageConfig:
173 """Create YamlCoverageConfig from a YAML dict.
175 Args:
176 data: Dict with 'format', 'file', 'threshold', and optionally
177 'command' and 'timeout' keys.
179 Returns:
180 YamlCoverageConfig instance.
182 Raises:
183 ConfigError: If required fields are missing or invalid.
184 """
185 # Validate required fields
186 required = ("format", "file", "threshold")
187 missing = [f for f in required if f not in data or data[f] is None]
188 if missing:
189 raise ConfigError(
190 f"Coverage enabled but missing required field(s): {', '.join(missing)}"
191 )
193 format_val = data["format"]
194 if not isinstance(format_val, str):
195 raise ConfigError(
196 f"Coverage format must be a string, got {type(format_val).__name__}"
197 )
199 file_val = data["file"]
200 if not isinstance(file_val, str):
201 raise ConfigError(
202 f"Coverage file must be a string, got {type(file_val).__name__}"
203 )
204 if not file_val:
205 raise ConfigError("Coverage file path cannot be empty")
207 threshold_val = data["threshold"]
208 # Reject booleans explicitly (bool is subclass of int)
209 if isinstance(threshold_val, bool) or not isinstance(
210 threshold_val, int | float
211 ):
212 raise ConfigError(
213 f"Coverage threshold must be a number, got {type(threshold_val).__name__}"
214 )
216 command_val = data.get("command")
217 if command_val is not None and not isinstance(command_val, str):
218 raise ConfigError(
219 f"Coverage command must be a string, got {type(command_val).__name__}"
220 )
221 if command_val == "":
222 raise ConfigError(
223 "Coverage command cannot be empty string. "
224 "Omit the field to use test command."
225 )
227 timeout_val = data.get("timeout")
228 if timeout_val is not None:
229 # Reject booleans explicitly (bool is subclass of int)
230 if isinstance(timeout_val, bool) or not isinstance(timeout_val, int):
231 raise ConfigError(
232 f"Coverage timeout must be an integer, got {type(timeout_val).__name__}"
233 )
235 return cls(
236 format=format_val,
237 file=file_val,
238 threshold=float(threshold_val),
239 command=command_val,
240 timeout=cast("int | None", timeout_val),
241 )
244@dataclass(frozen=True)
245class CommandsConfig:
246 """Configuration for all validation commands.
248 All fields are optional. When a field is None, it means the command
249 is not defined (may inherit from preset or be skipped). Commands
250 can be explicitly disabled by setting them to None even if a preset
251 defines them.
253 Attributes:
254 setup: Environment setup command (e.g., "uv sync", "npm install").
255 test: Test runner command (e.g., "uv run pytest", "go test ./...").
256 lint: Linter command (e.g., "uvx ruff check .", "golangci-lint run").
257 format: Formatter check command (e.g., "uvx ruff format --check .").
258 typecheck: Type checker command (e.g., "uvx ty check", "tsc --noEmit").
259 e2e: End-to-end test command (e.g., "uv run pytest -m e2e").
260 _fields_set: Set of field names that were explicitly provided in source.
261 Used by the merger to distinguish "not set" from "explicitly null".
262 """
264 setup: CommandConfig | None = None
265 test: CommandConfig | None = None
266 lint: CommandConfig | None = None
267 format: CommandConfig | None = None
268 typecheck: CommandConfig | None = None
269 e2e: CommandConfig | None = None
270 _fields_set: frozenset[str] = field(default_factory=frozenset)
272 @classmethod
273 def from_dict(cls, data: dict[str, object] | None) -> CommandsConfig:
274 """Create CommandsConfig from a YAML dict.
276 Args:
277 data: Dict with optional command fields. Each can be a string,
278 command object, or null.
280 Returns:
281 CommandsConfig instance.
283 Raises:
284 ConfigError: If a command value is invalid.
285 """
286 if data is None:
287 return cls()
289 valid_kinds = ("setup", "test", "lint", "format", "typecheck", "e2e")
290 unknown_kinds = set(data.keys()) - set(valid_kinds)
291 if unknown_kinds:
292 raise ConfigError(
293 f"Unknown command kind(s): {', '.join(sorted(unknown_kinds))}. "
294 f"Valid kinds: {', '.join(valid_kinds)}"
295 )
297 # Track which fields were explicitly present in the source dict
298 fields_set: set[str] = set()
300 def parse_command(key: str) -> CommandConfig | None:
301 if key in data:
302 fields_set.add(key)
303 value = data.get(key)
304 if value is None:
305 return None
306 if value == "":
307 raise ConfigError(
308 f"Command '{key}' cannot be empty string. Use null to disable."
309 )
310 # After the above checks, value is str or dict (from YAML)
311 return CommandConfig.from_value(cast("str | dict[str, object]", value))
313 return cls(
314 setup=parse_command("setup"),
315 test=parse_command("test"),
316 lint=parse_command("lint"),
317 format=parse_command("format"),
318 typecheck=parse_command("typecheck"),
319 e2e=parse_command("e2e"),
320 _fields_set=frozenset(fields_set),
321 )
324@dataclass(frozen=True)
325class ValidationConfig:
326 """Top-level configuration from mala.yaml.
328 This dataclass represents the fully parsed mala.yaml configuration.
329 It is frozen (immutable) after creation.
331 Attributes:
332 preset: Optional preset name to extend (e.g., "python-uv", "go").
333 commands: Command definitions. May be partially filled if extending preset.
334 run_level_commands: Optional overrides for run-level validation commands.
335 coverage: Coverage configuration. None means coverage is disabled.
336 code_patterns: Glob patterns for code files that trigger validation.
337 config_files: Tool config files that invalidate lint/format cache.
338 setup_files: Lock/dependency files that invalidate setup cache.
339 _fields_set: Set of field names that were explicitly provided in source.
340 Used by the merger to distinguish "not set" from "explicitly set".
341 """
343 commands: CommandsConfig = field(default_factory=CommandsConfig)
344 run_level_commands: CommandsConfig = field(default_factory=CommandsConfig)
345 preset: str | None = None
346 coverage: YamlCoverageConfig | None = None
347 code_patterns: tuple[str, ...] = field(default_factory=tuple)
348 config_files: tuple[str, ...] = field(default_factory=tuple)
349 setup_files: tuple[str, ...] = field(default_factory=tuple)
350 _fields_set: frozenset[str] = field(default_factory=frozenset)
352 def __post_init__(self) -> None:
353 """Normalize list fields to tuples for immutability."""
354 # Convert any list fields to tuples
355 if isinstance(self.code_patterns, list):
356 object.__setattr__(self, "code_patterns", tuple(self.code_patterns))
357 if isinstance(self.config_files, list):
358 object.__setattr__(self, "config_files", tuple(self.config_files))
359 if isinstance(self.setup_files, list):
360 object.__setattr__(self, "setup_files", tuple(self.setup_files))
362 @classmethod
363 def from_dict(cls, data: dict[str, object]) -> ValidationConfig:
364 """Create ValidationConfig from a parsed YAML dict.
366 Args:
367 data: Dict representing the parsed mala.yaml content.
369 Returns:
370 ValidationConfig instance.
372 Raises:
373 ConfigError: If any field is invalid.
374 """
375 # Track which fields were explicitly present in the source dict
376 fields_set: set[str] = set()
378 # Parse preset
379 preset = data.get("preset")
380 if "preset" in data:
381 fields_set.add("preset")
382 if preset is not None and not isinstance(preset, str):
383 raise ConfigError(f"preset must be a string, got {type(preset).__name__}")
385 # Parse commands
386 commands_data = data.get("commands")
387 if "commands" in data:
388 fields_set.add("commands")
389 if commands_data is not None and not isinstance(commands_data, dict):
390 raise ConfigError(
391 f"commands must be an object, got {type(commands_data).__name__}"
392 )
393 # commands_data is either None or dict at this point
394 commands = CommandsConfig.from_dict(
395 cast("dict[str, object] | None", commands_data)
396 )
398 # Parse run-level commands
399 run_level_commands_data = data.get("run_level_commands")
400 if "run_level_commands" in data:
401 fields_set.add("run_level_commands")
402 if run_level_commands_data is not None and not isinstance(
403 run_level_commands_data, dict
404 ):
405 raise ConfigError(
406 "run_level_commands must be an object, got "
407 f"{type(run_level_commands_data).__name__}"
408 )
409 run_level_commands = CommandsConfig.from_dict(
410 cast("dict[str, object] | None", run_level_commands_data)
411 )
413 # Parse coverage - track if explicitly present (even if null)
414 if "coverage" in data:
415 fields_set.add("coverage")
416 coverage_data = data.get("coverage")
417 coverage: YamlCoverageConfig | None = None
418 if coverage_data is not None:
419 if not isinstance(coverage_data, dict):
420 raise ConfigError(
421 f"coverage must be an object, got {type(coverage_data).__name__}"
422 )
423 # coverage_data is confirmed to be a dict here
424 coverage = YamlCoverageConfig.from_dict(
425 cast("dict[str, object]", coverage_data)
426 )
428 # Parse list fields - track if explicitly present (even if empty list)
429 def parse_string_list(key: str) -> tuple[str, ...]:
430 if key in data:
431 fields_set.add(key)
432 value = data.get(key)
433 if value is None:
434 return ()
435 if not isinstance(value, list):
436 raise ConfigError(f"{key} must be a list, got {type(value).__name__}")
437 result: list[str] = []
438 for i, item in enumerate(value):
439 if not isinstance(item, str):
440 raise ConfigError(
441 f"{key}[{i}] must be a string, got {type(item).__name__}"
442 )
443 result.append(item)
444 return tuple(result)
446 code_patterns = parse_string_list("code_patterns")
447 config_files = parse_string_list("config_files")
448 setup_files = parse_string_list("setup_files")
450 return cls(
451 preset=preset,
452 commands=commands,
453 run_level_commands=run_level_commands,
454 coverage=coverage,
455 code_patterns=code_patterns,
456 config_files=config_files,
457 setup_files=setup_files,
458 _fields_set=frozenset(fields_set),
459 )
461 def has_any_command(self) -> bool:
462 """Check if at least one command is defined.
464 Returns:
465 True if at least one command is defined, False otherwise.
466 """
467 return any(
468 [
469 self.commands.setup,
470 self.commands.test,
471 self.commands.lint,
472 self.commands.format,
473 self.commands.typecheck,
474 self.commands.e2e,
475 self.run_level_commands.setup,
476 self.run_level_commands.test,
477 self.run_level_commands.lint,
478 self.run_level_commands.format,
479 self.run_level_commands.typecheck,
480 self.run_level_commands.e2e,
481 ]
482 )
485@dataclass(frozen=True)
486class PromptValidationCommands:
487 """Validation commands formatted for use in prompt templates.
489 This dataclass holds the actual command strings to be substituted into
490 prompt templates like implementer_prompt.md and gate_followup.md.
491 Commands that are not configured will use fallback messages that exit
492 with code 0 to indicate the step was skipped (not falsely passing).
494 Attributes:
495 lint: Lint command string (e.g., "uvx ruff check ." or "golangci-lint run")
496 format: Format command string (e.g., "uvx ruff format ." or "gofmt -l .")
497 typecheck: Type check command string (e.g., "uvx ty check" or "go vet ./...")
498 test: Test command string (e.g., "uv run pytest" or "go test ./...")
499 """
501 lint: str
502 format: str
503 typecheck: str
504 test: str
506 # Default fallback message for unconfigured commands - exits with code 0
507 # since missing optional tooling is not a validation failure
508 _NOT_CONFIGURED = "echo 'No {kind} command configured - skipping' >&2 && exit 0"
510 @classmethod
511 def from_validation_config(
512 cls, config: ValidationConfig
513 ) -> PromptValidationCommands:
514 """Build PromptValidationCommands from a merged ValidationConfig.
516 Args:
517 config: The merged ValidationConfig (after preset merging).
519 Returns:
520 PromptValidationCommands with command strings for prompt templates.
521 """
522 cmds = config.commands
524 return cls(
525 lint=cmds.lint.command
526 if cmds.lint
527 else cls._NOT_CONFIGURED.format(kind="lint"),
528 format=cmds.format.command
529 if cmds.format
530 else cls._NOT_CONFIGURED.format(kind="format"),
531 typecheck=cmds.typecheck.command
532 if cmds.typecheck
533 else cls._NOT_CONFIGURED.format(kind="typecheck"),
534 test=cmds.test.command
535 if cmds.test
536 else cls._NOT_CONFIGURED.format(kind="test"),
537 )