@@ -369,6 +369,146 @@ def _check_run_trial_text_localization(text: str, cfg: dict[str, Any]) -> list[s
369369 return issues
370370
371371
372+ def _find_visible_show_without_context (text : str ) -> list [str ]:
373+ try :
374+ tree = ast .parse (text )
375+ except SyntaxError :
376+ return []
377+
378+ run_trial_fn = None
379+ for node in tree .body :
380+ if isinstance (node , ast .FunctionDef ) and node .name == "run_trial" :
381+ run_trial_fn = node
382+ break
383+ if run_trial_fn is None :
384+ return []
385+
386+ context_units : set [str ] = set ()
387+ unit_stims : dict [str , list [str ]] = {}
388+ unit_labels : dict [str , str ] = {}
389+ warnings : list [str ] = []
390+
391+ def _stmt_call (stmt : ast .stmt ) -> ast .Call | None :
392+ if isinstance (stmt , ast .Expr ) and isinstance (stmt .value , ast .Call ):
393+ return stmt .value
394+ if isinstance (stmt , ast .Assign ) and isinstance (stmt .value , ast .Call ):
395+ return stmt .value
396+ return None
397+
398+ def _kw_expr (call : ast .Call , key : str ) -> str :
399+ for kw in call .keywords :
400+ if kw .arg == key :
401+ try :
402+ return ast .unparse (kw .value )
403+ except Exception : # noqa: BLE001
404+ return ""
405+ return ""
406+
407+ def _node_name (node : ast .AST ) -> str :
408+ return node .id if isinstance (node , ast .Name ) else ""
409+
410+ def _add_stim_exprs (node : ast .AST ) -> list [str ]:
411+ out : list [str ] = []
412+ current = node
413+ while isinstance (current , ast .Call ):
414+ if isinstance (current .func , ast .Attribute ) and current .func .attr == "add_stim" and current .args :
415+ try :
416+ out .append (ast .unparse (current .args [0 ]))
417+ except Exception : # noqa: BLE001
418+ pass
419+ current = current .func .value
420+ elif isinstance (current .func , ast .Attribute ):
421+ current = current .func .value
422+ else :
423+ break
424+ out .reverse ()
425+ return out
426+
427+ def _unit_label_expr (node : ast .AST ) -> str :
428+ current = node
429+ while isinstance (current , ast .Call ):
430+ if isinstance (current .func , ast .Name ) and current .func .id in {"StimUnit" , "make_unit" }:
431+ label = _kw_expr (current , "unit_label" )
432+ if label :
433+ return label
434+ if current .args :
435+ try :
436+ return ast .unparse (current .args [0 ])
437+ except Exception : # noqa: BLE001
438+ return ""
439+ return ""
440+ if isinstance (current .func , ast .Attribute ):
441+ current = current .func .value
442+ else :
443+ break
444+ return ""
445+
446+ def _walk (stmts : list [ast .stmt ]) -> None :
447+ for stmt in stmts :
448+ if isinstance (stmt , ast .Assign ) and len (stmt .targets ) == 1 and isinstance (stmt .targets [0 ], ast .Name ):
449+ var = stmt .targets [0 ].id
450+ label = _unit_label_expr (stmt .value )
451+ if label :
452+ unit_labels [var ] = label
453+ stim_exprs = _add_stim_exprs (stmt .value )
454+ if stim_exprs :
455+ unit_stims .setdefault (var , []).extend (stim_exprs )
456+
457+ if isinstance (stmt , ast .If ):
458+ _walk (stmt .body )
459+ _walk (stmt .orelse )
460+ continue
461+ if isinstance (stmt , (ast .For , ast .AsyncFor , ast .While , ast .With , ast .AsyncWith )):
462+ _walk (stmt .body )
463+ _walk (getattr (stmt , "orelse" , []))
464+ continue
465+ if isinstance (stmt , ast .Try ):
466+ _walk (stmt .body )
467+ for handler in stmt .handlers :
468+ _walk (handler .body )
469+ _walk (stmt .orelse )
470+ _walk (stmt .finalbody )
471+ continue
472+
473+ call = _stmt_call (stmt )
474+ if call is None :
475+ continue
476+ if isinstance (call .func , ast .Name ) and call .func .id == "set_trial_context" and call .args :
477+ unit_var = _node_name (call .args [0 ])
478+ if unit_var :
479+ context_units .add (unit_var )
480+ continue
481+ if not (isinstance (call .func , ast .Attribute ) and call .func .attr == "show" ):
482+ continue
483+
484+ base = call .func .value
485+ unit_var = _node_name (base )
486+ stim_exprs = list (unit_stims .get (unit_var , [])) if unit_var else []
487+ for expr in _add_stim_exprs (base ):
488+ if expr not in stim_exprs :
489+ stim_exprs .append (expr )
490+ if not stim_exprs :
491+ continue
492+ if unit_var and unit_var in context_units :
493+ continue
494+
495+ label = _unit_label_expr (base )
496+ if not label and unit_var :
497+ label = unit_labels .get (unit_var , "" )
498+ token = str (label or unit_var or "show() phase" ).strip ().strip ("'\" " )
499+ warnings .append (token )
500+
501+ _walk (run_trial_fn .body )
502+ deduped : list [str ] = []
503+ seen : set [str ] = set ()
504+ for item in warnings :
505+ if not item or item in seen :
506+ continue
507+ seen .add (item )
508+ deduped .append (item )
509+ return deduped
510+
511+
372512def _looks_like_mid_identity (value : Any ) -> bool :
373513 low = str (value or "" ).strip ().lower ()
374514 if not low :
@@ -1128,6 +1268,12 @@ def _check_responder_context(task_dir: Path, cfg: dict[str, Any]) -> ContractRes
11281268 fails .append (f"Missing required run_trial token: { s } " )
11291269
11301270 fails .extend (_check_run_trial_text_localization (text , cfg ))
1271+ visible_without_context = _find_visible_show_without_context (text )
1272+ for token in visible_without_context :
1273+ warns .append (
1274+ "Participant-visible phase appears to call show() without preceding set_trial_context(...): "
1275+ f"{ token } "
1276+ )
11311277
11321278 req_any = list (cfg .get ("required_strings_any" ) or [])
11331279 if req_any and not any (str (s ) in text for s in req_any ):
@@ -1256,6 +1402,8 @@ def _check_responder_context(task_dir: Path, cfg: dict[str, Any]) -> ContractRes
12561402
12571403 if fails :
12581404 suggestions .append ("Call set_trial_context(...) with required fields before response windows." )
1405+ if visible_without_context :
1406+ suggestions .append ("Emit set_trial_context(...) for every participant-visible phase, not only response windows." )
12591407 if warns :
12601408 suggestions .append ("Include condition/block/task_factors context for richer simulation and audits." )
12611409 return _result (name , fails , warns , suggestions )
0 commit comments