1212
1313# Each of these builtins can run code or read and write attributes by computed name.
1414DYNAMIC_BUILTIN_CALLS = {"getattr" , "setattr" , "delattr" , "__import__" , "import_module" , "exec" , "eval" , "globals" , "locals" }
15+ CHANNEL_WAIT_METHODS = {"for_one" , "for_n" , "at_least" , "at_most" , "for_range" }
16+ # A Join is a ChannelMap; these methods wait on its round instance or build its movements.
17+ JOIN_WAIT_METHODS = {"for_all_branches" , "for_first_branch" , "for_branches" , "for_remaining_branches" }
18+ JOIN_FORK_METHODS = {"fork" , "fork_with_round_key" }
1519
1620
1721class Analyzer (ConnectorAnalyzer , StepBindingAnalyzer ):
@@ -42,6 +46,7 @@ def __init__(self, path, source):
4246 self .parents = {}
4347 self .resources = {}
4448 self .resource_calls = {}
49+ self .join_resources = set ()
4550 self .step_resources = {}
4651 self .failure_transitions = set ()
4752 self .node_ids = set ()
@@ -281,6 +286,7 @@ def index_resource_statements(self, statements, owner):
281286 self .resources [key ] = node_id
282287 self .resources [target .id ] = node_id
283288 self .resource_calls [value ] = node_id
289+ self .record_join_resource (value , node_id )
284290 resource = self .resource_details (value )
285291 if resource ["valueType" ] == "unknown" :
286292 self .warning ("unknown_resource_type" , f"{ kind } { resource_name } has no statically readable value type" , self .span (value ))
@@ -395,6 +401,7 @@ def add_resource(self, kind, target_name, call, owner, source_node):
395401 self .resources [key ] = node_id
396402 self .resources [target_name ] = node_id
397403 self .resource_calls [call ] = node_id
404+ self .record_join_resource (call , node_id )
398405 resource = self .resource_details (call )
399406 if resource ["valueType" ] == "unknown" :
400407 self .warning ("unknown_resource_type" , f"{ kind } { resource_name } has no statically readable value type" , self .span (call ))
@@ -927,6 +934,8 @@ def decision_type(self, expression, rpc):
927934 return None
928935 if isinstance (expression .func , ast .Attribute ) and expression .func .attr in {"with_canceling_steps" , "with_canceling_sibling_steps" }:
929936 return self .decision_type (expression .func .value , rpc )
937+ if not rpc and self .is_join_call (expression , {"end_branch" }):
938+ return "deadEnd"
930939 short = self .symbol (expression .func ).rsplit ("." , 1 )[- 1 ]
931940 names = {
932941 "go_to" : "goTo" ,
@@ -996,7 +1005,7 @@ def wait_type(self, call):
9961005 def conditional_wait_resource (self , wait_call ):
9971006 for node in ast .walk (wait_call ):
9981007 if isinstance (node , ast .Call ) and isinstance (node .func , ast .Attribute ) and isinstance (node .func .value , ast .IfExp ):
999- if node .func .attr in { "for_one" , "for_n" , "at_least" , "at_most" , "for_range" } :
1008+ if node .func .attr in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS :
10001009 return node .func .value
10011010 return None
10021011
@@ -1017,7 +1026,7 @@ def wait_conditions(self, owner_id, wait_id, wait_call, resource_override):
10171026 self .add_edge ("subflow" , wait_id , subflow_id , label = "start" , span = self .span (call ))
10181027 conditions .append ({"kind" : "subflow" , "label" : name , "subFlowId" : subflow_id , "span" : self .span (call )})
10191028 continue
1020- if not isinstance (call .func , ast .Attribute ) or call .func .attr not in { "for_one" , "for_n" , "at_least" , "at_most" , "for_range" } :
1029+ if not isinstance (call .func , ast .Attribute ) or call .func .attr not in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS :
10211030 continue
10221031 resource_expression = resource_override if isinstance (call .func .value , ast .IfExp ) and resource_override is not None else call .func .value
10231032 resource_id = self .resource_for (resource_expression , owner_id )
@@ -1042,7 +1051,15 @@ def channel_condition_label(self, resource_id, call):
10421051 instance = f"[{ arguments [0 ]} ]"
10431052 counts = arguments [1 :]
10441053 keywords = {keyword .arg : self .unparse (keyword .value ) for keyword in call .keywords if keyword .arg }
1045- if call .func .attr == "for_one" :
1054+ if call .func .attr == "for_all_branches" :
1055+ suffix = "for all branches"
1056+ elif call .func .attr == "for_first_branch" :
1057+ suffix = "for first branch"
1058+ elif call .func .attr == "for_branches" :
1059+ suffix = f"for { counts [0 ] if counts else 'N' } branches"
1060+ elif call .func .attr == "for_remaining_branches" :
1061+ suffix = f"for remaining branches after { counts [0 ] if counts else 'N' } "
1062+ elif call .func .attr == "for_one" :
10461063 suffix = "for 1"
10471064 elif call .func .attr == "for_n" :
10481065 suffix = f"for { counts [0 ] if counts else 'N' } "
@@ -1177,6 +1194,8 @@ def parse_decision(self, expression, condition, rpc, locals_map):
11771194 for argument in expression .args :
11781195 transitions .append ({"kind" : "cancel" , "target" : self .step_target (argument ), "targetNode" : argument , "label" : expression .func .attr , "condition" : condition , "span" : self .span (argument )})
11791196 return transitions
1197+ if not rpc and self .is_join_call (expression , {"end_branch" }):
1198+ return [{"kind" : "terminal" , "target" : "dead_end" , "label" : "Dead end" , "condition" : condition , "span" : self .span (expression )}]
11801199 symbol = self .symbol (expression .func )
11811200 short = symbol .rsplit ("." , 1 )[- 1 ]
11821201 if short == "go_to" and self .is_dex_reference (expression .func , "go_to" ):
@@ -1187,7 +1206,11 @@ def parse_decision(self, expression, condition, rpc, locals_map):
11871206 for argument in expression .args :
11881207 if isinstance (argument , ast .Starred ) and isinstance (argument .value , ast .Name ):
11891208 for movement in locals_map .get (argument .value .id , []):
1190- result .append ({** movement , "condition" : condition , "multiplicity" : movement .get ("multiplicity" ) or "×N" })
1209+ multiplicity = movement .get ("multiplicity" ) or ("" if movement .get ("hasExactMultiplicity" ) else "×N" )
1210+ result .append ({** movement , "condition" : condition , "multiplicity" : multiplicity })
1211+ elif isinstance (argument , ast .Starred ) and self .is_join_call (argument .value , JOIN_FORK_METHODS ):
1212+ for movement in self .join_fork_movements (argument .value ):
1213+ result .append ({** movement , "condition" : condition })
11911214 elif isinstance (argument , ast .Starred ) and isinstance (argument .value , (ast .GeneratorExp , ast .ListComp )):
11921215 movement = self .parse_movement (argument .value .elt , condition )
11931216 if movement :
@@ -1223,6 +1246,8 @@ def parse_decision(self, expression, condition, rpc, locals_map):
12231246 movement = self .parse_movement (node , condition )
12241247 if movement :
12251248 result .append (movement )
1249+ elif isinstance (node , ast .Name ):
1250+ result .extend ({** local_movement , "condition" : condition } for local_movement in locals_map .get (node .id , []))
12261251 return result
12271252 if rpc :
12281253 movement = self .parse_movement (expression , condition )
@@ -1245,7 +1270,9 @@ def local_movements(self, method):
12451270 if not targets or not isinstance (targets [0 ], ast .Name ):
12461271 continue
12471272 name = targets [0 ].id
1248- if isinstance (value , (ast .List , ast .Tuple )):
1273+ if self .is_join_call (value , JOIN_FORK_METHODS ):
1274+ result [name ] = self .join_fork_movements (value )
1275+ elif isinstance (value , (ast .List , ast .Tuple )):
12491276 result [name ] = [movement for element in value .elts if (movement := self .parse_movement (element , "" ))]
12501277 elif isinstance (value , ast .ListComp ):
12511278 movement = self .parse_movement (value .elt , "" )
@@ -1413,6 +1440,8 @@ def has_hidden_decision(self, method):
14131440 known = {"go_to" , "go_to_many" , "graceful_complete" , "force_complete" , "force_fail" , "dead_end" , "force_complete_if_channels_empty" , "RPCResult" }
14141441 for node in ast .walk (method ):
14151442 if isinstance (node , ast .Return ) and isinstance (node .value , ast .Call ):
1443+ if self .is_join_call (node .value , {"end_branch" }):
1444+ continue
14161445 short = self .symbol (node .value .func ).rsplit ("." , 1 )[- 1 ]
14171446 if short not in known or not self .is_dex_reference (node .value .func , short ):
14181447 return True
@@ -1471,7 +1500,7 @@ def resource_kind(self, name):
14711500 short = name .rsplit ("." , 1 )[- 1 ]
14721501 if short in {"Attribute" , "AttributeMap" }:
14731502 return "attribute"
1474- if short in {"Channel" , "ChannelMap" }:
1503+ if short in {"Channel" , "ChannelMap" , "Join" }:
14751504 return "channel"
14761505 if short == "Stream" :
14771506 return "stream"
@@ -1486,16 +1515,76 @@ def resource_details(self, call):
14861515 value_type = "None"
14871516 elif isinstance (call .func , ast .Subscript ):
14881517 value_type = self .unparse (call .func .slice )
1489- return {"valueType" : value_type or "unknown" , "map" : short in {"AttributeMap" , "ChannelMap" }}
1518+ return {"valueType" : value_type or "unknown" , "map" : short in {"AttributeMap" , "ChannelMap" , "Join" }}
1519+
1520+ def record_join_resource (self , call , node_id ):
1521+ if self .symbol (call .func ).rsplit ("." , 1 )[- 1 ] == "Join" :
1522+ self .join_resources .add (node_id )
1523+
1524+ def is_join_call (self , expression , methods ):
1525+ return (
1526+ isinstance (expression , ast .Call )
1527+ and isinstance (expression .func , ast .Attribute )
1528+ and expression .func .attr in methods
1529+ and self .resource_for (expression .func .value , "" ) in self .join_resources
1530+ )
1531+
1532+ def join_fork_movements (self , call ):
1533+ """Return the branch movements, then the join movement, built by one Join fork call."""
1534+ keywords = {keyword .arg : keyword .value for keyword in call .keywords if keyword .arg }
1535+ branch_builders = keywords .get ("branch_builders" , call .args [1 ] if len (call .args ) > 1 else None )
1536+ join_builder = keywords .get ("join_builder" , call .args [2 ] if len (call .args ) > 2 else None )
1537+ movements = []
1538+ if isinstance (branch_builders , (ast .List , ast .Tuple )):
1539+ for builder in branch_builders .elts :
1540+ movements .extend (self .join_builder_movements (builder , "" ))
1541+ elif isinstance (branch_builders , (ast .ListComp , ast .GeneratorExp )):
1542+ movements .extend (self .join_builder_movements (branch_builders .elt , "×N" ))
1543+ else :
1544+ self .dynamic_target ("Join fork branch builders must be a list literal or a comprehension" , branch_builders or call )
1545+ if join_builder is None :
1546+ self .dynamic_target ("Join fork must pass a join builder" , call )
1547+ else :
1548+ movements .extend (self .join_builder_movements (join_builder , "" ))
1549+ return movements
1550+
1551+ def join_builder_movements (self , builder , multiplicity ):
1552+ expression = self .join_builder_movement_expression (builder )
1553+ movement = self .parse_movement (expression , "" ) if expression is not None else None
1554+ if movement is None :
1555+ self .dynamic_target ("Join fork builders must be lambdas or module functions that return StepMovement.of" , builder )
1556+ return []
1557+ movement ["multiplicity" ] = multiplicity
1558+ movement ["hasExactMultiplicity" ] = True
1559+ return [movement ]
1560+
1561+ def join_builder_movement_expression (self , builder ):
1562+ if isinstance (builder , ast .Lambda ):
1563+ return builder .body
1564+ if isinstance (builder , ast .Name ):
1565+ return self .function_return_value (self .functions .get (builder .id ))
1566+ if isinstance (builder , ast .Call ) and isinstance (builder .func , ast .Name ):
1567+ factory_result = self .function_return_value (self .functions .get (builder .func .id ))
1568+ if isinstance (factory_result , ast .Lambda ):
1569+ return factory_result .body
1570+ return None
1571+
1572+ def function_return_value (self , function ):
1573+ if function is None :
1574+ return None
1575+ for statement in function .body :
1576+ if isinstance (statement , ast .Return ) and statement .value is not None :
1577+ return statement .value
1578+ return None
14901579
14911580 def resource_edge_kind (self , method , phase ):
1492- if phase == "wait_for" and method in { "for_one" , "for_n" , "at_least" , "at_most" , "for_range" } :
1581+ if phase == "wait_for" and method in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS :
14931582 return "wait_condition"
1494- if method in {"get" , "size" , "map_size" , "all_instance_keys" , "get_map_size" , "get_all_instance_keys" , "results" , "pending_messages" , "find_pending_message" }:
1583+ if method in {"get" , "size" , "map_size" , "all_instance_keys" , "get_map_size" , "get_all_instance_keys" , "results" , "pending_messages" , "find_pending_message" , "branch_results" , "remaining_branch_results" }:
14951584 return "resource_read"
14961585 if method in {"set" , "delete" , "write" }:
14971586 return "resource_write"
1498- if method == "publish" :
1587+ if method in { "publish" , "end_branch" , "report_branch_result" } :
14991588 return "resource_publish"
15001589 return None
15011590
0 commit comments