The classifier missed modules the update entry points import (_subprocess_compat, desktop_build_lock, memory_provider_migration, the scripts/build compilers) and never sent source_build/source_completion to the Desktop update suite. ci.yaml gated tests-os on `python` alone, so a TypeScript-only change to the Electron marker/result readers set desktop_updater and still never reached the Windows hand-off tests. - classify_changes.py: _UPDATE_ENTRY_POINTS / _DESKTOP_BUILD_ENTRY_POINTS, an owner list of what they import, and an explicit _SHARED_HUBS set. - tests/ci/test_update_ci_routing.py reads the entry points' real imports (AST incl. lazy imports; ESM import/step() closure of the build scripts) and fails until each is routed or is a real hub (>= 25 importers). - It also replays the REAL classifier's output through the composite action, ci.yaml detect outputs, job if:/with:, the called workflows' job if: and the rendered Windows step, for every combination of the gated lanes (tests/ci/_gha_expr.py models the expression subset and raises on anything else). - ci.yaml: tests runs for python|e2e|e2e_upgrade; tests-os for python|desktop_updater|e2e|e2e_upgrade. Red on 8cc78279a36: 30 of the new routing tests fail (R15 table cells, 16 lane combinations, ownership/closure tests). (cherry picked from commit 3b17b5d2ad8081534bbeefcf5d8334cd79a7e6fd) (cherry picked from commit 594f8b0c4ba32324da922d5289b0932299ffe7a9)
225 lines
7.5 KiB
Python
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.X)
|
|
_TEMPLATE = re.compile(r"\$\{\{(.*?)\}\}", re.S)
|
|
|
|
|
|
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)
|