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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-01 12:21:04 +01:00
co-authored by Claude Opus 4.8
parent 97656fae2d
commit 4a9613f726
5 changed files with 645 additions and 2 deletions
+88
View File
@@ -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:
+120 -2
View File
@@ -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
+138
View File
@@ -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"
+151
View File
@@ -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}"
+148
View File
@@ -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"