From 7a5635b1a2a91507dcbce6f54aa0f3cf9ae4e819 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 4 Sep 2026 08:04:47 +0700 Subject: [PATCH] fix: keep validation contracts truthful for new control regions --- src/wf_core/analysis/control_regions.py | 24 ++++++++++++++++++++++++ tests/wf_client/test_authoring.py | 8 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/wf_core/analysis/control_regions.py b/src/wf_core/analysis/control_regions.py index 81193aad..05bf4795 100644 --- a/src/wf_core/analysis/control_regions.py +++ b/src/wf_core/analysis/control_regions.py @@ -142,6 +142,30 @@ def analyze_control_regions(workflow: Workflow) -> ControlRegionAnalysis: continue is_terminal = target_id == END or isinstance(target_node, EndNode) if is_terminal: + # Explicit end nodes are still program locations with one + # static region; record them so they are not also reported as + # unreachable. The `END` token has no node to record. + if isinstance(target_node, EndNode): + visited_nodes.add(target_id) + recorded_target = owner_stack_by_node.get(target_id) + if recorded_target is None: + owner_stack_by_node[target_id] = target_stack + elif recorded_target != target_stack: + del owner_stack_by_node[target_id] + if target_id not in conflicted: + conflicted.add(target_id) + issues.append( + ControlRegionIssue( + kind=ControlRegionIssueKind.FOREACH_REGION_CONFLICT, + path=f"nodes[{target_id}]", + message=( + f"node {target_id!r} is reachable under " + "two foreach control regions" + ), + ) + ) + for prior_stack in (recorded_target, target_stack): + mark_ambiguous(prior_stack) if target_stack: issues.append( ControlRegionIssue( diff --git a/tests/wf_client/test_authoring.py b/tests/wf_client/test_authoring.py index 19980231..c4b70466 100644 --- a/tests/wf_client/test_authoring.py +++ b/tests/wf_client/test_authoring.py @@ -272,7 +272,13 @@ async def test_snapshotless_remote_node_upgrades_to_real_capability_contract() - replacement = graph.use(capability, id="replacement", input=[], output=[]) graph.connect(replacement, "ok", "done") - assert graph.validate_local().ok is True + # A disconnected replacement has no derivable control region; new + # validation reports it as unreachable rather than silently accepting it. + report = graph.validate_local() + assert any( + issue.code == "unreachable_node" and issue.path == "nodes[replacement]" + for issue in report.errors + ) assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == { "query": {"type": "string"} }