Coverage for src / domain / validation / spec.py: 45%

163 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-04 04:43 +0000

1"""ValidationSpec and related types for mala validation. 

2 

3This module provides: 

4- ValidationSpec: defines what validations to run for a given scope 

5- ValidationCommand: a single command in the validation pipeline 

6- ValidationContext: immutable context for a validation run 

7- ValidationArtifacts: record of validation outputs (re-exported from models) 

8- IssueResolution: how an issue was resolved (re-exported from models) 

9- Code vs docs classification helper 

10- Disable list handling for spec omissions 

11""" 

12 

13from __future__ import annotations 

14 

15import re 

16from dataclasses import dataclass, field 

17from enum import Enum 

18from pathlib import Path 

19from typing import TYPE_CHECKING, Literal 

20 

21# Re-export shared types from models for backward compatibility 

22# Note: Using explicit aliases (X as X) marks these as intentional re-exports 

23from src.core.models import ( 

24 IssueResolution as IssueResolution, 

25 ResolutionOutcome as ResolutionOutcome, 

26 ValidationArtifacts as ValidationArtifacts, 

27) 

28from src.domain.validation.config import CommandsConfig 

29 

30if TYPE_CHECKING: 

31 from re import Pattern 

32 

33 from src.domain.validation.config import CommandConfig, YamlCoverageConfig 

34 

35 

36class ValidationScope(Enum): 

37 """Scope for validation: per-issue or run-level.""" 

38 

39 PER_ISSUE = "per_issue" 

40 RUN_LEVEL = "run_level" 

41 

42 

43class CommandKind(Enum): 

44 """Kind of validation command.""" 

45 

46 SETUP = "setup" # Environment setup (uv sync, etc.) 

47 LINT = "lint" 

48 FORMAT = "format" 

49 TYPECHECK = "typecheck" 

50 TEST = "test" 

51 E2E = "e2e" 

52 

53 

54# Default timeout for validation commands in seconds 

55DEFAULT_COMMAND_TIMEOUT = 120 

56 

57# Command kinds that represent lint-like tools for LintCache 

58LINT_COMMAND_KINDS: frozenset[CommandKind] = frozenset( 

59 {CommandKind.LINT, CommandKind.FORMAT, CommandKind.TYPECHECK} 

60) 

61 

62 

63@dataclass(frozen=True) 

64class ValidationCommand: 

65 """A single command in the validation pipeline. 

66 

67 Attributes: 

68 name: Human-readable name (e.g., "pytest", "ruff check"). 

69 command: The command as a shell string. 

70 kind: Classification of the command type. 

71 shell: Whether to run command in shell mode. Defaults to True. 

72 timeout: Command timeout in seconds. Defaults to 120. 

73 detection_pattern: Compiled regex to detect this command in log evidence. 

74 If None, falls back to hardcoded patterns in QualityGate. 

75 use_test_mutex: Whether to wrap with test mutex. 

76 allow_fail: If True, failure doesn't stop the pipeline. 

77 """ 

78 

79 name: str 

80 command: str 

81 kind: CommandKind 

82 shell: bool = True 

83 timeout: int = DEFAULT_COMMAND_TIMEOUT 

84 detection_pattern: Pattern[str] | None = None 

85 use_test_mutex: bool = False 

86 allow_fail: bool = False 

87 

88 

89@dataclass 

90class CoverageConfig: 

91 """Configuration for coverage validation. 

92 

93 Attributes: 

94 enabled: Whether coverage is enabled. 

95 min_percent: Minimum coverage percentage. If None, uses "no decrease" 

96 mode where coverage must not drop below the baseline stored in 

97 .coverage-baseline.json. 

98 branch: Whether to include branch coverage. 

99 report_path: Path to coverage report file. 

100 """ 

101 

102 enabled: bool = True 

103 min_percent: float | None = None 

104 branch: bool = True 

105 report_path: Path | None = None 

106 

107 

108@dataclass 

109class E2EConfig: 

110 """Configuration for E2E fixture repo validation. 

111 

112 Attributes: 

113 enabled: Whether E2E is enabled. 

114 fixture_root: Root directory for fixture repos. 

115 command: Command to run for E2E. 

116 required_env: Environment variables required for E2E. 

117 """ 

118 

119 enabled: bool = True 

120 fixture_root: Path | None = None 

