feat(extract): resolve C# cross-file type references + enum/struct/record (#1466)
Adds _resolve_csharp_type_references (graphify/extractors/csharp.py), the C# counterpart to _resolve_java_type_references. It re-points dangling inherits/implements/references edges from no-source shadow stubs onto the real definitions, disambiguating same-named types across namespaces using the referencing file's `using` directives + enclosing namespace; ambiguous matches are refused (unique-hit guardrail), not guessed. Runs after id-disambiguation and the sourceless-stub rewire, on the ambiguous remainder, behind a log-and-skip try/except. _CSHARP_CONFIG is broadened to extract enum/struct/record as type definitions so references to them resolve too. Ported from PR #1466 by @TheFedaikin. Known follow-up: types declared in nested/multiple namespaces in one file aren't registered as targets yet (fails safe — under-resolves to a stub, never mis-resolves). Advances #1318 for C#. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
36b76ce8e0
commit
76b6eabdb0
+2
-1
@@ -4,7 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix: the Go AST extractor no longer creates phantom duplicate nodes for cross-file type references — the Go copy of `ensure_named_node` still used the older sourced-stub fallback; it now emits a sourceless stub like the other extractors, extending the #1402 fix to Go (#1500).
|
||||
- Feat: resolve C# cross-file type references and extract `enum`/`struct`/`record` declarations (#1466, thanks @TheFedaikin). A new `_resolve_csharp_type_references` (the C# counterpart to the Java resolver) re-points dangling `inherits`/`implements`/`references` edges from no-source "shadow" stubs to their real definitions, disambiguating same-named types in different namespaces via the referencing file's `using` directives and enclosing namespace; ambiguous matches are refused rather than guessed. `enum`/`struct`/`record` types are now extracted as definitions so those references resolve too. Advances #1318 for C#.
|
||||
- Fix: the Go AST extractor no longer creates phantom duplicate nodes for cross-file type references — the Go copy of `ensure_named_node` still used the older sourced-stub fallback; it now emits a sourceless stub like the other extractors, extending the #1402 fix to Go (#1500, thanks @TPAteeq).
|
||||
|
||||
## 0.8.50 (2026-06-27)
|
||||
|
||||
|
||||
+21
-2
@@ -22,6 +22,7 @@ from graphify.extractors.base import ( # noqa: F401
|
||||
_read_text,
|
||||
)
|
||||
from graphify.extractors.blade import extract_blade # noqa: F401
|
||||
from graphify.extractors.csharp import _resolve_csharp_type_references
|
||||
from graphify.extractors.elixir import extract_elixir # noqa: F401
|
||||
from graphify.extractors.razor import extract_razor # noqa: F401
|
||||
from graphify.extractors.zig import extract_zig # noqa: F401
|
||||
@@ -2131,7 +2132,13 @@ _RUBY_CONFIG = LanguageConfig(
|
||||
|
||||
_CSHARP_CONFIG = LanguageConfig(
|
||||
ts_module="tree_sitter_c_sharp",
|
||||
class_types=frozenset({"class_declaration", "interface_declaration"}),
|
||||
class_types=frozenset({
|
||||
"class_declaration",
|
||||
"interface_declaration",
|
||||
"enum_declaration",
|
||||
"struct_declaration",
|
||||
"record_declaration",
|
||||
}),
|
||||
function_types=frozenset({"method_declaration"}),
|
||||
import_types=frozenset({"using_directive"}),
|
||||
call_types=frozenset({"invocation_expression"}),
|
||||
@@ -4519,7 +4526,7 @@ def extract_ruby(path: Path) -> dict:
|
||||
|
||||
|
||||
def extract_csharp(path: Path) -> dict:
|
||||
"""Extract classes, interfaces, methods, namespaces, and usings from a .cs file."""
|
||||
"""Extract C# type declarations, methods, namespaces, and usings from a .cs file."""
|
||||
return _extract_generic(path, _CSHARP_CONFIG)
|
||||
|
||||
|
||||
@@ -13262,6 +13269,18 @@ def extract(
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
|
||||
|
||||
# Cross-file C# type-reference resolution: re-point dangling inherits/implements/
|
||||
# references edges left on shadow stubs, disambiguating same-named types by the
|
||||
# referencing file's `using` directives + enclosing namespace (mirrors Java #1318).
|
||||
cs_paths = [p for p in paths if p.suffix == ".cs"]
|
||||
if cs_paths:
|
||||
cs_results = [r for r, p in zip(per_file, paths) if p.suffix == ".cs"]
|
||||
try:
|
||||
_resolve_csharp_type_references(cs_results, cs_paths, all_nodes, all_edges)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("C# type-reference resolution failed, skipping: %s", exc)
|
||||
|
||||
# Cross-file call resolution for all languages
|
||||
# Each extractor saved unresolved calls in raw_calls. Now that we have all
|
||||
# nodes from all files, resolve any callee that exists in another file.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""C# cross-file resolution.
|
||||
|
||||
The config-driven C# *extractor* (``extract_csharp`` → ``_extract_generic``)
|
||||
still lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the
|
||||
config-driven languages cannot be ported one-by-one until the shared
|
||||
``_extract_generic`` core moves as its own coordinated batch. This module is
|
||||
the C# home for the parts that *are* cleanly separable — today, the cross-file
|
||||
type-reference resolver below — and is where ``extract_csharp`` will land when
|
||||
the core migration happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extractors.base import _read_text
|
||||
|
||||
|
||||
def _resolve_csharp_type_references(
|
||||
per_file: list[dict],
|
||||
paths: list[Path],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Re-point dangling C# ``inherits``/``implements``/``references`` edges to the
|
||||
real definition, using the referencing file's ``using`` directives + enclosing
|
||||
namespace for exact disambiguation. Mirrors ``_resolve_java_type_references``.
|
||||
|
||||
C# deltas from Java: a plain ``using N;`` is NAMESPACE-WIDE (resolve a bare ``T``
|
||||
by trying ``(N, T)`` for each open namespace and accepting only a UNIQUE hit — the
|
||||
god-node guardrail), while ``using X = N.T;`` is a single-type alias. ``global
|
||||
using`` is normalized (the ``global`` prefix stripped); ``using static N.T;`` is
|
||||
ignored (it imports members, not a namespace/type). The global namespace is keyed
|
||||
as the bare label (``""``). A file with MULTIPLE namespace blocks does not register
|
||||
its defs (which namespace each def belongs to needs source-range tracking) — deferred.
|
||||
|
||||
Mutates ``all_nodes``/``all_edges`` in place. Runs after id-disambiguation and
|
||||
``_rewire_unique_stub_nodes`` so target ids are final and only the ambiguous
|
||||
remainder is left on shadow stubs.
|
||||
"""
|
||||
try:
|
||||
import tree_sitter_c_sharp as tscs
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
language = Language(tscs.language())
|
||||
parser = Parser(language)
|
||||
|
||||
def _key(ns: str, label: str) -> str:
|
||||
return label if ns == "" else f"{ns}.{label}"
|
||||
|
||||
own_ns_by_file: dict[str, list[str]] = {}
|
||||
scope_by_file: dict[str, list[str]] = {}
|
||||
aliases_by_file: dict[str, dict[str, str]] = {}
|
||||
for path, result in zip(paths, per_file):
|
||||
srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
|
||||
if not srcs:
|
||||
continue
|
||||
try:
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
except Exception:
|
||||
continue
|
||||
own_ns: list[str] = []
|
||||
usings: list[str] = []
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
def walk(n) -> None:
|
||||
if n.type in ("namespace_declaration", "file_scoped_namespace_declaration"):
|
||||
nm = n.child_by_field_name("name")
|
||||
if nm is not None:
|
||||
own_ns.append(_read_text(nm, source).strip())
|
||||
elif n.type == "using_directive":
|
||||
text = _read_text(n, source).strip().rstrip(";")
|
||||
if text.startswith("global "):
|
||||
text = text[len("global "):].strip()
|
||||
if text.startswith("using"):
|
||||
body = text[len("using"):].strip()
|
||||
if body.startswith("static "):
|
||||
pass # `using static N.T;` imports members, not a type/namespace — skip
|
||||
elif "=" in body:
|
||||
lhs, rhs = body.split("=", 1)
|
||||
if lhs.strip() and rhs.strip():
|
||||
aliases[lhs.strip()] = rhs.strip()
|
||||
elif body:
|
||||
usings.append(body)
|
||||
for child in n.children:
|
||||
walk(child)
|
||||
|
||||
walk(tree.root_node)
|
||||
scope = list(dict.fromkeys((own_ns or [""]) + usings + [""]))
|
||||
for s in srcs:
|
||||
own_ns_by_file[s] = own_ns
|
||||
scope_by_file[s] = scope
|
||||
aliases_by_file[s] = aliases
|
||||
|
||||
fqn_to_id: dict[str, str] = {}
|
||||
for node in all_nodes:
|
||||
label = node.get("label", "")
|
||||
src = node.get("source_file", "")
|
||||
nid = node.get("id", "")
|
||||
if not (label and src and nid) or src not in own_ns_by_file:
|
||||
continue
|
||||
if not label[:1].isupper() or label.endswith(")") or label.endswith(".cs"):
|
||||
continue
|
||||
ns_list = own_ns_by_file.get(src, [])
|
||||
if len(ns_list) == 0:
|
||||
fqn_to_id.setdefault(_key("", label), nid)
|
||||
elif len(ns_list) == 1:
|
||||
fqn_to_id.setdefault(_key(ns_list[0], label), nid)
|
||||
# len > 1: skip (deferred)
|
||||
|
||||
stub_label: dict[str, str] = {
|
||||
node["id"]: node.get("label", "")
|
||||
for node in all_nodes
|
||||
if node.get("id") and not node.get("source_file") and node.get("label", "")[:1].isupper()
|
||||
}
|
||||
if not stub_label:
|
||||
return
|
||||
|
||||
REPOINT_RELATIONS = {"implements", "inherits", "references"}
|
||||
repointed_from: set[str] = set()
|
||||
for edge in all_edges:
|
||||
if edge.get("relation") not in REPOINT_RELATIONS:
|
||||
continue
|
||||
tgt = edge.get("target")
|
||||
label = stub_label.get(tgt)
|
||||
if not label:
|
||||
continue
|
||||
ref_file = edge.get("source_file", "")
|
||||
resolved = None
|
||||
alias_fqn = aliases_by_file.get(ref_file, {}).get(label)
|
||||
if alias_fqn:
|
||||
ns, _, simple = alias_fqn.rpartition(".")
|
||||
resolved = fqn_to_id.get(_key(ns, simple))
|
||||
if resolved is None:
|
||||
cands: list[str] = []
|
||||
for ns in scope_by_file.get(ref_file, []):
|
||||
hit = fqn_to_id.get(_key(ns, label))
|
||||
if hit and hit not in cands:
|
||||
cands.append(hit)
|
||||
if len(cands) == 1:
|
||||
resolved = cands[0]
|
||||
if resolved and resolved != tgt:
|
||||
edge["target"] = resolved
|
||||
repointed_from.add(tgt)
|
||||
|
||||
if not repointed_from:
|
||||
return
|
||||
|
||||
still_referenced: set[str] = set()
|
||||
for edge in all_edges:
|
||||
still_referenced.add(edge.get("source"))
|
||||
still_referenced.add(edge.get("target"))
|
||||
all_nodes[:] = [
|
||||
node for node in all_nodes
|
||||
if node.get("id") not in repointed_from or node.get("id") in still_referenced
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import extract
|
||||
|
||||
|
||||
def _write(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _node_by_id(result: dict, nid: str) -> dict | None:
|
||||
return next((n for n in result["nodes"] if n.get("id") == nid), None)
|
||||
|
||||
|
||||
def _targets(result: dict, relation: str, label: str) -> list[dict]:
|
||||
out = []
|
||||
for e in result["edges"]:
|
||||
if e.get("relation") != relation:
|
||||
continue
|
||||
n = _node_by_id(result, e.get("target"))
|
||||
if n is not None and n.get("label") == label:
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def _defs(result: dict, label: str) -> list[dict]:
|
||||
return [
|
||||
n for n in result["nodes"]
|
||||
if n.get("label") == label and n.get("source_file")
|
||||
]
|
||||
|
||||
|
||||
def test_csharp_cross_file_inherits_resolves_to_real_def(tmp_path: Path):
|
||||
core = _write(tmp_path / "core.cs",
|
||||
"namespace Game.Core { public class Damage { public int Calc() { return 1; } } }\n")
|
||||
combat = _write(tmp_path / "combat.cs",
|
||||
"using Game.Core;\nnamespace Game.Combat { public class Weapon : Damage {} }\n")
|
||||
result = extract([core, combat], cache_root=tmp_path)
|
||||
|
||||
damage = _targets(result, "inherits", "Damage")
|
||||
assert damage, "expected an inherits edge to Damage"
|
||||
assert all(d.get("source_file") for d in damage), \
|
||||
"Weapon : Damage must resolve to the real Damage def, not a shadow stub"
|
||||
|
||||
|
||||
def test_csharp_collision_disambiguated_by_using(tmp_path: Path):
|
||||
core = _write(tmp_path / "core.cs",
|
||||
"namespace Game.Core { public class WeaponData { public int Number; } }\n")
|
||||
ui = _write(tmp_path / "ui.cs",
|
||||
"namespace Game.UI { public class WeaponData { public int Width; } }\n")
|
||||
combat = _write(tmp_path / "combat.cs",
|
||||
"using Game.Core;\nnamespace Game.Combat { public class Holder { public WeaponData data; } }\n")
|
||||
result = extract([core, ui, combat], cache_root=tmp_path)
|
||||
|
||||
shadow = [n for n in result["nodes"]
|
||||
if n.get("label") == "WeaponData" and not n.get("source_file")]
|
||||
assert not shadow, f"orphan WeaponData shadow node(s) remain: {[n['id'] for n in shadow]}"
|
||||
|
||||
resolved = [w for w in _targets(result, "references", "WeaponData") if w.get("source_file")]
|
||||
assert resolved, "WeaponData reference should resolve to a real def"
|
||||
assert all("core.cs" in w["source_file"] for w in resolved), \
|
||||
"must disambiguate to Game.Core.WeaponData via `using Game.Core;`, not Game.UI"
|
||||
|
||||
|
||||
def test_csharp_global_using_and_global_namespace(tmp_path: Path):
|
||||
gadget = _write(tmp_path / "gadget.cs", "public class Gadget {}\n")
|
||||
user = _write(tmp_path / "user.cs",
|
||||
"global using System;\npublic class Widget : Gadget {}\n")
|
||||
result = extract([gadget, user], cache_root=tmp_path)
|
||||
|
||||
g = _targets(result, "inherits", "Gadget")
|
||||
assert g, "expected an inherits edge to Gadget"
|
||||
assert all(x.get("source_file") for x in g), \
|
||||
"Widget : Gadget (both global namespace) must resolve; `global using` must not break parsing"
|
||||
|
||||
|
||||
def test_csharp_cross_namespace_enum_reference_resolves_to_real_def(tmp_path: Path):
|
||||
core = _write(
|
||||
tmp_path / "core.cs",
|
||||
"namespace Game.Core { public enum Element { Fire, Ice } public class Damage {} }\n",
|
||||
)
|
||||
combat = _write(
|
||||
tmp_path / "combat.cs",
|
||||
"using Game.Core;\n"
|
||||
"namespace Game.Combat { public class Spell { Element element; Damage dmg; } }\n",
|
||||
)
|
||||
result = extract([core, combat], cache_root=tmp_path)
|
||||
|
||||
element_defs = _defs(result, "Element")
|
||||
assert element_defs, "enum Element should be emitted as a real type definition node"
|
||||
assert all("core.cs" in n["source_file"] for n in element_defs)
|
||||
|
||||
element_refs = [n for n in _targets(result, "references", "Element") if n.get("source_file")]
|
||||
assert element_refs, "Element field reference should resolve to the enum definition"
|
||||
assert all("core.cs" in n["source_file"] for n in element_refs)
|
||||
|
||||
|
||||
def test_csharp_cross_namespace_struct_and_record_references_resolve(tmp_path: Path):
|
||||
core = _write(
|
||||
tmp_path / "core.cs",
|
||||
"namespace Game.Core { "
|
||||
"public struct Coord { public int X; } "
|
||||
"public record Player(string Name); "
|
||||
"}\n",
|
||||
)
|
||||
combat = _write(
|
||||
tmp_path / "combat.cs",
|
||||
"using Game.Core;\n"
|
||||
"namespace Game.Combat { public class Spell { Coord coord; Player player; } }\n",
|
||||
)
|
||||
result = extract([core, combat], cache_root=tmp_path)
|
||||
|
||||
for label in ("Coord", "Player"):
|
||||
assert _defs(result, label), f"{label} should be emitted as a real type definition node"
|
||||
resolved = [n for n in _targets(result, "references", label) if n.get("source_file")]
|
||||
assert resolved, f"{label} field reference should resolve to the real definition"
|
||||
assert all("core.cs" in n["source_file"] for n in resolved)
|
||||
|
||||
|
||||
def test_csharp_ambiguous_using_does_not_resolve(tmp_path: Path):
|
||||
# WeaponData is defined in BOTH Game.Core and Game.UI, and the referrer opens
|
||||
# BOTH namespaces. With two candidates the resolver must REFUSE (accept only a
|
||||
# unique hit) and leave the reference dangling on a shadow stub, rather than
|
||||
# fabricate an edge to an arbitrary, possibly-wrong definition.
|
||||
core = _write(
|
||||
tmp_path / "core.cs",
|
||||
"namespace Game.Core { public class WeaponData { public int Number; } }\n",
|
||||
)
|
||||
ui = _write(
|
||||
tmp_path / "ui.cs",
|
||||
"namespace Game.UI { public class WeaponData { public int Width; } }\n",
|
||||
)
|
||||
holder = _write(
|
||||
tmp_path / "holder.cs",
|
||||
"using Game.Core;\n"
|
||||
"using Game.UI;\n"
|
||||
"namespace Game.Combat { public class Holder { public WeaponData data; } }\n",
|
||||
)
|
||||
result = extract([core, ui, holder], cache_root=tmp_path)
|
||||
|
||||
wd_refs = _targets(result, "references", "WeaponData")
|
||||
assert wd_refs, "expected a WeaponData reference edge (otherwise the test is vacuous)"
|
||||
resolved = [n for n in wd_refs if n.get("source_file")]
|
||||
assert not resolved, (
|
||||
"ambiguous WeaponData (Game.Core vs Game.UI, both opened) must NOT resolve to "
|
||||
f"either def; got wrong resolution(s): {[n.get('source_file') for n in resolved]}"
|
||||
)
|
||||
|
||||
|
||||
def test_csharp_using_alias_resolves_to_aliased_type(tmp_path: Path):
|
||||
# `using Dmg = Game.Core.Damage;` is a single-type alias. A base type written as
|
||||
# `Dmg` has no other resolution route, so it must resolve to the real
|
||||
# Game.Core.Damage definition via the alias map -- not stay on a `Dmg` stub.
|
||||
core = _write(
|
||||
tmp_path / "core.cs",
|
||||
"namespace Game.Core { public class Damage {} }\n",
|
||||
)
|
||||
combat = _write(
|
||||
tmp_path / "combat.cs",
|
||||
"using Dmg = Game.Core.Damage;\n"
|
||||
"namespace Game.Combat { public class Weapon : Dmg {} }\n",
|
||||
)
|
||||
result = extract([core, combat], cache_root=tmp_path)
|
||||
|
||||
damage = _targets(result, "inherits", "Damage")
|
||||
assert damage, "Weapon : Dmg must resolve (via the `using Dmg = ...` alias) to Damage"
|
||||
assert all("core.cs" in d["source_file"] for d in damage), (
|
||||
"the alias `Dmg` must resolve to the real Game.Core.Damage def, not a shadow stub"
|
||||
)
|
||||
Reference in New Issue
Block a user