mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-16 17:09:17 +00:00
fix(csharp): resolve nested types from using static
This commit is contained in:
1 parent
6478eb7123
commit
b66040d902
2 files changed
+358
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user