Skip to content

[Bug]: C#: name resolver ignores enclosing namespaces #4196

Description

@JensD-git

Pre-flight checks

  • I have checked the Troubleshooting section in the README

What happened?

C# makes a type declared in Demo visible inside Demo.Inner without a using: name lookup walks outward through the enclosing namespaces. CsharpNameResolver does not do that. A type that is only reachable this way resolves to nothing, so a type reference ends on a sourceless placeholder.

For type references (references, inherits, implements) this already happens on older releases (checked on 0.9.55). It also keeps the #3888 fix from working in this case. That fix (#3901, released in 0.9.74) binds a constructor call rescued from a same-file annotation stub only when there is import or namespace/using evidence, and it gets that evidence from the same resolver. So new Widget() still has no edge at all when Widget lives in an enclosing namespace and the file also uses Widget as a type.

Expected: the edges point to the definition in the enclosing namespace, as they already do when the same namespace is brought in with using (see the controls below).

Steps to reproduce

Six files, nothing else needed.

`Lib/Widget.cs`

namespace Demo
{
    public class Widget
    {
    }

    public class Base
    {
    }
}


`App/Dotted.cs`

using System.Collections.Generic;

namespace Demo.Inner
{
    public class Registry
    {
        public List<Widget> Items;
    }

    public class Factory : Base
    {
        public object MakeA() { return new Widget(); }
    }
}


`App/Nested.cs`

using System.Collections.Generic;

namespace Demo
{
    namespace Nested
    {
        public class NestedRegistry
        {
            public List<Widget> Items;
        }

        public class NestedFactory : Base
        {
            public object MakeN() { return new Widget(); }
        }
    }
}


`App/FileScoped.cs`

using System.Collections.Generic;

namespace Demo.Scoped;

public class ScopedRegistry
{
    public List<Widget> Items;
}

public class ScopedFactory : Base
{
    public object MakeF() { return new Widget(); }
}


`App/WithUsing.cs` (control: same code, but `Widget` comes in through `using Demo;`)

using System.Collections.Generic;
using Demo;

namespace Other
{
    public class UsingRegistry
    {
        public List<Widget> Items;
    }

    public class UsingFactory : Base
    {
        public object MakeU() { return new Widget(); }
    }
}


`App/Plain.cs` (control: enclosing namespace, but `Widget` is not used as a type in this file)

namespace Demo.Inner
{
    public class Plain
    {
        public object MakeP() { return new Widget(); }
    }
}


Then, in the folder containing `Lib/` and `App/`:


graphify extract . --code-only


and look at the `calls`/`references`/`inherits` edges in `graphify-out/graph.json` whose target is labelled `Widget` or `Base`.

Error output or graph output

"real" means the definition in `Lib/Widget.cs`, "stub" means a sourceless placeholder.


                                    0.9.55      0.9.79
MakeA()         calls      Widget   real        (no edge)
MakeN()         calls      Widget   real        (no edge)
MakeF()         calls      Widget   real        (no edge)
MakeU()         calls      Widget   real        real        <- control, using
MakeP()         calls      Widget   real        real        <- control, no type use in file
Registry        references Widget   stub        stub
NestedRegistry  references Widget   stub        stub
ScopedRegistry  references Widget   stub        stub
UsingRegistry   references Widget   real        real        <- control, using
Factory         inherits   Base     stub        stub
NestedFactory   inherits   Base     stub        stub
ScopedFactory   inherits   Base     stub        stub
UsingFactory    inherits   Base     real        real        <- control, using


All three namespace forms (dotted, nested blocks, file-scoped) behave the same.

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

No response

Additional context

Where it happens (0.9.79)

  • extractors/csharp.py, CsharpNameResolver._scopes_for() (lines 272–283): the scopes are the declaring namespace itself, the global namespace, and the using namespaces. The enclosing namespaces of the declaring namespace are never added.
  • resolve_type_name() looks the name up in exactly those scopes. For Widget inside Demo.Inner it finds nothing and returns (None, False).
  • extract.py lines 8703 and 8870: a constructor call marked via_stub is bound only on import evidence or a hit from that resolver. Neither exists here, so the call is skipped.

Size in one real C# code base (2,253 .cs files, raw graph.json, unpatched releases, same source tree):

  • new X() sites: 38 (caller, class) pairs have a calls edge on 0.9.55 and neither a calls nor a references edge on 0.9.79. For 29 of them the class is not found through the caller's own namespace or its usings, only through an enclosing namespace. The other 9 have other causes, which I did not analyse.
  • Type references: with the fallback below, 758 references, 55 inherits and 86 implements edges move from a sourceless placeholder to the real definition.

Suggested fix, measured, test suite not run

Consult the enclosing namespaces, innermost first, but only when the existing scopes know nothing about the name. A name that resolves today keeps exactly the same result:

--- a/graphify/extractors/csharp.py
+++ b/graphify/extractors/csharp.py
@@ -321,6 +321,15 @@
                 candidates.append(hit)
         if len(candidates) == 1:
             return candidates[0], True
+        if not candidates:
+            # Enclosing namespaces (innermost first): C# makes Demo.Widget visible
+            # inside Demo.Inner without a using. Only consulted when the scopes
+            # above know nothing about the name, so no existing binding changes.
+            parts = self._namespace(source_node).split(".")
+            for i in range(len(parts) - 1, 0, -1):
+                hit = self.type_def_index.get((".".join(parts[:i]), label))
+                if hit:
+                    return hit, True
         return None, bool(candidates)
 
     def resolve_label(self, label: str, source_node: dict, source_file: str) -> str | None:

Measured against unpatched 0.9.79 (two unpatched runs give identical calls/references/inherits/implements edges, so the differences come from the change):

  • Repro: all 13 edges above point to the real definition, controls unchanged.
  • Code base: 758 references, 55 inherits, 86 implements edges from placeholder to real definition; 65 new calls pairs (17 new X(), 48 method calls whose receiver or static type now resolves). I checked 14 of the 65 against their source line, and all 14 matched it. No edge moved from one real definition to a different one.
  • One existing references edge disappeared: a property whose type is resolved through a using, in a class whose other edges are all unchanged. I did not find the cause.

I tried the obvious alternative first, adding the enclosing namespaces to _scopes_for() itself, and it is worse. resolve_type_name() requires exactly one candidate across all scopes, so a name that is defined both in an enclosing namespace and in the declaring namespace or a using namespace becomes ambiguous and is dropped (that is what I found in the three cases I looked at). On the same code base that version lost 16 calls edges and turned 4 resolved type edges back into placeholders.

The fallback is not a complete model of C# lookup: C# prefers a type in an enclosing namespace over one brought in by a file-level using, and the fallback only runs when the usings find nothing. It errs towards today's behaviour.

All measurements: AST extraction only, no LLM.

Activity

  1. github-actions commented on Oct 7, 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. JensD-git commented on Oct 7, 2026

    @JensD-git
    Author

    Correction to the environment fields above: Python is 3.14.6 (the version list in the form ends at 3.13), and graphify was installed with uv venv + uv pip install graphifyy==0.9.79 (0.9.55 the same way), not with uv tool install.

  3. safishamsi commented on Oct 8, 2026

    @safishamsi
    Member

    Fixed by #4204 (thanks @hopstreax), shipped in v0.9.81. Closing as resolved.

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