Skip to content

[Bug]: C#: nested types imported with using static are never resolved #4216

Description

@JensD-git

Pre-flight checks

  • 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_kind namespace 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.

All measurements: AST extraction only, no LLM.

Related: #3888, #3901, #1466, #4196.

Activity

  1. github-actions commented on Oct 8, 2026

    @github-actions

    Thanks for opening this issue, @JensD-git. A maintainer will take a look soon.

    If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.

    To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.

  2. ThatAblaze commented on Oct 8, 2026

    @ThatAblaze

    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:

    namespace Demo
    {
        public static class Effects
        {
            public abstract class Effect { public virtual int ReadSlot(string p) { return 0; } }
            public class Tween : Effect { public override int ReadSlot(string p) { return base.ReadSlot(p) + 1; } }
        }
    }

    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.

    Type references from the enclosing class:

    using System.Collections.Generic;
    namespace Demo
    {
        public sealed class Layout
        {
            private struct Leaf { public int Id; }
            private readonly List<Leaf> _leaves = new List<Leaf>();
            private Leaf Make() { return new Leaf { Id = 1 }; }
        }
    }

    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.

    graphify 0.9.80, Python 3.13.1, Windows 10.

  3. safishamsi commented on Oct 9, 2026

    @safishamsi
    Member

    Fixed by #4235 (thanks @hopstreax), shipped in v0.9.83. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions