diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f0c128e0..8f1dd9f111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Feature: PHP instance-method calls on a typed receiver now resolve to the method they really reach, instead of a bare same-name match (#1682). `$this->prop->method()` binds to the type declared on the property — including a constructor-promoted param — and the same typing covers nullsafe receivers (`$obj?->method()`), natively typed parameters, and `$var = new T()` locals. `(new Service())->method()` resolves too and is the one form tagged EXTRACTED (1.0), since the class is named right there in the source; the promotion applies only when the written namespace corroborates the class node found for it, compared against the namespace the defining file DECLARES rather than against its PSR-4 path (a file at `app/Services/Client.php` may well declare `namespace App\Vendor;`). Every other typed receiver is INFERRED (0.8). The motivating case is a Laravel corpus: `$this->leadHunter->search(...)` used to reach nothing, and now reaches `App\Services\LeadHunterService::search`. - Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node; a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry. - Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. -- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the Java and C# halves are fixed by the next bullet, and the same exposure in the C++, Swift, TypeScript and Python resolvers is untouched and left as a follow-up (`lawnstarter/graphify#24`). -- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is tracked as `lawnstarter/graphify#24`. +- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the Java and C# halves are fixed by the next bullet, and the C++, Swift, TypeScript and Python halves by the one after it. +- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is closed by the next bullet. +- Fix: the last four receiver-type indexes — C++, Swift, TypeScript and Python — are now scoped to their own sources too, so no member-call resolver matches a receiver's declared type against class definitions written in another language any more (`lawnstarter/graphify#24`). This is the same two-way defect the PHP/ObjC and Java/C# bullets above describe, in its remaining copies: a C++ `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED, a Swift `let lead: Lead` and a TypeScript `private lead: Lead` did the same, and — in the other direction — a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore LOSE these cross-language edges, which were always wrong, and GAIN C++, Swift, TypeScript and Python edges that a foreign short-name collision previously deleted. Single-language corpora are unaffected: the filter admits everything such a corpus contains. Two notes on the edges of this change. `.h` is scoped into BOTH the C++ and the Objective-C index, because it routes to either extractor by content and a C++ class and an ObjC `@interface` both live in a header — the two languages are therefore isolated from every other language but not from each other, which no suffix can fix, and raw-call ownership for them stays on the extractor-stamped `lang` for exactly that reason. And Python is scoped on both of its arms, not just the class index: its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. All ten resolvers now share one `_is_owned_definition` predicate keyed off the same per-resolver suffix tuple that registers the resolver, so the registration and the scoping cannot drift. Pre-existing, not a regression. +- Fix: an `imports` edge no longer vanishes when any same-stem file sits beside its target, in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash (`lawnstarter/graphify#33`). Each of these names an import's target by the imported file's bare stem id (`import lead` -> `lead`), which resolves only while that id is unique: add a `lead.md` and the two file nodes collide, so id-disambiguation salts them into `lead_py_lead` and `lead_md_lead` while the edge — keyed by the importer's own file rather than the target's — was left pointing at an id that no longer named anything, and was dropped along with everything downstream of it (Python's `module.func()` call resolution among them). These edges now stamp the `target_file` hint the disambiguator already accepts for this (#1814), so the salt lands on the right file; an import written in one language can only mean a file of that language, so the choice stays unambiguous whatever the collider is. The hint is stamped centrally rather than in each extractor, because an extractor sees one file and cannot know which of the corpus's same-stem candidates the id will end up naming; it is transient and popped by its only reader, so it never reaches `graph.json`. An id claimed by more than one importable file of the same language is left dangling, as before, and a corpus with no collision is bit-identical — the hint only ever selects among the salted variants of an id the edge already named, so it cannot change WHICH node an edge resolves to. Bash additionally emitted the edge twice under a collision — once correct, once dangling — because its second producer in `resolve_bash_source_edges` derives ids from the path after disambiguation has already renamed them; that pass now reads the ids as they actually stand, which also repairs the source-backed `calls` edges it resolves. TypeScript/JavaScript and C/C++/Objective-C already had equivalent protection; Julia, Fortran and Verilog target an importer-scoped node and were never exposed; Dart mints its own stub nodes and Ruby/PHP emit no file-targeting import edges. Pre-existing, not a regression. - Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. - Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. - Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. diff --git a/graphify/extract.py b/graphify/extract.py index 5531408e78..b359bfc0e3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -262,6 +262,132 @@ def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None: e["target"] = alias_map[tgt] +# Languages whose import edges name their target by the imported file's bare +# stem id, as (importer suffixes, importable target suffixes). Each pair is +# closed within one language: an import written in one of the first set can only +# ever mean a file from the second, which is what makes the hint below +# unambiguous even when the colliding sibling belongs to another language (#33). +# +# Python's target set omits `.pyi` on purpose — it has no extractor, so it mints +# no file node to point a hint at, and listing it could only mask a real `.py` +# target behind a phantom ambiguity. +_IMPORT_STEM_LANGUAGES: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = ( + ((".py", ".pyi"), (".py",)), + ((".rs",), (".rs",)), + ((".zig",), (".zig",)), + ((".ex", ".exs"), (".ex", ".exs")), + ((".ps1", ".psm1", ".psd1"), (".ps1", ".psm1", ".psd1")), + ((".sh", ".bash"), (".sh", ".bash")), + ( + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + ), +) + + +def _file_nids_by_path(all_nodes, all_edges, root) -> dict: + """Resolved source path -> the id its file node carries RIGHT NOW. + + For passes that run after ``_disambiguate_colliding_node_ids`` and therefore + cannot derive a file's id from its path: a same-stem sibling salts the id + away from whatever the path formula would produce. A file node is the one + that ``contains`` its file's other nodes; an empty file contains nothing, so + fall back to the node whose label is the file's own basename. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + contains_sources = { + e.get("source") for e in all_edges if e.get("relation") == "contains" + } + by_path: dict[Path, str] = {} + fallback: dict[Path, str] = {} + for n in all_nodes: + source_file, nid = n.get("source_file"), n.get("id") + if not source_file or not nid: + continue + candidate = Path(str(source_file)) + try: + resolved = ( + candidate if candidate.is_absolute() else root / candidate + ).resolve() + except OSError: + continue + if nid in contains_sources: + by_path.setdefault(resolved, nid) + elif n.get("label") == resolved.name: + fallback.setdefault(resolved, nid) + for resolved, nid in fallback.items(): + by_path.setdefault(resolved, nid) + return by_path + + +def _hint_import_targets(paths, all_edges, root) -> None: + """Tell id-disambiguation which file each stem-named import edge targets. + + The extractors in ``_IMPORT_STEM_LANGUAGES`` name an import's target by the + bare file-node id of the imported file (``import lead`` -> ``lead``), which + resolves only while that id is unique. Add ANY same-stem file — a ``lead.md`` + will do — and the two file nodes collide, so + ``_disambiguate_colliding_node_ids`` salts them apart into ``lead_py_lead`` + and ``lead_md_lead``. The edge's target salt is keyed by the IMPORTER's + source_file, which matches neither, so the edge is left pointing at an id + that no longer names anything: silently dropped, along with everything + downstream of it (Python's ``module.func()`` call resolution among them). + + The disambiguator already accepts a ``target_file`` hint for exactly this + (#1814), keying the target salt by that file instead. Stamp it here rather + than in each extractor, because an extractor sees one file and cannot know + which of the corpus's same-stem candidates the id will end up naming. + + This cannot change WHICH node an edge resolves to — the hint only selects + among the salted variants of an id the edge already named — so a corpus with + no collision is bit-identical. Guards: never overwrite a hint an emitter + already stamped, and skip an id claimed by more than one importable file of + the same language (leave it dangling, as before). + + Must run BEFORE ``_disambiguate_colliding_node_ids``, the hint's only reader. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + hints: list[tuple[tuple[str, ...], dict[str, str]]] = [] + for importer_suffixes, target_suffixes in _IMPORT_STEM_LANGUAGES: + file_id_to_paths: dict[str, set[str]] = {} + for p in paths: + if p.suffix.lower() not in target_suffixes: + continue + try: + rel = Path(p).resolve().relative_to(root) + except (ValueError, OSError): + continue + file_id_to_paths.setdefault(_file_node_id(rel), set()).add(str(p)) + hint_map = { + fid: next(iter(ps)) for fid, ps in file_id_to_paths.items() if len(ps) == 1 + } + if hint_map: + hints.append((importer_suffixes, hint_map)) + if not hints: + return + for e in all_edges: + if not ( + isinstance(e, dict) + and e.get("relation") in ("imports", "imports_from") + and not e.get("target_file") + ): + continue + source_file = str(e.get("source_file", "")).lower() + for importer_suffixes, hint_map in hints: + if not source_file.endswith(importer_suffixes): + continue + target_path = hint_map.get(e.get("target")) + if target_path: + e["target_file"] = target_path + break + + SEMANTIC_RELATIONS = frozenset({ "inherits", "implements", "mixes_in", "embeds", "references", "calls", "imports", "imports_from", "re_exports", "contains", "method", @@ -2281,8 +2407,13 @@ def _assembly_of_node(nid: str) -> str: ) # `.h` routes to extract_cpp or extract_objc by content, so it appears in both # the C++ and ObjC sets. Raw calls are still claimed by the extractor-stamped -# `lang`; only the DEFINITION index is scoped by suffix, where including `.h` is -# correct — an ObjC @interface lives in one. +# `lang`, never by suffix; only the DEFINITION index is scoped by suffix, where +# including `.h` is correct — a C++ class and an ObjC @interface both live in +# one. The consequence is that C++ and ObjC are isolated from every other +# language but not from each other, which no suffix can fix (#24). +_CPP_RESOLVER_SUFFIXES = ( + ".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h", +) _OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") @@ -2298,6 +2429,17 @@ def _raw_call_is_owned(rc: dict, suffixes: tuple[str, ...]) -> bool: return str(rc.get("source_file") or "").lower().endswith(suffixes) +def _is_owned_definition(node: "dict | None", suffixes: tuple[str, ...]) -> bool: + """True when ``node`` was declared in a source file with one of ``suffixes``. + + The definition-index twin of ``_raw_call_is_owned`` (#8, #10, #24). Every + member-call resolver scopes its receiver-type index through this, so a + receiver's declared type can only ever bind to a type written in the same + language. + """ + return str((node or {}).get("source_file") or "").lower().endswith(suffixes) + + def _resolve_swift_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -2336,12 +2478,17 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} # Type name -> definition node ids (real, source-backed, type-like defs only). - # len != 1 is the god-node guard: an ambiguous type name bails. + # len != 1 is the god-node guard: an ambiguous type name bails. Scoped to + # Swift sources (#24): unscoped, a Python `class Lead` could type + # `let lead: Lead` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the guard to 2 and deleted the correct Swift + # edge. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _SWIFT_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, from `method` edges. @@ -2447,7 +2594,11 @@ def _key(label: str) -> str: # A class owns methods: it is the source of one or more `method` edges. Index # class label -> owning class node ids (len != 1 is the god-node guard), and - # (class_node_id, method_key) -> method_node_id. + # (class_node_id, method_key) -> method_node_id. Only classes declared in + # Python sources are candidates (#24): unscoped, `Lead.search()` bound to a + # Java `class Lead` at EXTRACTED, and a foreign class merely SHARING the name + # pushed the guard to 2 and deleted the correct Python edge. `method_index` + # needs no scoping — it is only ever keyed by a class id already admitted here. class_def_nids: dict[str, list[str]] = {} method_index: dict[tuple[str, str], str] = {} for e in all_edges: @@ -2455,7 +2606,7 @@ def _key(label: str) -> str: continue src, tgt = e.get("source"), e.get("target") cnode = node_by_id.get(src) - if cnode is not None: + if cnode is not None and _is_owned_definition(cnode, _PYTHON_RESOLVER_SUFFIXES): class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(src) tnode = node_by_id.get(tgt) if tnode is not None: @@ -2555,11 +2706,16 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None: # never match), then to the single callable that module contains. A # receiver also matches the local alias bound on that import edge # (#2082), so an aliased import resolves the same as the bare name. + # A candidate module must itself be a Python file (#24): matching on + # the stem alone, a `lead.ts` answered `import lead` and bound the + # call to a TypeScript function, and a `lead.ts` sitting beside the + # real `lead.py` made the pair ambiguous and deleted the true edge. rkey = _key(receiver) caller_file = file_of_node.get(caller) file_aliases = import_alias_by_filenode.get(caller_file, {}) mods = [t for t in imported_by_filenode.get(caller_file, ()) if t in contains_children + and _is_owned_definition(node_by_id.get(t), _PYTHON_RESOLVER_SUFFIXES) and (_module_stem_key(t) == rkey or file_aliases.get(t) == rkey)] if len(mods) != 1: # not an imported module, or ambiguous -> bail continue @@ -2596,11 +2752,16 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to TS/JS sources (#24): unscoped, a Python `class Lead` could type + # `private lead: Lead` and mint a cross-language edge, and a foreign class + # merely SHARING the short name pushed the single-definition guard to 2 and + # deleted the correct TypeScript edge. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _TYPESCRIPT_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} @@ -2709,11 +2870,17 @@ def _key(label: str) -> str: # excluding non-contained nodes keeps them from making a real type ambiguous. contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to C++ sources (#24): unscoped, a Python `class Lead` could type + # `Lead lead;` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the single-definition guard to 2 and deleted + # the correct C++ edge. `.h` is in the set (and in ObjC's), so the two stay + # distinguishable from every other language but not from each other. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _CPP_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type @@ -2848,8 +3015,7 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - sf = str(n.get("source_file") or "").lower() - if (sf.endswith(_CSHARP_RESOLVER_SUFFIXES) + if (_is_owned_definition(n, _CSHARP_RESOLVER_SUFFIXES) and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) @@ -3037,7 +3203,7 @@ def key(label: str) -> str: type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - str(node.get("source_file") or "").lower().endswith(_JAVA_RESOLVER_SUFFIXES) + _is_owned_definition(node, _JAVA_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3172,7 +3338,7 @@ def key(label: str) -> str: type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - str(node.get("source_file") or "").lower().endswith(_PHP_RESOLVER_SUFFIXES) + _is_owned_definition(node, _PHP_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -3366,8 +3532,7 @@ def _key(label: str) -> str: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - sf = str(n.get("source_file") or "").lower() - if (sf.endswith(_OBJC_RESOLVER_SUFFIXES) + if (_is_owned_definition(n, _OBJC_RESOLVER_SUFFIXES) and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) @@ -3475,7 +3640,7 @@ def _key(label: str) -> str: register_language_resolver( LanguageResolver( "cpp_member_calls", - frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}), + frozenset(_CPP_RESOLVER_SUFFIXES), _resolve_cpp_member_calls, ) ) @@ -5643,6 +5808,11 @@ def _learn(e: dict) -> None: # (src/) package root before the resolver/import-evidence passes run, so the # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) + # Then hint the disambiguator at each stem-named import's real target file, so + # a same-stem sibling cannot strand the edge on a salted-away id (#33). Must + # be after the repoint above (it rewrites some targets) and before + # disambiguation (the hint's only reader). + _hint_import_targets(paths, all_edges, root) _merge_swift_extensions(per_file, all_nodes, all_edges) _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) @@ -5748,7 +5918,10 @@ def _looks_like_bash(result: object) -> bool: sh_paths = [p for _, p in sh_pairs] try: all_edges.extend( - resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges) + resolve_bash_source_edges( + sh_results, sh_paths, root, existing_edges=all_edges, + file_nids=_file_nids_by_path(all_nodes, all_edges, root), + ) ) except Exception as exc: import logging diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 892f310650..852f3a91f4 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -406,6 +406,7 @@ def resolve_bash_source_edges( paths: Sequence[Path], root: Path, existing_edges: list[dict] | None = None, + file_nids: dict[Path, str] | None = None, ) -> list[dict]: """Resolve Bash source/import edges and source-backed function calls. @@ -428,7 +429,17 @@ def resolve_bash_source_edges( Anything else is silently skipped. """ path_by_index = [Path(p).resolve() for p in paths] - file_nid_by_path = {p: _file_node_id_for_path(p, root) for p in path_by_index} # resolved paths only + # `file_nids` carries the file node ids as they actually stand in the graph. + # This pass runs AFTER id-disambiguation, so when a same-stem sibling made a + # file id collide, the salted id no longer matches what + # `_file_node_id_for_path` derives from the path — every edge built from the + # formula would then name a node that does not exist (#33). Fall back to the + # formula for any path the caller did not resolve (and for direct callers + # that pass nothing), which is exactly the previous behavior. + known_nids = file_nids or {} + file_nid_by_path = { # resolved paths only + p: known_nids.get(p) or _file_node_id_for_path(p, root) for p in path_by_index + } functions_by_file: dict[str, dict[str, str]] = {} for result, path in zip(per_file, path_by_index): diff --git a/tests/test_import_alias_disambiguation.py b/tests/test_import_alias_disambiguation.py new file mode 100644 index 0000000000..7c1260617b --- /dev/null +++ b/tests/test_import_alias_disambiguation.py @@ -0,0 +1,141 @@ +"""An import edge must survive a same-stem file in another language (#33). + +Several extractors name an import's target by the bare file-stem id of the +imported file (``import lead`` -> ``lead``). That works only while the id is +unique: add ANY same-stem file -- a ``lead.md`` will do -- and the two file +nodes collide, so ``_disambiguate_colliding_node_ids`` salts them apart into +``lead_ex_lead`` and ``lead_md_lead``. The import edge's target salt is keyed by +the IMPORTER's own source_file, which matches neither, so the edge is left +pointing at an id that no longer names anything and is silently dropped. + +The disambiguator already accepts a ``target_file`` hint for exactly this shape +(#1814), keying the target salt by that file instead. Every language below +stamps it now. + +Each case asserts BOTH directions, so a fixture that never produced an import +edge in the first place cannot pass by accident: the control corpus must +resolve, and the collision corpus must resolve to the SAME file. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.extract import extract + +_IMPORT_RELATIONS = ("imports", "imports_from", "re_exports") + +# A language-neutral collider: it shares the stem, mints a file node, and has +# nothing whatsoever to do with the import under test. +_COLLIDER = ("lead.md", "# Lead\n\nUnrelated notes.\n") + +# (case id, importer file, importer body, target file, target body) +_CASES = [ + ( + "powershell-dot-source", + "caller.ps1", ". ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "powershell-import-module", + "caller.ps1", "Import-Module ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "rust-use", + "caller.rs", "use crate::lead;\n\npub fn run() { lead::search(); }\n", + "lead.rs", "pub fn search() {}\n", + ), + ( + "pascal-uses", + "caller.pas", + "unit Caller;\ninterface\nuses Lead;\nimplementation\nend.\n", + "lead.pas", + "unit Lead;\ninterface\nprocedure Search;\n" + "implementation\nprocedure Search; begin end;\nend.\n", + ), + ( + "zig-at-import", + "caller.zig", + 'const lead = @import("lead.zig");\npub fn run() void { lead.search(); }\n', + "lead.zig", "pub fn search() void {}\n", + ), + ( + "elixir-import", + "caller.ex", + "defmodule Caller do\n import Lead\n def run, do: search()\nend\n", + "lead.ex", "defmodule Lead do\n def search, do: []\nend\n", + ), + ( + "bash-source", + "caller.sh", "source ./lead.sh\nrun() { search; }\n", + "lead.sh", "search() { echo hi; }\n", + ), +] + + +def _extract(tmp_path: Path, files: list[tuple[str, str]]) -> dict: + tmp_path.mkdir(parents=True, exist_ok=True) + paths = [] + for name, body in files: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + paths.append(path) + return extract(paths, cache_root=tmp_path) + + +def _import_targets(result: dict) -> list[dict]: + """The node each import edge points at, or ``None`` where it dangles.""" + by_id = {node["id"]: node for node in result["nodes"]} + return [ + by_id.get(edge.get("target")) + for edge in result["edges"] + if edge.get("relation") in _IMPORT_RELATIONS + ] + + +@pytest.mark.parametrize( + ("importer", "importer_body", "target", "target_body"), + [case[1:] for case in _CASES], + ids=[case[0] for case in _CASES], +) +def test_import_edge_survives_a_same_stem_foreign_sibling( + tmp_path: Path, importer: str, importer_body: str, target: str, target_body: str, +): + corpus = [(importer, importer_body), (target, target_body)] + + control = _import_targets(_extract(tmp_path / "control", corpus)) + assert any( + node is not None and str(node.get("source_file", "")).endswith(target) + for node in control + ), f"fixture is inert: no import edge reached {target} even without a collider" + + collided = _import_targets(_extract(tmp_path / "collided", [*corpus, _COLLIDER])) + assert None not in collided, \ + "an import edge dangled on the pre-disambiguation stem id" + assert any( + str(node.get("source_file", "")).endswith(target) for node in collided + ), f"the import edge no longer reaches {target}" + assert not any( + str(node.get("source_file", "")).endswith(_COLLIDER[0]) for node in collided + ), "an import edge was repointed onto the unrelated collider" + + +def test_the_transient_target_file_hint_never_reaches_the_graph(tmp_path: Path): + """``target_file`` carries an absolute path and is popped by its only reader. + + Asserted across every case at once: a language that stamps the hint but is + somehow not reached by the disambiguator would ship the analysing machine's + filesystem layout inside `graph.json`. + """ + # Two cases share `caller.ps1`; dedupe by name (last wins). The point is + # breadth of emitters in one graph, not per-case isolation. + corpus = {_COLLIDER[0]: _COLLIDER[1]} + for _, importer, importer_body, target, target_body in _CASES: + corpus[importer] = importer_body + corpus[target] = target_body + result = _extract(tmp_path, list(corpus.items())) + + leaked = [edge for edge in result["edges"] if "target_file" in edge] + assert leaked == [], f"transient hint reached the graph: {leaked[:3]}" diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py index 33449b0f74..9fcb942a89 100644 --- a/tests/test_mixed_corpus_member_calls.py +++ b/tests/test_mixed_corpus_member_calls.py @@ -1,4 +1,4 @@ -"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, spec #1682). +"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, #24, spec #1682). A corpus that mixes languages must not let one language's raw call data mint an edge through a different language's member-call resolver. Two independent @@ -10,7 +10,7 @@ tag, so those three filter by source-file suffix instead (#10). * **Definition-index scoping** -- the receiver-type index a resolver builds holds only types declared in its own sources (#8 for PHP/ObjC, #10 for - Java/C#). + Java/C#, #24 for C++, Swift, TypeScript and Python). Every test goes through the public ``extract()`` seam, and the Python class here doubles as the cross-language decoy: it owns an identically named method, @@ -23,7 +23,7 @@ from graphify.extract import extract -def _calls(tmp_path: Path, files: dict[str, str]): +def _calls(tmp_path: Path, files: dict[str, str], cache_root: Path | None = None): """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" paths = [] for name, body in files.items(): @@ -31,7 +31,7 @@ def _calls(tmp_path: Path, files: dict[str, str]): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(body, encoding="utf-8") paths.append(path) - result = extract(paths, cache_root=tmp_path / "graphify-out") + result = extract(paths, cache_root=cache_root or (tmp_path / "graphify-out")) calls = { (edge["source"], edge["target"]): edge for edge in result["edges"] @@ -64,8 +64,27 @@ def _call_context_pairs(result: dict) -> set[tuple[str, str]]: } -# A Python class whose method name collides with the PHP call's callee. Nothing -# in a PHP file may ever bind to it. +def _cross_language_targets(result: dict, caller: str, file_suffix: str) -> set[str]: + """Call-context targets of ``caller`` that live in ``file_suffix``. + + Wider than naming one node: an index leak surfaces as ``calls`` onto the + foreign METHOD, or -- when the callee name misses on the foreign type -- as a + ``references`` edge onto the foreign TYPE itself. Both are the same bug. + """ + foreign = { + node["id"] + for node in result["nodes"] + if str(node.get("source_file") or "").endswith(file_suffix) + } + return { + target + for source, target in _call_context_pairs(result) + if source == caller and target in foreign + } + + +# A Python class whose method name collides with the other languages' callee. +# Nothing written in another language may ever bind to it. _PY_DECOY = ( "class Lead:\n" " def search(self, filters):\n" @@ -202,8 +221,13 @@ def test_php_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path) def test_objc_receiver_type_does_not_match_a_python_class(tmp_path: Path): - """Defect 1, ObjC twin: `[Lead search]` with no ObjC `Lead` in the corpus.""" - calls, result = _calls(tmp_path, { + """Defect 1, ObjC twin: `[Lead search]` with no ObjC `Lead` in the corpus. + + Asserted through ``_cross_language_targets`` rather than on one named node, + so a leak that lands as a ``references`` edge onto the Python CLASS (instead + of a ``calls`` edge onto its method) is caught too. + """ + _, result = _calls(tmp_path, { "svc.py": _PY_DECOY, "src/Runner.m": ( "@implementation Runner\n" @@ -213,8 +237,7 @@ def test_objc_receiver_type_does_not_match_a_python_class(tmp_path: Path): }) go = _nid(result, "-go", "Runner.m") - py_search = _nid(result, ".search()", "svc.py") - assert (go, py_search) not in calls, \ + assert not _cross_language_targets(result, go, "svc.py"), \ "an ObjC receiver type must not resolve against a Python class" @@ -463,3 +486,269 @@ def test_csharp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Pa assert (go, cs_search) in calls, \ "a same-named class in another language suppressed the real C# edge" assert (go, py_search) not in calls, "the Python decoy received an edge" + + +# ── Language-scoped C++, Swift and TypeScript receiver type indexes (#24) ───── +# +# The remaining copies of the same shape. Each of these three resolvers built +# `type_def_nids` from every type-like node in the corpus, so the receiver's +# declared type name was matched against class definitions written in ANY +# language. Both directions of the defect are covered per language: the foreign +# class TYPING the receiver, and the foreign class merely SHARING the short name +# pushing the single-definition guard to 2 and deleting the correct edge. +# +# Raw-call ownership (above) cannot close this: the raw call being resolved is +# genuinely the resolver's own, and the leak is in what its INDEX offers up. +# +# ObjC is not repeated here -- #8 scoped its index, and the two ObjC cases above +# already guard both directions. + +_CPP_CALLER = ( + "class Runner {\n" + "public:\n" + " void go() { Lead lead; lead.search(); }\n" + "};\n" +) +"""`Lead lead;` is a local declaration, so the C++ `cpp_type_table` types the +receiver and the call resolves at INFERRED.""" + +_SWIFT_CALLER = ( + "class Runner {\n" + " let lead: Lead\n" + " func go() { lead.search() }\n" + "}\n" +) +"""Declared without an initializer on purpose: `= Lead()` would additionally be +picked up by the shared cross-file CALL pass, which is a different mechanism +and would muddy what this test pins down.""" + +_TS_CALLER = ( + "export class Runner {\n" + " constructor(private lead: Lead) {}\n" + " go() { return this.lead.search(); }\n" + "}\n" +) + + +def test_cpp_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No C++ `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.cpp": _CPP_CALLER}) + + go = _nid(result, ".go()", "Runner.cpp") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a C++ receiver type must not resolve against a Python class" + + +def test_cpp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real C++ edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.cpp": "class Lead {\npublic:\n void search() {}\n};\n", + "Runner.cpp": _CPP_CALLER, + }) + + go = _nid(result, ".go()", "Runner.cpp") + cpp_search = _nid(result, ".search()", "lead.cpp") + py_search = _nid(result, ".search()", "svc.py") + assert (go, cpp_search) in calls, \ + "a same-named Python class suppressed the real C++ edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, cpp_search)]["confidence"] == "INFERRED" + + +def test_swift_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No Swift `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.swift": _SWIFT_CALLER}) + + go = _nid(result, ".go()", "Runner.swift") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a Swift receiver type must not resolve against a Python class" + + +def test_swift_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real Swift edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Lead.swift": "class Lead { func search() {} }\n", + "Runner.swift": _SWIFT_CALLER, + }) + + go = _nid(result, ".go()", "Runner.swift") + swift_search = _nid(result, ".search()", "Lead.swift") + py_search = _nid(result, ".search()", "svc.py") + assert (go, swift_search) in calls, \ + "a same-named Python class suppressed the real Swift edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, swift_search)]["confidence"] == "INFERRED" + + +def test_typescript_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No TypeScript `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "runner.ts": _TS_CALLER}) + + go = _nid(result, ".go()", "runner.ts") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a TypeScript receiver type must not resolve against a Python class" + + +def test_typescript_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real TypeScript edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.ts": "export class Lead { search() { return []; } }\n", + "runner.ts": _TS_CALLER, + }) + + go = _nid(result, ".go()", "runner.ts") + ts_search = _nid(result, ".search()", "lead.ts") + py_search = _nid(result, ".search()", "svc.py") + assert (go, ts_search) in calls, \ + "a same-named Python class suppressed the real TypeScript edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, ts_search)]["confidence"] == "EXTRACTED" + + +# ── Language-scoped Python class and module indexes (#24) ──────────────────── +# +# The Python resolver's two arms are indexed differently from every resolver +# above -- its class index is built by walking `method` edges rather than by +# filtering source-backed nodes, and its module arm resolves through the +# caller's own `imports` edges -- but both were corpus-wide all the same. +# +# Both arms are covered in both directions. The module arm's suppression case +# turned out not to be an index defect at all: two same-stem files disambiguate +# the FILE node ids (`lead_py_lead`, `lead_ts_lead`) while the `imports` edge +# target stays the bare alias `lead`, so the arm saw ZERO candidates rather +# than an ambiguous two. That is fixed one layer down, in the import-alias +# hinting (#33), and asserted at both layers below. + +_JAVA_DECOY = "class Lead { void search() {} }\n" + + +def test_python_class_receiver_does_not_match_a_java_class(tmp_path: Path): + """Class arm, defect 1: no Python `Lead` exists, only a Java one. + + `Lead.search()` is a Python raw call the Python resolver rightly owns. Its + class index held every class in the corpus, so the Java `Lead` answered for + the receiver and the call was minted at EXTRACTED -- the label reserved for + an explicitly named, unambiguous class reference. + """ + _, result = _calls(tmp_path, { + "caller.py": "def run():\n Lead.search()\n", + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "Lead.java"), \ + "a Python class receiver must not resolve against a Java class" + + +def test_python_class_receiver_resolves_despite_a_same_named_java_class(tmp_path: Path): + """Class arm, defect 2: the Java decoy must not delete the Python edge.""" + calls, result = _calls(tmp_path, { + "caller.py": "from svc import Lead\n\n\ndef run():\n Lead.search()\n", + "svc.py": _PY_DECOY, + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, ".search()", "svc.py") + java_search = _nid(result, ".search()", "Lead.java") + assert (run, py_search) in calls, \ + "a same-named Java class suppressed the real Python edge" + assert (run, java_search) not in calls, "the Java decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +# The module arm resolves an `import` to the imported file's node, which only +# lines up when ids are relativized against the corpus root itself -- hence the +# explicit `cache_root`, matching the prior art in +# `tests/test_extract.py::test_python_module_qualified_call_resolves_extracted`. +_MOD_CALLER = "import lead\n\n\ndef run():\n lead.search()\n" +_TS_MODULE_DECOY = "export function search() { return []; }\n" + + +def test_python_module_receiver_does_not_match_a_typescript_file(tmp_path: Path): + """Module arm, defect 1: `import lead` must not reach `lead.ts`. + + No `lead.py` exists. The arm matched any corpus file whose stem equalled the + receiver, so a TypeScript file of the same stem satisfied it and + `lead.search()` bound to a TypeScript function at EXTRACTED -- an import + edge Python could not possibly have. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "lead.ts"), \ + "a Python module receiver must not resolve against a TypeScript file" + + +def test_python_module_receiver_still_resolves_its_own_module(tmp_path: Path): + """The module arm's positive control: scoping must not cost the real edge.""" + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + assert (run, py_search) in calls, "the module arm stopped resolving" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_module_receiver_resolves_despite_a_same_stem_typescript_file( + tmp_path: Path, +): + """Module arm, defect 2: a same-stem foreign file must not delete the edge. + + `lead.py` and `lead.ts` both derive the file node id `lead`, so + disambiguation salts them into `lead_py_lead` and `lead_ts_lead` — while the + `imports` edge target stays the bare alias `lead`, which now names nothing. + The arm then sees ZERO candidate modules rather than an ambiguous two, and + the one edge Python's own import genuinely supports disappears (#33). + """ + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + ts_search = _nid(result, "search()", "lead.ts") + assert (run, py_search) in calls, \ + "a same-stem TypeScript file suppressed the real Python edge" + assert (run, ts_search) not in calls, "the TypeScript decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_import_edge_survives_a_same_stem_foreign_sibling(tmp_path: Path): + """The `imports` edge itself, one layer below the call edge above (#33). + + Asserted separately because the module arm is only one consumer: any query + over Python imports lost this edge to the same dangling alias. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + node_ids = {node["id"] for node in result["nodes"]} + py_file = _nid(result, "lead.py", "lead.py") + ts_file = _nid(result, "lead.ts", "lead.ts") + imports = { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge["relation"] in ("imports", "imports_from") + } + caller_file = _nid(result, "caller.py", "caller.py") + assert (caller_file, py_file) in imports, \ + "the Python import edge dangled on the pre-disambiguation alias" + assert (caller_file, ts_file) not in imports, \ + "a Python import must never resolve to a TypeScript file" + for source, target in imports: + assert target in node_ids, f"import edge dangles: {source} -> {target}"