diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index c64b40d5..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,22 +614,51 @@ 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 := 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 != "" { + 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. + 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 "" } @@ -999,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, ".", "/") @@ -1132,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 new file mode 100644 index 00000000..3a63ea12 --- /dev/null +++ b/internal/parser/languages/csharp_filescoped_ns_test.go @@ -0,0 +1,59 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "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. +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) + } +} 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..f23a8c59 --- /dev/null +++ b/internal/parser/languages/csharp_qualified_ref_test.go @@ -0,0 +1,79 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// 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) { + 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"]) +} + +// 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_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/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 new file mode 100644 index 00000000..b74ff095 --- /dev/null +++ b/internal/resolver/csharp_ns_narrow.go @@ -0,0 +1,187 @@ +package resolver + +import ( + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +// C# namespace narrowing. A using directive imports a whole namespace, +// 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 +// 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) csharpFileNS { + r.csharpNSMu.RLock() + ns, ok := r.csharpNSByFile[fileID] + r.csharpNSMu.RUnlock() + if ok { + return ns + } + + 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::"): + imp = strings.TrimPrefix(imp, "unresolved::import::") + case strings.HasPrefix(imp, "external::"): + imp = strings.TrimPrefix(imp, "external::") + default: + continue + } + if imp != "" { + ns.imported[strings.ReplaceAll(imp, "/", ".")] = struct{}{} + } + case graph.EdgeDefines: + n := r.cachedGetNode(e.To) + if n == nil { + continue + } + scope, _ := n.Meta["scope_ns"].(string) + for scope != "" { + ns.enclosing[scope] = struct{}{} + i := strings.LastIndex(scope, ".") + if i < 0 { + break + } + scope = scope[:i] + } + } + } + + r.csharpNSMu.Lock() + if r.csharpNSByFile == nil { + r.csharpNSByFile = map[string]csharpFileNS{} + } + r.csharpNSByFile[fileID] = ns + r.csharpNSMu.Unlock() + 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 +// 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 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, ".")] + dotQ := "." + q + var m []*graph.Node + for _, c := range candidates { + if !csharpNarrowEligible(c) { + continue + } + ns, _ := c.Meta["scope_ns"].(string) + if ns == q || strings.HasSuffix(ns, dotQ) { + 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 qualified { + if !csharpNarrowEligible(c) { + continue + } + ns, _ := c.Meta["scope_ns"].(string) + if ns == "" { + continue + } + 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) + } + } + if len(enclosing) > 0 { + return enclosing + } + 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 new file mode 100644 index 00000000..dd266807 --- /dev/null +++ b/internal/resolver/csharp_ns_narrow_test.go @@ -0,0 +1,290 @@ +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 +} + +// 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() + 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, + } + 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() + 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, + } + 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() + 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, + } + 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_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}) + 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"}) + 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_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"}, + }) + 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, + 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_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. +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/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] diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2e109149..bc08b4ca 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -298,6 +298,13 @@ type Resolver struct { importFilesByCaller map[string]map[string]struct{} importFilesMu sync.RWMutex + // 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 + // 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 +1925,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 +3470,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 +3539,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