diff --git a/cli/ucli/analyzers/python_analyzer.py b/cli/ucli/analyzers/python_analyzer.py index 7873b60..45d7c8b 100644 --- a/cli/ucli/analyzers/python_analyzer.py +++ b/cli/ucli/analyzers/python_analyzer.py @@ -282,6 +282,42 @@ def _resolve_relative_module(file_path: pathlib.Path, module: str | None, level: return ".".join(base_parts) if base_parts else None +def _is_type_checking_expr(node: ast.AST) -> bool: + """True for ``TYPE_CHECKING`` / ``typing.TYPE_CHECKING`` (and extensions).""" + if isinstance(node, ast.Name) and node.id == "TYPE_CHECKING": + return True + if isinstance(node, ast.Attribute) and node.attr == "TYPE_CHECKING": + return True + return False + + +def _record_import_node( + node: ast.AST, + file_path: pathlib.Path, + aliases: dict[str, str], +) -> None: + """Record one Import / ImportFrom into ``aliases`` (mutates in place).""" + if isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + base = _resolve_relative_module(file_path, node.module, node.level) + if not base: + return + elif node.module is None: + return + else: + base = node.module + for alias in node.names: + local = alias.asname or alias.name + if alias.name == "*": + continue + # from pkg.mod import func -> local name maps to pkg.mod:func candidate key + aliases[local] = f"{base}:{alias.name}" + elif isinstance(node, ast.Import): + for alias in node.names: + local = alias.asname or alias.name.split(".")[-1] + aliases[local] = alias.name + + def _collect_import_aliases(tree: ast.AST, file_path: pathlib.Path) -> dict[str, str]: """Map local alias -> module path fragment for high-confidence import resolution. @@ -294,29 +330,22 @@ def _collect_import_aliases(tree: ast.AST, file_path: pathlib.Path) -> dict[str, symbol imports (``from mod import fn``) from module imports (``from pkg import mod`` → treat ``mod`` as submodule ``pkg.mod`` when a scanned module uniquely matches). + + Wave 30: also records imports under ``if TYPE_CHECKING:`` / + ``if typing.TYPE_CHECKING:`` bodies (string annotations commonly pair with + these). ``if not TYPE_CHECKING:`` bodies are ignored. """ aliases: dict[str, str] = {} - for node in getattr(tree, "body", []) or []: - if isinstance(node, ast.ImportFrom): - if node.level and node.level > 0: - base = _resolve_relative_module(file_path, node.module, node.level) - if not base: - continue - elif node.module is None: - continue - else: - base = node.module - for alias in node.names: - local = alias.asname or alias.name - if alias.name == "*": - continue - # from pkg.mod import func -> local name maps to pkg.mod:func candidate key - aliases[local] = f"{base}:{alias.name}" - elif isinstance(node, ast.Import): - for alias in node.names: - local = alias.asname or alias.name.split(".")[-1] - aliases[local] = alias.name + def walk_stmts(stmts: list[ast.stmt]) -> None: + for node in stmts: + if isinstance(node, (ast.Import, ast.ImportFrom)): + _record_import_node(node, file_path, aliases) + elif isinstance(node, ast.If) and _is_type_checking_expr(node.test): + walk_stmts(list(node.body)) + # Do not walk unrelated If/try bodies — runtime-only imports stay opaque. + + walk_stmts(list(getattr(tree, "body", []) or [])) return aliases @@ -358,6 +387,11 @@ def _attr_call_token(node: ast.Attribute) -> str: "ClassVar", "Final", "Self", + "Annotated", + "Optional", + "Union", + "Required", + "NotRequired", "str", "int", "float", @@ -371,18 +405,83 @@ def _attr_call_token(node: ast.Attribute) -> str: } ) +# Subscript wrappers that unwrap to a single concrete type (Wave 30). +_TYPE_UNWRAP_SINGLE = frozenset({"Annotated", "Final", "ClassVar", "Required", "NotRequired"}) +_TYPE_UNWRAP_OPTIONAL_UNION = frozenset({"Optional", "Union"}) +_TYPE_UNWRAP_ALL = _TYPE_UNWRAP_SINGLE | _TYPE_UNWRAP_OPTIONAL_UNION +_TYPING_MODULES = frozenset({"typing", "typing_extensions"}) -def _simple_type_name(node: ast.AST | None) -> str | None: + +def _ast_from_annotation_string(text: str) -> ast.AST | None: + """Parse a quoted annotation string into an expression AST, or None.""" + s = text.strip() + if not s: + return None + try: + return ast.parse(s, mode="eval").body + except SyntaxError: + return None + + +def _typing_wrapper_kind( + node: ast.AST | None, + aliases: dict[str, str] | None = None, +) -> str | None: + """Return Optional/Union/Annotated/... when ``node`` is a typing wrapper. + + Recognizes bare names, ``typing.X`` / ``typing_extensions.X``, and + ``from typing import Optional as Opt`` aliases (import-gated to typing*). + """ + if node is None: + return None + if isinstance(node, ast.Name): + name = node.id + if aliases: + binding = aliases.get(name) + if binding is not None and ":" in binding: + mod, imported = binding.rsplit(":", 1) + if imported in _TYPE_UNWRAP_ALL and (not mod or mod in _TYPING_MODULES): + return imported + if name in _TYPE_UNWRAP_ALL: + return name + return None + if isinstance(node, ast.Attribute) and node.attr in _TYPE_UNWRAP_ALL: + # Only typing / typing_extensions (or an alias to those modules). + root = node.value + if isinstance(root, ast.Name): + if root.id in _TYPING_MODULES: + return node.attr + if aliases and aliases.get(root.id) in _TYPING_MODULES: + return node.attr + return None + if isinstance(root, ast.Attribute) and root.attr in _TYPING_MODULES: + return node.attr + return None + return None + + +def _simple_type_name( + node: ast.AST | None, + aliases: dict[str, str] | None = None, +) -> str | None: """High-confidence concrete class name from an annotation or constructor call. - Accepts bare ``Foo``, ``pkg.Foo``, and ``Optional[Foo]`` / ``Union[Foo, None]`` / - ``Foo | None`` when exactly one non-None concrete name remains. Rejects - multi-class unions, containers (``list[Foo]``), ``TypedDict`` / ``Protocol`` / + Accepts bare ``Foo``, ``pkg.Foo``, quoted ``\"Foo\"`` / ``\"Foo | None\"``, + and wrappers ``Optional`` / ``Union`` / ``Annotated`` / ``Final`` / + ``ClassVar`` (including ``typing.X`` and ``from typing import X as Y``) + when exactly one non-None concrete name remains. Rejects multi-class + unions, containers (``list[Foo]``), ``TypedDict`` / ``Protocol`` / builtins / ``Any`` — never invents a type. """ if node is None: return None - if isinstance(node, ast.Constant) and node.value is None: + if isinstance(node, ast.Constant): + if node.value is None: + return None + if isinstance(node.value, str): + inner = _ast_from_annotation_string(node.value) + # Recurse without re-entering string mode on nested metadata. + return _simple_type_name(inner, aliases) if inner is not None else None return None if isinstance(node, ast.Name): if node.id in _NON_CLASS_TYPE_NAMES: @@ -394,27 +493,29 @@ def _simple_type_name(node: ast.AST | None) -> str | None: return None return node.attr if isinstance(node, ast.Subscript): - # Optional[Foo], Union[Foo, None], typing.Optional[Foo] — single concrete - # class only. Skip TypedDict[...] / list[Foo] / dict[K, V] containers. - outer = _simple_type_name(node.value) - if outer in {"Optional", "Union"}: - return _unique_concrete_type(node.slice) + kind = _typing_wrapper_kind(node.value, aliases) + if kind in _TYPE_UNWRAP_OPTIONAL_UNION or kind in _TYPE_UNWRAP_SINGLE: + return _unique_concrete_type(node.slice, aliases) return None if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - return _unique_concrete_type(node) + return _unique_concrete_type(node, aliases) if isinstance(node, ast.Tuple): - return _unique_concrete_type(node) + return _unique_concrete_type(node, aliases) return None -def _unique_concrete_type(node: ast.AST | None) -> str | None: +def _unique_concrete_type( + node: ast.AST | None, + aliases: dict[str, str] | None = None, +) -> str | None: """Return a class name only when exactly one concrete type appears.""" names: list[str] = [] def walk(n: ast.AST | None) -> None: if n is None: return - if isinstance(n, ast.Constant) and n.value is None: + # Skip None and Annotated metadata string constants (not types). + if isinstance(n, ast.Constant): return if isinstance(n, ast.BinOp) and isinstance(n.op, ast.BitOr): walk(n.left) @@ -424,8 +525,14 @@ def walk(n: ast.AST | None) -> None: for elt in n.elts: walk(elt) return - name = _simple_type_name(n) - if name and name not in {"Optional", "Union"}: + # Nested Optional/Union/Annotated — unwrap rather than treating as class. + if isinstance(n, ast.Subscript): + kind = _typing_wrapper_kind(n.value, aliases) + if kind in _TYPE_UNWRAP_ALL: + walk(n.slice) + return + name = _simple_type_name(n, aliases) + if name and name not in _TYPE_UNWRAP_ALL: names.append(name) walk(node) @@ -546,11 +653,15 @@ def _type_ref_with_origin( """``(ClassName, origin_mod|None)`` from an annotation or constructor target. Origin is a dotted module when the type is import-aliased (``Foo`` / - ``F`` / ``models.Foo``). Optional/Union wrappers keep a single concrete - name; multi-class forms return ``(None, None)``. + ``F`` / ``models.Foo``). Optional/Union/Annotated/Final/ClassVar wrappers + keep a single concrete name; multi-class forms return ``(None, None)``. + Quoted annotations are parsed and re-entered. """ if node is None: return None, None + if isinstance(node, ast.Constant) and isinstance(node.value, str): + inner = _ast_from_annotation_string(node.value) + return _type_ref_with_origin(inner, aliases) if inner is not None else (None, None) if isinstance(node, ast.Call): # CapWords constructor — origin from the callee expression. if _constructor_type_name(node) is None: @@ -563,24 +674,27 @@ def _type_ref_with_origin( if isinstance(node, ast.Attribute): if node.attr in _NON_CLASS_TYPE_NAMES: return None, None + # typing.Optional is a wrapper, not a class — handled via Subscript. + if _typing_wrapper_kind(node, aliases): + return None, None mod = _module_path_for_attr_value(node.value, aliases) return node.attr, mod if isinstance(node, ast.Subscript): - outer = _simple_type_name(node.value) - if outer in {"Optional", "Union"}: - inner = _unique_concrete_type(node.slice) + kind = _typing_wrapper_kind(node.value, aliases) + if kind in _TYPE_UNWRAP_ALL: + inner = _unique_concrete_type(node.slice, aliases) if not inner: return None, None # Prefer Attribute/Name origin inside the slice when unique. return _type_ref_with_origin_for_name(node.slice, inner, aliases) return None, None if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - inner = _unique_concrete_type(node) + inner = _unique_concrete_type(node, aliases) if not inner: return None, None return _type_ref_with_origin_for_name(node, inner, aliases) if isinstance(node, ast.Tuple): - inner = _unique_concrete_type(node) + inner = _unique_concrete_type(node, aliases) if not inner: return None, None return _type_ref_with_origin_for_name(node, inner, aliases) @@ -598,7 +712,8 @@ def _type_ref_with_origin_for_name( def walk(n: ast.AST | None) -> None: if n is None: return - if isinstance(n, ast.Constant) and n.value is None: + if isinstance(n, ast.Constant): + # Skip None / Annotated metadata strings; quoted types are top-level only. return if isinstance(n, ast.BinOp) and isinstance(n.op, ast.BitOr): walk(n.left) @@ -614,7 +729,7 @@ def walk(n: ast.AST | None) -> None: if isinstance(n, ast.Attribute) and n.attr == target: found.append((n.attr, _module_path_for_attr_value(n.value, aliases))) return - # Optional[Foo] / Union[...] — descend into subscript slice only. + # Optional[Foo] / Union[...] / Annotated[Foo, ...] — descend into slice. if isinstance(n, ast.Subscript): walk(n.slice) @@ -701,7 +816,7 @@ def _infer_return_type( return ann_name, ann_mod # CapWords ctors + local aliases only (factory chains resolved later). - local = _collect_local_types(func_node) + local, _local_mods = _collect_local_type_bindings(func_node, aliases) found: list[tuple[str | None, str | None]] = [] class ReturnVisitor(ast.NodeVisitor): @@ -751,17 +866,34 @@ def visit_Return(self, node: ast.Return) -> None: return names[0], None -def _collect_local_types( +def _collect_local_type_bindings( func_node: ast.FunctionDef | ast.AsyncFunctionDef, -) -> dict[str, str]: - """Map local names → class names from annotations and constructors in scope. + import_aliases: dict[str, str] | None = None, +) -> tuple[dict[str, str], dict[str, str]]: + """Map local names → class names (+ optional origin mods) in scope. Only high-confidence bindings: annotated params/assigns and ``name = Class()``. - Later rebinding to another concrete type overwrites; ambiguous/unknown clears. - Factory calls (``name = make()``) are recorded separately for return-type - propagation at map-build time — they do not invent a class named ``make``. + Annotations go through ``_type_ref_with_origin`` so ``Foo as F``, quoted + ``\"Foo\"``, ``Annotated`` / ``Final`` / ``Opt[Foo]`` canonicalize with import + origin when known. Later rebinding to another concrete type overwrites; + ambiguous/unknown clears. Factory calls (``name = make()``) are recorded + separately for return-type propagation — they do not invent a class + named ``make``. """ + aliases = import_aliases or {} types: dict[str, str] = {} + mods: dict[str, str] = {} + + def _bind(name: str, class_name: str | None, origin: str | None) -> None: + if class_name: + types[name] = class_name + if origin: + mods[name] = origin + else: + mods.pop(name, None) + else: + types.pop(name, None) + mods.pop(name, None) # Parameter annotations (skip self/cls without annotation). for arg in [ @@ -771,9 +903,8 @@ def _collect_local_types( ]: if arg.arg in {"self", "cls"}: continue - t = _simple_type_name(arg.annotation) - if t: - types[arg.arg] = t + t, mod = _type_ref_with_origin(arg.annotation, aliases) + _bind(arg.arg, t, mod) class BindingVisitor(ast.NodeVisitor): def visit_FunctionDef(self, node: ast.FunctionDef) -> None: @@ -790,34 +921,50 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: def visit_AnnAssign(self, node: ast.AnnAssign) -> None: if isinstance(node.target, ast.Name): - ann = _simple_type_name(node.annotation) + ann, ann_mod = _type_ref_with_origin(node.annotation, aliases) ctor = _constructor_type_name(node.value) if node.value is not None else None + ctor_mod: str | None = None + if ctor is not None and node.value is not None: + _, ctor_mod = _type_ref_with_origin(node.value, aliases) factory = _factory_call_callee(node.value) if node.value is not None else None - chosen = ctor or ann - if chosen: - types[node.target.id] = chosen + if ctor: + _bind(node.target.id, ctor, ctor_mod) + elif ann: + _bind(node.target.id, ann, ann_mod) elif factory is not None: # Pending return-type bind — clear stale annotation confidence. - types.pop(node.target.id, None) - elif node.target.id in types and ann is None and ctor is None: + _bind(node.target.id, None, None) + elif node.target.id in types: # Explicit rebinding without a known type — drop confidence. - del types[node.target.id] + _bind(node.target.id, None, None) self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: ctor = _constructor_type_name(node.value) + ctor_mod: str | None = None + if ctor is not None: + _, ctor_mod = _type_ref_with_origin(node.value, aliases) factory = _factory_call_callee(node.value) for target in node.targets: if isinstance(target, ast.Name): if ctor: - types[target.id] = ctor + _bind(target.id, ctor, ctor_mod) elif factory is not None: - types.pop(target.id, None) + _bind(target.id, None, None) elif target.id in types: - del types[target.id] + _bind(target.id, None, None) self.generic_visit(node) BindingVisitor().visit(func_node) + return types, mods + + +def _collect_local_types( + func_node: ast.FunctionDef | ast.AsyncFunctionDef, + import_aliases: dict[str, str] | None = None, +) -> dict[str, str]: + """Map local names → class names (thin wrapper over bindings collector).""" + types, _mods = _collect_local_type_bindings(func_node, import_aliases) return types @@ -945,6 +1092,7 @@ def visit_Call(self, node: ast.Call): # Second pass: local type bindings + enclosing class per function/method. enclosing_by_func: dict[str, str | None] = {} local_types_by_func: dict[str, dict[str, str]] = {} + local_type_mods_by_func: dict[str, dict[str, str]] = {} factory_assigns_by_func: dict[str, dict[str, str]] = {} return_type_by_func: dict[str, str | None] = {} return_type_mod_by_func: dict[str, str | None] = {} @@ -971,7 +1119,9 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: enclosing_by_func[local] = ( ".".join(class_stack2) if class_stack2 and func_depth2 == 0 else None ) - local_types_by_func[local] = _collect_local_types(node) + types, mods = _collect_local_type_bindings(node, import_aliases) + local_types_by_func[local] = types + local_type_mods_by_func[local] = mods factory_assigns_by_func[local] = _collect_factory_assigns(node) ret_name, ret_mod = _infer_return_type(node, import_aliases) return_type_by_func[local] = ret_name @@ -1021,8 +1171,8 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: "_enclosing_class": enclosing_by_func.get(func), # High-confidence local name → class bindings (annotations / ctors). "_local_types": local_types_by_func.get(func) or {}, - # Module path gate for return-propagated bindings (filled later). - "_local_type_mods": {}, + # Module path gate for imported / return-propagated bindings. + "_local_type_mods": local_type_mods_by_func.get(func) or {}, # ``name = factory()`` pending binds for inter-procedural return types. "_factory_assigns": factory_assigns_by_func.get(func) or {}, } diff --git a/examples/python_toy/pkg/models.py b/examples/python_toy/pkg/models.py new file mode 100644 index 0000000..29b3a81 --- /dev/null +++ b/examples/python_toy/pkg/models.py @@ -0,0 +1,6 @@ +"""Wave 30 fixtures: high-confidence imported typing residuals.""" + + +class Widget: + def paint(self) -> str: + return "ok" diff --git a/examples/python_toy/pkg/typed_client.py b/examples/python_toy/pkg/typed_client.py new file mode 100644 index 0000000..34c4d9d --- /dev/null +++ b/examples/python_toy/pkg/typed_client.py @@ -0,0 +1,25 @@ +"""Wave 30 fixture client: quoted / Annotated / aliased Optional / Final.""" + +from __future__ import annotations + +from typing import Annotated, Final +from typing import Optional as Opt + +from .models import Widget + + +def via_quoted(obj: "Widget") -> str: # noqa: UP037 # intentional quoted annotation + return obj.paint() + + +def via_annotated(obj: Annotated[Widget, "ui"]) -> str: + return obj.paint() + + +def via_opt_alias(obj: Opt[Widget]) -> str: # noqa: UP045 # intentional Optional alias + return obj.paint() + + +def via_final() -> str: + obj: Final[Widget] = Widget() + return obj.paint() diff --git a/schemas/SCHEMA_REFERENCE.md b/schemas/SCHEMA_REFERENCE.md index 369e853..61c140e 100644 --- a/schemas/SCHEMA_REFERENCE.md +++ b/schemas/SCHEMA_REFERENCE.md @@ -108,7 +108,7 @@ Each function object also includes `simple_name`: the bare basename (`helper` fo - `self.method` uses enclosing class when known (`Class.method`); unbound `self` outside a class body is refused (no same-file basename guess) - `from mod import Foo` then `Foo.method` / `obj = Foo(); obj.method` when `Foo` uniquely maps to a scanned class under `mod` (import-gated; ambiguous same-named classes in other modules do not invent edges) - `import mod as m` then `m.fn` / `obj = m.Foo(); obj.method` when uniquely matched; `from . import Foo` resolves against package `__init__` when unambiguous -- `obj.method` when `obj` has a high-confidence type from a constructor (`obj = Foo()`), annotation (`obj: Foo`, `Optional[Foo]`, `Foo | None`), or **return-type propagation** (`obj = make()` where `make` uniquely returns a concrete class via `-> Foo`, `-> models.Foo`, `from mod import Foo as F` then `-> F` / `return F()`, a single `return Foo(...)`, or `return alias` bound to that class). **Multi-hop factory chains** (`outer` returns `inner()` / `return x` after `x = inner()`, up to 3 hops) adopt `inner`'s concrete type when every hop uniquely resolves and agrees; cycles and ambiguous callees omit. Imported return types gate `Class.method` to the import module (so same-named classes in other files do not invent edges). Multi-return / union / unimported ambiguous names omit edges. When origin is only the factory file (same-module class), resolve prefers that file then unique global. Relative class imports (`from ..pkg.models import Foo as F`) are uniqueness-gated the same way. +- `obj.method` when `obj` has a high-confidence type from a constructor (`obj = Foo()`), annotation (`obj: Foo`, `Optional[Foo]`, `Foo | None`, quoted `"Foo"` / `"Foo | None"`, `Annotated[Foo, ...]`, `Final[Foo]`, `ClassVar[Foo]`, or `from typing import Optional as Opt` then `Opt[Foo]`), or **return-type propagation** (`obj = make()` where `make` uniquely returns a concrete class via `-> Foo`, `-> models.Foo`, `from mod import Foo as F` then `-> F` / `return F()`, a single `return Foo(...)`, or `return alias` bound to that class). **Multi-hop factory chains** (`outer` returns `inner()` / `return x` after `x = inner()`, up to 3 hops) adopt `inner`'s concrete type when every hop uniquely resolves and agrees; cycles and ambiguous callees omit. Imported return types gate `Class.method` to the import module (so same-named classes in other files do not invent edges). Multi-return / union / unimported ambiguous names omit edges. When origin is only the factory file (same-module class), resolve prefers that file then unique global. Relative class imports (`from ..pkg.models import Foo as F`) are uniqueness-gated the same way. **Wave 30:** `if TYPE_CHECKING:` / `if typing.TYPE_CHECKING:` imports are recorded for quoted-annotation resolve; containers (`list[Foo]`) and multi-class unions still omit. - **Honesty rule (Wave 14):** untyped `obj.method` is **refused** even when a same-file method basename is unique. Prefer no edge over a guessed edge. Typed / `self` / import-gated paths above are unchanged. - High-confidence `return_type` (concrete class name, import aliases canonicalized) is emitted on function entries when inferred; it is absent when ambiguous. Reports list inferred return types; the dashboard counts functions that expose `return_type` when present in the uploaded map. diff --git a/tests/test_wave30.py b/tests/test_wave30.py new file mode 100644 index 0000000..f8568c1 --- /dev/null +++ b/tests/test_wave30.py @@ -0,0 +1,262 @@ +"""Wave 30: Python residual high-confidence imported typing gaps.""" + +from __future__ import annotations + +import ast +import pathlib + +from cli.ucli.analyzers.python_analyzer import ( + _collect_import_aliases, + _simple_type_name, + _type_ref_with_origin, + build_python_map, +) + +TOY = pathlib.Path("examples/python_toy") + + +def _helper_qn(repo: dict, *, file_stem: str, class_method: str) -> str: + return next( + k + for k in repo["functions"] + if file_stem in k.replace("\\", "/") and k.endswith(f":{class_method}") + ) + + +def _ann(src: str) -> ast.AST: + tree = ast.parse(src) + fn = tree.body[0] + assert isinstance(fn, ast.FunctionDef) + ann = fn.args.args[0].annotation + assert ann is not None + return ann + + +def test_string_annotation_optional_union_and_refuse(): + assert _simple_type_name(_ann('def f(x: "Foo"):\n pass\n')) == "Foo" + assert _simple_type_name(_ann('def f(x: "Foo | None"):\n pass\n')) == "Foo" + assert _simple_type_name(_ann('def f(x: "Foo | Bar"):\n pass\n')) is None + assert _simple_type_name(_ann('def f(x: "list[Foo]"):\n pass\n')) is None + + +def test_annotated_final_classvar_unwrap(): + assert _simple_type_name(_ann('def f(x: Annotated[Foo, "m"]):\n pass\n')) == "Foo" + assert _simple_type_name(_ann("def f(x: Final[Foo]):\n pass\n")) == "Foo" + assert _simple_type_name(_ann("def f(x: ClassVar[Foo]):\n pass\n")) == "Foo" + + +def test_optional_import_alias_unwrap(): + src = "from typing import Optional as Opt\ndef f(x: Opt[Foo]):\n pass\n" + tree = ast.parse(src) + aliases = _collect_import_aliases(tree, pathlib.Path("app.py")) + fn = tree.body[1] + assert isinstance(fn, ast.FunctionDef) + ann = fn.args.args[0].annotation + assert ann is not None + assert _simple_type_name(ann, aliases) == "Foo" + assert _type_ref_with_origin(ann, aliases) == ("Foo", None) + + +def test_type_checking_guard_records_import(tmp_path: pathlib.Path): + src = ( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " from mod import Foo\n" + "def f(x: Foo):\n" + " pass\n" + ) + (tmp_path / "app.py").write_text(src, encoding="utf-8") + tree = ast.parse(src) + aliases = _collect_import_aliases(tree, tmp_path / "app.py") + assert aliases.get("Foo") == "mod:Foo" + assert aliases.get("TYPE_CHECKING") == "typing:TYPE_CHECKING" + + +def test_quoted_imported_annotation_resolves(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + 'from mod import Foo\ndef run(obj: "Foo"):\n return obj.helper()\n', + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn in repo["functions"][helper]["callers"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_annotated_imported_annotation_resolves(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import Annotated\n" + "from mod import Foo\n" + 'def run(obj: Annotated[Foo, "meta"]):\n' + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_optional_as_alias_imported_annotation_resolves(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import Optional as Opt\n" + "from mod import Foo\n" + "def run(obj: Opt[Foo]):\n" + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_final_annassign_imported_resolves(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import Final\n" + "from mod import Foo\n" + "def run():\n" + " obj: Final[Foo] = Foo()\n" + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_type_checking_quoted_import_resolves(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " from mod import Foo\n" + 'def run(obj: "Foo"):\n' + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_foo_as_f_optional_wrapper_resolves(tmp_path: pathlib.Path): + """from mod import Foo as F; Optional[F] still import-gates.""" + (tmp_path / "mod.py").write_text( + "class Foo:\n def helper(self):\n return 1\n", + encoding="utf-8", + ) + (tmp_path / "other.py").write_text( + "class Foo:\n def helper(self):\n return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import Optional\n" + "from mod import Foo as F\n" + "def run(obj: Optional[F]):\n" + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + helper = _helper_qn(repo, file_stem="mod", class_method="Foo.helper") + other = _helper_qn(repo, file_stem="other", class_method="Foo.helper") + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert helper in repo["functions"][run_qn]["calls"] + assert run_qn not in repo["functions"][other]["callers"] + + +def test_multi_class_annotated_still_omits(tmp_path: pathlib.Path): + (tmp_path / "mod.py").write_text( + "class Foo:\n" + " def helper(self):\n" + " return 1\n" + "\n" + "class Bar:\n" + " def helper(self):\n" + " return 2\n", + encoding="utf-8", + ) + (tmp_path / "app.py").write_text( + "from typing import Annotated\n" + "from mod import Foo, Bar\n" + 'def run(obj: Annotated[Foo | Bar, "x"]):\n' + " return obj.helper()\n", + encoding="utf-8", + ) + repo = build_python_map(tmp_path, processes=1) + run_qn = next(k for k in repo["functions"] if k.endswith("app:run")) + assert repo["functions"][run_qn]["calls"] == ["obj.helper"] + for qn, meta in repo["functions"].items(): + if qn.endswith(".helper"): + assert run_qn not in meta.get("callers", []) + + +def test_python_toy_wave30_typed_client_resolves(): + """Durable fixture under examples/python_toy exercises Wave 30 paths.""" + assert (TOY / "pkg" / "models.py").is_file() + assert (TOY / "pkg" / "typed_client.py").is_file() + repo = build_python_map(TOY, processes=1) + paint = next(k for k in repo["functions"] if k.endswith("models:Widget.paint")) + for name in ("via_quoted", "via_annotated", "via_opt_alias", "via_final"): + qn = next(k for k in repo["functions"] if k.endswith(f"typed_client:{name}")) + assert paint in repo["functions"][qn]["calls"], name + assert qn in repo["functions"][paint]["callers"], name + + +def test_schema_documents_wave30_typing(): + ref = pathlib.Path("schemas/SCHEMA_REFERENCE.md").read_text(encoding="utf-8") + assert "Wave 30" in ref + assert "TYPE_CHECKING" in ref + assert "Annotated" in ref