Skip to content

Accept loop variables in the spelling examples actually use (#469) - #470

Merged
jeremymanning merged 1 commit into
mainfrom
fix/bare-loop-variables
Aug 4, 2026
Merged

Accept loop variables in the spelling examples actually use (#469)#470
jeremymanning merged 1 commit into
mainfrom
fix/bare-loop-variables

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

Closes #469.

The bug

The runtime binds each loop variable under two names — item and $item. The template validator knew only one of them:

self.loop_vars = {'$item', '$index', '$is_first', '$is_last', '$iteration', '$loop'}

So {{ item.name }} inside a for_each step — the form every example in the catalogue writes — was reported as Undefined variable: 'item'.

This is the same false-positive class removed in #448 (slugify), #450 (now), #454 (execution.timestamp) and #459 (pipeline_id): a name the runtime provides, rejected by validation.

The second defect underneath it

Adding the bare names did not make the affected examples pass — it changed their error message. The walker decided a step was a loop with:

is_loop = 'for_each' in obj or 'while' in obj

A step written with foreach — an alias the compiler accepts — was not a loop as far as the validator was concerned, so its loop variables became Loop variable used outside of loop context. Same wrongly-rejected pipeline, different complaint. A validator that knows one spelling and a compiler that accepts another is #465 one layer down.

LOOP_STEP_KEYS now covers for_each, foreach, while, create_parallel_queue, action_loop.

Correcting the issue

Issue #469 as I filed it predicted that accepting bare names would trade a false positive for a false negative. That was wrong. The in_loop_context gate already existed, so {{ item }} outside a loop is still an error, and a pipeline that declares its own input named item still wins over the loop reading. Both are pinned by tests.

One declaration

Both name sets move into core.template_globals alongside the rest of the pipeline language. data_flow_validator imports the same object rather than keeping an equal copy — the test asserts identity, not equality, because an equal copy is exactly what drifted into #469.

Baseline writer bug, found while updating the baseline

--update regenerates the file in full, header included. The header had been extended by hand when examples/supported/ landed, so my first --update silently deleted the paragraph distinguishing "passes validation" from "is a supported example" — an 8-line diff for two added entries.

Hand-fixing the file would have restored the text and left the trap armed. The header moves into BASELINE_HEADER, and a new test asserts the bytes on disk equal what write_baseline() would produce, so any hand-edit to the generated file is a red test rather than a deletion later.

Verification

Three mutations, all caught:

mutation result
revert to the $-only name set 16 failures
only for_each/while count as loops 3 failures — exactly the three aliases
remove the declared-input guard 1 failure
  • Blocking suite: 868 passed, 13 skipped
  • Lint: clean (E9,F63,F7,F82,F821,F823,F601,F811)
  • Catalogue: 50 → 52 validating; all 52 listed still validate

examples/web_research_pipeline.yaml now fails only on current_date, a name the runtime genuinely does not provide — a real defect in that example, correctly reported.

🤖 Generated with Claude Code

The runtime binds each loop variable under two names: item and $item.
The template validator knew only the $-prefixed set:

    self.loop_vars = {'$item', '$index', '$is_first', ...}

so {{ item.name }} inside a for_each step -- the form every example in
the catalogue writes -- was reported as "Undefined variable: 'item'".
That is the same false-positive class removed in #448 (slugify), #450
(now), #454 (execution.timestamp) and #459 (pipeline_id): a name the
runtime provides, rejected by validation.

Adding the bare names exposed a second disagreement underneath. The
walker decided a step was a loop with 'for_each' in obj or 'while' in
obj, so a step written with foreach -- an alias the compiler accepts --
was not a loop as far as the validator was concerned, and its loop
variables became "used outside of loop context". The message changed;
the pipeline stayed wrongly rejected. A validator that knows one
spelling and a compiler that accepts another is #465 one layer down.

Both name sets now live in core.template_globals with the rest of the
pipeline language, and data_flow_validator imports the same object
rather than keeping an equal copy.

The gate that makes this safe already existed: in_loop_context. A loop
variable outside a loop is still an error, and a pipeline declaring an
input named item still wins over the loop reading. The prediction in
issue #469 that this trade would introduce a false negative was wrong.

Also fixes a destructive bug in the baseline writer found while
updating it. The file is generated in full, header included, but its
header had been extended by hand when examples/supported/ landed --
so --update silently deleted the paragraph distinguishing "passes
validation" from "is a supported example". The header moves into
BASELINE_HEADER, and a test now asserts the bytes on disk equal what
--update would write, failing loudly on any hand-edit.

Catalogue: 50 -> 52 validating, no listed example regressed.
Blocking suite: 868 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Loop variables validate in one validator and are refused by the other

1 participant