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:
TheFedaikin
2026-06-27 23:36:47 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 36b76ce8e0
commit 76b6eabdb0
4 changed files with 353 additions and 3 deletions
+2 -1
View File
@@ -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
View File
@@ -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.
+158
View 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
]
+172
View File
@@ -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"
)