fix: interpolate multi-expression templates instead of returning None (#3208) (#3228)

* fix: interpolate multi-expression templates instead of returning None (#3208)

`evaluate_expression` returned None for templates containing two or more
`{{ }}` blocks with no surrounding literal text, e.g.
`"{{ context.run_id }} {{ inputs.issue }}"`.

The single-expression fast path used `_EXPR_PATTERN.fullmatch()`, but
`fullmatch` defeats the pattern's non-greedy `(.+?)` body: for two adjacent
expressions it still matches, capturing everything between the first `{{`
and the last `}}` (`"context.run_id }} {{ inputs.issue"`) as the body. That
garbage failed dot-path resolution and returned None directly, bypassing the
`sub()` interpolation path that would have resolved each expression. Downstream
this surfaced as the literal string "None" reaching commands.

Guard the fast path on `stripped.count("{{") == 1` so only genuine
single-expression templates take the typed return; multi-expression templates
fall through to `sub()` and interpolate correctly.

Add regression tests for two expressions separated by a space and for adjacent
expressions with no separator.

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(expressions): use match-span guard so single expressions with literal {{ keep their type

The previous `stripped.count("{{") == 1` guard misclassified a genuine
single expression whose string argument contains a literal `{{` (e.g.
`{{ inputs.text | contains('{{') }}`) as multi-expression, routing it
through `sub()` interpolation and coercing the typed (bool/int/list)
return value to a string -- breaking the type-preservation the docstring
promises (Copilot review on #3228).

Anchor a single match at the start and require it to consume the whole
stripped string instead. The non-greedy body stops at the first `}}`, so
a two-block template fails the span check (falls through to interpolation,
fixing #3208) while a lone expression -- including one with a `{{` inside
a string literal -- matches to the end and keeps its typed value.

Add a regression test for the literal-brace single-expression case.

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>

* fix(expressions): detect single expression with quote-aware scan

The match-span guard using the non-greedy _EXPR_PATTERN stopped at the
first `}}`, so a lone expression whose string argument contains a literal
`}}` (e.g. `{{ inputs.text | contains('}}') }}`) was misclassified as
multi-expression and mis-parsed by the interpolation path, raising
ValueError and turning CI red (Copilot review on #3228).

Replace the span check with `_is_single_expression`, which scans the
`{{ ... }}` body for a block-closing `}}` outside string literals (mirrors
the quote handling already in `_split_top_level_commas`). A genuine
two-block template closes early and falls through to interpolation
(fixing #3208); a lone expression with a literal `{{` or `}}` inside a
string argument keeps its typed return value.

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>

---------

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-02 02:05:50 +05:00
committed by GitHub
parent 490566847c
commit 5b682b2cb3
2 changed files with 86 additions and 4 deletions

View File

@@ -226,6 +226,40 @@ class TestExpressions:
result = evaluate_expression("Feature: {{ inputs.name }} done", ctx)
assert result == "Feature: login done"
def test_multi_expression_no_surrounding_text(self):
"""Two expressions with no surrounding literal text must interpolate each,
not collapse to None via the fullmatch fast path (#3208)."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"issue": "23"}, run_id="47c5eb4b")
result = evaluate_expression(
"{{ context.run_id }} {{ inputs.issue }}", ctx
)
assert result == "47c5eb4b 23"
def test_multi_expression_adjacent_no_separator(self):
"""Back-to-back expressions with no separator still interpolate (#3208)."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"a": "foo", "b": "bar"})
result = evaluate_expression("{{ inputs.a }}{{ inputs.b }}", ctx)
assert result == "foobar"
def test_single_expression_with_literal_braces_preserves_type(self):
"""A lone expression whose string argument contains a literal ``{{`` or ``}}``
must still take the typed fast path and return a bool, not a string
(the fix for #3208 must not coerce it to ``\"True\"``)."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"text": "uses {{ jinja }} syntax"})
assert evaluate_expression("{{ inputs.text | contains('{{') }}", ctx) is True
ctx = StepContext(inputs={"text": "uses }} syntax"})
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
def test_comparison_equals(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext