fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)

* fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447)

The `in` / `not in` operators in `_evaluate_simple_expression` only guarded
`right is not None`, but `left in right` also raises `TypeError` for any other
non-iterable right operand (int, bool, float). So a workflow condition like
`{{ inputs.tag in inputs.count }}` where `count` is a number leaked a raw
`TypeError: argument of type 'int' is not iterable` and crashed the whole run,
instead of evaluating like the None case beside it.

This was asymmetric with `_safe_compare`, which already swallows `TypeError`
and returns False for the ordering operators.

Add a `_safe_contains` helper (mirroring `_safe_compare`) that treats both a
None and a non-container right operand as "nothing is contained": `in` -> False,
`not in` -> True. Add a regression test covering int/bool/float/None right
operands and asserting genuine containment against iterables still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(workflows): address review feedback on #3468

#3447 was fixed independently by #3448 (merged first), which added the
same _safe_membership helper this branch introduced. Per Copilot review:

- Revert the redundant _safe_contains rename in expressions.py so the file
  matches main; the working membership guard already lives there.
- Drop the duplicate test_in_operator_non_iterable_right_operand test and
  fold its only new coverage (not in against float/bool/None right operands,
  which the base test only checked for the int case) into the existing
  test_membership_against_non_iterable_is_false_not_error.

Also merges latest upstream/main into the branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Noor ul ain
2026-07-14 18:07:33 +05:00
committed by GitHub
parent d83b8d1188
commit 73093954e2

View File

@@ -475,11 +475,11 @@ class TestExpressions:
# previous `right is not None` guard and mirrors _safe_compare, which
# already swallows TypeError for the ordering operators.
ctx = StepContext(inputs={"tag": "x", "count": 5, "ratio": 1.5, "flag": True})
assert evaluate_expression("{{ inputs.tag in inputs.count }}", ctx) is False
assert evaluate_expression("{{ inputs.tag not in inputs.count }}", ctx) is True
assert evaluate_expression("{{ 'a' in inputs.ratio }}", ctx) is False
assert evaluate_expression("{{ 'a' in inputs.flag }}", ctx) is False
assert evaluate_expression("{{ inputs.tag in inputs.missing }}", ctx) is False
# `in` -> False and `not in` -> True for every non-iterable right
# operand (int, float, bool, None), so neither operator can drift.
for right in ("count", "ratio", "flag", "missing"):
assert evaluate_expression(f"{{{{ inputs.tag in inputs.{right} }}}}", ctx) is False
assert evaluate_expression(f"{{{{ inputs.tag not in inputs.{right} }}}}", ctx) is True
# A condition that would otherwise crash the run now evaluates cleanly.
assert evaluate_condition("{{ inputs.tag in inputs.count }}", ctx) is False