diff --git a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py index a585aaf..b748412 100644 --- a/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py +++ b/shepherd/packages/dialect/src/shepherd_dialect/workspace_control/workspace.py @@ -3009,17 +3009,26 @@ def _fence_generated_module_resolves(source_text: str, qualname: str) -> None: The generated module carries only ``import shepherd as sp`` plus the def, so its signature annotations must resolve against the shepherd vocabulary and builtins. - Defining the (bodyless) function evaluates its annotations exactly as the confined - runner's import will — running only the import and the def statement, no body — so - a clean exec here turns a confusing in-jail ``NameError`` into a teachable refusal. + We reconstruct the artifact and force its annotations to evaluate against that same + namespace: a name the signature needs but the artifact lacks raises ``NameError``, + which we turn into a teachable refusal instead of a confusing in-jail failure later. """ try: # dont_inherit=True: the generated module has no `from __future__ import - # annotations`, so the confined runner's import evaluates annotations eagerly. - # This module (workspace.py) does have it; without dont_inherit the fence would - # inherit PEP 563 and never see the NameError the real import will raise. + # annotations`, so it does not inherit this module's PEP 563 posture. code = compile(source_text, f"", "exec", dont_inherit=True) - exec(code, {}) # noqa: S102 + namespace: dict[str, Any] = {} + exec(code, namespace) # noqa: S102 + # Force annotation evaluation explicitly rather than relying on the ``def`` + # statement to do it. Through Python 3.13 the def eager-evaluates annotations, + # so the exec above already raised; on 3.14+ (PEP 649/749) annotation + # evaluation is deferred, so the def succeeds and the check must force it. + # ``get_type_hints`` resolves against the artifact's own globals (which carry + # only ``import shepherd as sp``), so an undefined script-local name raises + # ``NameError`` on every supported Python. + fn = namespace.get(qualname) + if fn is not None: + get_type_hints(fn, globalns=namespace, include_extras=True) except NameError as exc: raise TaskRegistrationError( f"task {qualname!r} has a signature that references a name only its script defines " diff --git a/shepherd/packages/dialect/tests/test_register_ergonomics.py b/shepherd/packages/dialect/tests/test_register_ergonomics.py index e031e9e..a06dd7e 100644 --- a/shepherd/packages/dialect/tests/test_register_ergonomics.py +++ b/shepherd/packages/dialect/tests/test_register_ergonomics.py @@ -251,6 +251,30 @@ def uses_local(repo: sp.May[sp.GitRepo, sp.ReadWrite], thing: LocalThing) -> Non workspace.tasks.register(fn, may_default="ReadWrite") +def test_main_task_with_string_forwardref_local_annotation_refuses( + workspace: ShepherdWorkspace, define_in_main +) -> None: + # Version-independent guard for the annotation fence. A *string* forward-ref is + # never evaluated by the `def` statement on any Python, so an exec-only fence + # misses it everywhere (and, since 3.14 defers all annotation evaluation, a bare + # annotation slips through there too). The fence must force evaluation itself. + fn = define_in_main( + ''' + import shepherd as sp + + class LocalThing: + pass + + @sp.task + def uses_forwardref(repo: sp.May[sp.GitRepo, sp.ReadWrite], thing: "LocalThing") -> None: + """Bodyless, with a string forward-ref only the script defines.""" + ''', + "uses_forwardref", + ) + with pytest.raises(Exception, match="only its script defines"): + workspace.tasks.register(fn, may_default="ReadWrite") + + # ============================================================================= # Row 6: locals refuse # =============================================================================