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

1"""Configuration dataclasses for mala.yaml validation configuration. 

2 

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. 

6 

7These dataclasses represent the deserialized configuration. They are immutable 

8(frozen) to ensure configuration cannot be accidentally modified after loading. 

9 

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""" 

17 

18from __future__ import annotations 

19 

20from dataclasses import dataclass, field 

21from typing import cast 

22 

23 

24class ConfigError(Exception): 

25 """Base exception for configuration errors. 

26 

27 Raised when mala.yaml has invalid content, missing required fields, 

28 or other configuration problems. 

29 """ 

30 

31 pass 

32 

33 

34class PresetNotFoundError(ConfigError): 

35 """Raised when a referenced preset does not exist. 

36 

37 Example: 

38 >>> raise PresetNotFoundError("unknown-preset", ["python-uv", "go", "rust"]) 

39 PresetNotFoundError: Unknown preset 'unknown-preset'. Available presets: python-uv, go, rust 

40 """ 

41 

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) 

53 

54 

55@dataclass(frozen=True) 

56class CommandConfig: 

57 """Configuration for a single validation command. 

58 

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} 

62 

63 The factory method `from_value` handles both forms. 

64 

65 Attributes: 

66 command: The shell command string to execute. 

67 timeout: Optional timeout in seconds. None means use system default. 

68 """ 

69 

70 command: str 

71 timeout: int | None = None 

72 

73 @classmethod 

74 def from_value(cls, value: str | dict[str, object]) -> CommandConfig: 

75 """Create CommandConfig from YAML value (string or dict). 

76 

77 Args: 

78 value: Either a command string or a dict with 'command' and 

79 optional 'timeout' keys. 

80 

81 Returns: 

82 CommandConfig instance. 

83 

84 Raises: 

85 ConfigError: If value is neither string nor valid dict. 

86 

87 Examples: 

88 >>> CommandConfig.from_value("uv run pytest") 

89 CommandConfig(command='uv run pytest', timeout=None) 

90 

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) 

100 

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 ) 

109 

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 ) 

117 

118 return cls(command=command, timeout=cast("int | None", timeout)) 

119 

120 raise ConfigError( 

121 f"Command must be a string or object, got {type(value).__name__}" 

122 ) 

123 

124 

125@dataclass(frozen=True) 

126class YamlCoverageConfig: 

127 """Coverage configuration from mala.yaml. 

128 

129 Named YamlCoverageConfig to avoid collision with the existing CoverageConfig 

130 in spec.py which is used by the validation runner. 

131 

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. 

135 

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 """ 

144 

145 format: str 

146 file: str 

147 threshold: float 

148 command: str | None = None 

149 timeout: int | None = None 

150 

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 ) 

160 

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 ) 

166 

167 # Validate file is not empty 

168 if not self.file: 

169 raise ConfigError("Coverage file path cannot be empty") 

170 

171 @classmethod 

172 def from_dict(cls, data: dict[str, object]) -> YamlCoverageConfig: 

173 """Create YamlCoverageConfig from a YAML dict. 

174 

175 Args: 

176 data: Dict with 'format', 'file', 'threshold', and optionally 

177 'command' and 'timeout' keys. 

178 

179 Returns: 

180 YamlCoverageConfig instance. 

181 

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 ) 

192 

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 ) 

198 

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") 

206 

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 ) 

215 

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 ) 

226 

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 ) 

234 

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 ) 

242 

243 

244@dataclass(frozen=True) 

245class CommandsConfig: 

246 """Configuration for all validation commands. 

247 

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. 

252 

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 """ 

263 

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) 

271 

272 @classmethod 

273 def from_dict(cls, data: dict[str, object] | None) -> CommandsConfig: 

274 """Create CommandsConfig from a YAML dict. 

275 

276 Args: 

277 data: Dict with optional command fields. Each can be a string, 

278 command object, or null. 

279 

280 Returns: 

281 CommandsConfig instance. 

282 

283 Raises: 

284 ConfigError: If a command value is invalid. 

285 """ 

286 if data is None: 

287 return cls() 

288 

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 ) 

296 

297 # Track which fields were explicitly present in the source dict 

298 fields_set: set[str] = set() 

299 

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)) 

312 

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 ) 

322 

323 

324@dataclass(frozen=True) 

325class ValidationConfig: 

326 """Top-level configuration from mala.yaml. 

327 

328 This dataclass represents the fully parsed mala.yaml configuration. 

329 It is frozen (immutable) after creation. 

330 

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 """ 

342 

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) 

351 

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)) 

361 

362 @classmethod 

363 def from_dict(cls, data: dict[str, object]) -> ValidationConfig: 

364 """Create ValidationConfig from a parsed YAML dict. 

365 

366 Args: 

367 data: Dict representing the parsed mala.yaml content. 

368 

369 Returns: 

370 ValidationConfig instance. 

371 

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() 

377 

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__}") 

384 

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 ) 

397 

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 ) 

412 

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 ) 

427 

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) 

445 

446 code_patterns = parse_string_list("code_patterns") 

447 config_files = parse_string_list("config_files") 

448 setup_files = parse_string_list("setup_files") 

449 

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 ) 

460 

461 def has_any_command(self) -> bool: 

462 """Check if at least one command is defined. 

463 

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 ) 

483 

484 

485@dataclass(frozen=True) 

486class PromptValidationCommands: 

487 """Validation commands formatted for use in prompt templates. 

488 

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). 

493 

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 """ 

500 

501 lint: str 

502 format: str 

503 typecheck: str 

504 test: str 

505 

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" 

509 

510 @classmethod 

511 def from_validation_config( 

512 cls, config: ValidationConfig 

513 ) -> PromptValidationCommands: 

514 """Build PromptValidationCommands from a merged ValidationConfig. 

515 

516 Args: 

517 config: The merged ValidationConfig (after preset merging). 

518 

519 Returns: 

520 PromptValidationCommands with command strings for prompt templates. 

521 """ 

522 cmds = config.commands 

523 

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 )