You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: C#: nested types imported with using static are never resolved #4216
I have checked the Troubleshooting section in the README
What happened?
using static Demo.Layer; makes the nested types of Layer usable without a qualifier, not only its static members. Code that keeps its classes inside static holder classes typically relies on this: new Record() where Record is declared as Layer.Record.
graphify does not resolve such a name at all:
The C# type-definition index skips nested types (extractors/csharp.py, _build_csharp_type_def_index(), line 44: if metadata.get("is_nested_type"): continue).
CsharpNameResolver only takes using_kindnamespace and alias from the import edges (lines 243–252). A using static edge (using_kind: "static") is ignored. Resolve C# cross-file type references and extract enum/struct/record declarations #1466, which introduced the resolver, says so on purpose: "using static N.T; is ignored (it imports members, not a namespace/type)". C# also imports the nested types of T with it.
This also keeps the #3888 fix (#3901, released in 0.9.74) from bringing constructor edges back. A new X() rescued from a same-file stub is bound only with import or namespace/using evidence from that resolver (extract.py lines 8703 and 8870). For a nested type the resolver never has any, so the call is still dropped. 0.9.55 bound these calls by name alone, which found the right class but also bound calls the code cannot see (see the controls).
Type references to nested types (public Record Current;) end on a sourceless placeholder on both 0.9.55 and 0.9.79, including from inside the holder class.
Expected: new Record() and Record as a type resolve to Demo.Layer.Record when the file has using static Demo.Layer; or the code sits inside Layer. They stay unresolved when neither is the case.
Steps to reproduce
Seven files, nothing else needed.
`Lib/Layer.cs`
namespace Demo
{
public static partial class Layer
{
public partial class Record
{
}
public class Queue
{
}
}
}
`Lib/Deep.cs`
namespace Demo
{
public static partial class Print
{
public static partial class Engine
{
public class Info
{
}
}
}
}
`App/Static.cs` (imports the holder with `using static`)
using static Demo.Layer;
namespace Demo
{
public class Holder
{
public Record Current;
public object MakeS() { return new Record(); }
}
}
`App/Inside.cs` (code inside the holder class)
namespace Demo
{
public static partial class Layer
{
public class Builder
{
public Record Last;
public object MakeI() { return new Record(); }
}
}
}
`App/DeepUse.cs` (two levels of nesting)
using static Demo.Print.Engine;
namespace Demo
{
public class Reader
{
public Info Last;
public object MakeD() { return new Info(); }
}
}
`App/NoImport.cs` (control: `Record` is not visible here, this file would not compile)
namespace Demo
{
public class Stranger
{
public Record Current;
public object MakeX() { return new Record(); }
}
}
`App/Framework.cs` (control: this is `System.Collections.Generic.Queue<T>`, not `Demo.Layer.Queue`)
using System.Collections.Generic;
namespace Other
{
public class User
{
public Queue<int> Items;
public object MakeQ() { return new Queue<int>(); }
}
}
Then, in the folder containing `Lib/` and `App/`:
graphify extract . --code-only
and look at the `calls` edges leaving the five `Make*()` methods in`graphify-out/graph.json`.
Error output or graph output
"real" means the definition in `Lib/`.
0.9.55 0.9.79
MakeS() calls Record real (no edge)
MakeI() calls Record real (no edge)
MakeD() calls Info real (no edge)
MakeX() calls Record real <- wrong (no edge) control, not visible
MakeQ() calls Queue real <- wrong (no edge) control, framework type
The `references` edges of `Holder`, `Builder`, `Reader`, `Stranger` and `User` point to a sourceless placeholder on both versions.
Graphify version
0.9.79 (latest release); compared with 0.9.55
Operating System
Windows
Python Version
3.1
Installation Method
uv tool install (recommended)
Additional Environment Details
Python 3.14.6 (the version list in the form ends at 3.13). graphify installed with uv venv + uv pip install graphifyy==0.9.79 (0.9.55 the same way), not with uv tool install.
Additional context
Size in two real C# code bases (raw graph.json, unpatched releases, same source tree):
Code base 1 (1,288 .cs files): new X() (caller, class) pairs with a calls edge to a class defined in the code base go from 2,106 on 0.9.55 to 1,307 on 0.9.79. Of the 830 pairs that have neither a calls nor a references edge on 0.9.79, 825 target a nested type and the calling file has a using static directive. I did not analyse the other 5.
Code base 2 (143 .cs files): 156 on 0.9.55, 95 on 0.9.79. All 61 lost pairs target a nested type, and each calling file has a using static directive.
"Has a using static directive" was counted per file, not matched against the specific holder type. Every counted site was matched against its source line (new <Name> at the reported line).
Suggested fix, measured, test suite not run
The diff below only covers the constructor case. In the rescued-call branch, when the resolver returns nothing for a C# new X(), it binds to the single candidate that is a nested type whose holder class the caller can see: imported with using static (holder name built through the whole holder chain, so using static Demo.Print.Engine; works) or enclosing the caller. Everything else is left as it is.
--- a/graphify/extract.py+++ b/graphify/extract.py@@ -7494,6 +7494,89 @@
_PARALLEL_THRESHOLD = 20
+def _csharp_build_nested_index(all_nodes: list[dict], all_edges: list[dict]):+ """Holder type of each nested C# type, the parent chain of each member, and the+ `using static` imports per file -- all read from edges the extractor already emits."""+ by_id = {n.get("id"): n for n in all_nodes if isinstance(n.get("id"), str)}++ def _type_key(node: dict) -> tuple[str, str]:+ md = node.get("metadata") or {}+ ns = md.get("namespace", "") if isinstance(md, dict) else ""+ return (ns if isinstance(ns, str) else "", str(node.get("label") or ""))++ container: dict[str, tuple[str, str]] = {}+ parent: dict[str, str] = {}+ static_usings: dict[str, list[tuple[str, str, str | None]]] = {}+ for edge in all_edges:+ rel = edge.get("relation")+ src = by_id.get(edge.get("source"))+ tgt = by_id.get(edge.get("target"))+ if src is None:+ continue+ if rel in ("contains", "method") and tgt is not None:+ label = str(src.get("label") or "")+ if (+ src.get("source_file")+ and src.get("type") != "namespace"+ and not label.endswith((".cs", ")"))+ ):+ parent.setdefault(tgt["id"], src["id"])+ md = tgt.get("metadata") or {}+ if rel == "contains" and isinstance(md, dict) and md.get("is_nested_type"):+ container[tgt["id"]] = _type_key(src)+ elif rel == "imports":+ md = edge.get("metadata") or {}+ if isinstance(md, dict) and md.get("using_kind") == "static" and isinstance(md.get("target_fqn"), str):+ static_usings.setdefault(str(src.get("source_file") or ""), []).append(+ (md["target_fqn"], md.get("scope_kind") or "file", md.get("scope_id"))+ )+ return by_id, container, parent, static_usings, _type_key+++def _csharp_nested_visible(idx, resolver, caller_node: dict, candidates: list[str]) -> str | None:+ """The single candidate that is a nested type whose holder type the caller can see:+ imported with `using static`, or enclosing the caller."""+ by_id, container, parent, static_usings, type_key = idx+ imported = {+ fqn+ for fqn, kind, scope_id in static_usings.get(str(caller_node.get("source_file") or ""), [])+ if resolver._using_in_scope(kind, scope_id, caller_node)+ }+ enclosing: set[tuple[str, str]] = set()+ cur, seen = caller_node.get("id"), set()+ while cur in parent and cur not in seen:+ seen.add(cur)+ cur = parent[cur]+ enclosing.add(type_key(by_id[cur]))+ def _fqn(nid: str, depth: int = 0) -> str:+ # Full name through the holder chain (`Demo.Print.Engine`).+ node = by_id[nid]+ outer = parent.get(nid)+ if outer is not None and outer in by_id and nid in container and depth < 16:+ return f"{_fqn(outer, depth + 1)}.{node.get('label')}"+ ns, label = type_key(node)+ return f"{ns}.{label}" if ns else label++ hits = []+ for cand in candidates:+ key = container.get(cand)+ if key is None:+ continue+ outer = parent.get(cand)+ fqn = _fqn(outer) if outer in by_id else (f"{key[0]}.{key[1]}" if key[0] else key[1])+ if fqn in imported or key in enclosing:+ hits.append((fqn, cand))+ # The parts of one partial nested type are separate nodes here; they are the+ # same type, so they count once. Bind to the first part by source file, the+ # same tie-break the C# type-definition index uses.+ if len({fqn for fqn, _ in hits}) != 1:+ return None+ return min(+ (cand for _, cand in hits),+ key=lambda nid: (str(by_id[nid].get("source_file") or ""), str(by_id[nid].get("source_location") or ""), nid),+ )++
def extract(
paths: list[Path],
cache_root: Path | None = None,
@@ -8641,6 +8724,7 @@
str(n.get("label", ""))
)
_csharp_stub_resolver: CsharpNameResolver | None = None # built on first use
+ _csharp_nested_idx = None # built on first use
for rc in all_raw_calls:
if rc.get("_ambiguous_python_import"):
continue
@@ -8711,6 +8795,18 @@
if scoped in candidates:
candidates = [scoped]
stub_scope_hit = True
+ elif scoped is None and rc.get("csharp_new") and caller_node is not None:+ # Nested types are not in the type-definition index, so the resolver+ # cannot see `new Record()` for `Layer.Record` even when the caller+ # imports `Layer` with `using static` or sits inside `Layer`.+ if _csharp_nested_idx is None:+ _csharp_nested_idx = _csharp_build_nested_index(all_nodes, all_edges)+ nested_hit = _csharp_nested_visible(+ _csharp_nested_idx, _csharp_stub_resolver, caller_node, candidates+ )+ if nested_hit is not None:+ candidates = [nested_hit]+ stub_scope_hit = True
# Cross-language guard: never bind a call to a definition in a different
# language family. Name-only matching was resolving a TSX callback passed
# by name to a same-named Kotlin method in the Android half of the repo
Edit: the first version of this diff required exactly one visible candidate. That misses a nested type declared partial across two files, because each part is its own node at this stage (for example a generated part and a hand-written part). The diff above treats parts with the same holder and name as one type and binds to the first part by source file, the same tie-break the type-definition index uses. I checked it on the repro (with a second partial part of Layer.Record added) and on a real case of that shape. The counts below were measured with the first version, so they are a lower bound for the corrected one.
Measured against unpatched 0.9.79 (the patched build differs from the unpatched one only in extract.py):
Repro: MakeS, MakeI and MakeD get their calls edge to the real definition. MakeX and MakeQ get none.
Eight graphs built from real code, including the two code bases above: the patch only adds calls edges, 927 in total (829 + 71 + 25 + 2, nothing in the other four graphs). Each one is at a new <Name> source line and points to a nested type. No edge is removed, and no edge moves to a different target.
Code base 1: 1,307 -> 2,133 pairs (0.9.55: 2,106). Code base 2: 95 -> 161 (0.9.55: 156).
Of the pairs 0.9.55 had, 10 in code base 1 stay without an edge. 8 of them are calls whose file imports a holder type of the same name in a different namespace. The patch does not bind those because the imported holder does not contain the type. I did not check how that code compiles.
Type references to nested types are not touched by this diff. Teaching CsharpNameResolver about using static targets and the enclosing type chain would probably cover both. I have not built or measured that.
The same root cause (the type-definition index skipping nested types) breaks nested types
referenced without any using static, from the enclosing class and from sibling nested
classes. The proposed patch covers new calls only. These are type references, base types
and the base calls that depend on them, on 0.9.80:
Base type and base call between sibling nested classes:
Actual: effects_demo_tween --inherits--> effect, where effect is a sourceless placeholder,
and base.ReadSlot(p) gets no edge.
Expected: effects_demo_tween --inherits--> effects_demo_effect and effects_demo_tween_readslot --calls--> effects_demo_effect_readslot. The same two classes at
namespace level resolve both.
Actual: layout_demo_layout --references--> leaf and layout_demo_layout_make --references--> leaf,
to a placeholder leaf, beside the real layout_demo_leaf. (new Leaf does resolve.)
Expected: both references end on layout_demo_leaf, with no placeholder.
Impact: in a Unity project whose runtime core is declared as classes nested in static classes,
127 types end up split across a declaration node and placeholders, and 82 base.M() calls get
no edge, because every override's base class is a placeholder.
Pre-flight checks
What happened?
using static Demo.Layer;makes the nested types ofLayerusable without a qualifier, not only its static members. Code that keeps its classes inside static holder classes typically relies on this:new Record()whereRecordis declared asLayer.Record.graphify does not resolve such a name at all:
extractors/csharp.py,_build_csharp_type_def_index(), line 44:if metadata.get("is_nested_type"): continue).CsharpNameResolveronly takesusing_kindnamespaceandaliasfrom the import edges (lines 243–252). Ausing staticedge (using_kind: "static") is ignored. Resolve C# cross-file type references and extract enum/struct/record declarations #1466, which introduced the resolver, says so on purpose: "using static N.T;is ignored (it imports members, not a namespace/type)". C# also imports the nested types ofTwith it.This also keeps the #3888 fix (#3901, released in 0.9.74) from bringing constructor edges back. A
new X()rescued from a same-file stub is bound only with import or namespace/usingevidence from that resolver (extract.pylines 8703 and 8870). For a nested type the resolver never has any, so the call is still dropped. 0.9.55 bound these calls by name alone, which found the right class but also bound calls the code cannot see (see the controls).Type references to nested types (
public Record Current;) end on a sourceless placeholder on both 0.9.55 and 0.9.79, including from inside the holder class.Expected:
new Record()andRecordas a type resolve toDemo.Layer.Recordwhen the file hasusing static Demo.Layer;or the code sits insideLayer. They stay unresolved when neither is the case.Steps to reproduce
Error output or graph output
Graphify version
0.9.79 (latest release); compared with 0.9.55
Operating System
Windows
Python Version
3.1
Installation Method
uv tool install (recommended)
Additional Environment Details
Python 3.14.6 (the version list in the form ends at 3.13). graphify installed with
uv venv+uv pip install graphifyy==0.9.79(0.9.55 the same way), not withuv tool install.Additional context
Size in two real C# code bases (raw
graph.json, unpatched releases, same source tree):.csfiles):new X()(caller, class) pairs with acallsedge to a class defined in the code base go from 2,106 on 0.9.55 to 1,307 on 0.9.79. Of the 830 pairs that have neither acallsnor areferencesedge on 0.9.79, 825 target a nested type and the calling file has ausing staticdirective. I did not analyse the other 5..csfiles): 156 on 0.9.55, 95 on 0.9.79. All 61 lost pairs target a nested type, and each calling file has ausing staticdirective."Has a
using staticdirective" was counted per file, not matched against the specific holder type. Every counted site was matched against its source line (new <Name>at the reported line).Suggested fix, measured, test suite not run
The diff below only covers the constructor case. In the rescued-call branch, when the resolver returns nothing for a C#
new X(), it binds to the single candidate that is a nested type whose holder class the caller can see: imported withusing static(holder name built through the whole holder chain, sousing static Demo.Print.Engine;works) or enclosing the caller. Everything else is left as it is.Edit: the first version of this diff required exactly one visible candidate. That misses a nested type declared
partialacross two files, because each part is its own node at this stage (for example a generated part and a hand-written part). The diff above treats parts with the same holder and name as one type and binds to the first part by source file, the same tie-break the type-definition index uses. I checked it on the repro (with a secondpartialpart ofLayer.Recordadded) and on a real case of that shape. The counts below were measured with the first version, so they are a lower bound for the corrected one.Measured against unpatched 0.9.79 (the patched build differs from the unpatched one only in
extract.py):MakeS,MakeIandMakeDget theircallsedge to the real definition.MakeXandMakeQget none.callsedges, 927 in total (829 + 71 + 25 + 2, nothing in the other four graphs). Each one is at anew <Name>source line and points to a nested type. No edge is removed, and no edge moves to a different target.Type references to nested types are not touched by this diff. Teaching
CsharpNameResolveraboutusing statictargets and the enclosing type chain would probably cover both. I have not built or measured that.All measurements: AST extraction only, no LLM.
Related: #3888, #3901, #1466, #4196.