diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9fbc1..99aa3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/extract.py b/graphify/extract.py index 0ccb6b8..5575e70 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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. diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py new file mode 100644 index 0000000..4a4fa91 --- /dev/null +++ b/graphify/extractors/csharp.py @@ -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 + ] diff --git a/tests/test_csharp_type_resolution.py b/tests/test_csharp_type_resolution.py new file mode 100644 index 0000000..54e5482 --- /dev/null +++ b/tests/test_csharp_type_resolution.py @@ -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" + )