Skip to content

[Bug]: expression evaluator returns None for multi-expression templates without surrounding text #3208

Description

@markuswondrak

Bug Description

In the workflow expression evaluator (src/specify_cli/workflows/expressions.py), evaluate_expression() returns None when a template contains two or more {{ }} blocks with no surrounding literal text (e.g., "{{ context.run_id }} {{ inputs.issue }}").

Root cause: The regex _EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") uses non-greedy (.+?), but fullmatch() defeats non-greediness. For "{{ a }} {{ b }}", fullmatch succeeds by expanding (.+?) to capture everything between the first {{ and the last }}, producing "a }} {{ b" as the expression body. This garbage is passed to _evaluate_simple_expression() → dot-path lookup fails → returns None. Since fullmatch succeeded, the function returns None directly (line 389), bypassing the safe sub() multi-expression path that would have correctly interpolated each expression separately.

Impact: Any workflow step using a multi-expression template without surrounding text silently resolves to None. In our case, a finish step with args: "{{ context.run_id }} {{ inputs.issue }}" resolved to None, causing the finish command to receive the string "None" and fail with "no issue was provided" despite issue: "23" being in the run inputs.

Steps to Reproduce

  1. Create a workflow with a command step using a two-expression template:

    - id: finish
      command: my.command
      input:
        args: "{{ context.run_id }} {{ inputs.issue }}"
  2. Run the workflow with issue: "23" in inputs.

  3. Observe that state.json shows step_results.finish.input.args: null instead of "47c5eb4b 23".

Direct reproduction:

import sys
sys.path.insert(0, "/path/to/specify-cli/lib/python3.12/site-packages")

from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

ctx = StepContext(
    inputs={"issue": "23"},
    run_id="47c5eb4b",
)

template = "{{ context.run_id }} {{ inputs.issue }}"
result = evaluate_expression(template, ctx)

print(f"Template: {template!r}")
print(f"Result:   {result!r}")
print(f"Type:     {type(result).__name__}")

Output:

Template: '{{ context.run_id }} {{ inputs.issue }}'
Result:   None
Type:     NoneType

Expected Behavior

evaluate_expression("{{ context.run_id }} {{ inputs.issue }}", ctx) should return "47c5eb4b 23" (a string with both values interpolated).

The sub() multi-expression path (lines 392-396) is correct and would produce the right result. The bug is that fullmatch incorrectly succeeds for two-expression templates, bypassing the safe path.

Actual Behavior

evaluate_expression returns None. The captured group from fullmatch is "context.run_id }} {{ inputs.issue" (garbage), which fails dot-path resolution and returns None. This None is then converted to the string "None" by CommandStep.execute() (line 57: args_str = str(resolved_input.get("args", ""))), causing downstream commands to receive the confusing string "None" instead of an empty string or error.

Specify CLI Version

0.11.9

AI Agent

opencode

Operating System

Ubuntu 22.04 (Linux x86_64)

Python Version

Python 3.12.3

Error Logs

$ python3 reproduce.py
Template: '{{ context.run_id }} {{ inputs.issue }}'
Result:   None
Type:     NoneType

BUG CONFIRMED: evaluate_expression returned None
Expected: '47c5eb4b 23' (string)

Additional Context

Why single-expression and shell-step templates work:

Template type Example fullmatch result Why it works
Single expression {{ inputs.issue }} Succeeds, captures inputs.issue Correctly treated as single expression
Shell step (text prefix) bash script.sh "{{ inputs.issue }}" Fails (starts with bash) Falls to sub() path — correct
Two expressions, no prefix {{ context.run_id }} {{ inputs.issue }} Succeeds incorrectly — captures garbage BUG — returns None

Proposed fix:

Add a count("{{") == 1 guard before fullmatch in expressions.py lines 386-389:

    # Single expression: return typed value.
    # Guard against multi-expression templates: fullmatch with non-greedy
    # (.+?) treats "{{ a }} {{ b }}" as a single expression, capturing
    # "a }} {{ b" as the body. Only use the single-expression fast path
    # when there is exactly one {{ ... }} block, so two-expression
    # templates fall through to the sub() interpolation path.
    stripped = template.strip()
    if stripped.count("{{") == 1:
        match = _EXPR_PATTERN.fullmatch(stripped)
        if match:
            return _evaluate_simple_expression(match.group(1).strip(), namespace)

Verification after fix:

>>> evaluate_expression("{{ context.run_id }} {{ inputs.issue }}", ctx)
'47c5eb4b 23'   # FIXED

This is a one-line behavioral guard that preserves typed return for single-expression templates while correctly routing multi-expression templates to the sub() path.

Related issue: #3073 (expression evaluator silently ignores unknown filters) — similar "silent failure" pattern in the expression engine, but different root cause.

Workaround: Change the workflow template to use a single expression (args: "{{ context.run_id }}") and have the command read issue from .specify/workflows/runs/<run_id>/inputs.json directly.

AI disclosure: This issue was investigated and written with AI assistance (opencode); I reproduced and verified the behavior against specify-cli 0.11.9 myself.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions