160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Shared primitives and dispatch for handoff acceptance evaluators.
|
||
|
|
|
||
|
|
Evaluators are registered by name so the check catalog stays declarative: an
|
||
|
|
entry says *what* it asserts, not how to plumb it. The dispatch here is the one
|
||
|
|
place that turns an unexpected exception into ``NOT_RUN`` — a harness bug must
|
||
|
|
never be indistinguishable from a passing release.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from collections.abc import Callable, Mapping, Sequence
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from hermes_handoff_exec import Outcome
|
||
|
|
from hermes_handoff_model import NOT_RUN, CheckSpec
|
||
|
|
|
||
|
|
DENIAL_MARKERS = (
|
||
|
|
"forbidden",
|
||
|
|
"is not allowed",
|
||
|
|
"unauthorized",
|
||
|
|
"permission denied",
|
||
|
|
"cannot get",
|
||
|
|
"cannot list",
|
||
|
|
"cannot create",
|
||
|
|
"cannot delete",
|
||
|
|
"cannot patch",
|
||
|
|
"not permitted",
|
||
|
|
"no such route",
|
||
|
|
"404 not found",
|
||
|
|
"403",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Evaluation:
|
||
|
|
"""A classification plus the small facts that justify it."""
|
||
|
|
|
||
|
|
status: str
|
||
|
|
reason: str
|
||
|
|
evidence: dict[str, Any] = field(default_factory=dict)
|
||
|
|
|
||
|
|
|
||
|
|
Evaluator = Callable[[CheckSpec, dict[str, Outcome]], Evaluation]
|
||
|
|
EVALUATORS: dict[str, Evaluator] = {}
|
||
|
|
|
||
|
|
|
||
|
|
def evaluator(name: str) -> Callable[[Evaluator], Evaluator]:
|
||
|
|
"""Register an evaluator under the rule name catalog entries use."""
|
||
|
|
|
||
|
|
def register(function: Evaluator) -> Evaluator:
|
||
|
|
EVALUATORS[name] = function
|
||
|
|
return function
|
||
|
|
|
||
|
|
return register
|
||
|
|
|
||
|
|
|
||
|
|
def missing_steps(spec: CheckSpec, outcomes: Mapping[str, Outcome]) -> Evaluation | None:
|
||
|
|
"""Return NOT_RUN when a required step never produced usable evidence."""
|
||
|
|
for step in spec.steps:
|
||
|
|
if step.optional:
|
||
|
|
continue
|
||
|
|
outcome = outcomes.get(step.key)
|
||
|
|
if outcome is None:
|
||
|
|
return Evaluation(NOT_RUN, f"step {step.key} was never executed")
|
||
|
|
if not outcome.ran:
|
||
|
|
return Evaluation(NOT_RUN, f"step {step.key} did not run: {outcome.error}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def looks_denied(outcome: Outcome) -> bool:
|
||
|
|
"""Report whether a real request was refused by policy rather than failing."""
|
||
|
|
if outcome.ok:
|
||
|
|
return False
|
||
|
|
haystack = outcome.combined.lower()
|
||
|
|
return any(marker in haystack for marker in DENIAL_MARKERS)
|
||
|
|
|
||
|
|
|
||
|
|
def step_keys(spec: CheckSpec, kind: str) -> tuple[str, ...]:
|
||
|
|
"""Return the keys of every step of one kind."""
|
||
|
|
return tuple(step.key for step in spec.steps if step.kind == kind)
|
||
|
|
|
||
|
|
|
||
|
|
def is_list(value: Any) -> bool:
|
||
|
|
"""Report whether a value is a JSON array rather than a string."""
|
||
|
|
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
||
|
|
|
||
|
|
|
||
|
|
def dotted(payload: Any, path: str) -> Any:
|
||
|
|
"""Return a value addressed by a dotted path.
|
||
|
|
|
||
|
|
``a.b[0].c`` indexes; ``a[].b`` maps the remainder of the path over a list,
|
||
|
|
which is how the catalog records one field from every pull request or every
|
||
|
|
Flux object without a bespoke parser per check.
|
||
|
|
"""
|
||
|
|
current = payload
|
||
|
|
segments = [segment for segment in path.split(".") if segment]
|
||
|
|
for position, raw in enumerate(segments):
|
||
|
|
name, bracket, indexes = raw.partition("[")
|
||
|
|
if name:
|
||
|
|
if not isinstance(current, Mapping) or name not in current:
|
||
|
|
raise KeyError(path)
|
||
|
|
current = current[name]
|
||
|
|
if not bracket:
|
||
|
|
continue
|
||
|
|
for chunk in indexes.rstrip("]").split("]["):
|
||
|
|
if not is_list(current):
|
||
|
|
raise KeyError(path)
|
||
|
|
if chunk == "":
|
||
|
|
remainder = ".".join(segments[position + 1 :])
|
||
|
|
return [dotted(item, remainder) if remainder else item for item in current]
|
||
|
|
offset = int(chunk)
|
||
|
|
if offset >= len(current):
|
||
|
|
raise KeyError(path)
|
||
|
|
current = current[offset]
|
||
|
|
return current
|
||
|
|
|
||
|
|
|
||
|
|
def parse_json(outcome: Outcome) -> Any:
|
||
|
|
"""Parse a step's stdout as JSON, raising ``ValueError`` when it is not.
|
||
|
|
|
||
|
|
Control characters are tolerated. Task bodies and session titles routinely
|
||
|
|
carry raw newlines and tabs, and refusing to read a report because a human
|
||
|
|
pasted a tab into a Kanban card would be a false NO_GO.
|
||
|
|
"""
|
||
|
|
text = outcome.stdout.strip()
|
||
|
|
if not text:
|
||
|
|
raise ValueError("empty output")
|
||
|
|
return json.loads(text, strict=False)
|
||
|
|
|
||
|
|
|
||
|
|
def lines(outcome: Outcome) -> list[str]:
|
||
|
|
"""Return non-empty, stripped stdout lines."""
|
||
|
|
return [line.strip() for line in outcome.stdout.splitlines() if line.strip()]
|
||
|
|
|
||
|
|
|
||
|
|
def parsed_step(spec: CheckSpec, outcomes: dict[str, Outcome]) -> tuple[Any, str] | Evaluation:
|
||
|
|
"""Return the parsed payload of ``expect['step']`` or the NOT_RUN to report."""
|
||
|
|
key = spec.expect["step"]
|
||
|
|
outcome = outcomes[key]
|
||
|
|
if not outcome.ok:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})")
|
||
|
|
try:
|
||
|
|
return (parse_json(outcome), key)
|
||
|
|
except ValueError as exc:
|
||
|
|
return Evaluation(NOT_RUN, f"step {key} did not return JSON: {exc}")
|
||
|
|
|
||
|
|
|
||
|
|
def evaluate(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation:
|
||
|
|
"""Dispatch to the named evaluator, converting any surprise into NOT_RUN."""
|
||
|
|
rule = EVALUATORS.get(spec.rule)
|
||
|
|
if rule is None:
|
||
|
|
return Evaluation(NOT_RUN, f"unknown evaluator: {spec.rule}")
|
||
|
|
try:
|
||
|
|
return rule(spec, outcomes)
|
||
|
|
except Exception as exc: # noqa: BLE001 - a harness bug must never read as PASS
|
||
|
|
return Evaluation(NOT_RUN, f"evaluator {spec.rule} raised {type(exc).__name__}: {exc}")
|