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"