121 command: list[str] = field(default_factory=list) 

122 required_env: list[str] = field(default_factory=list) 

123 

124 

125@dataclass(frozen=True) 

126class ValidationContext: 

127 """Immutable context for a single validation run. 

128 

129 Attributes: 

130 issue_id: The issue ID (None for run-level). 

131 repo_path: Path to the repository. 

132 commit_hash: The commit being validated. 

133 log_path: Path to the Claude session log. 

134 changed_files: List of files changed in this commit. 

135 scope: Whether this is per-issue or run-level. 

136 """ 

137 

138 issue_id: str | None 

139 repo_path: Path 

140 commit_hash: str 

141 changed_files: list[str] 

142 scope: ValidationScope 

143 log_path: Path | None = None 

144 

145 

146@dataclass 

147class ValidationSpec: 

148 """Defines what validations to run for a given scope. 

149 

150 Attributes: 

151 commands: List of validation commands to run. 

152 require_clean_git: Whether git working tree must be clean. 

153 require_pytest_for_code_changes: If code changed, pytest is required. 

154 coverage: Coverage configuration. 

155 e2e: E2E configuration. 

156 scope: The validation scope. 

157 code_patterns: Glob patterns for code files that trigger validation. 

158 config_files: Tool config files that invalidate lint/format cache. 

159 setup_files: Lock/dependency files that invalidate setup cache. 

160 yaml_coverage_config: Optional YamlCoverageConfig from mala.yaml for 

161 baseline refresh (contains command and file path). 

162 """ 

163 

164 commands: list[ValidationCommand] 

165 scope: ValidationScope 

166 require_clean_git: bool = True 

167 require_pytest_for_code_changes: bool = True 

168 coverage: CoverageConfig = field(default_factory=CoverageConfig) 

169 e2e: E2EConfig = field(default_factory=E2EConfig) 

170 code_patterns: list[str] = field(default_factory=list) 

171 config_files: list[str] = field(default_factory=list) 

172 setup_files: list[str] = field(default_factory=list) 

173 yaml_coverage_config: YamlCoverageConfig | None = None 

174 

175 def commands_by_kind(self, kind: CommandKind) -> list[ValidationCommand]: 

176 """Return commands matching the given kind.""" 

177 return [cmd for cmd in self.commands if cmd.kind == kind] 

178 

179 def extract_lint_tools(self) -> frozenset[str]: 

180 """Extract lint tool names from this ValidationSpec. 

181 

182 Extracts tool names from commands with LINT, FORMAT, or TYPECHECK kinds. 

183 These are the tools that LintCache should recognize and cache. 

184 

185 Returns: 

186 Frozenset of lint tool names. Empty frozenset if no lint commands. 

187 """ 

188 from src.core.tool_name_extractor import extract_tool_name 

189 

190 lint_tools: set[str] = set() 

191 for cmd in self.commands: 

192 if cmd.kind in LINT_COMMAND_KINDS: 

193 tool_name = extract_tool_name(cmd.command) 

194 if tool_name: 

195 lint_tools.add(tool_name) 

196 

197 return frozenset(lint_tools) 

198 

199 

200# Code classification constants 

201# Paths that are considered code changes 

202CODE_PATHS = frozenset({"src/", "tests/", "commands/", "src/scripts/"}) 

203 

204# Specific files that are considered code changes 

205CODE_FILES = frozenset({"pyproject.toml", "uv.lock"}) 

206 

207# Extensions that are considered code changes 

208CODE_EXTENSIONS = frozenset({".py", ".sh", ".toml", ".yml", ".yaml", ".json"}) 

209 

210# Extensions that are considered documentation 

211DOC_EXTENSIONS = frozenset({".md", ".rst", ".txt"}) 

212 

213 

214def classify_change(file_path: str) -> Literal["code", "docs"]: 

215 """Classify a file change as code or docs. 

216 

217 Code changes require tests + coverage. Doc changes may skip tests. 

218 

219 Classification rules (per quality-hardening-plan.md): 

220 - Paths: src/**, tests/**, commands/**, src/scripts/** => code 

221 - Files: pyproject.toml, uv.lock, .env templates => code 

222 - Extensions: .py, .sh, .toml, .yml, .yaml, .json => code 

223 - Extensions: .md, .rst, .txt => docs 

224 - Unknown extensions outside code paths => docs 

225 

226 Args: 

227 file_path: The file path to classify. 

228 

229 Returns: 

230 "code" if the file is code, "docs" if documentation. 

231 """ 

232 path = Path(file_path) 

233 

234 # Check if it's a known code file 

235 if path.name in CODE_FILES: 

236 return "code" 

237 

238 # Check for .env templates 

239 if path.name.startswith(".env"): 

240 return "code" 

241 

242 # Check if it's under a code path 

243 path_str = str(path) 

244 for code_path in CODE_PATHS: 

245 if path_str.startswith(code_path): 

246 return "code" 

247 

248 # Check extension 

249 suffix = path.suffix.lower() 

250 if suffix in CODE_EXTENSIONS: 

251 return "code" 

252 if suffix in DOC_EXTENSIONS: 

253 return "docs" 

254 

255 # Default to docs for unknown types 

256 return "docs" 

257 

258 

259def build_validation_spec( 

260 repo_path: Path, 

261 scope: ValidationScope | None = None, 

262 disable_validations: set[str] | None = None, 

263) -> ValidationSpec: 

264 """Build a ValidationSpec from config files. 

265 

266 This function loads the mala.yaml configuration from the repository, 

267 merges it with any preset configuration, and builds a ValidationSpec. 

268 

269 Disable values: 

270 - "post-validate": Skip test commands entirely 

271 - "run-level-validate": (handled elsewhere, not here) 

272 - "integration-tests": (handled by config, not here) 

273 - "coverage": Disable coverage checking 

274 - "e2e": Disable E2E fixture repo test 

275 

276 Args: 

277 repo_path: Path to the repository root directory. 

278 scope: The validation scope. Defaults to PER_ISSUE. 

279 disable_validations: Set of validation types to disable. 

280 

281 Returns: 

282 A ValidationSpec configured according to the config files. 

283 """ 

284 from src.domain.validation.config import ConfigError 

285 from src.domain.validation.config_loader import ConfigMissingError, load_config 

286 from src.domain.validation.config_merger import merge_configs 

287 from src.domain.validation.preset_registry import PresetRegistry 

288 

289 # Use default scope if not specified 

290 if scope is None: 

291 scope = ValidationScope.PER_ISSUE 

292 

293 disable = disable_validations or set() 

294 

295 # Determine if we should skip tests 

296 skip_tests = "post-validate" in disable 

297 

298 # Load config from repo 

299 # - ConfigMissingError: gracefully return empty spec (optional config) 

300 # - ConfigError: fail fast (invalid syntax, unknown fields, etc.) 

301 try: 

302 user_config = load_config(repo_path) 

303 except ConfigMissingError: 

304 # No config file - return empty spec with all validations disabled 

305 return ValidationSpec( 

306 commands=[], 

307 scope=scope, 

308 require_clean_git=True, 

309 require_pytest_for_code_changes=True, 

310 coverage=CoverageConfig(enabled=False), 

311 e2e=E2EConfig(enabled=False), 

312 code_patterns=[], 

313 config_files=[], 

314 setup_files=[], 

315 ) 

316 

317 # Load and merge preset if specified 

318 if user_config.preset is not None: 

319 registry = PresetRegistry() 

320 preset_config = registry.get(user_config.preset) 

321 merged_config = merge_configs(preset_config, user_config) 

322 else: 

323 merged_config = user_config 

324 

325 # Ensure at least one command is defined after merge 

326 if not merged_config.has_any_command(): 

327 raise ConfigError( 

328 "At least one command must be defined. " 

329 "Specify a preset or define commands directly." 

330 ) 

331 

332 # Resolve commands for scope (run-level may override base commands) 

333 commands_config = merged_config.commands 

334 if scope == ValidationScope.RUN_LEVEL: 

335 commands_config = _apply_command_overrides( 

336 merged_config.commands, merged_config.run_level_commands 

337 ) 

338 

339 # Coverage requires a test command in the effective commands_config 

340 # This check must happen after _apply_command_overrides to catch cases where 

341 # run_level_commands.test is explicitly null (disabling the base test command) 

342 # However, we only raise an error if there's no run_level_commands.test set either - 

343 # if run_level_commands.test is set, coverage will be generated at run-level 

344 if merged_config.coverage is not None and commands_config.test is None: 

345 # Check if run_level_commands.test provides a test command for coverage 

