From 92d9197787ae8e11d600f46fc2ffebe8725e175c Mon Sep 17 00:00:00 2001 From: JensD-git <299119159+JensD-git@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:18:31 +0200 Subject: [PATCH] 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` 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) --- ARCHITECTURE.md | 6 + graphify/cache.py | 2 +- graphify/extract.py | 107 ++++++++++-- graphify/extractors/engine.py | 26 ++- tests/test_csharp_partial_classes.py | 246 +++++++++++++++++++++++++-- tests/test_csharp_type_resolution.py | 3 +- 6 files changed, 358 insertions(+), 32 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f69b39712..817472ac9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` 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 diff --git a/graphify/cache.py b/graphify/cache.py index f4cddebc0..df958c76e 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/graphify/extract.py b/graphify/extract.py index 6343c294d..1521fb236 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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` 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` 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 diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index cff0c0687..9e31495c6 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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 ( diff --git a/tests/test_csharp_partial_classes.py b/tests/test_csharp_partial_classes.py index 84b227164..8f7be508f 100644 --- a/tests/test_csharp_partial_classes.py +++ b/tests/test_csharp_partial_classes.py @@ -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` 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 { 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 {" + " 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` 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 { 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()"]] diff --git a/tests/test_csharp_type_resolution.py b/tests/test_csharp_type_resolution.py index b29638dda..79a65b798 100644 --- a/tests/test_csharp_type_resolution.py +++ b/tests/test_csharp_type_resolution.py @@ -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