Files
hermes-agent/tests/ci/_gha_expr.py
2026-10-08 17:22:54 +02:00

225 lines
7.5 KiB
Python

"""A small evaluator for the GitHub Actions expression subset our workflows gate on.
Enough to replay ``detect`` -> ``ci.yaml`` job ``if:``/``with:`` -> a called
workflow's job ``if:`` and its ``${{ }}``-rendered step text, so a test can feed
the real classifier's output through the real workflow files instead of
asserting booleans in isolation. Anything outside the subset raises
:class:`Unsupported`: a new construct must be modelled here, never ignored.
Semantics follow the Actions docs: ``&&``/``||`` return an operand, ``!``
negates truthiness, ``==``/``!=`` compare strings case-insensitively and coerce
mismatched types to numbers (so ``'true' == true`` is false), and a missing
property is ``null``.
"""
from __future__ import annotations
import json
import math
import re
from typing import Any, Mapping
_TOKEN = re.compile(r"""\s*(?:
(?P<str>'(?:[^']|'')*')
| (?P<num>-?\d+(?:\.\d+)?)
| (?P<op>==|!=|&&|\|\||<=|>=|[!()<>,])
| (?P<path>[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_*][A-Za-z0-9_-]*)*)
)""", re.VERBOSE)
_TEMPLATE = re.compile(r"\$\{\{(.*?)\}\}", re.DOTALL)
class Unsupported(ValueError):
"""An expression construct this evaluator does not model."""
def _tokens(text: str) -> list[tuple[str, str]]:
out, pos = [], 0
text = text.strip()
while pos < len(text):
match = _TOKEN.match(text, pos)
if not match or match.end() == pos:
raise Unsupported(f"cannot tokenize {text[pos:]!r} in {text!r}")
kind = match.lastgroup or ""
out.append((kind, match.group(kind)))
pos = match.end()
return out
def truthy(value: Any) -> bool:
if value is None or value is False:
return False
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value != 0 and not math.isnan(value)
if isinstance(value, str):
return value != ""
return True
def _number(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip()) if value.strip() else 0.0
except ValueError:
return math.nan
return math.nan
def _equal(a: Any, b: Any) -> bool:
if type(a) is type(b) or (a is None and b is None):
if isinstance(a, str) and isinstance(b, str):
return a.lower() == b.lower()
return a == b
return _number(a) == _number(b)
def to_string(value: Any) -> str:
"""How ``${{ }}`` renders a value into text (``true``/``false``, ``''`` for null)."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float) and value.is_integer():
return str(int(value))
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
class _Parser:
def __init__(self, text: str, context: Mapping[str, Any]):
self.text, self.toks, self.i, self.ctx = text, _tokens(text), 0, context
def peek(self) -> tuple[str, str] | None:
return self.toks[self.i] if self.i < len(self.toks) else None
def take(self, value: str | None = None) -> tuple[str, str]:
tok = self.peek()
if tok is None or (value is not None and tok[1] != value):
raise Unsupported(f"expected {value or 'a token'} in {self.text!r}")
self.i += 1
return tok
def parse(self) -> Any:
value = self.or_()
tok = self.peek()
if tok is not None:
raise Unsupported(f"trailing {tok[1]!r} in {self.text!r}")
return value
def or_(self) -> Any:
value = self.and_()
while self.peek() == ("op", "||"):
self.take()
right = self.and_()
value = value if truthy(value) else right
return value
def and_(self) -> Any:
value = self.cmp()
while self.peek() == ("op", "&&"):
self.take()
right = self.cmp()
value = right if truthy(value) else value
return value
def cmp(self) -> Any:
value = self.unary()
while self.peek() in (("op", "=="), ("op", "!=")):
op = self.take()[1]
right = self.unary()
value = _equal(value, right) if op == "==" else not _equal(value, right)
tok = self.peek()
if tok is not None and tok[1] in ("<", ">", "<=", ">="):
raise Unsupported(f"ordering comparison in {self.text!r}")
return value
def unary(self) -> Any:
if self.peek() == ("op", "!"):
self.take()
return not truthy(self.unary())
return self.atom()
def atom(self) -> Any:
kind, text = self.take()
if kind == "op" and text == "(":
value = self.or_()
self.take(")")
return value
if kind == "str":
return text[1:-1].replace("''", "'")
if kind == "num":
return float(text)
if kind != "path":
raise Unsupported(f"unexpected {text!r} in {self.text!r}")
if text in ("true", "false"):
return text == "true"
if text == "null":
return None
if self.peek() == ("op", "("):
return self.call(text)
return self.lookup(text)
def call(self, name: str) -> Any:
self.take("(")
args = []
while self.peek() != ("op", ")"):
args.append(self.or_())
if self.peek() == ("op", ","):
self.take()
self.take(")")
if name in ("always", "success", "failure", "cancelled"):
fn = self.ctx.get("__status__", {}).get(name)
if fn is None:
raise Unsupported(f"{name}() outside a modelled job status in {self.text!r}")
return fn
if name == "toJSON":
return json.dumps(args[0])
if name == "fromJSON":
return json.loads(args[0])
if name == "contains":
hay, needle = args
if isinstance(hay, list):
return any(_equal(h, needle) for h in hay)
return to_string(needle).lower() in to_string(hay).lower()
raise Unsupported(f"function {name}() in {self.text!r}")
def lookup(self, path: str) -> Any:
node: Any = self.ctx
for part in path.split("."):
if part == "*":
raise Unsupported(f"object filter in {self.text!r}")
node = node.get(part) if isinstance(node, Mapping) else None
return node
def evaluate(expr: str, context: Mapping[str, Any]) -> Any:
"""Value of a bare expression or a single ``${{ expr }}``."""
text = expr.strip()
whole = _TEMPLATE.fullmatch(text)
return _Parser(whole.group(1) if whole else text, context).parse()
def condition(expr: Any, context: Mapping[str, Any]) -> bool:
"""A job/step ``if:`` (absent means ``success()``, which the caller models)."""
if expr is None:
return True
if isinstance(expr, bool):
return expr
return truthy(evaluate(str(expr), context))
def render(text: Any, context: Mapping[str, Any]) -> Any:
"""``with:``/``env:``/``run:`` value: a lone ``${{ x }}`` keeps its type, embedded ones stringify."""
if not isinstance(text, str):
return text
whole = _TEMPLATE.fullmatch(text.strip())
if whole:
return evaluate(whole.group(1), context)
return _TEMPLATE.sub(lambda m: to_string(evaluate(m.group(1), context)), text)