diff --git a/src/veritext/validators/composite.py b/src/veritext/validators/composite.py new file mode 100644 index 0000000..3a9022f --- /dev/null +++ b/src/veritext/validators/composite.py @@ -0,0 +1,100 @@ +"""Composite validators for combining multiple checks. + +Note: CompositeCheck classes (AllOf, AnyOf) intentionally return ValidationResult +rather than CheckResult. This allows callers to inspect individual check results +for detailed error reporting. They implement a compatible interface but are not +substitutable where Check is expected as a type constraint. +""" + +from veritext.core.types import CheckResult, ValidationContext, ValidationResult +from veritext.validators.base import Check + + +class AllOf: + """Passes only if all checks pass. + + Note: Returns ValidationResult (not CheckResult) to expose child results. + """ + + def __init__(self, checks: list[Check]) -> None: + """ + Initialise the AllOf composite validator. + + Args: + checks: List of checks that must all pass. + + Raises: + ValueError: If checks list is empty. + """ + if not checks: + raise ValueError("checks list cannot be empty") + + self._checks = list(checks) + + @property + def name(self) -> str: + return "all_of" + + def check(self, text: str, context: ValidationContext) -> ValidationResult: + """ + Run all checks and return aggregate result. + + Args: + text: The text to validate. + context: Validation context containing reference text and metadata. + + Returns: + ValidationResult that passes only if all checks pass. + """ + results: list[CheckResult] = [] + for check in self._checks: + results.append(check.check(text, context)) + + all_passed = all(r.passed for r in results) + + return ValidationResult(passed=all_passed, checks=results) + + +class AnyOf: + """Passes if any check passes. + + Note: Returns ValidationResult (not CheckResult) to expose child results. + """ + + def __init__(self, checks: list[Check]) -> None: + """ + Initialise the AnyOf composite validator. + + Args: + checks: List of checks where at least one must pass. + + Raises: + ValueError: If checks list is empty. + """ + if not checks: + raise ValueError("checks list cannot be empty") + + self._checks = list(checks) + + @property + def name(self) -> str: + return "any_of" + + def check(self, text: str, context: ValidationContext) -> ValidationResult: + """ + Run all checks and return aggregate result. + + Args: + text: The text to validate. + context: Validation context containing reference text and metadata. + + Returns: + ValidationResult that passes if any check passes. + """ + results: list[CheckResult] = [] + for check in self._checks: + results.append(check.check(text, context)) + + any_passed = any(r.passed for r in results) + + return ValidationResult(passed=any_passed, checks=results)