From c9846b9937bc051bff05057c0eec4509640a4b3a Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 14:38:02 +0200 Subject: [PATCH 1/6] feat(resolver): namespace-aware C# type-reference disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/resolver/csharp_ns_narrow.go | 101 +++++++++++++++ internal/resolver/csharp_ns_narrow_test.go | 138 +++++++++++++++++++++ internal/resolver/resolver.go | 24 ++++ 3 files changed, 263 insertions(+) create mode 100644 internal/resolver/csharp_ns_narrow.go create mode 100644 internal/resolver/csharp_ns_narrow_test.go diff --git a/internal/resolver/csharp_ns_narrow.go b/internal/resolver/csharp_ns_narrow.go new file mode 100644 index 00000000..706f05ee --- /dev/null +++ b/internal/resolver/csharp_ns_narrow.go @@ -0,0 +1,101 @@ +package resolver + +import ( + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +// C# namespace narrowing. A using directive imports a whole namespace, +// not a name — unlike PHP there is no per-reference FQN to stamp at +// extraction. The evidence is already in the graph instead: using +// directives are EdgeImports off the file node, and every C# type +// carries Meta["scope_ns"]. Joining the two applies the compiler's own +// lookup rule where the bare-name ranking would otherwise tie-break +// same-named types by lexicographic ID — a sibling module's type. + +// csharpFileNamespaceSet returns the namespaces visible to a C# file: +// its using-directive imports (pre-resolution unresolved::import:: or +// post-resolution external:: shape — in-pass ordering must not matter) +// plus the file's own declared namespaces and their enclosing prefixes. +func (r *Resolver) csharpFileNamespaceSet(fileID string) map[string]struct{} { + r.csharpNSMu.RLock() + set, ok := r.csharpNSByFile[fileID] + r.csharpNSMu.RUnlock() + if ok { + return set + } + + set = map[string]struct{}{} + for _, e := range r.graph.GetOutEdges(fileID) { + if e == nil { + continue + } + switch e.Kind { + case graph.EdgeImports: + ns := e.To + switch { + case strings.HasPrefix(ns, "unresolved::import::"): + ns = strings.TrimPrefix(ns, "unresolved::import::") + case strings.HasPrefix(ns, "external::"): + ns = strings.TrimPrefix(ns, "external::") + default: + continue + } + if ns != "" { + set[strings.ReplaceAll(ns, "/", ".")] = struct{}{} + } + case graph.EdgeDefines: + n := r.cachedGetNode(e.To) + if n == nil { + continue + } + ns, _ := n.Meta["scope_ns"].(string) + for ns != "" { + set[ns] = struct{}{} + i := strings.LastIndex(ns, ".") + if i < 0 { + break + } + ns = ns[:i] + } + } + } + + r.csharpNSMu.Lock() + if r.csharpNSByFile == nil { + r.csharpNSByFile = map[string]map[string]struct{}{} + } + r.csharpNSByFile[fileID] = set + r.csharpNSMu.Unlock() + return set +} + +// csharpNarrowByNamespace filters same-named C# type candidates to the +// ones declared in a namespace the referencing file can see. Same +// contract as phpNarrowByTargetFQN: narrowing only — nil when no +// candidate namespace is visible — so a binding can sharpen but never +// be lost. +func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.Node) []*graph.Node { + if len(candidates) < 2 || !strings.HasSuffix(e.FilePath, ".cs") { + return nil + } + visible := r.csharpFileNamespaceSet(e.FilePath) + if len(visible) == 0 { + return nil + } + var out []*graph.Node + for _, c := range candidates { + if c == nil || c.Language != "csharp" { + continue + } + ns, _ := c.Meta["scope_ns"].(string) + if ns == "" { + continue + } + if _, ok := visible[ns]; ok { + out = append(out, c) + } + } + return out +} diff --git a/internal/resolver/csharp_ns_narrow_test.go b/internal/resolver/csharp_ns_narrow_test.go new file mode 100644 index 00000000..c14ac916 --- /dev/null +++ b/internal/resolver/csharp_ns_narrow_test.go @@ -0,0 +1,138 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// Fixture: two C# types named PricingRules in sibling namespaces. The +// lexicographic tie-break always picks Billing (smaller ID) — the +// narrowing must let the file's using directives pick Sales instead. +func csharpNSFixture() *graph.Graph { + g := graph.New() + g.AddNode(&graph.Node{ID: "app/Billing/Rules/PricingRules.cs", Kind: graph.KindFile, Name: "PricingRules.cs", FilePath: "app/Billing/Rules/PricingRules.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Billing/Rules/PricingRules.cs::PricingRules", Kind: graph.KindType, Name: "PricingRules", + FilePath: "app/Billing/Rules/PricingRules.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Billing.Rules", "visibility": "public"}, + }) + g.AddNode(&graph.Node{ID: "app/Sales/Rules/PricingRules.cs", Kind: graph.KindFile, Name: "PricingRules.cs", FilePath: "app/Sales/Rules/PricingRules.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Sales/Rules/PricingRules.cs::PricingRules", Kind: graph.KindType, Name: "PricingRules", + FilePath: "app/Sales/Rules/PricingRules.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Sales.Rules", "visibility": "public"}, + }) + g.AddNode(&graph.Node{ID: "app/Web/Startup.cs", Kind: graph.KindFile, Name: "Startup.cs", FilePath: "app/Web/Startup.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Web/Startup.cs::Startup", Kind: graph.KindType, Name: "Startup", + FilePath: "app/Web/Startup.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Web", "visibility": "public"}, + }) + g.AddNode(&graph.Node{ + ID: "app/Web/Startup.cs::Startup.Configure", Kind: graph.KindMethod, Name: "Configure", + FilePath: "app/Web/Startup.cs", Language: "csharp", RepoPrefix: "app", + }) + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "app/Web/Startup.cs::Startup", Kind: graph.EdgeDefines, FilePath: "app/Web/Startup.cs", Line: 3}) + return g +} + +// TestCSharpNamespaceNarrow_UsingWins: the referencing file imports +// App.Sales.Rules — the reference must bind there, not to the +// lexicographically-smaller Billing rival. +func TestCSharpNamespaceNarrow_UsingWins(t *testing.T) { + g := csharpNSFixture() + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Sales/Rules", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "using directive must pick the imported namespace over the lexicographic rival") +} + +// TestCSharpNamespaceNarrow_ExtendsPath: same disambiguation on the +// type-hierarchy path (resolveTypeRef) — a base-class reference follows +// the using directives too. +func TestCSharpNamespaceNarrow_ExtendsPath(t *testing.T) { + g := csharpNSFixture() + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Sales/Rules", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ext := &graph.Edge{ + From: "app/Web/Startup.cs::Startup", To: "unresolved::PricingRules", + Kind: graph.EdgeExtends, FilePath: "app/Web/Startup.cs", Line: 3, + } + g.AddEdge(ext) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ext.To, + "extends must follow the using directive") +} + +// TestCSharpNamespaceNarrow_OwnNamespaceChain: no using needed — a file +// whose own namespace is App.Sales.Rules.Internal sees App.Sales.Rules +// through the enclosing-namespace chain, exactly as the compiler does. +func TestCSharpNamespaceNarrow_OwnNamespaceChain(t *testing.T) { + g := csharpNSFixture() + g.AddNode(&graph.Node{ID: "app/Sales/Rules/Sub/Helpers.cs", Kind: graph.KindFile, Name: "Helpers.cs", FilePath: "app/Sales/Rules/Sub/Helpers.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Sales/Rules/Sub/Helpers.cs::Helpers", Kind: graph.KindType, Name: "Helpers", + FilePath: "app/Sales/Rules/Sub/Helpers.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Sales.Rules.Internal", "visibility": "public"}, + }) + g.AddEdge(&graph.Edge{From: "app/Sales/Rules/Sub/Helpers.cs", To: "app/Sales/Rules/Sub/Helpers.cs::Helpers", Kind: graph.EdgeDefines, FilePath: "app/Sales/Rules/Sub/Helpers.cs", Line: 3}) + ref := &graph.Edge{ + From: "app/Sales/Rules/Sub/Helpers.cs::Helpers", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Sales/Rules/Sub/Helpers.cs", Line: 8, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "enclosing-namespace chain must reach the sibling namespace") +} + +// TestCSharpNamespaceNarrow_NoMatchKeepsOldPick: narrowing only, never a +// loss — when no candidate namespace is imported (external assembly), +// the previous deterministic ranking stands and the edge stays resolved. +func TestCSharpNamespaceNarrow_NoMatchKeepsOldPick(t *testing.T) { + g := csharpNSFixture() + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::ThirdParty/Sdk", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + require.NotContains(t, ref.To, "unresolved::", "edge must not be lost") + assert.Equal(t, "app/Billing/Rules/PricingRules.cs::PricingRules", ref.To, + "without namespace evidence the previous deterministic pick stands") +} + +// TestCSharpNamespaceNarrow_ExternalImportShape: by the time a reference +// resolves, the same-pass import resolution may already have rewritten +// the using edge to its external:: form — both shapes must count. +func TestCSharpNamespaceNarrow_ExternalImportShape(t *testing.T) { + g := csharpNSFixture() + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "external::App/Sales/Rules", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "an already-resolved external:: using edge still carries the namespace") +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2e109149..ba523db6 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -298,6 +298,15 @@ type Resolver struct { importFilesByCaller map[string]map[string]struct{} importFilesMu sync.RWMutex + // csharpNSByFile memoises, per C# file, the namespaces the file can + // see: its using-directive imports plus its own declared namespaces + // with every enclosing prefix. Built lazily inside the parallel + // resolve workers — csharpNSMu guards it — and cleared with the + // per-pass lookup caches. Consulted by csharpNarrowByNamespace; see + // csharp_ns_narrow.go. + csharpNSByFile map[string]map[string]struct{} + csharpNSMu sync.RWMutex + // incrementalSkip holds the source-shapes of a single re-resolved file's // out-edges that were already unresolved before the edit; the forward // pass skips them. Set/cleared around ResolveFileAndIncoming by the @@ -1918,6 +1927,9 @@ func (r *Resolver) clearLookupCache() { r.importFilesMu.Lock() r.importFilesByCaller = nil r.importFilesMu.Unlock() + r.csharpNSMu.Lock() + r.csharpNSByFile = nil + r.csharpNSMu.Unlock() } // cachedGetNode returns the node for id, consulting the per-pass @@ -3460,6 +3472,12 @@ func (r *Resolver) resolveTypeOrFunc(e *graph.Edge, name string, stats *ResolveS candidates = narrowed } + // C#: narrow to the namespaces the file imports or encloses — same + // narrowing-only contract as the PHP pass above. See csharp_ns_narrow.go. + if narrowed := r.csharpNarrowByNamespace(e, candidates); len(narrowed) > 0 { + candidates = narrowed + } + // Land the edge on the canonical type/interface definition (real, // exported, top-level, non-test), preferring same-package only as a // tiebreak. See bestTypeCandidate / resolveTypeRef for the rationale: @@ -3523,6 +3541,12 @@ func (r *Resolver) resolveTypeRef(e *graph.Edge, name string, stats *ResolveStat candidates = narrowed } + // C#: narrow to the namespaces the file imports or encloses — same + // narrowing-only contract as the PHP pass above. See csharp_ns_narrow.go. + if narrowed := r.csharpNarrowByNamespace(e, candidates); len(narrowed) > 0 { + candidates = narrowed + } + // Land the edge on the canonical type/interface definition. The // ranker prefers a real, exported, top-level, non-test definition // over same-named rivals (external stubs, test/mock defs, private or From 5f4b4ffcb39966ec65d7e180bc535f18c8466326 Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 15:07:54 +0200 Subject: [PATCH 2/6] feat(resolver): enclosing namespaces outrank usings in C# narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/resolver/csharp_ns_narrow.go | 78 ++++++++++++++-------- internal/resolver/csharp_ns_narrow_test.go | 41 ++++++++++++ internal/resolver/resolver.go | 2 +- 3 files changed, 92 insertions(+), 29 deletions(-) diff --git a/internal/resolver/csharp_ns_narrow.go b/internal/resolver/csharp_ns_narrow.go index 706f05ee..ee0666a5 100644 --- a/internal/resolver/csharp_ns_narrow.go +++ b/internal/resolver/csharp_ns_narrow.go @@ -14,77 +14,87 @@ import ( // lookup rule where the bare-name ranking would otherwise tie-break // same-named types by lexicographic ID — a sibling module's type. +// csharpFileNS is a C# file's visible-namespace evidence, split the way +// the compiler consults it: enclosing namespaces are searched before +// using directives, so an own-namespace type shadows an imported one. +type csharpFileNS struct { + enclosing map[string]struct{} // declared namespaces + their prefixes + imported map[string]struct{} // using-directive namespaces +} + // csharpFileNamespaceSet returns the namespaces visible to a C# file: // its using-directive imports (pre-resolution unresolved::import:: or // post-resolution external:: shape — in-pass ordering must not matter) // plus the file's own declared namespaces and their enclosing prefixes. -func (r *Resolver) csharpFileNamespaceSet(fileID string) map[string]struct{} { +func (r *Resolver) csharpFileNamespaceSet(fileID string) csharpFileNS { r.csharpNSMu.RLock() - set, ok := r.csharpNSByFile[fileID] + ns, ok := r.csharpNSByFile[fileID] r.csharpNSMu.RUnlock() if ok { - return set + return ns } - set = map[string]struct{}{} + ns = csharpFileNS{enclosing: map[string]struct{}{}, imported: map[string]struct{}{}} for _, e := range r.graph.GetOutEdges(fileID) { if e == nil { continue } switch e.Kind { case graph.EdgeImports: - ns := e.To + imp := e.To switch { - case strings.HasPrefix(ns, "unresolved::import::"): - ns = strings.TrimPrefix(ns, "unresolved::import::") - case strings.HasPrefix(ns, "external::"): - ns = strings.TrimPrefix(ns, "external::") + case strings.HasPrefix(imp, "unresolved::import::"): + imp = strings.TrimPrefix(imp, "unresolved::import::") + case strings.HasPrefix(imp, "external::"): + imp = strings.TrimPrefix(imp, "external::") default: continue } - if ns != "" { - set[strings.ReplaceAll(ns, "/", ".")] = struct{}{} + if imp != "" { + ns.imported[strings.ReplaceAll(imp, "/", ".")] = struct{}{} } case graph.EdgeDefines: n := r.cachedGetNode(e.To) if n == nil { continue } - ns, _ := n.Meta["scope_ns"].(string) - for ns != "" { - set[ns] = struct{}{} - i := strings.LastIndex(ns, ".") + scope, _ := n.Meta["scope_ns"].(string) + for scope != "" { + ns.enclosing[scope] = struct{}{} + i := strings.LastIndex(scope, ".") if i < 0 { break } - ns = ns[:i] + scope = scope[:i] } } } r.csharpNSMu.Lock() if r.csharpNSByFile == nil { - r.csharpNSByFile = map[string]map[string]struct{}{} + r.csharpNSByFile = map[string]csharpFileNS{} } - r.csharpNSByFile[fileID] = set + r.csharpNSByFile[fileID] = ns r.csharpNSMu.Unlock() - return set + return ns } // csharpNarrowByNamespace filters same-named C# type candidates to the -// ones declared in a namespace the referencing file can see. Same -// contract as phpNarrowByTargetFQN: narrowing only — nil when no -// candidate namespace is visible — so a binding can sharpen but never -// be lost. +// ones declared in a namespace the referencing file can see, enclosing +// namespaces first (deepest match wins, mirroring inner-to-outer scope +// search) and using directives second. Same contract as +// phpNarrowByTargetFQN: narrowing only — nil when no candidate +// namespace is visible — so a binding can sharpen but never be lost. func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.Node) []*graph.Node { if len(candidates) < 2 || !strings.HasSuffix(e.FilePath, ".cs") { return nil } visible := r.csharpFileNamespaceSet(e.FilePath) - if len(visible) == 0 { + if len(visible.enclosing) == 0 && len(visible.imported) == 0 { return nil } - var out []*graph.Node + var enclosing, imported []*graph.Node + deepest := 0 for _, c := range candidates { if c == nil || c.Language != "csharp" { continue @@ -93,9 +103,21 @@ func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.No if ns == "" { continue } - if _, ok := visible[ns]; ok { - out = append(out, c) + if _, ok := visible.enclosing[ns]; ok { + switch { + case len(ns) > deepest: + enclosing, deepest = []*graph.Node{c}, len(ns) + case len(ns) == deepest: + enclosing = append(enclosing, c) + } + continue + } + if _, ok := visible.imported[ns]; ok { + imported = append(imported, c) } } - return out + if len(enclosing) > 0 { + return enclosing + } + return imported } diff --git a/internal/resolver/csharp_ns_narrow_test.go b/internal/resolver/csharp_ns_narrow_test.go index c14ac916..a6bafb7e 100644 --- a/internal/resolver/csharp_ns_narrow_test.go +++ b/internal/resolver/csharp_ns_narrow_test.go @@ -119,6 +119,47 @@ func TestCSharpNamespaceNarrow_NoMatchKeepsOldPick(t *testing.T) { "without namespace evidence the previous deterministic pick stands") } +// TestCSharpNamespaceNarrow_EnclosingBeatsImported: the compiler searches +// enclosing namespaces before using directives — an internal type in the +// file's own namespace wins over a public same-named type in an imported +// one, even though the ranker prefers exported candidates. +func TestCSharpNamespaceNarrow_EnclosingBeatsImported(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "app/Billing/Module.cs", Kind: graph.KindFile, Name: "Module.cs", FilePath: "app/Billing/Module.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Billing/Module.cs::Module", Kind: graph.KindType, Name: "Module", + FilePath: "app/Billing/Module.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Billing", "visibility": "public"}, + }) + g.AddEdge(&graph.Edge{From: "app/Billing/Module.cs", To: "app/Billing/Module.cs::Module", Kind: graph.EdgeDefines, FilePath: "app/Billing/Module.cs", Line: 3}) + g.AddEdge(&graph.Edge{From: "app/Billing/Module.cs", To: "unresolved::import::App/Shared/Lookup", Kind: graph.EdgeImports, FilePath: "app/Billing/Module.cs", Line: 1}) + // Own-namespace candidate: internal (ranked below public by the + // canonical-definition ranker). + g.AddNode(&graph.Node{ID: "app/Billing/Rules.cs", Kind: graph.KindFile, Name: "Rules.cs", FilePath: "app/Billing/Rules.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Billing/Rules.cs::Rules", Kind: graph.KindType, Name: "Rules", + FilePath: "app/Billing/Rules.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Billing", "visibility": "internal"}, + }) + // Imported-namespace candidate: public. + g.AddNode(&graph.Node{ID: "app/Shared/Lookup/Rules.cs", Kind: graph.KindFile, Name: "Rules.cs", FilePath: "app/Shared/Lookup/Rules.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Shared/Lookup/Rules.cs::Rules", Kind: graph.KindType, Name: "Rules", + FilePath: "app/Shared/Lookup/Rules.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Shared.Lookup", "visibility": "public"}, + }) + ref := &graph.Edge{ + From: "app/Billing/Module.cs::Module", To: "unresolved::Rules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Billing/Module.cs", Line: 10, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Billing/Rules.cs::Rules", ref.To, + "the enclosing-namespace type wins over an imported one regardless of visibility rank") +} + // TestCSharpNamespaceNarrow_ExternalImportShape: by the time a reference // resolves, the same-pass import resolution may already have rewritten // the using edge to its external:: form — both shapes must count. diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index ba523db6..8657ba2d 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -304,7 +304,7 @@ type Resolver struct { // resolve workers — csharpNSMu guards it — and cleared with the // per-pass lookup caches. Consulted by csharpNarrowByNamespace; see // csharp_ns_narrow.go. - csharpNSByFile map[string]map[string]struct{} + csharpNSByFile map[string]csharpFileNS csharpNSMu sync.RWMutex // incrementalSkip holds the source-shapes of a single re-resolved file's From b1fb809bea60a2fb7d5b45706779865147e523f9 Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 15:16:47 +0200 Subject: [PATCH 3/6] fix(csharp): stamp scope_ns for C#10 file-scoped namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/parser/languages/csharp.go | 36 ++++++++++++++---- .../languages/csharp_filescoped_ns_test.go | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 internal/parser/languages/csharp_filescoped_ns_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index c64b40d5..d5592f77 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -616,19 +616,39 @@ func csharpExtensionReceiverType(methodNode *sitter.Node, src []byte) string { // csharpEnclosingNamespace returns the dotted name of the nearest enclosing // namespace declaration (block or file-scoped), or "". func csharpEnclosingNamespace(node *sitter.Node, src []byte) string { + root := node for n := node; n != nil; n = n.Parent() { t := n.Type() if t == "namespace_declaration" || t == "file_scoped_namespace_declaration" { - if nm := n.ChildByFieldName("name"); nm != nil { - return strings.TrimSpace(nm.Content(src)) - } - for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { - c := n.NamedChild(i) - if c.Type() == "identifier" || c.Type() == "qualified_name" { - return strings.TrimSpace(c.Content(src)) - } + if nm := csharpNamespaceName(n, src); nm != "" { + return nm } } + root = n + } + // File-scoped form: `namespace X;` spans only its own statement in the + // AST — the declarations it governs are later siblings under the + // compilation unit, so the ancestor walk above never sees it. + for i, _nc := 0, int(root.NamedChildCount()); i < _nc; i++ { + c := root.NamedChild(i) + if c.Type() == "file_scoped_namespace_declaration" && c.StartByte() <= node.StartByte() { + return csharpNamespaceName(c, src) + } + } + return "" +} + +// csharpNamespaceName extracts the dotted name from a namespace_declaration +// or file_scoped_namespace_declaration node. +func csharpNamespaceName(n *sitter.Node, src []byte) string { + if nm := n.ChildByFieldName("name"); nm != nil { + return strings.TrimSpace(nm.Content(src)) + } + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + c := n.NamedChild(i) + if c.Type() == "identifier" || c.Type() == "qualified_name" { + return strings.TrimSpace(c.Content(src)) + } } return "" } diff --git a/internal/parser/languages/csharp_filescoped_ns_test.go b/internal/parser/languages/csharp_filescoped_ns_test.go new file mode 100644 index 00000000..95f7004d --- /dev/null +++ b/internal/parser/languages/csharp_filescoped_ns_test.go @@ -0,0 +1,38 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCSharpExtractor_FileScopedNamespaceScopeNS: a C#10 file-scoped +// namespace spans only its own line in the AST — the declarations it +// governs are siblings, not children, so the ancestor walk alone never +// finds it and every type in such a file loses its scope_ns. +func TestCSharpExtractor_FileScopedNamespaceScopeNS(t *testing.T) { + src := []byte(`namespace App.Core.Metrics; + +public class MetricsCollector +{ + public void Configure() {} +} + +public interface IMetrics {} +`) + res, err := NewCSharpExtractor().Extract("a.cs", src) + require.NoError(t, err) + + for _, name := range []string{"MetricsCollector", "Configure", "IMetrics"} { + found := false + for _, n := range res.Nodes { + if n.Name == name { + found = true + assert.Equal(t, "App.Core.Metrics", n.Meta["scope_ns"], + "%s must carry the file-scoped namespace", name) + } + } + require.True(t, found, "node %s missing", name) + } +} From 9dfd28146dde3a37820cc95796483a672c6c633b Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 15:30:36 +0200 Subject: [PATCH 4/6] feat(csharp): qualified type spellings carry their namespace evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../parser/languages/csharp_function_shape.go | 37 +++++++++++-- .../languages/csharp_qualified_ref_test.go | 55 +++++++++++++++++++ internal/parser/languages/csharp_typeuse.go | 8 +++ internal/resolver/csharp_ns_narrow.go | 42 +++++++++++++- internal/resolver/csharp_ns_narrow_test.go | 47 ++++++++++++++++ 5 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 internal/parser/languages/csharp_qualified_ref_test.go diff --git a/internal/parser/languages/csharp_function_shape.go b/internal/parser/languages/csharp_function_shape.go index eb97f758..23445142 100644 --- a/internal/parser/languages/csharp_function_shape.go +++ b/internal/parser/languages/csharp_function_shape.go @@ -186,6 +186,37 @@ func emitCSharpGenericParamNodes(ownerID string, methodNode *sitter.Node, src [] } } +// csharpContainerGenerics are wrapper types whose single type argument is +// the type a reference is really about; canonicalisation unwraps them. +var csharpContainerGenerics = []string{ + "Task", "ValueTask", "List", "IList", "IEnumerable", + "ICollection", "IReadOnlyList", "IReadOnlyCollection", + "IAsyncEnumerable", "Nullable", "Span", "ReadOnlySpan", +} + +// csharpQualifiedTypeRef mirrors canonicalizeCSharpTypeRef but keeps the +// dotted qualifier ("Common.Lookup.Foo?" -> "Common.Lookup.Foo"). +// Returns "" when the spelling carries no qualifier. +func csharpQualifiedTypeRef(t string) string { + t = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), "?")) + for strings.HasSuffix(t, "[]") { + t = strings.TrimSpace(strings.TrimSuffix(t, "[]")) + } + for _, wrapper := range csharpContainerGenerics { + prefix := wrapper + "<" + if strings.HasPrefix(t, prefix) && strings.HasSuffix(t, ">") { + return csharpQualifiedTypeRef(t[len(prefix) : len(t)-1]) + } + } + if idx := strings.Index(t, "<"); idx > 0 { + t = t[:idx] + } + if !strings.Contains(t, ".") { + return "" + } + return t +} + func canonicalizeCSharpTypeRef(t string) string { t = strings.TrimSpace(t) if t == "" { @@ -200,11 +231,7 @@ func canonicalizeCSharpTypeRef(t string) string { t = strings.TrimSpace(t) } // Unwrap container generics. - for _, wrapper := range []string{ - "Task", "ValueTask", "List", "IList", "IEnumerable", - "ICollection", "IReadOnlyList", "IReadOnlyCollection", - "IAsyncEnumerable", "Nullable", "Span", "ReadOnlySpan", - } { + for _, wrapper := range csharpContainerGenerics { prefix := wrapper + "<" if strings.HasPrefix(t, prefix) && strings.HasSuffix(t, ">") { inner := t[len(prefix) : len(t)-1] diff --git a/internal/parser/languages/csharp_qualified_ref_test.go b/internal/parser/languages/csharp_qualified_ref_test.go new file mode 100644 index 00000000..83a724fb --- /dev/null +++ b/internal/parser/languages/csharp_qualified_ref_test.go @@ -0,0 +1,55 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// TestCSharpExtractor_QualifiedTypeRefKeepsFQN: a qualified type spelling +// (`AddProfile()`) names its namespace in +// the source — canonicalisation must not silently discard it. The bare +// name stays the target; the dotted spelling rides Meta["target_fqn"] +// for the resolver's namespace narrowing. +func TestCSharpExtractor_QualifiedTypeRefKeepsFQN(t *testing.T) { + src := []byte(`public class Wiring { + public void Configure(MapperConfig cfg) { + cfg.AddProfile(); + cfg.AddProfile(); + var x = new Data.Models.Order(); + } +} +`) + res, err := NewCSharpExtractor().Extract("w.cs", src) + require.NoError(t, err) + + var qualified, bare, created *graph.Edge + for _, e := range res.Edges { + switch e.To { + case "unresolved::SalesProfile": + if e.Kind == graph.EdgeReferences { + qualified = e + } + case "unresolved::LocalProfile": + if e.Kind == graph.EdgeReferences { + bare = e + } + case "unresolved::Order": + if e.Kind == graph.EdgeInstantiates { + created = e + } + } + } + + require.NotNil(t, qualified, "qualified generic arg must emit a reference") + assert.Equal(t, "Shared.Reporting.SalesProfile", qualified.Meta["target_fqn"]) + + require.NotNil(t, bare, "bare generic arg must emit a reference") + assert.Nil(t, bare.Meta["target_fqn"], "bare spellings carry no qualifier") + + require.NotNil(t, created, "qualified new must emit an instantiates edge") + assert.Equal(t, "Data.Models.Order", created.Meta["target_fqn"]) +} diff --git a/internal/parser/languages/csharp_typeuse.go b/internal/parser/languages/csharp_typeuse.go index d5d08bad..f00ef33a 100644 --- a/internal/parser/languages/csharp_typeuse.go +++ b/internal/parser/languages/csharp_typeuse.go @@ -121,6 +121,14 @@ func emitCSharpReferenceForms(root *sitter.Node, src []byte, filePath, fileID st if refContext != "" { edge.Meta = map[string]any{"ref_context": refContext} } + // A qualified spelling names its namespace in the source — keep it + // as evidence for the resolver's namespace narrowing. + if fqn := csharpQualifiedTypeRef(rawType); fqn != "" { + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["target_fqn"] = fqn + } result.Edges = append(result.Edges, edge) } diff --git a/internal/resolver/csharp_ns_narrow.go b/internal/resolver/csharp_ns_narrow.go index ee0666a5..2ba4a696 100644 --- a/internal/resolver/csharp_ns_narrow.go +++ b/internal/resolver/csharp_ns_narrow.go @@ -89,13 +89,43 @@ func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.No if len(candidates) < 2 || !strings.HasSuffix(e.FilePath, ".cs") { return nil } + + // A qualifier written at the reference site (Meta["target_fqn"], from + // a qualified spelling like Shared.Reporting.Foo) is the strongest + // evidence: keep only candidates whose namespace ends in it. C# + // resolves partial qualifiers against visible namespaces, so a suffix + // match on a dot boundary covers both the full and partial forms. + qualified := candidates + if fqn, _ := e.Meta["target_fqn"].(string); strings.Contains(fqn, ".") { + q := fqn[:strings.LastIndex(fqn, ".")] + var m []*graph.Node + for _, c := range candidates { + if c == nil || c.Language != "csharp" { + continue + } + ns, _ := c.Meta["scope_ns"].(string) + if ns == q || strings.HasSuffix(ns, "."+q) { + m = append(m, c) + } + } + if len(m) == 1 { + return m + } + if len(m) > 1 { + qualified = m + } + } + visible := r.csharpFileNamespaceSet(e.FilePath) if len(visible.enclosing) == 0 && len(visible.imported) == 0 { + if len(qualified) < len(candidates) { + return qualified + } return nil } var enclosing, imported []*graph.Node deepest := 0 - for _, c := range candidates { + for _, c := range qualified { if c == nil || c.Language != "csharp" { continue } @@ -119,5 +149,13 @@ func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.No if len(enclosing) > 0 { return enclosing } - return imported + if len(imported) > 0 { + return imported + } + // The qualifier narrowed but no visible-namespace tier confirmed — + // the written qualifier alone still beats a bare-name guess. + if len(qualified) < len(candidates) { + return qualified + } + return nil } diff --git a/internal/resolver/csharp_ns_narrow_test.go b/internal/resolver/csharp_ns_narrow_test.go index a6bafb7e..4224a31a 100644 --- a/internal/resolver/csharp_ns_narrow_test.go +++ b/internal/resolver/csharp_ns_narrow_test.go @@ -160,6 +160,53 @@ func TestCSharpNamespaceNarrow_EnclosingBeatsImported(t *testing.T) { "the enclosing-namespace type wins over an imported one regardless of visibility rank") } +// TestCSharpNamespaceNarrow_QualifiedReference: a fully qualified +// spelling needs no using directive at all — the qualifier stamped as +// Meta["target_fqn"] must pick its namespace exactly, even when the +// referencing file imports nothing relevant. +func TestCSharpNamespaceNarrow_QualifiedReference(t *testing.T) { + g := csharpNSFixture() + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + Meta: map[string]any{"target_fqn": "App.Sales.Rules.PricingRules"}, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "the written qualifier must pick the namespace without any using") +} + +// TestCSharpNamespaceNarrow_PartialQualifier: C# resolves a partially +// qualified spelling (`Rules.PricingRules`) against visible namespaces — +// a qualifier suffix-match must narrow before the using tier can pick a +// same-named type in a namespace that does not end in the qualifier. +func TestCSharpNamespaceNarrow_PartialQualifier(t *testing.T) { + g := csharpNSFixture() + // A third rival whose namespace the file imports but which does not + // end in the written qualifier. + g.AddNode(&graph.Node{ID: "app/Shared/PricingRules.cs", Kind: graph.KindFile, Name: "PricingRules.cs", FilePath: "app/Shared/PricingRules.cs", Language: "csharp", RepoPrefix: "app"}) + g.AddNode(&graph.Node{ + ID: "app/Shared/PricingRules.cs::PricingRules", Kind: graph.KindType, Name: "PricingRules", + FilePath: "app/Shared/PricingRules.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Shared", "visibility": "public"}, + }) + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Shared", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + Meta: map[string]any{"target_fqn": "Sales.Rules.PricingRules"}, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "the qualifier suffix must beat the imported same-named rival") +} + // TestCSharpNamespaceNarrow_ExternalImportShape: by the time a reference // resolves, the same-pass import resolution may already have rewritten // the using edge to its external:: form — both shapes must count. From bda4eb535ad5d4f13402cf664299d305162e23d7 Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 16:17:05 +0200 Subject: [PATCH 5/6] docs(csharp): tighten namespace-narrowing comments --- .../languages/csharp_filescoped_ns_test.go | 5 ++-- .../languages/csharp_qualified_ref_test.go | 8 +++--- internal/resolver/csharp_ns_narrow.go | 26 +++++++------------ internal/resolver/resolver.go | 10 +++---- 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/internal/parser/languages/csharp_filescoped_ns_test.go b/internal/parser/languages/csharp_filescoped_ns_test.go index 95f7004d..114ee800 100644 --- a/internal/parser/languages/csharp_filescoped_ns_test.go +++ b/internal/parser/languages/csharp_filescoped_ns_test.go @@ -8,9 +8,8 @@ import ( ) // TestCSharpExtractor_FileScopedNamespaceScopeNS: a C#10 file-scoped -// namespace spans only its own line in the AST — the declarations it -// governs are siblings, not children, so the ancestor walk alone never -// finds it and every type in such a file loses its scope_ns. +// namespace's declarations are AST siblings, not children — an +// ancestor walk alone loses every such file's scope_ns. func TestCSharpExtractor_FileScopedNamespaceScopeNS(t *testing.T) { src := []byte(`namespace App.Core.Metrics; diff --git a/internal/parser/languages/csharp_qualified_ref_test.go b/internal/parser/languages/csharp_qualified_ref_test.go index 83a724fb..3b30e13f 100644 --- a/internal/parser/languages/csharp_qualified_ref_test.go +++ b/internal/parser/languages/csharp_qualified_ref_test.go @@ -9,11 +9,9 @@ import ( "github.com/zzet/gortex/internal/graph" ) -// TestCSharpExtractor_QualifiedTypeRefKeepsFQN: a qualified type spelling -// (`AddProfile()`) names its namespace in -// the source — canonicalisation must not silently discard it. The bare -// name stays the target; the dotted spelling rides Meta["target_fqn"] -// for the resolver's namespace narrowing. +// TestCSharpExtractor_QualifiedTypeRefKeepsFQN: a qualified spelling +// names its namespace in the source. The bare name stays the target; +// the dotted spelling rides Meta["target_fqn"] for the resolver. func TestCSharpExtractor_QualifiedTypeRefKeepsFQN(t *testing.T) { src := []byte(`public class Wiring { public void Configure(MapperConfig cfg) { diff --git a/internal/resolver/csharp_ns_narrow.go b/internal/resolver/csharp_ns_narrow.go index 2ba4a696..5dcf37f6 100644 --- a/internal/resolver/csharp_ns_narrow.go +++ b/internal/resolver/csharp_ns_narrow.go @@ -7,12 +7,10 @@ import ( ) // C# namespace narrowing. A using directive imports a whole namespace, -// not a name — unlike PHP there is no per-reference FQN to stamp at -// extraction. The evidence is already in the graph instead: using -// directives are EdgeImports off the file node, and every C# type -// carries Meta["scope_ns"]. Joining the two applies the compiler's own -// lookup rule where the bare-name ranking would otherwise tie-break -// same-named types by lexicographic ID — a sibling module's type. +// not a name, so there is no per-reference FQN to stamp at extraction +// (the PHP approach). Instead, join what the graph already holds — the +// file's EdgeImports and each type's Meta["scope_ns"] — so a bare-name +// tie no longer falls to lexicographic ID (a sibling module's type). // csharpFileNS is a C# file's visible-namespace evidence, split the way // the compiler consults it: enclosing namespaces are searched before @@ -80,21 +78,17 @@ func (r *Resolver) csharpFileNamespaceSet(fileID string) csharpFileNS { } // csharpNarrowByNamespace filters same-named C# type candidates to the -// ones declared in a namespace the referencing file can see, enclosing -// namespaces first (deepest match wins, mirroring inner-to-outer scope -// search) and using directives second. Same contract as -// phpNarrowByTargetFQN: narrowing only — nil when no candidate -// namespace is visible — so a binding can sharpen but never be lost. +// namespaces the referencing file can see: written qualifier, then +// enclosing namespaces (deepest wins), then using directives. Same +// contract as phpNarrowByTargetFQN — narrowing only, never a loss. func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.Node) []*graph.Node { if len(candidates) < 2 || !strings.HasSuffix(e.FilePath, ".cs") { return nil } - // A qualifier written at the reference site (Meta["target_fqn"], from - // a qualified spelling like Shared.Reporting.Foo) is the strongest - // evidence: keep only candidates whose namespace ends in it. C# - // resolves partial qualifiers against visible namespaces, so a suffix - // match on a dot boundary covers both the full and partial forms. + // A written qualifier (Meta["target_fqn"]) is the strongest evidence. + // C# resolves partial qualifiers against visible namespaces, so a + // dot-boundary suffix match covers the full and partial forms alike. qualified := candidates if fqn, _ := e.Meta["target_fqn"].(string); strings.Contains(fqn, ".") { q := fqn[:strings.LastIndex(fqn, ".")] diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 8657ba2d..bc08b4ca 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -298,12 +298,10 @@ type Resolver struct { importFilesByCaller map[string]map[string]struct{} importFilesMu sync.RWMutex - // csharpNSByFile memoises, per C# file, the namespaces the file can - // see: its using-directive imports plus its own declared namespaces - // with every enclosing prefix. Built lazily inside the parallel - // resolve workers — csharpNSMu guards it — and cleared with the - // per-pass lookup caches. Consulted by csharpNarrowByNamespace; see - // csharp_ns_narrow.go. + // csharpNSByFile memoises each C# file's visible namespaces (usings + // + own namespace chain). Built lazily inside the parallel resolve + // workers — csharpNSMu guards it — and cleared with the per-pass + // lookup caches. See csharp_ns_narrow.go. csharpNSByFile map[string]csharpFileNS csharpNSMu sync.RWMutex From dcde5e81d99bd684f95c4ea160c8f1d50eede2ab Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 18:39:07 +0200 Subject: [PATCH 6/6] fix(csharp): harden namespace narrowing against review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- internal/parser/languages/csharp.go | 68 +++++++++++++++-- .../languages/csharp_filescoped_ns_test.go | 22 ++++++ .../languages/csharp_qualified_ref_test.go | 26 +++++++ .../languages/csharp_usings_meta_test.go | 55 ++++++++++++++ internal/resolver/csharp_ns_narrow.go | 38 +++++++++- internal/resolver/csharp_ns_narrow_test.go | 74 +++++++++++++++++-- internal/resolver/php_override_dispatch.go | 16 +++- 7 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 internal/parser/languages/csharp_usings_meta_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index d5592f77..6ff4efbb 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -214,6 +214,7 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex } fileID := fileNode.ID result.Nodes = append(result.Nodes, fileNode) + stampCSharpUsings(root, src, fileNode) seen := make(map[string]bool) annotationSeen := make(map[string]bool) @@ -613,19 +614,28 @@ func csharpExtensionReceiverType(methodNode *sitter.Node, src []byte) string { return normalizeCSharpBaseName(t.Content(src)) } -// csharpEnclosingNamespace returns the dotted name of the nearest enclosing -// namespace declaration (block or file-scoped), or "". +// csharpEnclosingNamespace returns the dotted name of the enclosing +// namespace scope, or "". Nested block declarations join outer-to-inner +// (`namespace A { namespace B {` → "A.B"). func csharpEnclosingNamespace(node *sitter.Node, src []byte) string { + var parts []string root := node for n := node; n != nil; n = n.Parent() { t := n.Type() if t == "namespace_declaration" || t == "file_scoped_namespace_declaration" { if nm := csharpNamespaceName(n, src); nm != "" { - return nm + parts = append(parts, nm) } } root = n } + if len(parts) > 0 { + // Collected innermost-first — reverse into source order. + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + return strings.Join(parts, ".") + } // File-scoped form: `namespace X;` spans only its own statement in the // AST — the declarations it governs are later siblings under the // compilation unit, so the ancestor walk above never sees it. @@ -1019,6 +1029,42 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st emitCSharpTypeUseEdges(id, propTypeRaw, filePath, def.StartLine+1, result) } +// stampCSharpUsings records the file's plain namespace usings (global +// ones included) on the file node as Meta["usings"]. Aliases and +// using-static grant no bare-name namespace visibility and are skipped. +// Resolution rewrites the per-directive import edges, so the resolver's +// namespace narrowing reads this shape, which nothing mutates. +func stampCSharpUsings(root *sitter.Node, src []byte, fileNode *graph.Node) { + var usings []string + seen := map[string]bool{} + walkNodes(root, func(n *sitter.Node) { + if n.Type() != "using_directive" { + return + } + var name string + for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ { + c := n.Child(i) + switch c.Type() { + case "static", "name_equals", "=": + return + case "identifier", "qualified_name": + name = strings.TrimSpace(c.Content(src)) + } + } + if name != "" && !seen[name] { + seen[name] = true + usings = append(usings, name) + } + }) + if len(usings) == 0 { + return + } + if fileNode.Meta == nil { + fileNode.Meta = map[string]any{} + } + fileNode.Meta["usings"] = usings +} + func (e *CSharpExtractor) emitUsing(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) { path := m.Captures["using.path"] importPath := strings.ReplaceAll(path.Text, ".", "/") @@ -1152,11 +1198,23 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s kind = graph.EdgeExtends extendsTaken = true } - result.Edges = append(result.Edges, &graph.Edge{ + edge := &graph.Edge{ From: typeID, To: "unresolved::" + name, Kind: kind, FilePath: filePath, Line: line, Origin: graph.OriginASTInferred, - }) + } + // A qualified base spelling names its namespace — keep it for the + // resolver's namespace narrowing (same stamp as reference forms). + raw := entry.Content(src) + if isCtorBase { + if tn := entry.ChildByFieldName("type"); tn != nil { + raw = tn.Content(src) + } + } + if fqn := csharpQualifiedTypeRef(raw); fqn != "" { + edge.Meta = map[string]any{"target_fqn": fqn} + } + result.Edges = append(result.Edges, edge) } } diff --git a/internal/parser/languages/csharp_filescoped_ns_test.go b/internal/parser/languages/csharp_filescoped_ns_test.go index 114ee800..3a63ea12 100644 --- a/internal/parser/languages/csharp_filescoped_ns_test.go +++ b/internal/parser/languages/csharp_filescoped_ns_test.go @@ -7,6 +7,28 @@ import ( "github.com/stretchr/testify/require" ) +// TestCSharpExtractor_NestedNamespaceBlocksJoin: nested block +// namespaces join outer-to-inner — `B` alone would collide with any +// other `B` in the graph and never match a `using A.B;`. +func TestCSharpExtractor_NestedNamespaceBlocksJoin(t *testing.T) { + src := []byte(`namespace App { + namespace Core { + public class Widget { + public void Render() {} + } + } +} +`) + res, err := NewCSharpExtractor().Extract("w.cs", src) + require.NoError(t, err) + + for _, n := range res.Nodes { + if n.Name == "Widget" || n.Name == "Render" { + assert.Equal(t, "App.Core", n.Meta["scope_ns"], "%s must carry the joined namespace", n.Name) + } + } +} + // TestCSharpExtractor_FileScopedNamespaceScopeNS: a C#10 file-scoped // namespace's declarations are AST siblings, not children — an // ancestor walk alone loses every such file's scope_ns. diff --git a/internal/parser/languages/csharp_qualified_ref_test.go b/internal/parser/languages/csharp_qualified_ref_test.go index 3b30e13f..f23a8c59 100644 --- a/internal/parser/languages/csharp_qualified_ref_test.go +++ b/internal/parser/languages/csharp_qualified_ref_test.go @@ -51,3 +51,29 @@ func TestCSharpExtractor_QualifiedTypeRefKeepsFQN(t *testing.T) { require.NotNil(t, created, "qualified new must emit an instantiates edge") assert.Equal(t, "Data.Models.Order", created.Meta["target_fqn"]) } + +// TestCSharpExtractor_QualifiedBaseListKeepsFQN: base-list spellings +// resolve through resolveTypeRef, which consumes the same qualifier +// evidence — extends and implements must carry it too. +func TestCSharpExtractor_QualifiedBaseListKeepsFQN(t *testing.T) { + src := []byte(`public class Repo : Data.Access.RepoBase, Core.Abstractions.IStore +{ +} +`) + res, err := NewCSharpExtractor().Extract("r.cs", src) + require.NoError(t, err) + + var ext, impl *graph.Edge + for _, e := range res.Edges { + switch { + case e.Kind == graph.EdgeExtends && e.To == "unresolved::RepoBase": + ext = e + case e.Kind == graph.EdgeImplements && e.To == "unresolved::IStore": + impl = e + } + } + require.NotNil(t, ext, "qualified base class must emit extends") + assert.Equal(t, "Data.Access.RepoBase", ext.Meta["target_fqn"]) + require.NotNil(t, impl, "qualified interface must emit implements") + assert.Equal(t, "Core.Abstractions.IStore", impl.Meta["target_fqn"]) +} diff --git a/internal/parser/languages/csharp_usings_meta_test.go b/internal/parser/languages/csharp_usings_meta_test.go new file mode 100644 index 00000000..51a25614 --- /dev/null +++ b/internal/parser/languages/csharp_usings_meta_test.go @@ -0,0 +1,55 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// TestCSharpExtractor_UsingsMeta: the file node carries its plain +// namespace usings as Meta["usings"] — resolution can rewrite the +// import edges, so the resolver's namespace narrowing needs a shape +// nothing mutates. Aliases and using-static grant no bare-name +// namespace visibility and are excluded. +func TestCSharpExtractor_UsingsMeta(t *testing.T) { + src := []byte(`using App.Billing; +using App.Sales.Rules; +global using App.Platform; +using Alias = App.Legacy.OldRules; +using static System.Math; + +namespace App.Web +{ + public class Startup {} +} +`) + res, err := NewCSharpExtractor().Extract("Startup.cs", src) + require.NoError(t, err) + + var file *graph.Node + for _, n := range res.Nodes { + if n.Kind == graph.KindFile { + file = n + } + } + require.NotNil(t, file) + + usings, _ := file.Meta["usings"].([]string) + assert.ElementsMatch(t, []string{"App.Billing", "App.Sales.Rules", "App.Platform"}, usings, + "plain and global usings only — no aliases, no using-static") +} + +// TestCSharpExtractor_UsingsMeta_NoUsings: a file without using +// directives must not grow an empty Meta entry. +func TestCSharpExtractor_UsingsMeta_NoUsings(t *testing.T) { + res, err := NewCSharpExtractor().Extract("Plain.cs", []byte("public class Plain {}\n")) + require.NoError(t, err) + for _, n := range res.Nodes { + if n.Kind == graph.KindFile { + assert.Nil(t, n.Meta["usings"]) + } + } +} diff --git a/internal/resolver/csharp_ns_narrow.go b/internal/resolver/csharp_ns_narrow.go index 5dcf37f6..b74ff095 100644 --- a/internal/resolver/csharp_ns_narrow.go +++ b/internal/resolver/csharp_ns_narrow.go @@ -33,12 +33,35 @@ func (r *Resolver) csharpFileNamespaceSet(fileID string) csharpFileNS { } ns = csharpFileNS{enclosing: map[string]struct{}{}, imported: map[string]struct{}{}} + // Primary using evidence is the extractor's Meta["usings"] stamp — + // resolveImport rewrites the per-directive edges (a namespace tail + // matching any directory basename becomes a file-node target), so + // only the stamp is order-independent. The edge shapes below remain + // as a fallback for graphs extracted before the stamp existed. + if f := r.cachedGetNode(fileID); f != nil { + switch v := f.Meta["usings"].(type) { + case []string: + for _, u := range v { + ns.imported[u] = struct{}{} + } + case []any: + for _, u := range v { + if s, ok := u.(string); ok { + ns.imported[s] = struct{}{} + } + } + } + } + hasStamp := len(ns.imported) > 0 for _, e := range r.graph.GetOutEdges(fileID) { if e == nil { continue } switch e.Kind { case graph.EdgeImports: + if hasStamp { + continue + } imp := e.To switch { case strings.HasPrefix(imp, "unresolved::import::"): @@ -77,6 +100,14 @@ func (r *Resolver) csharpFileNamespaceSet(fileID string) csharpFileNS { return ns } +// csharpNarrowEligible gates narrowing to type-shaped candidates in the +// dotnet family. Methods carry scope_ns too — letting one match would +// steal the bind from (or lose it for) the type the reference names. +func csharpNarrowEligible(c *graph.Node) bool { + return c != nil && (c.Kind == graph.KindType || c.Kind == graph.KindInterface) && + sameLanguageFamily("csharp", c.Language) +} + // csharpNarrowByNamespace filters same-named C# type candidates to the // namespaces the referencing file can see: written qualifier, then // enclosing namespaces (deepest wins), then using directives. Same @@ -92,13 +123,14 @@ func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.No qualified := candidates if fqn, _ := e.Meta["target_fqn"].(string); strings.Contains(fqn, ".") { q := fqn[:strings.LastIndex(fqn, ".")] + dotQ := "." + q var m []*graph.Node for _, c := range candidates { - if c == nil || c.Language != "csharp" { + if !csharpNarrowEligible(c) { continue } ns, _ := c.Meta["scope_ns"].(string) - if ns == q || strings.HasSuffix(ns, "."+q) { + if ns == q || strings.HasSuffix(ns, dotQ) { m = append(m, c) } } @@ -120,7 +152,7 @@ func (r *Resolver) csharpNarrowByNamespace(e *graph.Edge, candidates []*graph.No var enclosing, imported []*graph.Node deepest := 0 for _, c := range qualified { - if c == nil || c.Language != "csharp" { + if !csharpNarrowEligible(c) { continue } ns, _ := c.Meta["scope_ns"].(string) diff --git a/internal/resolver/csharp_ns_narrow_test.go b/internal/resolver/csharp_ns_narrow_test.go index 4224a31a..dd266807 100644 --- a/internal/resolver/csharp_ns_narrow_test.go +++ b/internal/resolver/csharp_ns_narrow_test.go @@ -40,12 +40,22 @@ func csharpNSFixture() *graph.Graph { return g } +// stampUsings sets the extractor's Meta["usings"] shape on a fixture +// file node — the primary using evidence the narrowing consumes. +func stampUsings(g *graph.Graph, fileID string, usings ...string) { + n := g.GetNode(fileID) + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta["usings"] = usings +} + // TestCSharpNamespaceNarrow_UsingWins: the referencing file imports // App.Sales.Rules — the reference must bind there, not to the // lexicographically-smaller Billing rival. func TestCSharpNamespaceNarrow_UsingWins(t *testing.T) { g := csharpNSFixture() - g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Sales/Rules", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + stampUsings(g, "app/Web/Startup.cs", "App.Sales.Rules") ref := &graph.Edge{ From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, @@ -63,7 +73,7 @@ func TestCSharpNamespaceNarrow_UsingWins(t *testing.T) { // the using directives too. func TestCSharpNamespaceNarrow_ExtendsPath(t *testing.T) { g := csharpNSFixture() - g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Sales/Rules", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + stampUsings(g, "app/Web/Startup.cs", "App.Sales.Rules") ext := &graph.Edge{ From: "app/Web/Startup.cs::Startup", To: "unresolved::PricingRules", Kind: graph.EdgeExtends, FilePath: "app/Web/Startup.cs", Line: 3, @@ -105,7 +115,7 @@ func TestCSharpNamespaceNarrow_OwnNamespaceChain(t *testing.T) { // the previous deterministic ranking stands and the edge stays resolved. func TestCSharpNamespaceNarrow_NoMatchKeepsOldPick(t *testing.T) { g := csharpNSFixture() - g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::ThirdParty/Sdk", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + stampUsings(g, "app/Web/Startup.cs", "ThirdParty.Sdk") ref := &graph.Edge{ From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, @@ -132,7 +142,7 @@ func TestCSharpNamespaceNarrow_EnclosingBeatsImported(t *testing.T) { Meta: map[string]any{"scope_ns": "App.Billing", "visibility": "public"}, }) g.AddEdge(&graph.Edge{From: "app/Billing/Module.cs", To: "app/Billing/Module.cs::Module", Kind: graph.EdgeDefines, FilePath: "app/Billing/Module.cs", Line: 3}) - g.AddEdge(&graph.Edge{From: "app/Billing/Module.cs", To: "unresolved::import::App/Shared/Lookup", Kind: graph.EdgeImports, FilePath: "app/Billing/Module.cs", Line: 1}) + stampUsings(g, "app/Billing/Module.cs", "App.Shared.Lookup") // Own-namespace candidate: internal (ranked below public by the // canonical-definition ranker). g.AddNode(&graph.Node{ID: "app/Billing/Rules.cs", Kind: graph.KindFile, Name: "Rules.cs", FilePath: "app/Billing/Rules.cs", Language: "csharp", RepoPrefix: "app"}) @@ -193,7 +203,7 @@ func TestCSharpNamespaceNarrow_PartialQualifier(t *testing.T) { FilePath: "app/Shared/PricingRules.cs", Language: "csharp", RepoPrefix: "app", Meta: map[string]any{"scope_ns": "App.Shared", "visibility": "public"}, }) - g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "unresolved::import::App/Shared", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + stampUsings(g, "app/Web/Startup.cs", "App.Shared") ref := &graph.Edge{ From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, @@ -207,6 +217,60 @@ func TestCSharpNamespaceNarrow_PartialQualifier(t *testing.T) { "the qualifier suffix must beat the imported same-named rival") } +// TestCSharpNamespaceNarrow_RewrittenImportEdge: resolveImport rewrites +// a using edge to a file-node target when the namespace tail matches a +// directory basename — the Meta["usings"] stamp must keep the evidence +// alive regardless of what shape the edge is in. +func TestCSharpNamespaceNarrow_RewrittenImportEdge(t *testing.T) { + g := csharpNSFixture() + stampUsings(g, "app/Web/Startup.cs", "App.Sales.Rules") + // The edge as resolveImport leaves it: a concrete file-node target. + g.AddEdge(&graph.Edge{From: "app/Web/Startup.cs", To: "app/Sales/Rules/PricingRules.cs", Kind: graph.EdgeImports, FilePath: "app/Web/Startup.cs", Line: 1}) + ref := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + } + g.AddEdge(ref) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ref.To, + "a rewritten import edge must not erase the using evidence") +} + +// TestCSharpNamespaceNarrow_MethodDecoyCannotStealOrLose: methods carry +// scope_ns too — a same-named method in the caller's own namespace must +// not narrow the set to itself (stealing the bind on the type-or-func +// path, losing the edge outright on the type-only extends path). +func TestCSharpNamespaceNarrow_MethodDecoyCannotStealOrLose(t *testing.T) { + g := csharpNSFixture() + stampUsings(g, "app/Web/Startup.cs", "App.Sales.Rules") + // A method named like the type, in the caller's own namespace — the + // enclosing tier would outrank the imported type if kinds were ignored. + g.AddNode(&graph.Node{ + ID: "app/Web/Helpers.cs::Helpers.PricingRules", Kind: graph.KindMethod, Name: "PricingRules", + FilePath: "app/Web/Helpers.cs", Language: "csharp", RepoPrefix: "app", + Meta: map[string]any{"scope_ns": "App.Web"}, + }) + ext := &graph.Edge{ + From: "app/Web/Startup.cs::Startup", To: "unresolved::PricingRules", + Kind: graph.EdgeExtends, FilePath: "app/Web/Startup.cs", Line: 3, + } + inst := &graph.Edge{ + From: "app/Web/Startup.cs::Startup.Configure", To: "unresolved::PricingRules", + Kind: graph.EdgeInstantiates, Origin: graph.OriginASTResolved, FilePath: "app/Web/Startup.cs", Line: 10, + } + g.AddEdge(ext) + g.AddEdge(inst) + + New(g).ResolveAll() + + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", ext.To, + "extends must not be lost to a same-named method decoy") + assert.Equal(t, "app/Sales/Rules/PricingRules.cs::PricingRules", inst.To, + "instantiates must bind the type, not the method decoy") +} + // TestCSharpNamespaceNarrow_ExternalImportShape: by the time a reference // resolves, the same-pass import resolution may already have rewritten // the using edge to its external:: form — both shapes must count. diff --git a/internal/resolver/php_override_dispatch.go b/internal/resolver/php_override_dispatch.go index bb6e121b..79eafddf 100644 --- a/internal/resolver/php_override_dispatch.go +++ b/internal/resolver/php_override_dispatch.go @@ -391,15 +391,23 @@ func phpTypedReceiverCaller(caller *graph.Node) bool { // imports and its own namespace, which is precisely PHP's own class-name // resolution rule. // -// Returns nil when the edge carries no fully-qualified target, when the caller -// is not PHP, or when no candidate matches — callers treat nil as "keep the -// candidates you had", so a reference into a vendor namespace the graph does -// not hold degrades to the previous ranking instead of losing its edge. +// Returns nil when the edge carries no fully-qualified target, when the +// target_fqn is not PHP-shaped (the C# extractor stamps the same key with +// dotted namespaces — see csharpNarrowByNamespace), or when no candidate +// matches — callers treat nil as "keep the candidates you had", so a +// reference into a vendor namespace the graph does not hold degrades to +// the previous ranking instead of losing its edge. func phpNarrowByTargetFQN(e *graph.Edge, candidates []*graph.Node) []*graph.Node { fqn := phpEdgeMetaString(e, "target_fqn") if fqn == "" || len(candidates) < 2 { return nil } + // A dotted qualifier without a backslash is a C# spelling, never PHP — + // without this gate it would read as namespace "" and narrow onto a + // global-namespace PHP class of the same name. + if strings.Contains(fqn, ".") && !strings.Contains(fqn, `\`) { + return nil + } ns := "" if i := strings.LastIndex(fqn, `\`); i >= 0 { ns = fqn[:i]