346 run_level_test_set = ( 

347 merged_config.run_level_commands._fields_set 

348 and "test" in merged_config.run_level_commands._fields_set 

349 and merged_config.run_level_commands.test is not None 

350 ) 

351 if not run_level_test_set: 

352 raise ConfigError( 

353 "Coverage requires a test command to generate coverage data." 

354 ) 

355 

356 # Build commands list from config 

357 commands: list[ValidationCommand] = [] 

358 

359 if not skip_tests: 

360 commands = _build_commands_from_config(commands_config) 

361 

362 # Determine if coverage is enabled 

363 # Coverage is disabled for PER_ISSUE scope when run_level_commands.test provides 

364 # a different test command (e.g., with --cov flags). In this pattern, only the 

365 # run-level test command generates coverage.xml, so per-issue validation should 

366 # not check coverage. 

367 # Note: If run_level_commands.test is explicitly null, we don't disable coverage 

368 # for PER_ISSUE - the intent is to skip tests at run level, not to move coverage 

369 # to run level. 

370 run_level_has_different_test_command = ( 

371 merged_config.run_level_commands._fields_set 

372 and "test" in merged_config.run_level_commands._fields_set 

373 and merged_config.run_level_commands.test is not None 

374 ) 

375 coverage_only_at_run_level = ( 

376 run_level_has_different_test_command and scope == ValidationScope.PER_ISSUE 

377 ) 

378 coverage_enabled = ( 

379 merged_config.coverage is not None 

380 and "coverage" not in disable 

381 and not skip_tests 

382 and not coverage_only_at_run_level 

383 ) 

384 

385 # Build coverage config 

386 coverage_config = CoverageConfig( 

387 enabled=coverage_enabled, 

388 min_percent=merged_config.coverage.threshold 

389 if merged_config.coverage 

390 else None, 

391 branch=True, 

392 report_path=( 

393 Path(merged_config.coverage.file) if merged_config.coverage else None 

394 ), 

395 ) 

396 

397 # Configure E2E 

398 # Use commands_config which has run_level_commands overrides applied 

399 e2e_enabled = ( 

400 scope == ValidationScope.RUN_LEVEL 

401 and "e2e" not in disable 

402 and commands_config.e2e is not None 

403 ) 

404 e2e_config = E2EConfig( 

405 enabled=e2e_enabled, 

406 required_env=[], 

407 ) 

408 

409 return ValidationSpec( 

410 commands=commands, 

411 scope=scope, 

412 require_clean_git=True, 

413 require_pytest_for_code_changes=True, 

414 coverage=coverage_config, 

415 e2e=e2e_config, 

416 code_patterns=list(merged_config.code_patterns), 

417 config_files=list(merged_config.config_files), 

418 setup_files=list(merged_config.setup_files), 

419 yaml_coverage_config=merged_config.coverage if coverage_enabled else None, 

420 ) 

421 

422 

423def _apply_command_overrides( 

424 base: CommandsConfig, overrides: CommandsConfig 

425) -> CommandsConfig: 

426 """Apply run-level command overrides on top of base commands.""" 

427 

428 def is_explicit(field_name: str, value: CommandConfig | None) -> bool: 

429 if overrides._fields_set: 

430 return field_name in overrides._fields_set 

431 return value is not None 

432 

433 def pick( 

434 field_name: str, 

435 base_cmd: CommandConfig | None, 

436 override_cmd: CommandConfig | None, 

437 ) -> CommandConfig | None: 

438 return override_cmd if is_explicit(field_name, override_cmd) else base_cmd 

439 

440 return CommandsConfig( 

441 setup=pick("setup", base.setup, overrides.setup), 

442 test=pick("test", base.test, overrides.test), 

443 lint=pick("lint", base.lint, overrides.lint), 

444 format=pick("format", base.format, overrides.format), 

445 typecheck=pick("typecheck", base.typecheck, overrides.typecheck), 

446 e2e=pick("e2e", base.e2e, overrides.e2e), 

447 _fields_set=overrides._fields_set, 

448 ) 

449 

450 

451def _build_commands_from_config(config: CommandsConfig) -> list[ValidationCommand]: 

452 """Build ValidationCommand list from a CommandsConfig. 

453 

454 Args: 

455 config: The command configuration. 

456 

457 Returns: 

458 List of ValidationCommand instances. 

459 """ 

