From 769851dfae9e58c8e7562eaac733787ab5d6d97e Mon Sep 17 00:00:00 2001 From: himanshupatro-334 Date: Thu, 24 Sep 2026 23:38:44 +0530 Subject: [PATCH] fix: exclude non-types from C# type resolution (cherry picked from commit dfe5a220b21c5bdb9e06141eec7d90be356f1b82) --- graphify/extractors/csharp.py | 23 ++++- tests/test_csharp_type_resolution.py | 137 +++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py index f823d64c3..213aafc30 100644 --- a/graphify/extractors/csharp.py +++ b/graphify/extractors/csharp.py @@ -16,8 +16,23 @@ from pathlib import Path from graphify.extractors.base import _make_id -def _build_csharp_type_def_index(all_nodes: list[dict]) -> dict[tuple[str, str], str]: +def _build_csharp_type_def_index( + all_nodes: list[dict], + all_edges: list[dict] | None = None, +) -> dict[tuple[str, str], str]: """Return deterministic ``(namespace, name) -> node_id`` C# type definitions.""" + contained: set[str] = set() + non_type_ids: set[str] = set() + if all_edges is not None: + for edge in all_edges: + rel = edge.get("relation") + tgt = edge.get("target") + if isinstance(tgt, str): + if rel == "contains": + contained.add(tgt) + elif rel in ("case_of", "defines", "method"): + non_type_ids.add(tgt) + candidates: dict[tuple[str, str], list[dict]] = {} for node in all_nodes: if node.get("type") == "namespace": @@ -31,6 +46,8 @@ def _build_csharp_type_def_index(all_nodes: list[dict]) -> dict[tuple[str, str], label = node.get("label") if not (isinstance(nid, str) and nid and isinstance(label, str) and label): continue + if all_edges is not None and (nid not in contained or nid in non_type_ids): + continue source_file = node.get("source_file") if ( not isinstance(source_file, str) @@ -105,7 +122,7 @@ def _resolve_cross_file_csharp_imports( if isinstance(label, str) and label and isinstance(nid, str) and nid: namespace_id_by_label.setdefault(label, nid) - type_def_index = _build_csharp_type_def_index(all_nodes) + type_def_index = _build_csharp_type_def_index(all_nodes, all_edges) if not namespace_id_by_label and not type_def_index: return @@ -184,7 +201,7 @@ class CsharpNameResolver: for node in all_nodes if isinstance(node.get("id"), str) and node.get("id") } - self.type_def_index = _build_csharp_type_def_index(all_nodes) + self.type_def_index = _build_csharp_type_def_index(all_nodes, all_edges) self.known_namespaces = { node.get("label") for node in all_nodes diff --git a/tests/test_csharp_type_resolution.py b/tests/test_csharp_type_resolution.py index 694a491d2..41144d8fd 100644 --- a/tests/test_csharp_type_resolution.py +++ b/tests/test_csharp_type_resolution.py @@ -567,3 +567,140 @@ def test_csharp_alias_using_scoped_to_its_block(tmp_path: Path): inh = {(e["source"], e["target"]) for e in result["edges"] if e.get("relation") == "inherits"} assert (good["id"], n_t["id"]) in inh, "Good must bind N.T via the in-block alias" assert (bad["id"], n_t["id"]) not in inh, "Bad (sibling block) must NOT see the alias" + + +def test_csharp_same_file_enum_member_does_not_preempt_class_inheritance(tmp_path: Path): + # Repro A (#3795): enum member Follow must not take the inheritance edge from class Follow. + f = _write( + tmp_path / "effects.cs", + "namespace Demo {\n" + " public enum EffectKind { Tween, Follow }\n" + " public abstract class Follow { }\n" + " public sealed class FollowFloat : Follow { }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + follow_class = next( + n for n in result["nodes"] + if n.get("label") == "Follow" and "effectkind" not in str(n.get("id", "")).lower() + ) + follow_member = next( + n for n in result["nodes"] + if n.get("label") == "Follow" and "effectkind" in str(n.get("id", "")).lower() + ) + followfloat = next(n for n in result["nodes"] if n.get("label") == "FollowFloat") + inh = {(e["source"], e["target"]) for e in result["edges"] if e.get("relation") == "inherits"} + + assert (followfloat["id"], follow_class["id"]) in inh, \ + "FollowFloat must inherit from class Follow" + assert (followfloat["id"], follow_member["id"]) not in inh, \ + "FollowFloat must NOT inherit from enum member EffectKind.Follow" + + +def test_csharp_enum_member_cannot_steal_external_parameter_type(tmp_path: Path): + # Repro B (#3795): Vector2 parameter must not bind to Channel.Vector2 enum member. + kinds = _write( + tmp_path / "Kinds.cs", + "namespace Demo {\n" + " public enum Channel { Vector2 }\n" + "}\n", + ) + mover = _write( + tmp_path / "Mover.cs", + "using UnityEngine;\n" + "namespace Demo {\n" + " public class Mover {\n" + " public void Move(Vector2 delta) { }\n" + " }\n" + "}\n", + ) + result = extract([kinds, mover], cache_root=tmp_path) + channel_v2 = next( + n for n in result["nodes"] + if n.get("label") == "Vector2" and n.get("source_file") + ) + move_refs = [ + e["target"] for e in result["edges"] + if e.get("relation") == "references" + and e.get("context") == "parameter_type" + and "move" in str(e.get("source", "")).lower() + ] + assert channel_v2["id"] not in move_refs, \ + "Mover.Move(Vector2) must NOT resolve to Channel.Vector2 enum member" + by_id = {n["id"]: n for n in result["nodes"]} + assert any( + by_id[tgt].get("label") == "Vector2" and not by_id[tgt].get("source_file") + for tgt in move_refs + ), f"Vector2 parameter must remain on an external stub: {move_refs}" + + +def test_csharp_property_cannot_steal_external_parameter_type(tmp_path: Path): + # Repro B (#3795): System.Type parameter must not bind to Node.Type property. + node = _write( + tmp_path / "Node.cs", + "namespace Demo {\n" + " public class Node {\n" + " public System.Type Type { get; set; }\n" + " }\n" + "}\n", + ) + mover = _write( + tmp_path / "Mover.cs", + "using System;\n" + "namespace Demo {\n" + " public class Mover {\n" + " public string Describe(Type t) => t.Name;\n" + " }\n" + "}\n", + ) + result = extract([node, mover], cache_root=tmp_path) + node_prop_type = next( + n for n in result["nodes"] + if n.get("label") == "Type" and n.get("source_file") + ) + describe_refs = [ + e["target"] for e in result["edges"] + if e.get("relation") == "references" + and e.get("context") == "parameter_type" + and "describe" in str(e.get("source", "")).lower() + ] + assert node_prop_type["id"] not in describe_refs, \ + "Mover.Describe(Type) must NOT resolve to Node.Type property" + by_id = {n["id"]: n for n in result["nodes"]} + assert any( + by_id[tgt].get("label") == "Type" and not by_id[tgt].get("source_file") + for tgt in describe_refs + ), f"Type parameter must remain on an external stub: {describe_refs}" + + +def test_csharp_cross_file_collision_resolved_by_kind_not_filename_order(tmp_path: Path): + # Enum member in A_Enum.cs (alphabetically first) must not steal inheritance from class in Follow.cs. + a_enum = _write( + tmp_path / "A_Enum.cs", + "namespace Demo {\n" + " public enum EffectKind { Tween, Follow }\n" + "}\n", + ) + follow = _write( + tmp_path / "Follow.cs", + "namespace Demo {\n" + " public abstract class Follow { }\n" + " public sealed class FollowFloat : Follow { }\n" + "}\n", + ) + result = extract([a_enum, follow], cache_root=tmp_path) + follow_class = next( + n for n in result["nodes"] + if n.get("label") == "Follow" and "follow.cs" in str(n.get("source_file", "")).lower() + ) + follow_member = next( + n for n in result["nodes"] + if n.get("label") == "Follow" and "a_enum.cs" in str(n.get("source_file", "")).lower() + ) + followfloat = next(n for n in result["nodes"] if n.get("label") == "FollowFloat") + inh = {(e["source"], e["target"]) for e in result["edges"] if e.get("relation") == "inherits"} + + assert (followfloat["id"], follow_class["id"]) in inh, \ + "FollowFloat must inherit from class Follow despite A_Enum.cs sorting first" + assert (followfloat["id"], follow_member["id"]) not in inh, \ + "FollowFloat must NOT inherit from enum member EffectKind.Follow in A_Enum.cs"