Pre-flight checks
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.
Pre-flight checks
What happened?
C# makes a type declared in
Demovisible insideDemo.Innerwithout ausing: name lookup walks outward through the enclosing namespaces.CsharpNameResolverdoes 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/usingevidence, and it gets that evidence from the same resolver. Sonew Widget()still has no edge at all whenWidgetlives in an enclosing namespace and the file also usesWidgetas 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
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
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 theusingnamespaces. The enclosing namespaces of the declaring namespace are never added.resolve_type_name()looks the name up in exactly those scopes. ForWidgetinsideDemo.Innerit finds nothing and returns(None, False).extract.pylines 8703 and 8870: a constructor call markedvia_stubis 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
.csfiles, rawgraph.json, unpatched releases, same source tree):new X()sites: 38 (caller, class) pairs have acallsedge on 0.9.55 and neither acallsnor areferencesedge on 0.9.79. For 29 of them the class is not found through the caller's own namespace or itsusings, only through an enclosing namespace. The other 9 have other causes, which I did not analyse.references, 55inheritsand 86implementsedges 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:
Measured against unpatched 0.9.79 (two unpatched runs give identical
calls/references/inherits/implementsedges, so the differences come from the change):references, 55inherits, 86implementsedges from placeholder to real definition; 65 newcallspairs (17new 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.referencesedge disappeared: a property whose type is resolved through ausing, 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 ausingnamespace 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 16callsedges 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 theusings find nothing. It errs towards today's behaviour.All measurements: AST extraction only, no LLM.