460 from src.core.tool_name_extractor import extract_tool_name 

461 

462 commands: list[ValidationCommand] = [] 

463 cmds = config 

464 

465 # Setup command 

466 if cmds.setup is not None: 

467 commands.append( 

468 ValidationCommand( 

469 name="setup", 

470 command=cmds.setup.command, 

471 kind=CommandKind.SETUP, 

472 timeout=cmds.setup.timeout or DEFAULT_COMMAND_TIMEOUT, 

473 detection_pattern=re.compile( 

474 _tool_name_to_pattern(extract_tool_name(cmds.setup.command)), 

475 re.IGNORECASE, 

476 ), 

477 ) 

478 ) 

479 

480 # Format command 

481 if cmds.format is not None: 

482 commands.append( 

483 ValidationCommand( 

484 name="format", 

485 command=cmds.format.command, 

486 kind=CommandKind.FORMAT, 

487 timeout=cmds.format.timeout or DEFAULT_COMMAND_TIMEOUT, 

488 detection_pattern=re.compile( 

489 _tool_name_to_pattern(extract_tool_name(cmds.format.command)), 

490 re.IGNORECASE, 

491 ), 

492 ) 

493 ) 

494 

495 # Lint command 

496 if cmds.lint is not None: 

497 commands.append( 

498 ValidationCommand( 

499 name="lint", 

500 command=cmds.lint.command, 

501 kind=CommandKind.LINT, 

502 timeout=cmds.lint.timeout or DEFAULT_COMMAND_TIMEOUT, 

503 detection_pattern=re.compile( 

504 _tool_name_to_pattern(extract_tool_name(cmds.lint.command)), 

505 re.IGNORECASE, 

506 ), 

507 ) 

508 ) 

509 

510 # Typecheck command 

511 if cmds.typecheck is not None: 

512 commands.append( 

513 ValidationCommand( 

514 name="typecheck", 

515 command=cmds.typecheck.command, 

516 kind=CommandKind.TYPECHECK, 

517 timeout=cmds.typecheck.timeout or DEFAULT_COMMAND_TIMEOUT, 

518 detection_pattern=re.compile( 

519 _tool_name_to_pattern(extract_tool_name(cmds.typecheck.command)), 

520 re.IGNORECASE, 

521 ), 

522 ) 

523 ) 

524 

525 # Test command 

526 if cmds.test is not None: 

527 commands.append( 

528 ValidationCommand( 

529 name="test", 

530 command=cmds.test.command, 

531 kind=CommandKind.TEST, 

532 timeout=cmds.test.timeout or DEFAULT_COMMAND_TIMEOUT, 

533 detection_pattern=re.compile( 

534 _tool_name_to_pattern(extract_tool_name(cmds.test.command)), 

535 re.IGNORECASE, 

536 ), 

537 ) 

538 ) 

539 

540 # E2E command (not added to regular command list, handled separately) 

541 # E2E is controlled by E2EConfig.enabled, not by command presence 

542 

543 return commands 

544 

545 

546def _tool_name_to_pattern(tool_name: str) -> str: 

547 """Convert a tool name to a regex pattern. 

548 

549 Args: 

550 tool_name: The tool name to convert. 

551 

552 Returns: 

553 Regex pattern string to match the tool name. 

554 """ 

555 if not tool_name: 

556 return r"$^" # Matches nothing 

557 # Escape special regex characters in the tool name 

558 escaped = re.escape(tool_name) 

559 return rf"\b{escaped}\b" 

560 

561 

562def extract_lint_tools_from_spec(spec: ValidationSpec | None) -> frozenset[str] | None: 

563 """Extract lint tool names from a ValidationSpec. 

564 

565 Extracts tool names from commands with LINT, FORMAT, or TYPECHECK kinds. 

566 These are the tools that LintCache should recognize and cache. 

567 

568 This is a convenience wrapper around ValidationSpec.extract_lint_tools() 

569 that handles None specs. 

570 

571 Args: 

572 spec: The ValidationSpec to extract from. If None, returns None. 

573 

574 Returns: 

575 Frozenset of lint tool names, or None if spec is None or has no 

576 lint commands (allowing LintCache to use defaults). 

577 """ 

578 if spec is None: 

579 return None 

580 

581 lint_tools = spec.extract_lint_tools() 

582 return lint_tools if lint_tools else None