Skip to content

Give each loop construct its own bindings and scope (#472) - #473

Merged
jeremymanning merged 1 commit into
mainfrom
fix/loop-binding-contracts
Aug 4, 2026
Merged

Give each loop construct its own bindings and scope (#472)#473
jeremymanning merged 1 commit into
mainfrom
fix/loop-binding-contracts

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

Closes #472. Addresses critical finding 2 from review.

Two defects, one cause

Validation treated "inside a loop" as one boolean and one union of names.

The iterable was validated in loop scope

- id: process
  for_each: "{{ item.children }}"   # accepted; no item exists yet
  parameters:
    text: "{{ item.name }}"         # correct

_validate_object_templates computed is_loop from the step dict and passed it to every child, including the field that introduces the loop. The iterable must resolve before there is an item to bind.

Every construct got every name

while binds no is_last; only create_parallel_queue binds queue. Accepting the union let {{ is_last }} pass inside a while loop and then fail to render.

That false negative arrived with #470, whose description claimed it was impossible. Corrected on #469.

The contracts

core/loop_contracts.py — one table per construct, read from the runtime rather than from docs/loop_variables.md, which claims names the runtime does not bind in every construct:

construct source fields bindings
for_each / foreach for_each, foreach item, index, is_first, is_last
while while, until iteration, index, is_first, position, loop_state, loop_id
create_parallel_queue create_parallel_queue item, index, queue, queue_size, is_first, is_last, …
action_loop action_loop, until loop_id, iteration, is_first, has_previous, …

Verified behaviour:

case outcome
item in the for_each iterable rejected at steps[0].for_each
item in the body accepted
is_last in a while loop_variable_wrong_construct
iteration in a while accepted
queue_size in a parallel queue accepted
queue_size in a for_each rejected

A rejection names what the construct does bind, so it is actionable.

One declaration

template_globals now derives from the contracts instead of holding a second copy. ALL_LOOP_VARIABLES is ALL_BINDINGS is asserted by identity — an equal copy can drift, and drift is what produced #469. LOOP_STEP_KEYS is tuple(LOOP_CONTRACTS), so a key cannot be recognised as a loop without declaring what it binds.

Strengthening #470's test exposed that it was wrong

Review noted the #470 test "only checks that errors mentioning item are absent; it does not require complete validation."

Requiring full validity made it fail for all five constructs — it probed every one with item, and while/action_loop bind no item. The weaker assertion had been satisfied by a pipeline rejected for an unrelated reason. Each construct is now probed with a name its own contract binds.

Verification

  • Mutations: routing source fields through body scope fails exactly the 2 source-scope tests; giving every construct the union fails exactly the 4 per-construct tests
  • Blocking suite: 916 passed, 13 skipped
  • Lint: clean
  • Catalogue: all 52 still validate — no example depended on the over-broad acceptance

Still open from the review

compile-and-run coverage for every alias, and CLI/Python-API agreement, are not in this PR — they need the runtime harness rather than the validator, and belong with the examples/supported/ work. The position/remaining/has_next/has_prev gap is now closed for while (which binds position); the others remain absent from every construct's table because no runtime site binds them bare.

🤖 Generated with Claude Code

Validation treated "inside a loop" as one boolean and one union of
names. Both halves were wrong.

The boolean reached every field of a loop-shaped step, including the
iterable that introduces the loop, so this was accepted:

    - id: process
      for_each: "{{ item.children }}"   # no item exists yet
      parameters:
        text: "{{ item.name }}"         # correct

The iterable must resolve before there is an item to bind. A loop
variable in it can never work, and validation said nothing.

The union gave every construct every name. `while` binds no `is_last`;
only `create_parallel_queue` binds `queue`. So `{{ is_last }}` inside a
`while` loop passed validation and then failed to render -- the false
negative #470 introduced while removing a false positive, and which
that PR's description claimed was impossible. The correction is posted
on #469.

core.loop_contracts holds one table per construct, each declaring the
fields evaluated before the loop exists and the names bound inside it.
The tables were read from the runtime -- context_manager, loops.py,
parallel_queue_task and action_loop_context -- rather than from
docs/loop_variables.md, which claims names the runtime does not bind in
every construct.

A rejection now names what the construct does bind, so it is
actionable rather than a puzzle.

template_globals derives its flat sets from the contracts instead of
keeping a second copy: ALL_LOOP_VARIABLES is ALL_BINDINGS by identity,
and LOOP_STEP_KEYS is tuple(LOOP_CONTRACTS), so a key cannot be
recognised as a loop without declaring what it binds.

Strengthening #470's alias test to require full validity, rather than
merely the absence of errors naming `item`, immediately failed for all
five constructs: it probed every one with `item`, which only passed
because every construct saw the union. It now probes each construct
with a name its own contract binds.

Mutations: routing source fields through body scope fails exactly the
two source-scope tests; giving every construct the union fails exactly
the four per-construct tests.

Blocking suite: 916 passed. Catalogue: all 52 still validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremymanning
jeremymanning merged commit 74214ec into main Aug 4, 2026
11 checks passed
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 scope is one boolean where it needs per-construct binding contracts

1 participant