fix(csharp): merge nested and generic partial types by their full type path (#4363)

The partial merge grouped halves by (namespace, label) and skipped nested
types, because nested ids omit the enclosing type. A nested partial type split
across files stayed split, and `Box` / `Box<T>` partials were folded into one
node although they are distinct types (#4249).

- The C# extractor records each partial type's path inside its namespace,
  enclosing types first, each with its arity (`Outer`1.Inner`), as
  metadata.type_path; the merge keys on (assembly, namespace, type_path).
- The cross-file call pass binds a C# unqualified call to the one candidate
  that is a member of the caller's own type, so a call into another half of a
  partial type is not lost to a same-named method elsewhere (#4252 does this
  in-file only). Ties fall through unchanged.
- After C# type resolution, a merged partial type drops a placeholder base
  that sits next to a real base of the same name; one half resolving a sibling
  nested base and the other not left two bases, and base-chain lookups failed.
- _AST_CACHE_SCHEMA 8 -> 9, so cached C# results without type_path are
  re-extracted.

Fixes #4363.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JensD-gitandClaude Opus 5.5 committed 2026-10-12 14:18:31 +02:00
1 parent a5ba60ddf6
commit 92d9197787
6 files changed
+358 -32

No files matched your search

+6
View File
@@ -118,6 +118,12 @@ classes under different parents retain separate definitions, methods, and
containment edges; top-level class ids are unchanged. AST cache schema 7 prevents
older cached definitions from retaining the collapsed identities.
C# partial types merge across files by their path inside the namespace,
enclosing types first and each with its arity (`` Outer`1.Inner ``), within one
assembly. Nested halves merge only under the same enclosing chain, and `Foo` and
`Foo<T>` stay separate types. AST cache schema 9 retires cached C# results that
lack this path.
R extraction preserves symbol-only bindings such as `%||%` as separate nodes.
When normal ID normalization removes an entire binding name, its UTF-8 bytes
provide a deterministic operator suffix within the same lexical owner. Its double
+1 -1
View File
@@ -37,7 +37,7 @@ except Exception:
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 8 # Rust `use` groups: one imports_from edge per binding; prior (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
_AST_CACHE_SCHEMA = 9 # C# partial types carry `type_path`, the nested/generic partial merge key; prior (8): Rust `use` groups: one imports_from edge per binding; (7): Python opaque-base/super(args) raw-call markers and nested-class enclosing ids.
# Per-process memo: distributions() scans site-packages metadata, which is
+96 -11
View File
@@ -3844,13 +3844,15 @@ def _merge_csharp_partial_class_nodes(
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 (assembly, 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 `partial` keyword only fuses declarations compiled into
the SAME assembly, so the key also carries the nearest ancestor directory
holding a `*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under
type nodes by (assembly, namespace, type path) — same-named types in
different namespaces are distinct types, non-partial same-named types are
separate declarations. The type path carries the enclosing types and each
arity (`` Outer`1.Inner ``): nested ids omit the enclosing type, so the
path is what keeps same-named nested types under different outers apart,
and the arity keeps `Foo` and `Foo<T>` apart. The `partial` keyword only
fuses declarations compiled into the SAME assembly, so the key also
carries the nearest ancestor directory holding a
`*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under
different project dirs are genuinely distinct types and stay apart. Halves
with NO project file on any ancestor (up to the scan root) all key to ""
and still merge together, so single-project/snippet corpora behave exactly
@@ -3871,12 +3873,21 @@ def _merge_csharp_partial_class_nodes(
if n.get("file_type") != "code":
continue
md = n.get("metadata") or {}
if not md.get("is_partial") or md.get("is_nested_type"):
if not md.get("is_partial"):
continue
label = n.get("label")
if not label:
# `type_path` names the type with its enclosing types and arities
# (`` Outer`1.Inner ``), so nested halves only meet under the same
# outer and `Foo` / `Foo<T>` stay apart (#4249). A nested type whose
# path is unknown stays unmerged: its label alone would join
# same-named types under different outers.
type_path = md.get("type_path")
if not type_path:
if md.get("is_nested_type"):
continue
type_path = n.get("label")
if not type_path:
continue
groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n)
groups.setdefault((str(md.get("namespace", "")), str(type_path)), []).append(n)
if not any(len(members) >= 2 for members in groups.values()):
return
@@ -4015,6 +4026,52 @@ def _merge_csharp_partial_class_nodes(
rc["caller_nid"] = remap[cn]
def _drop_shadowed_csharp_partial_bases(all_nodes: list[dict], all_edges: list[dict]) -> None:
"""Drop a merged partial type's placeholder base when a real base of the
same name sits next to it.
Every half of a partial type may repeat the base list, and C# requires
the halves to agree. Each half's base name is resolved in its own file's
scope, so a half whose file the resolver cannot see the base from (a
sibling nested base without `using static` for its holder, say) leaves a
placeholder edge while another half binds the real type. After the merge the type then has two same-named bases, and
lookups through the base chain bail as ambiguous. The placeholder is the
unresolved spelling of the resolved base, so it goes.
"""
nodes_by_id = {str(n.get("id", "")): n for n in all_nodes}
def _is_partial_cs(nid: str) -> bool:
n = nodes_by_id.get(nid) or {}
return (
str(n.get("source_file", "")).endswith(".cs")
and bool((n.get("metadata") or {}).get("is_partial"))
)
real_bases: dict[str, set[str]] = {}
for e in all_edges:
if e.get("relation") not in ("inherits", "implements"):
continue
src = str(e.get("source", ""))
if not _is_partial_cs(src):
continue
tgt = nodes_by_id.get(str(e.get("target", ""))) or {}
if tgt.get("source_file"):
real_bases.setdefault(src, set()).add(str(tgt.get("label", "")))
if not real_bases:
return
def _shadowed(e: dict) -> bool:
if e.get("relation") not in ("inherits", "implements"):
return False
names = real_bases.get(str(e.get("source", "")))
if not names:
return False
tgt = nodes_by_id.get(str(e.get("target", ""))) or {}
return not tgt.get("source_file") and str(tgt.get("label", "")) in names
all_edges[:] = [e for e in all_edges if not _shadowed(e)]
UNRESOLVED_CALLS_KEY = "unresolved_calls"
_MAX_PARKED_CALLS_PER_NODE = 64
@@ -8854,6 +8911,7 @@ def extract(
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc)
_drop_shadowed_csharp_partial_bases(all_nodes, all_edges)
# Cross-file Bash source-backed call resolution: a call to a function defined
# in a file this one `source`s is left unresolved by the per-file extractor
@@ -9058,6 +9116,7 @@ def extract(
str(n.get("label", ""))
)
_csharp_stub_resolver: CsharpNameResolver | None = None # built on first use
_csharp_method_owner: dict[str, str] | None = None # method nid -> type nid, built on first use
for rc in all_raw_calls:
if rc.get("_ambiguous_python_import"):
continue
@@ -9205,9 +9264,35 @@ def extract(
or (candidate_file_nid is not None and candidate_file_nid in imported_modules)
)
# C#: an unqualified call binds to a member of the caller's own type
# first. The extractor already does that in-file (#4252), but a partial
# type's halves live in different files: after the merge they are one
# type, and a same-named method elsewhere must not hide the other half's
# member. Exactly one own-type candidate binds; a tie (overloads) falls
# through to the generic tie-breakers unchanged.
own_type_matches: list[str] = []
if (
len(candidates) > 1
and rc.get("lang") == "csharp"
and not rc.get("csharp_new")
):
if _csharp_method_owner is None:
_csharp_method_owner = {
e["target"]: e["source"] for e in all_edges
if e.get("relation") == "method"
}
caller_owner = _csharp_method_owner.get(rc.get("caller_nid", ""))
if caller_owner is not None:
own_type_matches = [
c for c in candidates if _csharp_method_owner.get(c) == caller_owner
]
if len(candidates) == 1:
tgt = candidates[0]
has_import_evidence = go_exact_import or _has_import_evidence(tgt)
elif len(own_type_matches) == 1:
tgt = own_type_matches[0]
has_import_evidence = True
else:
# Ambiguous name (defined in 2+ files). Don't bail outright (#1219):
# if the caller has explicit import evidence pointing at exactly one
+24 -2
View File
@@ -4750,6 +4750,10 @@ def _extract_generic(
# extensions don't (file stem is part of the id), so they're collected here
# for a corpus-level merge after every file has been parsed.
swift_extensions: list[dict] = []
# C# type id -> its name within the namespace, enclosing types first
# (`` Outer`1.Inner ``). Partial types carry it as `type_path` so the
# corpus-level partial merge can key nested and generic halves exactly.
csharp_type_paths: dict[str, str] = {}
# #1356: call expressions in property/field initializers (e.g.
# `let vm = VM()`) live outside function bodies, so the call-walk never
# reaches them. Collect (owner_nid, call_node) here and walk them too.
@@ -4997,6 +5001,21 @@ def _extract_generic(
config.ts_module == "tree_sitter_ruby" and class_nid in seen_ids
)
if config.ts_module == "tree_sitter_c_sharp":
# The type's name within its namespace, enclosing types first,
# each with its arity (`` Outer`1.Inner ``). The id omits the
# enclosing types, so this is what tells two same-named nested
# types under different outers apart. None when an enclosing
# type's path is unknown.
if parent_class_nid:
_parent_path = csharp_type_paths.get(parent_class_nid)
_type_path = (
f"{_parent_path}.{class_name_for_id}"
if _parent_path is not None else None
)
else:
_type_path = class_name_for_id
if _type_path is not None:
csharp_type_paths.setdefault(class_nid, _type_path)
if parent_class_nid:
metadata = {"is_nested_type": True}
# #2332: `partial class Foo` split across files mints one node
@@ -5013,8 +5032,11 @@ def _extract_generic(
c.type == "modifier" and _read_text(c, source) == "partial"
for c in node.children
):
metadata = dict(metadata or {})
metadata["is_partial"] = True
partial_metadata: dict[str, object] = dict(metadata or {})
partial_metadata["is_partial"] = True
if _type_path is not None:
partial_metadata["type_path"] = _type_path
metadata = partial_metadata
if config.ts_module == "tree_sitter_ruby":
ruby_body = _find_body(node, config)
if ruby_absolute_declaration or ruby_reopened or (
+229 -17
View File
@@ -128,30 +128,166 @@ def test_non_partial_same_name_not_merged(tmp_path):
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."""
def _methods_of(r, nid):
label_of = {n["id"]: n["label"] for n in r["nodes"]}
return {label_of[e["target"]] for e in r["edges"]
if e["relation"] == "method" and e["source"] == nid}
_NESTED_HALVES = {
"A.cs": (
"namespace App {\n"
" public partial class Outer {\n"
" public partial class Inner { public void FromA() { FromB(); } }\n"
" }\n"
"}\n"
),
"B.cs": (
"namespace App {\n"
" public partial class Outer {\n"
" public partial class Inner { public virtual void FromB() {} }\n"
" }\n"
"}\n"
),
# Same member names on an unrelated type, so a name-only fallback cannot
# stand in for the merge.
"Decoy.cs": (
"namespace App {\n"
" public class Decoy { public void FromA() {} public virtual void FromB() {} }\n"
"}\n"
),
}
def test_nested_partial_halves_merge_under_the_same_outer(tmp_path):
"""A nested partial type split across files is one type, like a top-level
one: its halves collapse onto one node that owns both halves' members."""
calls, r = _extract(tmp_path, _NESTED_HALVES)
outers = _nodes_labeled(r, "Outer")
inners = _nodes_labeled(r, "Inner")
assert len(outers) == 1, "top-level partial halves still merge"
assert len(inners) == 1, f"nested partial halves must merge: {inners}"
assert _methods_of(r, inners[0]["id"]) == {".FromA()", ".FromB()"}
# Still contained by the outer type, not by a file.
assert any(e["relation"] == "contains" and e["source"] == outers[0]["id"]
and e["target"] == inners[0]["id"] for e in r["edges"])
def test_nested_partial_cross_half_unqualified_call_resolves(tmp_path):
calls, r = _extract(tmp_path, _NESTED_HALVES)
assert (_find(r, ".FromA()", "inner"), _find(r, ".FromB()", "inner")) in calls
def test_cross_half_unqualified_call_beats_a_same_named_method_elsewhere(tmp_path):
"""The in-file own-class binding (#4252) cannot see the other half of a
partial type; a same-named method on another type must not make the
cross-half call ambiguous."""
calls, r = _extract(tmp_path, {
"A.cs": (
**_HALVES,
"Decoy.cs": "namespace App { public class Decoy { public void Alpha() {} } }\n",
})
alpha = _find(r, ".Alpha()", "foo")
beta = _find(r, ".Beta()", "foo")
assert (beta, alpha) in calls
assert (beta, _find(r, ".Alpha()", "decoy")) not in calls
def test_nested_partial_base_call_resolves_into_the_other_half(tmp_path):
"""`base.M()` on a nested partial base whose M is declared in the half
that is not the merge's canonical node."""
calls, r = _extract(tmp_path, {
**_NESTED_HALVES,
"Derived.cs": (
"using static App.Outer;\n"
"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"
" public class Derived : Inner {\n"
" public override void FromB() { base.FromB(); }\n"
" }\n"
"}\n"
),
})
outers = _nodes_labeled(r, "Outer")
derived_from_b = _find(r, ".FromB()", "derived")
inner_from_b = _find(r, ".FromB()", "inner")
assert (derived_from_b, inner_from_b) in calls
def test_receiver_typed_calls_resolve_into_both_nested_halves(tmp_path):
calls, r = _extract(tmp_path, {
**_NESTED_HALVES,
"Caller.cs": (
"namespace App {\n"
" public class Caller {\n"
" public void Run(Outer.Inner i) { i.FromA(); i.FromB(); }\n"
" }\n"
"}\n"
),
})
run = _find(r, ".Run()", "caller")
assert (run, _find(r, ".FromA()", "inner")) in calls
assert (run, _find(r, ".FromB()", "inner")) in calls
def test_same_named_nested_partials_under_different_outers_not_merged(tmp_path):
"""Nested ids omit the enclosing type; the merge must still keep
`OuterA.Inner` and `OuterB.Inner` apart, each owning only its own halves."""
def _half(outer, method):
return (
"namespace App {\n"
f" public partial class {outer} {{\n"
f" public partial class Inner {{ public void {method}() {{}} }}\n"
" }\n"
"}\n"
)
calls, r = _extract(tmp_path, {
"A1.cs": _half("OuterA", "A1"), "A2.cs": _half("OuterA", "A2"),
"B1.cs": _half("OuterB", "B1"), "B2.cs": _half("OuterB", "B2"),
})
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}"
assert len(inners) == 2, f"one Inner per outer: {inners}"
owned = sorted(sorted(_methods_of(r, n["id"])) for n in inners)
assert owned == [[".A1()", ".A2()"], [".B1()", ".B2()"]]
def test_deeply_nested_partials_key_on_the_whole_enclosing_chain(tmp_path):
def _half(mid, method):
return (
"namespace App {\n"
" public partial class Outer {\n"
f" public partial class {mid} {{\n"
f" public partial class Inner {{ public void {method}() {{}} }}\n"
" }\n"
" }\n"
"}\n"
)
calls, r = _extract(tmp_path, {
"M1a.cs": _half("Mid1", "One"), "M1b.cs": _half("Mid1", "Two"),
"M2.cs": _half("Mid2", "Three"),
})
inners = _nodes_labeled(r, "Inner")
owned = sorted(sorted(_methods_of(r, n["id"])) for n in inners)
assert owned == [[".One()", ".Two()"], [".Three()"]]
def test_generic_and_non_generic_partials_not_merged(tmp_path):
"""`Foo` and `Foo<T>` are distinct types with distinct ids (#4249); the
merge must not fold them back together, top-level or as an outer."""
calls, r = _extract(tmp_path, {
"A.cs": "namespace App { public partial class Foo { public void A() {} } }\n",
"B.cs": "namespace App { public partial class Foo<T> { public void B() {} } }\n",
"C.cs": "namespace App { public partial class Foo { public void C() {} } }\n",
"D.cs": (
"namespace App { public partial class Box {"
" public partial class Item { public void D() {} } } }\n"
),
"E.cs": (
"namespace App { public partial class Box<T> {"
" public partial class Item { public void E() {} } } }\n"
),
})
foos = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Foo"))
assert foos == [[".A()", ".C()"], [".B()"]]
items = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Item"))
assert items == [[".D()"], [".E()"]]
_TWO_ASSEMBLIES = {
@@ -344,3 +480,79 @@ def test_merge_does_not_dedup_edges_it_never_rewrote(tmp_path):
]
got = _merge(tmp_path, nodes, edges)
assert got == {("x", "y", "calls", "EXTRACTED"), ("x", "y", "calls", "INFERRED")}
def test_base_named_by_two_halves_resolves_once(tmp_path):
"""Both halves name the base, a sibling nested type of the same holder.
Only the half that imports the holder (`using static`) resolves it; the
other leaves a placeholder. After the merge the type must not carry that
placeholder next to the real base — it made the base-chain lookup for
`base.Init()` look ambiguous and dropped the edge."""
calls, r = _extract(tmp_path, {
"Base.cs": (
"namespace App {\n"
" public static partial class Agents {\n"
" public class Base { public virtual void Init() {} }\n"
" }\n"
"}\n"
),
"DA.cs": (
"using static App.Agents;\n"
"namespace App {\n"
" public static partial class Agents {\n"
" public partial class Derived : Base { public override void Init() { base.Init(); } }\n"
" }\n"
"}\n"
),
"DB.cs": (
"namespace App {\n"
" public static partial class Agents {\n"
" public partial class Derived : Base { public void Other() {} }\n"
" }\n"
"}\n"
),
"Decoy.cs": "namespace App { public class Decoy { public virtual void Init() {} } }\n",
})
derived = _nodes_labeled(r, "Derived")
assert len(derived) == 1
by_id = {n["id"]: n for n in r["nodes"]}
bases = [by_id[e["target"]] for e in r["edges"]
if e["relation"] in ("inherits", "implements") and e["source"] == derived[0]["id"]]
assert [b["label"] for b in bases] == ["Base"], bases
assert bases[0].get("source_file"), "the one base must be the real Agents.Base"
assert (_find(r, ".Init()", "derived"), _find(r, ".Init()", "base")) in calls
def test_schema_bump_retires_csharp_entries_cached_without_type_path(tmp_path, monkeypatch):
"""An entry from schema 8 has no `type_path`; replayed, its nested halves
stay split and `Box` / `Box<T>` fold back into one node."""
import graphify.cache as cache_mod
from graphify.cache import save_cached
from graphify.extract import extract_csharp
files = {
**{k: v for k, v in _NESTED_HALVES.items() if k != "Decoy.cs"},
"Box.cs": "namespace App { public partial class Box { public void Plain() {} } }\n",
"BoxOfT.cs": "namespace App { public partial class Box<T> { public void Typed() {} } }\n",
}
paths = []
for name, body in files.items():
p = tmp_path / name
p.write_text(body)
paths.append(p)
current_schema = cache_mod._AST_CACHE_SCHEMA
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "same-version")
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", 8) # last schema without type_path
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
for p in paths:
stale = extract_csharp(p)
for node in stale["nodes"]:
(node.get("metadata") or {}).pop("type_path", None)
save_cached(p, stale, root=tmp_path, cache_root=tmp_path, kind="ast")
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema)
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
r = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
assert len(_nodes_labeled(r, "Inner")) == 1
boxes = sorted(sorted(_methods_of(r, n["id"])) for n in _nodes_labeled(r, "Box"))
assert boxes == [[".Plain()"], [".Typed()"]]
+2 -1
View File
@@ -1205,7 +1205,8 @@ def test_csharp_using_static_on_partial_holder_class(tmp_path: Path):
assert len(slice_a_defs) == 1
assert len(slice_b_defs) == 1
assert len(shared_defs) == 2, "nested partial SharedNested declarations remain unmerged in graph"
assert len(shared_defs) == 1, "nested partial SharedNested halves merge under the same outer"
assert shared_defs[0]["id"] in contains_targets
assert slice_a_defs[0]["id"] in contains_targets
assert slice_b_defs[0]["id"] in contains_targets