Coverage for src/domain/validation/config.py: 64%

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

2

3 This module provides the data structures for the language-agnostic configuration

4 system. Users define their validation commands in mala.yaml, which is parsed into

5 these frozen dataclasses.

6

7 These dataclasses represent the deserialized configuration. They are immutable

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

9

10 Key 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

18 from __future__ import annotations

19

20 from dataclasses import dataclass, field

21 from typing import cast

22

23

24 class 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

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

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

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

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

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

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