""" State Gate — Pre-merge / pre-ship state completeness validation. Inspired by ADW's ``validate_state_completeness()`` which checks every core field is non-None before allowing a merge. Architecture-agnostic: works with JSON state files, dicts, or any key-value store. Usage:: gate = StateGate( FieldSpec("plan_file", required=True, predicate=lambda v: os.path.exists(v)), FieldSpec("branch_name", required=True), FieldSpec("backend_port", required=True, type_hint=int), ) state = PipelineState({"plan_file": "/tmp/plan.md", "branch_name": "feat/x"}) result = gate.validate(state) # result.passed == False (missing backend_port) # result.missing == ["backend_port"] """ from __future__ import annotations import json from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from typing import Any, Callable, Optional # ── FieldSpec ────────────────────────────────────────────────────────────────── @dataclass class FieldSpec: """Specification for a single pipeline state field. Args: name: Field key in the state dict. required: If True, field must be present and non-None. type_hint: Optional Python type for runtime type checking. predicate: Optional custom predicate that receives the field value and returns True if valid. Useful for file-exists checks, port availability, URL validity, etc. description: Human-readable description of what this field represents. """ name: str required: bool = True type_hint: Optional[type] = None predicate: Optional[Callable[[Any], bool]] = None description: str = "" # ── PipelineState ────────────────────────────────────────────────────────────── class PipelineState: """Immutable-ish state container with field filtering (ADW-style). The ``core_fields`` filter prevents state bloat: only known keys are accepted on update. Unknown keys are silently dropped with a warning. """ def __init__(self, initial: Optional[dict[str, Any]] = None, core_fields: Optional[list[str]] = None): self._core_fields = core_fields or [] self._data: dict[str, Any] = {} if initial: self._apply(initial) def _apply(self, data: dict[str, Any]) -> None: if self._core_fields: for key in list(data.keys()): if key not in self._core_fields: continue # silently drop unknown fields (ADW pattern) self._data[key] = data[key] else: self._data.update(data) def get(self, key: str, default: Any = None) -> Any: return self._data.get(key, default) def set(self, key: str, value: Any) -> None: if self._core_fields and key not in self._core_fields: return self._data[key] = value def update(self, **kwargs) -> None: self._apply(kwargs) @property def data(self) -> dict[str, Any]: return dict(self._data) @property def keys(self) -> list[str]: return list(self._data.keys()) def __contains__(self, key: str) -> bool: return key in self._data def __repr__(self) -> str: return f"PipelineState({self._data})" # ── StateGate ────────────────────────────────────────────────────────────────── @dataclass class GateResult: """Result of a StateGate validation check.""" passed: bool fields_checked: int = 0 missing: list[str] = field(default_factory=list) type_errors: list[tuple[str, str]] = field(default_factory=list) predicate_failures: list[tuple[str, str]] = field(default_factory=list) timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) def dict(self) -> dict[str, Any]: return { "passed": self.passed, "fields_checked": self.fields_checked, "missing": self.missing, "type_errors": self.type_errors, "predicate_failures": self.predicate_failures, "timestamp": self.timestamp, } def summary(self) -> str: if self.passed: return f"Gate PASSED: all {self.fields_checked} fields validated." parts = [f"Gate FAILED ({self.fields_checked} fields checked):"] if self.missing: parts.append(f" Missing: {', '.join(self.missing)}") if self.type_errors: for name, msg in self.type_errors: parts.append(f" Type error [{name}]: {msg}") if self.predicate_failures: for name, msg in self.predicate_failures: parts.append(f" Predicate fail [{name}]: {msg}") return "\n".join(parts) class StateGate: """Validates that pipeline state is complete and consistent before allowing a merge, ship, or transition to the next phase. Analogous to ADW's ``validate_state_completeness()`` in adw_ship_iso.py. """ def __init__(self, *field_specs: FieldSpec, label: str = "pre-ship"): self.field_specs = list(field_specs) self.label = label def validate(self, state: PipelineState) -> GateResult: """Run all field specs against a PipelineState. Returns a GateResult with detailed failure information. The caller decides whether to block based on ``result.passed``. """ missing: list[str] = [] type_errors: list[tuple[str, str]] = [] predicate_failures: list[tuple[str, str]] = [] for spec in self.field_specs: value = state.get(spec.name) # Required check if spec.required and value is None: missing.append(spec.name) continue # Type check (skip if value is None and not required — already caught above) if spec.type_hint and value is not None: if not isinstance(value, spec.type_hint): type_errors.append( (spec.name, f"expected {spec.type_hint.__name__}, got {type(value).__name__}") ) # Predicate check if spec.predicate and value is not None: try: if not spec.predicate(value): predicate_failures.append( (spec.name, f"predicate failed for value {value!r}") ) except Exception as exc: predicate_failures.append( (spec.name, f"predicate raised {type(exc).__name__}: {exc}") ) total = len(self.field_specs) passed = not (missing or type_errors or predicate_failures) return GateResult( passed=passed, fields_checked=total, missing=missing, type_errors=type_errors, predicate_failures=predicate_failures, )