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
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
1"""ValidationSpec and related types for mala validation.
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"""
13from __future__ import annotations
15import re
16from dataclasses import dataclass, field
17from enum import Enum
18from pathlib import Path
19from typing import TYPE_CHECKING, Literal
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
30if TYPE_CHECKING:
31 from re import Pattern
33 from src.domain.validation.config import CommandConfig, YamlCoverageConfig
36class ValidationScope(Enum):
37 """Scope for validation: per-issue or run-level."""
39 PER_ISSUE = "per_issue"
40 RUN_LEVEL = "run_level"
43class CommandKind(Enum):
44 """Kind of validation command."""
46 SETUP = "setup" # Environment setup (uv sync, etc.)
47 LINT = "lint"
48 FORMAT = "format"
49 TYPECHECK = "typecheck"
50 TEST = "test"
51 E2E = "e2e"
54# Default timeout for validation commands in seconds
55DEFAULT_COMMAND_TIMEOUT = 120
57# Command kinds that represent lint-like tools for LintCache
58LINT_COMMAND_KINDS: frozenset[CommandKind] = frozenset(
59 {CommandKind.LINT, CommandKind.FORMAT, CommandKind.TYPECHECK}
60)
63@dataclass(frozen=True)
64class ValidationCommand:
65 """A single command in the validation pipeline.
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 """
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
89@dataclass
90class CoverageConfig:
91 """Configuration for coverage validation.
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 """
102 enabled: bool = True
103 min_percent: float | None = None
104 branch: bool = True
105 report_path: Path | None = None
108@dataclass
109class E2EConfig:
110 """Configuration for E2E fixture repo validation.
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 """
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)
125@dataclass(frozen=True)
126class ValidationContext:
127 """Immutable context for a single validation run.
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 """
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
146@dataclass
147class ValidationSpec:
148 """Defines what validations to run for a given scope.
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 """
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
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]
179 def extract_lint_tools(self) -> frozenset[str]:
180 """Extract lint tool names from this ValidationSpec.
182 Extracts tool names from commands with LINT, FORMAT, or TYPECHECK kinds.
183 These are the tools that LintCache should recognize and cache.
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
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)
197 return frozenset(lint_tools)
200# Code classification constants
201# Paths that are considered code changes
202CODE_PATHS = frozenset({"src/", "tests/", "commands/", "src/scripts/"})
204# Specific files that are considered code changes
205CODE_FILES = frozenset({"pyproject.toml", "uv.lock"})
207# Extensions that are considered code changes
208CODE_EXTENSIONS = frozenset({".py", ".sh", ".toml", ".yml", ".yaml", ".json"})
210# Extensions that are considered documentation
211DOC_EXTENSIONS = frozenset({".md", ".rst", ".txt"})
214def classify_change(file_path: str) -> Literal["code", "docs"]:
215 """Classify a file change as code or docs.
217 Code changes require tests + coverage. Doc changes may skip tests.
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
226 Args:
227 file_path: The file path to classify.
229 Returns:
230 "code" if the file is code, "docs" if documentation.
231 """
232 path = Path(file_path)
234 # Check if it's a known code file
235 if path.name in CODE_FILES:
236 return "code"
238 # Check for .env templates
239 if path.name.startswith(".env"):
240 return "code"
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"
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"
255 # Default to docs for unknown types
256 return "docs"
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.
266 This function loads the mala.yaml configuration from the repository,
267 merges it with any preset configuration, and builds a ValidationSpec.
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
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.
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
289 # Use default scope if not specified
290 if scope is None:
291 scope = ValidationScope.PER_ISSUE
293 disable = disable_validations or set()
295 # Determine if we should skip tests
296 skip_tests = "post-validate" in disable
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 )
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
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 )
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 )
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 )
356 # Build commands list from config
357 commands: list[ValidationCommand] = []
359 if not skip_tests:
360 commands = _build_commands_from_config(commands_config)
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 )
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 )
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 )
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 )
423def _apply_command_overrides(
424 base: CommandsConfig, overrides: CommandsConfig
425) -> CommandsConfig:
426 """Apply run-level command overrides on top of base commands."""
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
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
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 )
451def _build_commands_from_config(config: CommandsConfig) -> list[ValidationCommand]:
452 """Build ValidationCommand list from a CommandsConfig.
454 Args:
455 config: The command configuration.
457 Returns:
458 List of ValidationCommand instances.
459 """
460 from src.core.tool_name_extractor import extract_tool_name
462 commands: list[ValidationCommand] = []
463 cmds = config
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 )
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 )
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 )
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 )
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 )
540 # E2E command (not added to regular command list, handled separately)
541 # E2E is controlled by E2EConfig.enabled, not by command presence
543 return commands
546def _tool_name_to_pattern(tool_name: str) -> str:
547 """Convert a tool name to a regex pattern.
549 Args:
550 tool_name: The tool name to convert.
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"
562def extract_lint_tools_from_spec(spec: ValidationSpec | None) -> frozenset[str] | None:
563 """Extract lint tool names from a ValidationSpec.
565 Extracts tool names from commands with LINT, FORMAT, or TYPECHECK kinds.
566 These are the tools that LintCache should recognize and cache.
568 This is a convenience wrapper around ValidationSpec.extract_lint_tools()
569 that handles None specs.
571 Args:
572 spec: The ValidationSpec to extract from. If None, returns None.
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
581 lint_tools = spec.extract_lint_tools()
582 return lint_tools if lint_tools else None