band-aider-pub/band_aider/context/validation_utils.py
2026-10-17 11:57:37 -07:00

289 lines
10 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
"""Validate shape by reference-owned structure and primitive compatibility.
Complex reference types define shape through member presence: objects require
their declared members and reject undeclared members, while lists apply their
first declared member shape without comparing cardinality. An empty reference
list is intentionally under-specified and therefore leaves list members
unconstrained. Native primitives use reference-directed compatibility: matching
primitive types are valid, and an integer can be marshalled to a declared float,
but a float cannot be marshalled to a declared integer. Disjunctive validation
admits any complete reference; conjunctive validation recursively overlays
objects in order, prefers a specified side over null during collisions, and
lets the right reference replace primitives and lists.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import reduce
from typing import cast
ITEMS = "__items__"
_TYPE_LABELS: dict[type[object], str] = {
dict: "an object",
list: "a list",
str: "a string",
int: "an int",
float: "a float",
bool: "a boolean",
type(None): "null",
}
@dataclass(frozen=True)
class ValidationLocation:
"""Store validation path segments and render their dotted location.
Example: ``ValidationLocation(["A", "b", ITEMS, "0"]).stringify()``
returns ``"A.b[0]"`` while retaining parseable path parts.
"""
parts: list[str]
def child(self, part: str) -> ValidationLocation:
"""Return an independent location extended by one nested part."""
return ValidationLocation([*self.parts, part])
def stringify(self) -> str:
"""Return the location as one dotted path."""
rendered: list[str] = []
item = False
# Render ordinary members with dots and reserved list members with brackets.
for part in self.parts:
# Reserve the item marker for the following list index.
if part == ITEMS:
item = True
# Attach a list index to its owning list without a dotted separator.
elif item:
# Preserve bracket rendering for a root-level list item.
if rendered:
rendered[-1] = f"{rendered[-1]}[{part}]"
else:
rendered.append(f"[{part}]")
item = False
# Retain ordinary object members as dotted path segments.
else:
rendered.append(part)
return ".".join(rendered)
@dataclass(frozen=True)
class ValidationError:
"""Pair one invalid value's owning location with its validation error.
Example: ``error.stringify()`` combines ``A`` with ``b must be a string``.
"""
location: ValidationLocation
error: str
def stringify(self) -> str:
"""Return the location and validation error as one correction message."""
location = self.location.stringify()
return f"{location}: {self.error}" if location else self.error
class SemanticValidationError(ValidationError):
"""Mark a shape-admitted domain error without changing feedback rendering."""
class MechanicalValidationError(ValidationError):
"""Mark a deterministic verification error without changing feedback rendering."""
@dataclass(frozen=True)
class ValidationResult:
"""Record valid admission or every structured invalid result.
Example: ``result.valid`` admits a value, while ``result.stringify()``
renders all invalid locations and errors for a retrying caller.
"""
errors: tuple[ValidationError, ...] = ()
@property
def valid(self) -> bool:
"""Return whether no validation error rejected the value."""
return not self.errors
def stringify(self) -> str:
"""Return every structured validation error on its own line."""
return "\n".join(map(ValidationError.stringify, self.errors))
_VALID = ValidationResult()
def _merge(left: object, right: object) -> object:
"""Overlay two references, preferring a specified side over null."""
# Preserve the specified side when null collides with a declaration.
if left is None or right is None:
return right if left is None else left
# Replace primitives, lists, and cross-kind references from the right.
if not isinstance(left, dict) or not isinstance(right, dict):
return right
merged = dict(left)
# Recursively resolve only members declared by both object references.
for name, value in right.items():
# Apply collision rules without changing unmatched member declarations.
if name in merged:
merged[name] = _merge(merged[name], value)
else:
merged[name] = value
return merged
def _validate_reference(
data: object,
reference: object,
*,
location: ValidationLocation,
member: str | None = None,
item_index: int | None = None,
) -> ValidationResult:
"""Validate one structural or primitive value against one reference."""
value_location = location
member_name = member
# Advance object members and list members to their respective value locations.
if item_index is not None:
value_location = location.child(ITEMS).child(str(item_index))
member_name = f"[{item_index}]"
elif member is not None:
value_location = location.child(member)
data_type = type(data)
reference_type = type(reference)
compatible_type = (
data_type is reference_type
or (reference_type is float and data_type is int)
)
# Assign nullability to the container and other invalid types to the existing value.
if not compatible_type:
nullability = data is None or reference is None
error_location = location if nullability else value_location
member_prefix = (
f"member {member_name} "
if nullability and member_name is not None
else ""
)
return ValidationResult(errors=(ValidationError(
location=error_location,
error=(
f"{member_prefix}expected "
f"{_TYPE_LABELS.get(reference_type, reference_type.__name__)} "
f"but found {_TYPE_LABELS.get(data_type, data_type.__name__)}."
),
),))
errors: list[ValidationError] = []
# Require every referenced object field and reject every unreferenced field.
if isinstance(reference, dict):
actual = cast("dict[str, object]", data)
missing = set(reference) - set(actual)
unexpected = set(actual) - set(reference)
# Assign absent member existence to the object that should contain it.
for name in sorted(missing, key=str):
errors.append(ValidationError(
location=value_location,
error=f"missing member {name}.",
))
# Assign unexpected member existence to the object that contains it.
for name in sorted(unexpected, key=str):
errors.append(ValidationError(
location=value_location,
error=f"unexpected member {name}.",
))
# Validate every available object member against its corresponding reference member.
for name, expected in reference.items():
# Missing members are already represented by the complete-membership error.
if name in actual:
result = _validate_reference(
actual[name],
expected,
location=value_location,
member=name,
)
errors.extend(result.errors)
# Treat the first reference item as the reusable shape of every array member.
if isinstance(reference, list):
# Validate members only when the reference declares a member shape.
if reference:
actual_items = cast("list[object]", data)
expected_item = reference[0]
# Validate every array member without comparing actual/reference cardinality.
for index, actual_item in enumerate(actual_items):
result = _validate_reference(
actual_item,
expected_item,
location=value_location,
item_index=index,
)
errors.extend(result.errors)
return ValidationResult(errors=tuple(errors))
def validate_shape(
data: object,
references: list[object],
*,
location: ValidationLocation,
conjunctive: bool = False,
) -> ValidationResult:
"""Admit data when any complete reference shape is compatible.
Disjunctive validation admits any complete reference. Conjunctive validation
recursively overlays object references in order; the right reference replaces
primitive, list, and cross-kind collisions, while a null/non-null collision
retains the non-null side. Unmatched and null/null members remain declared.
Complex types are compared recursively by member presence and member shape. Native
primitives follow reference-directed marshalling compatibility rather than
literal equality. An empty reference list leaves list members unconstrained;
an empty ``references`` argument admits no value.
"""
# Reject an absent union contract because no reference can admit the value.
if not references:
return ValidationResult(errors=(ValidationError(
location=location,
error="at least one reference shape is required.",
),))
effective = references
# Form one ordered reference before validating conjunctive input.
if conjunctive:
effective = [reduce(_merge, references)]
matched = False
errors: list[ValidationError] = []
# Evaluate every allowed reference so invalid unions retain all correction evidence.
for reference in effective:
result = _validate_reference(
data,
reference,
location=location,
)
# Remember any complete alternative without retaining other alternatives' errors.
if result.valid:
matched = True
else:
errors.extend(result.errors)
# Admit the value when any one reference matched completely.
if matched:
return _VALID
return ValidationResult(errors=tuple(errors))