From 4a9613f7267920e21344b9006b491e2dbc603cb2 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sat, 1 Aug 2026 12:21:04 +0100 Subject: [PATCH] fix(extract): C# inline-declared + partial-class receivers, Kotlin anonymous-object members (#2346, #2332, #2347) - C# receivers declared inline via out-var / is / case / switch-arm patterns are now typed into the per-method table, so their member calls resolve (#2346). - partial class halves across files now merge to one class node (new _merge_csharp_partial_class_nodes pass, mirroring the Swift-extension merge), so cross-half member calls resolve instead of splitting the class (#2332). - Kotlin anonymous-object (object : Foo {}) members now get nodes, contains/ implements edges, and their calls resolve (#2347). All in-corpus only, never a wrong edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 88 ++++++++++++++++ graphify/extractors/engine.py | 122 +++++++++++++++++++++- tests/test_csharp_member_calls.py | 138 ++++++++++++++++++++++++ tests/test_csharp_partial_classes.py | 151 +++++++++++++++++++++++++++ tests/test_kotlin_object_literal.py | 148 ++++++++++++++++++++++++++ 5 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 tests/test_csharp_partial_classes.py create mode 100644 tests/test_kotlin_object_literal.py diff --git a/graphify/extract.py b/graphify/extract.py index 300a5961..30f31d32 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2082,6 +2082,93 @@ def _merge_swift_extensions( all_edges[:] = rewritten +def _merge_csharp_partial_class_nodes( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Collapse C# `partial class Foo` halves split across files into ONE node + (#2332). + + The per-file extractor mints class ids with the file stem, so each file + declaring `partial class Foo` produces its own `Foo` node: members split + across the halves and cross-half calls don't resolve (two candidate types + make every receiver-typed lookup bail as ambiguous). Group partial-stamped + type nodes by (namespace, label) — same-named types in different namespaces + are distinct types, non-partial same-named types are separate declarations, + and nested partials are excluded (their ids omit the enclosing type, so a + same-named nested pair under different outers would falsely merge). The + canonical node is the sorted-first half by (source_file, source_location, + id); every edge endpoint and raw-call caller is remapped onto it. Member + node ids are left untouched — only the class-level nodes collapse. + + Must run BEFORE _disambiguate_colliding_node_ids / _rewire_unique_stub_nodes / + _resolve_csharp_type_references and the resolver registry, so every later + pass sees one definition per partial type. + """ + groups: dict[tuple[str, str], list[dict]] = {} + for n in all_nodes: + if not str(n.get("source_file", "")).endswith(".cs"): + continue + if n.get("file_type") != "code": + continue + md = n.get("metadata") or {} + if not md.get("is_partial") or md.get("is_nested_type"): + continue + label = n.get("label") + if not label: + continue + groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n) + + remap: dict[str, str] = {} + for members in groups.values(): + if len(members) < 2: + continue + members.sort(key=lambda n: ( + str(n.get("source_file", "")), + str(n.get("source_location", "")), + str(n.get("id", "")), + )) + canonical_nid = members[0]["id"] + for other in members[1:]: + if other["id"] != canonical_nid: + remap[other["id"]] = canonical_nid + + if not remap: + return + + all_nodes[:] = [n for n in all_nodes if n.get("id") not in remap] + + # Each half's file keeps a `contains` edge to the canonical type — multiple + # files containing one node is the intended shape (same as the Swift + # extension merge): the type owns the members, the files own their slice. + # Self-loops are dropped, exact duplicates dedup. + rewritten: list[dict] = [] + seen_keys: set[tuple] = set() + for e in all_edges: + src = remap.get(e.get("source"), e.get("source")) + tgt = remap.get(e.get("target"), e.get("target")) + if src == tgt: + continue + e["source"] = src + e["target"] = tgt + key = (src, tgt, e.get("relation"), e.get("source_file"), e.get("source_location")) + if key in seen_keys: + continue + seen_keys.add(key) + rewritten.append(e) + all_edges[:] = rewritten + + # raw_calls carry caller_nid, consumed by the member-call resolvers and the + # cross-file call pass after this merge — a top-level raw call whose caller + # is a merged-away class half must follow it onto the canonical node. + for result in per_file: + for rc in result.get("raw_calls", []) or []: + cn = rc.get("caller_nid") + if cn in remap: + rc["caller_nid"] = remap[cn] + + def _resolve_swift_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -5088,6 +5175,7 @@ def extract( # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) _merge_swift_extensions(per_file, all_nodes, all_edges) + _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) _canonicalize_csharp_namespace_nodes(all_nodes, all_edges) # PHP namespace/use disambiguation must run BEFORE the unique-stub rewire: diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 7f25d02a..3e2ac823 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1547,6 +1547,23 @@ def _csharp_method_receiver_types( ) break bind(_read_text(name_node, source), type_name) + elif node.type in ("declaration_expression", "declaration_pattern"): + # #2346: inline-declared receivers. `out Sect s` is a + # declaration_expression; `is Leaf lf`, `is not Node nd`, + # `case Twig tw:` and a switch-arm `Stem st =>` are + # declaration_patterns — all carry `type` + `name` fields and + # bind the name for the rest of the method. `out var v` + # (implicit_type) yields None from _csharp_receiver_type_name + # and poisons the name method-locally, matching the + # untypable-local rule above (no guess). + name_node = node.child_by_field_name("name") + if name_node is not None and name_node.type == "identifier": + bind( + _read_text(name_node, source), + _csharp_receiver_type_name( + node.child_by_field_name("type"), source + ), + ) stack.extend(node.children) table = dict(field_types) @@ -2470,8 +2487,25 @@ def _extract_generic( class_nid = _make_id(stem, ".".join(namespace_stack), class_name) line = node.start_point[0] + 1 metadata = None - if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: - metadata = {"is_nested_type": True} + if config.ts_module == "tree_sitter_c_sharp": + if parent_class_nid: + metadata = {"is_nested_type": True} + # #2332: `partial class Foo` split across files mints one node + # per file (the id carries the file stem). Stamp the halves so + # the corpus-level _merge_csharp_partial_class_nodes pass can + # collapse them onto one canonical node. Grammar: `partial` is + # a `modifier` direct child of the type declaration. + if t in ( + "class_declaration", + "struct_declaration", + "interface_declaration", + "record_declaration", + ) and any( + c.type == "modifier" and _read_text(c, source) == "partial" + for c in node.children + ): + metadata = dict(metadata or {}) + metadata["is_partial"] = True add_node(class_nid, class_name, line, metadata=metadata) callable_def_nids.add(class_nid) # a class is callable (constructor) callable_class_nids.add(class_nid) # ...but only via its constructor (#2137) @@ -3688,6 +3722,90 @@ def _extract_generic( if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: csharp_method_scopes[id(body)] = (node, parent_class_nid) function_bodies.append((func_nid, body)) + if config.ts_module == "tree_sitter_kotlin": + # #2347: Kotlin anonymous objects (`object : Foo { … }`, + # node type `object_literal`). The function branch never + # recurses into bodies and object_literal is not a + # class_type, so the literal's members (and every call + # inside them) got no nodes at all. Scan this body for + # object_literal descendants — without crossing a nested + # function_declaration boundary (a local fun's literals + # are not this function's) and without descending into a + # found literal — then emit an owner node per literal and + # walk its class_body exactly like the class branch, so + # members and their calls flow through the normal + # machinery (walk_calls' function_boundary_types already + # keep the enclosing function from absorbing them). + _kt_literals = [] + _kt_stack = list(body.children) + while _kt_stack: + _kt_node = _kt_stack.pop() + if _kt_node.type == "function_declaration": + continue + if _kt_node.type == "object_literal": + _kt_literals.append(_kt_node) + continue + _kt_stack.extend(_kt_node.children) + _kt_literals.sort(key=lambda n: n.start_byte) + for lit in _kt_literals: + lit_line = lit.start_point[0] + 1 + # Supertypes from the literal's delegation_specifiers, + # shaped like the Kotlin class-branch handling: + # constructor_invocation -> inherits, bare user_type + # (or explicit_delegation) -> implements. + lit_bases: list[tuple[str, str]] = [] + for dchild in lit.children: + if dchild.type != "delegation_specifiers": + continue + for spec in dchild.children: + if spec.type != "delegation_specifier": + continue + relation = "implements" + user_type_node = None + for sub in spec.children: + if sub.type == "constructor_invocation": + relation = "inherits" + for inner in sub.children: + if inner.type == "user_type": + user_type_node = inner + break + break + if sub.type == "user_type": + user_type_node = sub + break + if sub.type == "explicit_delegation": + for inner in sub.children: + if inner.type == "user_type": + user_type_node = inner + break + break + base = _kotlin_user_type_name( + user_type_node, source + ) + if base: + lit_bases.append((base, relation)) + obj_label = ( + lit_bases[0][0] if lit_bases + else f"object@L{lit_line}" + ) + obj_nid = _make_id( + func_nid, f"object:{obj_label}", f"L{lit_line}" + ) + add_node(obj_nid, obj_label, lit_line) + add_edge(func_nid, obj_nid, "contains", lit_line) + callable_def_nids.add(obj_nid) + callable_class_nids.add(obj_nid) + for base, relation in lit_bases: + base_nid = ensure_named_node(base, lit_line) + if base_nid != obj_nid: + add_edge(obj_nid, base_nid, relation, lit_line) + lit_body = next( + (c for c in lit.children if c.type == "class_body"), + None, + ) + if lit_body is not None: + for child in lit_body.children: + walk(child, parent_class_nid=obj_nid) return # JS/TS arrow functions and C# namespaces — language-specific extra handling diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index b29dea01..a27f2e15 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -452,3 +452,141 @@ def test_method_chained_off_new_expression_resolves(tmp_path): "run" in s and label.get(t) == ".Combine()" for s, t in calls ), f"chained call off new Merger(...) not captured: {[(s, label.get(t)) for s, t in calls]}" + + +# ── Inline-declared receivers (#2346) ───────────────────────────────────────── +# `out T x`, `is T x`, `is not T x`, `case T x:` and switch-arm `T x =>` all +# introduce a binding the receiver table never saw — `x.Method()` on any of +# them silently dropped the edge. `out var x` stays untypable (poison, never a +# guess), and the existing bind/poison conflict rules apply unchanged. + + +_TWO_GO = ( + "public class Sect { public bool Go() => true; }\n" + "public class Twig { public bool Go() => false; }\n" +) + + +def test_out_declared_receiver_resolves(tmp_path): + """`b.TryGet(out Sect s)` binds s: Sect — `s.Go()` resolves to Sect.Go.""" + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class Box { public bool TryGet(out Sect s) { s = new Sect(); return true; } }\n" + "public class R {\n" + " public bool A(Box b) { if (b.TryGet(out Sect s)) { return s.Go(); } return false; }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) in calls, "out-declared receiver must resolve to its declared type" + assert (r_a, twig_go) not in calls + + +def test_out_var_receiver_stays_unbound(tmp_path): + """`out var v` carries no type name — `v.Go()` must emit NO edge (poison, + not a guess).""" + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class Box { public bool TryGet(out Sect s) { s = new Sect(); return true; } }\n" + "public class R {\n" + " public bool B(Box b) { b.TryGet(out var v); return v.Go(); }\n" + "}\n" + ) + }) + assert not any("_r_b" in s and "go" in t.lower() for s, t in calls), \ + "`out var` receiver is untypable — no edge to either Go()" + + +def test_is_pattern_receiver_resolves(tmp_path): + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class R {\n" + " public bool A(object o) { if (o is Sect s) { return s.Go(); } return false; }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) in calls, "is-pattern receiver must resolve" + assert (r_a, twig_go) not in calls + + +def test_is_not_pattern_receiver_resolves(tmp_path): + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class R {\n" + " public bool A(object o) { if (o is not Sect s) { return false; } return s.Go(); }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) in calls, "is-not-pattern receiver must resolve" + assert (r_a, twig_go) not in calls + + +def test_case_pattern_receiver_resolves(tmp_path): + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class R {\n" + " public bool A(object o) {\n" + " switch (o) { case Sect s: return s.Go(); }\n" + " return false;\n" + " }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) in calls, "case-pattern receiver must resolve" + assert (r_a, twig_go) not in calls + + +def test_switch_arm_pattern_receiver_resolves(tmp_path): + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class R {\n" + " public bool A(object o) {\n" + " return o switch { Sect s => s.Go(), _ => false };\n" + " }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) in calls, "switch-expression-arm receiver must resolve" + assert (r_a, twig_go) not in calls + + +def test_sibling_pattern_rebind_conflict_poisons(tmp_path): + """The same name pattern-bound to two DIFFERENT types in one method: raw + calls carry no lexical position, so neither candidate may win — no edge.""" + calls, r = _calls(tmp_path, { + "S.cs": ( + _TWO_GO + + "public class R {\n" + " public bool A(object o) {\n" + " if (o is Sect x) { return x.Go(); }\n" + " if (o is Twig x) { return x.Go(); }\n" + " return false;\n" + " }\n" + "}\n" + ) + }) + r_a = _find(r, ".A()", "_r_a") + sect_go = _find(r, ".Go()", "sect") + twig_go = _find(r, ".Go()", "twig") + assert (r_a, sect_go) not in calls, "conflicting pattern bindings must poison the name" + assert (r_a, twig_go) not in calls, "conflicting pattern bindings must poison the name" diff --git a/tests/test_csharp_partial_classes.py b/tests/test_csharp_partial_classes.py new file mode 100644 index 00000000..39a2ba9c --- /dev/null +++ b/tests/test_csharp_partial_classes.py @@ -0,0 +1,151 @@ +"""C# partial classes split across files (#2332). + +`partial class Foo` declared in two files minted TWO class nodes (the node id +carries the per-file stem), so the type's members split across the halves and +every receiver-typed lookup on `Foo` bailed as ambiguous — cross-half calls +never resolved. `_merge_csharp_partial_class_nodes` collapses the halves onto +one canonical node, keyed by (namespace, label); same-named types in other +namespaces, non-partial declarations, and nested partial types are left alone. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache") + finally: + os.chdir(old) + calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"} + return calls, r + + +def _nodes_labeled(r, label): + return [n for n in r["nodes"] if n["label"] == label] + + +def _find(r, label, id_contains): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +_HALVES = { + "FooPartA.cs": ( + "namespace App {\n" + " public partial class Foo {\n" + " public void Alpha() {}\n" + " }\n" + "}\n" + ), + "FooPartB.cs": ( + "namespace App {\n" + " public partial class Foo {\n" + " public void Beta() { Alpha(); }\n" + " }\n" + "}\n" + ), +} + + +def test_partial_halves_merge_to_one_class_node(tmp_path): + calls, r = _extract(tmp_path, _HALVES) + foos = _nodes_labeled(r, "Foo") + assert len(foos) == 1, f"partial halves must collapse to ONE class node, got {foos}" + # Both halves' members hang off the canonical node. + foo_nid = foos[0]["id"] + methods = {e["target"] for e in r["edges"] + if e["relation"] == "method" and e["source"] == foo_nid} + labels = {n["label"] for n in r["nodes"] if n["id"] in methods} + assert {".Alpha()", ".Beta()"} <= labels, \ + f"canonical Foo must own members from BOTH halves, got {labels}" + + +def test_cross_file_caller_resolves_into_both_halves(tmp_path): + calls, r = _extract(tmp_path, { + **_HALVES, + "Caller.cs": ( + "namespace App {\n" + " public class Caller {\n" + " public void Run(Foo f) { f.Alpha(); f.Beta(); }\n" + " }\n" + "}\n" + ), + }) + run = _find(r, ".Run()", "caller") + alpha = _find(r, ".Alpha()", "foo") + beta = _find(r, ".Beta()", "foo") + assert (run, alpha) in calls, "receiver-typed call into half A must resolve" + assert (run, beta) in calls, "receiver-typed call into half B must resolve" + + +def test_cross_half_unqualified_in_class_call_resolves(tmp_path): + """Beta() in half B calls Alpha() declared in half A — an in-class + unqualified call that spans the file boundary.""" + calls, r = _extract(tmp_path, _HALVES) + alpha = _find(r, ".Alpha()", "foo") + beta = _find(r, ".Beta()", "foo") + assert (beta, alpha) in calls, "cross-half unqualified in-class call must resolve" + + +def test_same_name_different_namespace_not_merged(tmp_path): + calls, r = _extract(tmp_path, { + "A.cs": ( + "namespace Alpha { public partial class Foo { public void FromA() {} } }\n" + ), + "B.cs": ( + "namespace Beta { public partial class Foo { public void FromB() {} } }\n" + ), + }) + foos = _nodes_labeled(r, "Foo") + assert len(foos) == 2, \ + f"same-named partials in DIFFERENT namespaces are distinct types: {foos}" + + +def test_non_partial_same_name_not_merged(tmp_path): + calls, r = _extract(tmp_path, { + "A.cs": ( + "namespace App { public partial class Foo { public void FromA() {} } }\n" + ), + "B.cs": ( + "namespace App { public class Foo { public void FromB() {} } }\n" + ), + }) + foos = _nodes_labeled(r, "Foo") + assert len(foos) == 2, \ + f"a non-partial declaration never merges with a partial half: {foos}" + + +def test_nested_partial_not_merged(tmp_path): + """Nested partial types are excluded: their ids omit the enclosing type + name, so same-named nested pairs would falsely merge across outers.""" + calls, r = _extract(tmp_path, { + "A.cs": ( + "namespace App {\n" + " public partial class Outer {\n" + " public partial class Inner { public void FromA() {} }\n" + " }\n" + "}\n" + ), + "B.cs": ( + "namespace App {\n" + " public partial class Outer {\n" + " public partial class Inner { public void FromB() {} }\n" + " }\n" + "}\n" + ), + }) + outers = _nodes_labeled(r, "Outer") + inners = _nodes_labeled(r, "Inner") + assert len(outers) == 1, "top-level partial halves still merge" + assert len(inners) == 2, \ + f"nested partial types must NOT merge (id has no outer qualifier): {inners}" diff --git a/tests/test_kotlin_object_literal.py b/tests/test_kotlin_object_literal.py new file mode 100644 index 00000000..de9ecb08 --- /dev/null +++ b/tests/test_kotlin_object_literal.py @@ -0,0 +1,148 @@ +"""Kotlin anonymous-object members (#2347). + +`object : Foo { ... }` (node type `object_literal`) got no nodes at all: +object_literal is not a class_type (it has no name), and the function branch +never recurses into function bodies — so the literal's members AND every call +inside them were silently dropped. The extractor now emits an owner node per +literal (labeled after its first supertype), a `contains` edge from the +enclosing function, the implements/inherits edge to the supertype, and walks +the literal's class_body like a class so members flow normally. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache") + finally: + os.chdir(old) + return r + + +def _edges(r, relation): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def _find(r, label, id_contains=""): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +_REGISTRY = { + "Registry.kt": ( + "interface EventListener {\n" + " fun process(e: Event)\n" + "}\n" + "class Event\n" + "class Registry {\n" + " fun register() {\n" + " val listener = object : EventListener {\n" + " fun process(e: Event) { handleSomething(e) }\n" + " fun handleSomething(e: Event) { }\n" + " }\n" + " }\n" + "}\n" + ), +} + + +def test_object_literal_members_get_nodes_and_method_edges(tmp_path): + r = _extract(tmp_path, _REGISTRY) + obj_nid = _find(r, "EventListener", "object") + process = _find(r, ".process()", "object") + handle = _find(r, ".handleSomething()", "object") + methods = _edges(r, "method") + assert (obj_nid, process) in methods, "anonymous-object member must hang off the owner" + assert (obj_nid, handle) in methods, "anonymous-object member must hang off the owner" + # The owner itself is contained by the enclosing function. + register = _find(r, ".register()", "registry") + assert (register, obj_nid) in _edges(r, "contains"), \ + "the enclosing function contains the anonymous object" + + +def test_object_literal_implements_supertype(tmp_path): + r = _extract(tmp_path, _REGISTRY) + obj_nid = _find(r, "EventListener", "object") + iface = next(n["id"] for n in r["nodes"] + if n["label"] == "EventListener" and "object" not in n["id"]) + assert (obj_nid, iface) in _edges(r, "implements"), \ + "object : EventListener must implement the in-corpus interface" + + +def test_object_literal_member_calls_sibling_member(tmp_path): + r = _extract(tmp_path, _REGISTRY) + process = _find(r, ".process()", "object") + handle = _find(r, ".handleSomething()", "object") + assert (process, handle) in _edges(r, "calls"), \ + "a call between two anonymous-object members must resolve" + + +def test_two_object_literals_in_one_function_do_not_collide(tmp_path): + r = _extract(tmp_path, { + "Make.kt": ( + "interface Alpha {\n" + " fun one()\n" + "}\n" + "interface Beta {\n" + " fun two()\n" + "}\n" + "class Maker {\n" + " fun make() {\n" + " val a = object : Alpha {\n" + " fun one() { }\n" + " }\n" + " val b = object : Beta {\n" + " fun two() { }\n" + " }\n" + " }\n" + "}\n" + ), + }) + obj_a = _find(r, "Alpha", "object") + obj_b = _find(r, "Beta", "object") + assert obj_a != obj_b + methods = _edges(r, "method") + one = _find(r, ".one()", "object") + two = _find(r, ".two()", "object") + assert (obj_a, one) in methods + assert (obj_b, two) in methods + assert (obj_a, two) not in methods, "members must not leak across sibling literals" + assert (obj_b, one) not in methods, "members must not leak across sibling literals" + + +def test_named_object_and_plain_class_unchanged(tmp_path): + """Keep-the-bar: named `object` declarations and plain classes extract + exactly as before — the literal handling is purely additive.""" + r = _extract(tmp_path, { + "Mix.kt": ( + "object Singleton {\n" + " fun go() { }\n" + "}\n" + "class Plain {\n" + " fun run() { go2() }\n" + " fun go2() { }\n" + "}\n" + ), + }) + singleton = _find(r, "Singleton") + plain = _find(r, "Plain") + methods = _edges(r, "method") + go = _find(r, ".go()") + run = _find(r, ".run()") + go2 = _find(r, ".go2()") + assert (singleton, go) in methods + assert (plain, run) in methods and (plain, go2) in methods + assert (run, go2) in _edges(r, "calls") + assert not any("object" in n["id"] and n["label"].startswith("object@") + for n in r["nodes"]), "no phantom object-literal owner nodes"