Skip to content

Namespace-aware C# type-reference resolution - #401

Merged
zzet merged 6 commits into
zzet:mainfrom
pbednarcik:feat/csharp-namespace-aware-refs
Jul 29, 2026
Merged

Namespace-aware C# type-reference resolution#401
zzet merged 6 commits into
zzet:mainfrom
pbednarcik:feat/csharp-namespace-aware-refs

Conversation

@pbednarcik

Copy link
Copy Markdown

Summary

C# type references resolve by bare name, so when several types share a name across namespaces — routine in module-per-domain solutions, where each module declares its own XProfile / XConfiguration — the ranker ties and falls to the lexicographically-smallest ID. Measured on a real solution: all 9 references to one module's mapping profile bound to a sibling module's same-named profile, leaving the real target a dead-code false positive and giving the rival 9 phantom callers.

The evidence to disambiguate is already in the graph: every using directive is an EdgeImports off the file node, and every C# type carries Meta["scope_ns"]. This PR joins the two in a narrowing step that applies the compiler's own lookup order — written qualifier, then enclosing namespaces (deepest wins), then using directives — ahead of bestTypeCandidate's ranking. Same narrowing-only contract as phpNarrowByTargetFQN: when no candidate namespace is visible, the previous ranking stands untouched, so a binding can sharpen but never be lost.

Changes

  • csharpNarrowByNamespace (csharp_ns_narrow.go), hooked beside the PHP narrow in resolveTypeOrFunc and resolveTypeRef. Only type/interface candidates in the dotnet family participate — methods carry scope_ns too and would otherwise steal the bind (or lose the edge on the type-only path). Per-file namespace sets are memoised behind an RWMutex (built lazily inside the parallel workers, cleared with the per-pass lookup caches — the importFilesByCaller discipline).
  • Using evidence rides the file node as Meta["usings"], stamped at extraction with C# visibility semantics (aliases and using static excluded — they grant no bare-name namespace visibility). The per-directive import edges are not a stable source: resolveImport rewrites one to a file-node target whenever the namespace tail matches a directory basename — the standard folder-mirrors-namespace layout — which would silently and order-dependently empty the tier. Edge shapes remain a fallback for pre-stamp graphs.
  • Enclosing namespaces outrank usings: the compiler searches inner scopes first, so an internal type in the file's own namespace must shadow a public same-named type in an imported one — the exported-first ranker was inverting that on real code.
  • fix(csharp): C#10 file-scoped namespaces (namespace X;) never got scope_ns — the governed declarations are AST siblings of the directive, not children, so the ancestor walk missed them. Every type in such files was invisible to any namespace evidence (and to the scope_ns-keyed partial merge). Nested block namespaces now also join outer-to-inner (A.B, previously the bare inner segment).
  • Qualified spellings keep their namespace: canonicalisation stripped A.B.Foo to Foo, discarding the one namespace fact the source states outright — and aggregator-style files qualify precisely because they import nothing. The dotted spelling rides Meta["target_fqn"] on reference, instantiates, and base-list edges (the key the PHP extractor already uses; the PHP reader now rejects dotted C# qualifiers) and narrows by dot-boundary suffix first, covering C#'s partially-qualified forms.

Known gaps (documented, not silent)

  • Alias visibility (using X = Full.Name;) is not modelled — aliases are excluded from the using set rather than misread as namespace imports; a file importing only via aliases falls back to the previous ranking. global using counts for its declaring file only.
  • Cross-assembly visibility (internal types across project boundaries) is not consulted — candidates are namespace-filtered, not assembly-filtered.
  • A qualifier naming a nested type (Ns.Outer.Inner) reads Ns.Outer as a namespace; standard namespace-qualified spellings are unaffected.
  • Types in the global namespace carry no scope_ns and never match a tier; they resolve exactly as before.
  • Namespace sets are per file, not per site — a file declaring several sibling namespace blocks uses their union.

Real-world results (reference Autofac + EF solution, 10,375 files)

  • The flagship misbind: 9/9 references to a module's mapping profile bound to the wrong module before, 9/9 to the right one after.
  • Same-named-profile false dead-code positives: 39 → 2 — and both survivors were verified genuinely unregistered types (real dead code the misbinds had been masking).
  • The enclosing-vs-imported case (internal own-namespace type vs public imported rival): 4/4 references corrected.
  • A fully-qualified registration in an aggregator file (imports nothing, qualifies everything) bound exactly; it bound to an arbitrary same-named type before.
  • Types in file-scoped-namespace files went from no scope_ns at all to fully resolvable.

Testing

  • All tests pass (go test -race ./...) — Linux-equivalent scope; Windows dev box shows only the known pre-existing failures
  • New tests: 10 resolver tests (using-directive pick, extends path, enclosing chain incl. deepest-wins, enclosing-beats-imported, full + partial qualifier, rewritten-import shape, method-decoy guard, external:: fallback, never-lose fallback) and 6 extractor tests (file-scoped + nested-block scope_ns, usings Meta incl. alias/static exclusion, qualified-spelling target_fqn on reference and base-list forms)

Checklist

  • Code follows existing patterns (mirrors the PHP target-FQN narrow and the Razor using pass; cache mirrors importFilesByCaller)
  • No unnecessary abstractions added

Peter Bednarcik added 6 commits July 29, 2026 17:48
Same-named C# types across namespaces resolved by lexicographic ID —
every reference in one module could bind to a sibling module's type,
orphaning the real target. Narrow candidates to namespaces the file
imports (EdgeImports) or encloses (scope_ns prefixes) before ranking.
Narrowing only: no visible namespace keeps the previous pick.
The compiler searches enclosing namespaces before using directives, so
an internal type in the file's own namespace shadows a public same-named
type in an imported one — the exported-first ranker was inverting that.
Split the visible set into enclosing (deepest match wins) and imported
tiers.
A file-scoped namespace declaration spans only its own statement in the
tree-sitter AST — governed declarations are siblings, not children — so
the ancestor walk never found it and every type in such a file lost its
scope_ns. Fall back to scanning the compilation unit's children.
Canonicalisation stripped Shared.Reporting.Foo to Foo, so a fully
qualified reference — written qualified precisely because the file
imports nothing relevant — lost the one namespace fact the source
stated outright. Stamp the dotted spelling as Meta["target_fqn"] and
narrow by qualifier suffix first, above the enclosing/imported tiers.
- Narrowing gates to type/interface candidates in the dotnet family:
  methods carry scope_ns too and could steal the bind (or lose the
  edge on the type-only path); razor components now participate.
- Using evidence rides the file node as Meta["usings"] — resolveImport
  rewrites a using edge to a file-node target when the namespace tail
  matches a directory basename, which silently emptied the imported
  tier. Aliases and using-static grant no bare-name visibility and are
  excluded. Edge shapes remain as fallback for pre-stamp graphs.
- Nested block namespaces join outer-to-inner (A.B, not B).
- Base-list spellings carry target_fqn so extends/implements reach the
  qualifier tier; phpNarrowByTargetFQN rejects dotted C# qualifiers.
@zzet
zzet merged commit 400404e into zzet:main Jul 29, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants