diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py index 767560021..f03e11104 100644 --- a/graphify/extractors/csharp.py +++ b/graphify/extractors/csharp.py @@ -219,6 +219,9 @@ class CsharpNameResolver: self.namespace_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {} self.aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {} + self.static_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {} + self.nested_types_by_holder: dict[str, dict[str, str]] = {} + self._holder_nid_cache: dict[str, str | None] = {} for edge in all_edges: if edge.get("relation") != "imports": @@ -252,6 +255,44 @@ class CsharpNameResolver: bucket = self.aliases_by_file.setdefault(source_file, {}).setdefault(alias, []) if entry not in bucket: bucket.append(entry) + elif using_kind == "static": + entry = (target_fqn, scope_kind, scope_id) + bucket = self.static_usings_by_file.setdefault(source_file, []) + if entry not in bucket: + bucket.append(entry) + + nested_candidates: dict[tuple[str, str], dict[str, dict]] = {} + for edge in all_edges: + if edge.get("relation") != "contains": + continue + target_nid = edge.get("target") + if not isinstance(target_nid, str) or not target_nid: + continue + target_node = self.node_by_id.get(target_nid) + if not ( + target_node + and target_node.get("file_type") == "code" + and _metadata(target_node.get("metadata")).get("is_nested_type") is True + ): + continue + label = target_node.get("label") + if not isinstance(label, str) or not label: + continue + holder_nid = edge.get("source") + if not isinstance(holder_nid, str) or not holder_nid: + continue + nested_candidates.setdefault((holder_nid, label), {})[target_nid] = target_node + + for (holder_nid, label), node_dict in nested_candidates.items(): + canonical_nid = sorted( + node_dict.values(), + key=lambda n: ( + str(n.get("source_file") or ""), + str(n.get("source_location") or ""), + str(n.get("id") or ""), + ), + )[0]["id"] + self.nested_types_by_holder.setdefault(holder_nid, {})[label] = canonical_nid @staticmethod def _namespace(node: dict | None) -> str: @@ -299,6 +340,34 @@ class CsharpNameResolver: hits.add(hit) return next(iter(hits)) if len(hits) == 1 else None + def _resolve_holder_nid(self, target_fqn: str) -> str | None: + base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn)).strip() + if not base_fqn: + return None + if base_fqn in self._holder_nid_cache: + return self._holder_nid_cache[base_fqn] + + prefix, sep, name = base_fqn.rpartition(".") + if not sep: + nid = self.type_def_index.get(("", base_fqn)) + self._holder_nid_cache[base_fqn] = nid + return nid + + top_hit = self.type_def_index.get((prefix, name)) + if top_hit: + self._holder_nid_cache[base_fqn] = top_hit + return top_hit + + parent_nid = self._resolve_holder_nid(prefix) + if parent_nid: + nested_hit = self.nested_types_by_holder.get(parent_nid, {}).get(name) + if nested_hit: + self._holder_nid_cache[base_fqn] = nested_hit + return nested_hit + + self._holder_nid_cache[base_fqn] = None + return None + def resolve_type_name( self, label: str, source_node: dict, source_file: str ) -> tuple[str | None, bool]: @@ -319,6 +388,13 @@ class CsharpNameResolver: hit = self.type_def_index.get((namespace, label)) if hit and hit not in candidates: candidates.append(hit) + for target_fqn, scope_kind, scope_id in self.static_usings_by_file.get(source_file, []): + if self._using_in_scope(scope_kind, scope_id, source_node): + holder_nid = self._resolve_holder_nid(target_fqn) + if holder_nid: + hit = self.nested_types_by_holder.get(holder_nid, {}).get(label) + if hit and hit not in candidates: + candidates.append(hit) if len(candidates) == 1: return candidates[0], True return None, bool(candidates) diff --git a/tests/test_csharp_type_resolution.py b/tests/test_csharp_type_resolution.py index 6db6e8d8e..10437e8db 100644 --- a/tests/test_csharp_type_resolution.py +++ b/tests/test_csharp_type_resolution.py @@ -312,7 +312,7 @@ def test_csharp_nested_type_carries_metadata(tmp_path: Path): def test_csharp_cross_namespace_ref_not_misbound(tmp_path: Path): - # Use in namespace B must NOT bind to C.T (B never opens C) — even though T is globally unique. + # Use in namespace B must NOT bind to C.T (B never opens C) — even though T is globally unique. f = _write(tmp_path / "x.cs", "namespace B { class Use : T {} } namespace C { class T {} }\n") result = extract([f], cache_root=tmp_path) resolved = [t for t in _targets(result, "inherits", "T") if t.get("source_file")] @@ -320,7 +320,7 @@ def test_csharp_cross_namespace_ref_not_misbound(tmp_path: Path): def test_csharp_same_file_cross_namespace_ref_not_misbound(tmp_path: Path): - # Same file, T defined in B, Use in C : T — must NOT bind B.T (the eager same-file binding case). + # Same file, T defined in B, Use in C : T — must NOT bind B.T (the eager same-file binding case). f = _write(tmp_path / "x.cs", "namespace B { class T {} } namespace C { class Use : T {} }\n") result = extract([f], cache_root=tmp_path) resolved = [t for t in _targets(result, "inherits", "T") if t.get("source_file")] @@ -328,7 +328,7 @@ def test_csharp_same_file_cross_namespace_ref_not_misbound(tmp_path: Path): def test_csharp_inherits_does_not_bind_namespace_node(tmp_path: Path): - # class Use : Game where Game is a namespace — must NOT bind the namespace node (Chunk-1 review B1). + # class Use : Game where Game is a namespace — must NOT bind the namespace node (Chunk-1 review B1). f = _write(tmp_path / "y.cs", "namespace Game { class Damage {} class Use : Game {} }\n") result = extract([f], cache_root=tmp_path) nsids = {n["id"] for n in result["nodes"] if n.get("type") == "namespace"} @@ -819,3 +819,282 @@ def test_csharp_scope_metadata_does_not_depend_on_line_endings(tmp_path: Path): assert damage and all("core.cs" in d["source_file"] for d in damage), ( "the namespace-scoped alias must still resolve in a CRLF file" ) + + +# ------------------------------------------------------------------------- +# Issue #4216: C#: nested types imported with `using static` are resolved +# ------------------------------------------------------------------------- + + +def test_csharp_using_static_resolves_nested_type_reference(tmp_path: Path): + layer = _write( + tmp_path / "layer.cs", + "namespace Demo\n{\n public class Layer\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + "using static Demo.Layer;\nnamespace Consumer\n{\n public class Service\n {\n public Record value;\n }\n}\n", + ) + result = extract([layer, consumer], cache_root=tmp_path) + record_defs = _defs(result, "Record") + assert len(record_defs) == 1, "nested Record definition must exist" + record_def = record_defs[0] + + resolved = [t for t in _targets(result, "references", "Record") if t.get("source_file")] + assert resolved, "Record reference must resolve to the real definition via `using static Demo.Layer;`" + assert resolved[0]["id"] == record_def["id"] + + +def test_csharp_using_static_resolves_nested_constructor_call(tmp_path: Path): + layer = _write( + tmp_path / "layer.cs", + "namespace Demo\n{\n public class Layer\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + ( + "using static Demo.Layer;\n" + "namespace Consumer\n" + "{\n" + " public class Service\n" + " {\n" + " public void Run()\n" + " {\n" + " var r = new Record();\n" + " }\n" + " }\n" + "}\n" + ), + ) + result = extract([layer, consumer], cache_root=tmp_path) + record_defs = _defs(result, "Record") + assert len(record_defs) == 1, "nested Record definition must exist" + record_def = record_defs[0] + + resolved_calls = [t for t in _targets(result, "calls", "Record") if t.get("source_file")] + assert resolved_calls, "new Record() call must resolve to nested Record via `using static Demo.Layer;`" + assert resolved_calls[0]["id"] == record_def["id"] + + +def test_csharp_using_static_multi_level_nested_type(tmp_path: Path): + print_file = _write( + tmp_path / "print.cs", + ( + "namespace Demo\n" + "{\n" + " public class Print\n" + " {\n" + " public class Engine\n" + " {\n" + " public class Info {}\n" + " }\n" + " }\n" + "}\n" + ), + ) + consumer = _write( + tmp_path / "consumer.cs", + "using static Demo.Print.Engine;\nnamespace Consumer\n{\n public class App\n {\n public Info value;\n }\n}\n", + ) + result = extract([print_file, consumer], cache_root=tmp_path) + info_defs = _defs(result, "Info") + assert len(info_defs) == 1, "nested Info definition must exist" + info_def = info_defs[0] + + resolved = [t for t in _targets(result, "references", "Info") if t.get("source_file")] + assert resolved, "Info reference must resolve via multi-level `using static Demo.Print.Engine;`" + assert resolved[0]["id"] == info_def["id"] + + +def test_csharp_using_static_on_partial_holder_class(tmp_path: Path): + slice_a = _write( + tmp_path / "layer_a.cs", + ( + "namespace Demo\n" + "{\n" + " public partial class Layer\n" + " {\n" + " public class SliceA {}\n" + " public partial class SharedNested {}\n" + " }\n" + "}\n" + ), + ) + slice_b = _write( + tmp_path / "layer_b.cs", + ( + "namespace Demo\n" + "{\n" + " public partial class Layer\n" + " {\n" + " public class SliceB {}\n" + " public partial class SharedNested {}\n" + " }\n" + "}\n" + ), + ) + consumer = _write( + tmp_path / "consumer.cs", + ( + "using static Demo.Layer;\n" + "namespace Consumer\n" + "{\n" + " public class App\n" + " {\n" + " public SliceA a;\n" + " public SliceB b;\n" + " public SharedNested s;\n" + " }\n" + "}\n" + ), + ) + result = extract([slice_a, slice_b, consumer], cache_root=tmp_path) + + layer_defs = _defs(result, "Layer") + assert len(layer_defs) == 1, "top-level partial Layer should merge into one canonical node" + layer_nid = layer_defs[0]["id"] + + contains_targets = { + e["target"] for e in result["edges"] + if e.get("relation") == "contains" and e.get("source") == layer_nid + } + slice_a_defs = _defs(result, "SliceA") + slice_b_defs = _defs(result, "SliceB") + shared_defs = _defs(result, "SharedNested") + + 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 slice_a_defs[0]["id"] in contains_targets + assert slice_b_defs[0]["id"] in contains_targets + + resolved_a = [t for t in _targets(result, "references", "SliceA") if t.get("source_file")] + assert resolved_a and resolved_a[0]["id"] == slice_a_defs[0]["id"] + + resolved_b = [t for t in _targets(result, "references", "SliceB") if t.get("source_file")] + assert resolved_b and resolved_b[0]["id"] == slice_b_defs[0]["id"] + + resolved_s = [t for t in _targets(result, "references", "SharedNested") if t.get("source_file")] + assert resolved_s, "SharedNested reference must resolve via `using static Demo.Layer;`" + assert resolved_s[0]["id"] in {d["id"] for d in shared_defs} + + +def test_csharp_using_static_scoped_to_namespace_block(tmp_path: Path): + layer = _write( + tmp_path / "layer.cs", + "namespace Demo\n{\n public class Layer\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + ( + "namespace Good\n" + "{\n" + " using static Demo.Layer;\n" + " public class Service\n" + " {\n" + " public Record goodValue;\n" + " }\n" + "}\n" + "namespace Bad\n" + "{\n" + " public class OtherService\n" + " {\n" + " public Record badValue;\n" + " }\n" + "}\n" + ), + ) + result = extract([layer, consumer], cache_root=tmp_path) + record_defs = _defs(result, "Record") + assert len(record_defs) == 1 + record_def = record_defs[0] + + found_good = False + found_bad = False + for e in result["edges"]: + if e.get("relation") != "references": + continue + tgt = _node_by_id(result, e.get("target", "")) + if tgt and tgt.get("label") == "Record": + src = _node_by_id(result, e.get("source", "")) + src_label = src.get("label", "") if src else "" + if src_label == "Service": + found_good = True + assert tgt.get("source_file"), "Good.Service must resolve Record" + assert tgt["id"] == record_def["id"] + elif src_label == "OtherService": + found_bad = True + assert not tgt.get("source_file"), "Bad.OtherService must NOT resolve Record" + + assert found_good, "Good.Service Record reference edge must be found" + assert found_bad, "Bad.OtherService Record reference edge must be found" + + +def test_csharp_unimported_nested_type_dangles(tmp_path: Path): + layer = _write( + tmp_path / "layer.cs", + "namespace Demo\n{\n public class Layer\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + "namespace Consumer\n{\n public class Service\n {\n public Record value;\n }\n}\n", + ) + result = extract([layer, consumer], cache_root=tmp_path) + resolved = [t for t in _targets(result, "references", "Record") if t.get("source_file")] + assert not resolved, f"unimported nested Record must not resolve: {resolved}" + + +def test_csharp_ambiguous_static_imports_dangle(tmp_path: Path): + layer_a = _write( + tmp_path / "layer_a.cs", + "namespace DemoA\n{\n public class LayerA\n {\n public class Record {}\n }\n}\n", + ) + layer_b = _write( + tmp_path / "layer_b.cs", + "namespace DemoB\n{\n public class LayerB\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + ( + "using static DemoA.LayerA;\n" + "using static DemoB.LayerB;\n" + "namespace Consumer\n" + "{\n" + " public class Service\n" + " {\n" + " public Record value;\n" + " }\n" + "}\n" + ), + ) + result = extract([layer_a, layer_b, consumer], cache_root=tmp_path) + resolved = [t for t in _targets(result, "references", "Record") if t.get("source_file")] + assert not resolved, f"ambiguous static imports must fail closed and remain unresolved: {resolved}" + + +def test_csharp_static_import_vs_namespace_conflict_dangles(tmp_path: Path): + other = _write( + tmp_path / "other.cs", + "namespace Other\n{\n public class Record {}\n}\n", + ) + layer = _write( + tmp_path / "layer.cs", + "namespace Demo\n{\n public class Layer\n {\n public class Record {}\n }\n}\n", + ) + consumer = _write( + tmp_path / "consumer.cs", + ( + "using Other;\n" + "using static Demo.Layer;\n" + "namespace Consumer\n" + "{\n" + " public class Service\n" + " {\n" + " public Record value;\n" + " }\n" + "}\n" + ), + ) + result = extract([other, layer, consumer], cache_root=tmp_path) + resolved = [t for t in _targets(result, "references", "Record") if t.get("source_file")] + assert not resolved, f"conflict between namespace and static import must fail closed: {resolved}"