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

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

2

3 This 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

13 from __future__ import annotations

14

15 import re

16 from dataclasses import dataclass, field

17 from enum import Enum

18 from pathlib import Path

19 from 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

23 from src.core.models import (

24 IssueResolution as IssueResolution,

25 ResolutionOutcome as ResolutionOutcome,

26 ValidationArtifacts as ValidationArtifacts,

27)

28 from src.domain.validation.config import CommandsConfig

29

30 if TYPE_CHECKING:

31 from re import Pattern

32

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

34

35

36 class ValidationScope(Enum):

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

38

39 PER_ISSUE = "per_issue"

40 RUN_LEVEL = "run_level"

41

42

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

55 DEFAULT_COMMAND_TIMEOUT = 120

56

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

58 LINT_COMMAND_KINDS: frozenset[CommandKind] = frozenset(

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

60)

61

62

63@dataclass(frozen=True)

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

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

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

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

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

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

203

204# Specific files that are considered code changes

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

206

207# Extensions that are considered code changes

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

209

210# Extensions that are considered documentation

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

212

213

214 def 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

259 def 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

423 def _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

451 def _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

546 def _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

562 def 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