From 3b5cfd688e3ddb25b3df5cb714764680fee54232 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:31:40 +0200 Subject: [PATCH 01/38] resolver: generic type arguments gate the C# interface-dispatch fan-out The dispatch synthesizer builds implements-families over erased interface targets, so IBoxStore and IBoxStore implementors land in one family and every through-interface call fans out to both. On a per-entity-repository codebase that inflates every data-access usage answer (189 implementors of one repository interface; single methods carrying 40-70 distinct fan-out targets). The extractor now stamps CLOSED generic arguments as evidence: base-list entries on the implements/extends edge (target_type_args) and field/property declared types on the node (field_type_args). The rules live in one place, at extraction, where the full syntax context is visible: arguments naming a type parameter of the declaring type or ANY enclosing type stamp nothing; arguments matching an in-scope using alias stamp nothing (an alias may spell any type - opaque to a string comparison); non-simple arguments stamp nothing; a base list closing the same erased target twice stamps neither (the entries collapse to one stored edge); a qualified base whose generic segment is not the final one stamps nothing. Arguments compare by type identity, not spelling: BCL alias forms fold to the keyword canonical (System.Int32, Int32 and int all stamp "int") - folding can only create matches, and a match always keeps the edge, so the fold is recall-safe. Sites where two same-named member calls share one line are marked receiver_ambiguous, since they dedupe to a single edge carrying one arbitrary receiver's evidence. The dispatch pass derives each source site's constructed arguments (a sibling site from its bound implementor's stamp; a through-interface site from its receiver field's stamp, located via receiver_name or the same-member companion edge, refused on ambiguous sites) and skips fan-out members stamped with DIFFERENT arguments. Absence of evidence anywhere keeps the full fan-out, and a family with no stamps at all never pays the receiver lookup. Typed-local receivers are a named remainder: the tenv strips generics before receiver_type is stamped, so local-receiver sites keep the full fan-out until local type arguments are carried too. Every stamp rule and seven end-to-end dispatch behaviors are test-pinned, each watched fail first - including the four shapes two adversarial review rounds reproduced as silent false suppression (enclosing type parameter, double closure, same-line receiver ambiguity, BCL alias spelling). --- internal/parser/languages/csharp.go | 72 ++++ .../parser/languages/csharp_base_type_args.go | 299 +++++++++++++++ .../languages/csharp_base_type_args_test.go | 296 ++++++++++++++ internal/resolver/csharp_iface_dispatch.go | 185 ++++++++- .../csharp_iface_dispatch_generic_test.go | 360 ++++++++++++++++++ 5 files changed, 1209 insertions(+), 3 deletions(-) create mode 100644 internal/parser/languages/csharp_base_type_args.go create mode 100644 internal/parser/languages/csharp_base_type_args_test.go create mode 100644 internal/resolver/csharp_iface_dispatch_generic_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 5b66cc00..5686263b 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -644,6 +644,31 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // already covered by emitCSharpBaseList, so it is not re-emitted here. emitCSharpReferenceForms(root, src, filePath, fileID, result) + // Two same-named member calls on ONE line (`_a.Fetch(_b.Fetch(1))`) + // dedupe to a single stored edge — identical (from, to, kind, file, + // line) — carrying one arbitrary receiver's evidence. Mark those sites + // so no downstream consumer applies one receiver's typing to the other + // call's edge (the dispatch gate's receiver evidence in particular). + type csharpCallSite struct { + name string + line int + } + memberSiteReceiver := map[csharpCallSite]string{} + memberSiteAmbiguous := map[csharpCallSite]bool{} + for _, c := range calls { + if !c.isMember || c.receiver == "" { + continue + } + key := csharpCallSite{c.name, c.line} + if prev, ok := memberSiteReceiver[key]; ok { + if prev != c.receiver { + memberSiteAmbiguous[key] = true + } + } else { + memberSiteReceiver[key] = c.receiver + } + } + for _, c := range calls { callerID := funcRanges.enclosing(c.line) if callerID == "" { @@ -701,6 +726,14 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // before it can compare argument counts. edge.Meta = map[string]any{"receiver_name": c.receiver} } + // Stamped AFTER the receiver-evidence chain — every branch + // above assigns a fresh Meta map and would clobber it. + if memberSiteAmbiguous[csharpCallSite{c.name, c.line}] { + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["receiver_ambiguous"] = true + } // Eviction restubs a member call to a bare unresolved name; the // marker is what lets the resolver still route the rebind through // the extension rule instead of a locality guess. @@ -1445,6 +1478,11 @@ func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID strin fieldTypeRaw := csharpFieldDeclType(def.Node, src) if fieldTypeRaw != "" { meta["field_type"] = fieldTypeRaw + // Closed generic arguments of the declared type — the dispatch + // gate's receiver evidence (see csharp_base_type_args.go). + if args := csharpSimpleTypeArgsFromText(fieldTypeRaw, csharpUnstampableArgNames(def.Node, src)); args != "" { + meta["field_type_args"] = args + } } // A `const` field is a compile-time constant, not a mutable field — // classify it as KindConstant so it joins the value-reference impact @@ -1511,6 +1549,11 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st if t := def.Node.ChildByFieldName("type"); t != nil { propTypeRaw = strings.TrimSpace(t.Content(src)) meta["field_type"] = propTypeRaw + // Same closed-generic-arguments stamp fields carry (dispatch + // gate receiver evidence — csharp_base_type_args.go). + if args := csharpSimpleTypeArgsFromText(propTypeRaw, csharpUnstampableArgNames(def.Node, src)); args != "" { + meta["field_type_args"] = args + } } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc @@ -1797,6 +1840,24 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s // the semantic engine applies), bypassing the discrimination below. ifaceDecl := decl.Type() == "interface_declaration" allowsBaseClass := csharpDeclAllowsBaseClass(decl) + // Names a base argument must never be compared by: type parameters of + // the declaring type AND every enclosing type (Relay : IBoxStore, + // or a type nested inside a generic outer), plus in-scope using + // aliases (opaque spellings). + declTypeParams := csharpUnstampableArgNames(decl, src) + // A base list closing the SAME erased target twice + // (Both : IBoxStore, IBoxStore) collapses to one stored + // edge — identical (from, to, kind, file, line) — so a stamp would + // arbitrarily keep one closure and suppress the other's implementors + // downstream. Count targets first; a repeated one stamps nothing. + baseNameCount := map[string]int{} + for i, _nc := 0, int(baseList.NamedChildCount()); i < _nc; i++ { + if entry := baseList.NamedChild(i); entry != nil { + if name, _ := csharpBaseTypeName(entry, src); name != "" { + baseNameCount[name]++ + } + } + } extendsTaken := false for i, _nc := 0, int(baseList.NamedChildCount()); i < _nc; i++ { entry := baseList.NamedChild(i) @@ -1837,6 +1898,17 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s if fqn := csharpQualifiedTypeRef(raw); fqn != "" { edge.Meta = map[string]any{"target_fqn": fqn} } + // Closed generic arguments ride the edge so the dispatch fan-out + // can exclude type-impossible implementors — see the package doc + // in csharp_base_type_args.go for the conservative rules. + if baseNameCount[name] == 1 { + if args := csharpBaseTypeArgs(entry, src, declTypeParams); args != "" { + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["target_type_args"] = args + } + } result.Edges = append(result.Edges, edge) } } diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go new file mode 100644 index 00000000..6ce5d9ac --- /dev/null +++ b/internal/parser/languages/csharp_base_type_args.go @@ -0,0 +1,299 @@ +package languages + +import ( + "strings" + + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// Generic type arguments (interface-dispatch precision). +// +// A class implementing IBoxStore and a class implementing +// IBoxStore implement DIFFERENT constructed interfaces, but the +// implements edges both target the erased IBoxStore — so the +// interface-dispatch fan-out treats every implementor as one family and +// fans an IBoxStore receiver's calls into the Widget impl. On a +// per-entity-repository codebase (one generic repository interface with +// a hundred-plus implementors) that inflates every data-access usage +// answer. +// +// Two stamps carry the CLOSED arguments as evidence: +// +// - implements/extends edges: Meta["target_type_args"] from the +// base-list entry (emitCSharpBaseList); +// - field/property nodes: Meta["field_type_args"] from the declared +// type (emitField/emitProperty) — the dispatch pass reads the stamp +// instead of re-parsing field_type text, so the open/closed rules +// live in exactly one place: here, where the enclosing-type chain +// is visible. +// +// The rules are deliberately conservative — absence means "do not +// filter", never "no arguments": +// +// - an argument that names a type parameter of the declaring type OR +// of ANY enclosing type (class Outer { class Inner : IBoxStore }) +// closes nothing — skip; +// - a non-simple argument (nested generic, array, nullable, tuple) +// is beyond the resolver's cheap string comparison — skip; +// - a qualified argument (App.Crate) normalizes to Crate, the same +// last-segment convention resolver-side type names already use; +// - a base list that names the SAME erased target twice +// (Both : IBoxStore, IBoxStore) collapses to one +// stored edge, so the ambiguity would be invisible downstream — +// neither closure stamps (guarded in emitCSharpBaseList); +// - a qualified base whose generic segment is not the FINAL one +// (Outer.IInner) never stamps the outer segment's arguments. + +// csharpUnstampableArgNames collects every identifier that must NOT be +// read as a closed concrete type argument at node's position, walking the +// ancestor chain once: +// +// - type parameters of every enclosing type declaration, the node's own +// included — a nested type legitimately closes over its outer types' +// parameters, and every one of them is open; +// - using-alias names in scope (`using MyCrate = App.Crate;`, at file +// level or inside an enclosing namespace) — an alias may spell any +// type, including one whose canonical form differs from the alias +// identifier, so it is opaque to a string comparison. +// +// Both categories mean the same thing to the caller: this spelling does +// not denote a type the dispatch gate may compare by name. +func csharpUnstampableArgNames(node *sitter.Node, src []byte) map[string]bool { + var out map[string]bool + add := func(name string) { + if name == "" { + return + } + if out == nil { + out = map[string]bool{} + } + out[name] = true + } + for n := node; n != nil; n = n.Parent() { + switch n.Type() { + case "class_declaration", "struct_declaration", "record_declaration", "interface_declaration": + for name := range csharpMethodTypeParamNames(n, src) { + add(name) + } + case "compilation_unit", "namespace_declaration", "file_scoped_namespace_declaration": + // Using directives are DIRECT children of the compilation + // unit or of a namespace declaration (a block namespace + // keeps its members one level down, in declaration_list) — + // all of which sit on the ancestor chain of any type, so + // this shallow scan sees every alias actually in scope, + // scoped usings included, without a whole-tree walk. + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + c := n.NamedChild(i) + if c != nil && c.Type() == "declaration_list" { + for j, _jc := 0, int(c.NamedChildCount()); j < _jc; j++ { + add(csharpUsingAliasName(c.NamedChild(j), src)) + } + continue + } + add(csharpUsingAliasName(c, src)) + } + } + } + return out +} + +// csharpUsingAliasName returns the alias identifier a using directive +// introduces (`using MyCrate = App.Crate;` → "MyCrate"), or "" when the +// node is not an alias directive. Grammar revisions differ — some wrap +// the alias in a name_equals node, others lay it out flat (identifier, +// bare `=` token, target); stampCSharpUsings' skip branch matches the +// same pair. +func csharpUsingAliasName(n *sitter.Node, src []byte) string { + if n == nil || n.Type() != "using_directive" { + return "" + } + firstIdent := "" + for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ { + c := n.Child(i) + if c == nil { + continue + } + switch c.Type() { + case "name_equals": + for j, _jc := 0, int(c.NamedChildCount()); j < _jc; j++ { + if id := c.NamedChild(j); id != nil && id.Type() == "identifier" { + return strings.TrimSpace(id.Content(src)) + } + } + case "=": + return firstIdent + case "identifier": + if firstIdent == "" { + firstIdent = strings.TrimSpace(c.Content(src)) + } + } + } + return "" +} + +// csharpCanonicalTypeArg folds the BCL alias spellings of the C# built-in +// types onto their keyword form, so `System.Int32`, `Int32` and `int` — +// the SAME constructed type — compare equal on both sides of the gate. +// Mirrors the resolver's own csharpTypeSuffixTrim fold; kept local +// because the parser package must not depend on the resolver. +func csharpCanonicalTypeArg(t string) string { + switch t { + case "String": + return "string" + case "Boolean": + return "bool" + case "Byte": + return "byte" + case "SByte": + return "sbyte" + case "Char": + return "char" + case "Decimal": + return "decimal" + case "Double": + return "double" + case "Single": + return "float" + case "Int16": + return "short" + case "UInt16": + return "ushort" + case "Int32": + return "int" + case "UInt32": + return "uint" + case "Int64": + return "long" + case "UInt64": + return "ulong" + case "Object": + return "object" + } + return t +} + +// csharpBaseTypeArgs returns the comma-joined, normalized type-argument +// list of a generic base-list entry, or "" when the entry is not generic +// or any argument is open or non-simple. openParams is the full +// enclosing-chain parameter set (csharpEnclosingTypeParams). +func csharpBaseTypeArgs(entry *sitter.Node, src []byte, openParams map[string]bool) string { + argList := csharpEntryTypeArgumentList(entry) + if argList == nil { + return "" + } + var args []string + for i, _nc := 0, int(argList.NamedChildCount()); i < _nc; i++ { + arg := argList.NamedChild(i) + if arg == nil { + continue + } + norm := csharpNormalizeSimpleArg(arg.Content(src), openParams) + if norm == "" { + return "" + } + args = append(args, norm) + } + if len(args) == 0 { + return "" + } + return strings.Join(args, ",") +} + +// csharpNormalizeSimpleArg reduces one type-argument spelling to its +// comparable form, or "" when it is non-simple or names an open +// parameter. +func csharpNormalizeSimpleArg(text string, openParams map[string]bool) string { + text = strings.TrimSpace(text) + if text == "" || strings.ContainsAny(text, "<[?(, ") { + // Nested generic, array, nullable, tuple, or anything else + // beyond one identifier chain — not comparable by string. + return "" + } + if dot := strings.LastIndex(text, "."); dot >= 0 { + text = text[dot+1:] + } + if text == "" || openParams[text] { + return "" + } + // Fold AFTER the open-name check: a type parameter or alias named + // like a CLR type is caught above; a genuine BCL spelling folds to + // the keyword so both sides of the gate agree. A user type that + // happens to be named Int32 folds too — harmless, because folding + // can only CREATE matches and a match always keeps the edge. + return csharpCanonicalTypeArg(text) +} + +// csharpSimpleTypeArgsFromText is csharpBaseTypeArgs over a declared-type +// TEXT (a field/property's type spelling): "IBoxStore" → "Crate". +// "" when the text is not generic, the argument section is non-simple, +// or any argument is an open parameter. +func csharpSimpleTypeArgsFromText(text string, openParams map[string]bool) string { + text = strings.TrimSpace(text) + lt := strings.Index(text, "<") + if lt <= 0 || !strings.HasSuffix(text, ">") { + return "" + } + inner := text[lt+1 : len(text)-1] + if inner == "" || strings.ContainsAny(inner, "<[?(") { + return "" + } + rawArgs := strings.Split(inner, ",") + args := make([]string, 0, len(rawArgs)) + for _, a := range rawArgs { + norm := csharpNormalizeSimpleArg(a, openParams) + if norm == "" { + return "" + } + args = append(args, norm) + } + return strings.Join(args, ",") +} + +// csharpEntryTypeArgumentList returns the base entry's OWN +// type_argument_list: the final name segment's, and only when it is the +// single one in the whole entry. `Outer.IInner` carries a list on a +// non-final segment — its arguments belong to Outer, never to the edge's +// target IInner — and `Outer.IInner` is beyond the cheap +// comparison entirely; both answer nil (no stamp). +func csharpEntryTypeArgumentList(entry *sitter.Node) *sitter.Node { + if entry == nil { + return nil + } + if csharpCountTypeArgumentLists(entry) != 1 { + return nil + } + // Descend to the final name segment of a qualified spelling. + final := entry + for final.Type() == "qualified_name" { + next := final.ChildByFieldName("name") + if next == nil { + return nil + } + final = next + } + if final.Type() != "generic_name" { + return nil + } + for i, _nc := 0, int(final.ChildCount()); i < _nc; i++ { + if c := final.Child(i); c != nil && c.Type() == "type_argument_list" { + return c + } + } + return nil +} + +// csharpCountTypeArgumentLists counts every type_argument_list in the +// subtree. +func csharpCountTypeArgumentLists(n *sitter.Node) int { + if n == nil { + return 0 + } + count := 0 + if n.Type() == "type_argument_list" { + count++ + } + for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ { + count += csharpCountTypeArgumentLists(n.Child(i)) + } + return count +} diff --git a/internal/parser/languages/csharp_base_type_args_test.go b/internal/parser/languages/csharp_base_type_args_test.go new file mode 100644 index 00000000..c5c1fc03 --- /dev/null +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -0,0 +1,296 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// implementsMeta returns the Meta of the implements/extends edge From fromID +// whose unresolved target names base, and whether that edge exists at all +// (its Meta is legitimately nil when nothing was stamped). +func implementsMeta(edges []*graph.Edge, fromID, base string) (map[string]any, bool) { + for _, e := range edges { + if e == nil || (e.Kind != graph.EdgeImplements && e.Kind != graph.EdgeExtends) { + continue + } + if e.From == fromID && e.To == "unresolved::"+base { + return e.Meta, true + } + } + return nil, false +} + +// Generic base-list entries carry their CLOSED type arguments on the +// implements/extends edge (target_type_args), so the interface-dispatch +// fan-out can exclude type-impossible implementations: an IBoxStore +// receiver can never dispatch into the class implementing IBoxStore. +// Open arguments (the declaring type's own type parameters) and non-simple +// arguments (nested generics, arrays, nullables) stamp NOTHING - absence of +// the stamp means "do not filter", never "no arguments". +func TestCSharpExtractor_BaseListTypeArgs(t *testing.T) { + src := []byte(`namespace App { + public interface IBoxStore { } + public class Crate { } + + public class CrateBoxStore : IBoxStore { } + + // Open generic: T is the class's own parameter - no stamp. + public class Relay : IBoxStore { } + + // Qualified argument normalizes to its last segment. + public class DeepStore : IBoxStore { } + + // Nested generic argument is not simple - no stamp. + public class ListStore : IBoxStore> { } + + // Non-generic base keeps no stamp at all. + public class PlainStore : IBoxStore, System.IDisposable { } + + public interface IPair { } + public class PairStore : IPair { } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Stores.cs", src) + require.NoError(t, err) + + closed, ok := implementsMeta(result.Edges, "Stores.cs::CrateBoxStore", "IBoxStore") + require.True(t, ok, "implements edge must exist") + assert.Equal(t, "Crate", closed["target_type_args"]) + + open, ok := implementsMeta(result.Edges, "Stores.cs::Relay", "IBoxStore") + require.True(t, ok) + assert.NotContains(t, open, "target_type_args", + "an open type parameter closes nothing - no stamp, no filtering") + + qualified, ok := implementsMeta(result.Edges, "Stores.cs::DeepStore", "IBoxStore") + require.True(t, ok) + assert.Equal(t, "Crate", qualified["target_type_args"], + "namespace-qualified argument normalizes to its last segment") + + nested, ok := implementsMeta(result.Edges, "Stores.cs::ListStore", "IBoxStore") + require.True(t, ok) + assert.NotContains(t, nested, "target_type_args", + "nested generic arguments are not simple - no stamp") + + plainIface, ok := implementsMeta(result.Edges, "Stores.cs::PlainStore", "IDisposable") + require.True(t, ok) + assert.NotContains(t, plainIface, "target_type_args", + "a non-generic base entry carries no stamp") + + // Multi-argument closures stamp the full normalized list. + pair, ok := implementsMeta(result.Edges, "Stores.cs::PairStore", "IPair") + require.True(t, ok) + assert.Equal(t, "int,Crate", pair["target_type_args"]) +} + +// Review findings (G9 gate, first pass): three shapes where a stamp (or a +// receiver reading) treated OPEN or AMBIGUOUS arguments as closed and +// silently suppressed real fan-out edges. Each pins "stamp nothing". +func TestCSharpExtractor_BaseListTypeArgsOpenAndAmbiguousShapes(t *testing.T) { + src := []byte(`namespace App { + public interface IBoxStore { } + public interface IPlain { } + public class Crate { } + public class Widget { } + public class Outer2 { + public interface IInner { } + } + + // RED 1 (extractor half): a type nested in a generic OUTER uses the + // outer's parameter - open, not closed. + public class Outer { + public class Inner : IBoxStore { } + } + + // RED 2: one base list closing the SAME interface twice collapses to + // one edge upstream - the ambiguity is invisible downstream, so + // neither closure may stamp. + public class Both : IBoxStore, IBoxStore { } + + // Important 1: a qualified base whose GENERIC segment is not the + // final one must not stamp the outer segment's arguments - here onto + // an interface with no parameters at all. + public class Q2 : Outer2.IInner { } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Shapes.cs", src) + require.NoError(t, err) + + inner, ok := implementsMeta(result.Edges, "Shapes.cs::Inner", "IBoxStore") + require.True(t, ok) + assert.NotContains(t, inner, "target_type_args", + "the enclosing generic type's parameter is open - no stamp") + + both, ok := implementsMeta(result.Edges, "Shapes.cs::Both", "IBoxStore") + require.True(t, ok) + assert.NotContains(t, both, "target_type_args", + "double closure of one interface collapses to one edge - neither closure may stamp") + + q2, ok := implementsMeta(result.Edges, "Shapes.cs::Q2", "IInner") + require.True(t, ok) + assert.NotContains(t, q2, "target_type_args", + "a non-final generic segment's arguments never stamp the final target") +} + +// Codex review RED: the gate compares SPELLINGS, but C# types have alias +// spellings. `IBox` and `IBox` are the SAME constructed +// interface - both sides must stamp the keyword canonical form ("int") or +// the gate suppresses a valid edge. And a `using X = ...` alias is opaque +// at extraction time (it may spell any type), so an argument matching one +// stamps nothing. +func TestCSharpExtractor_TypeArgsAliasCanonicalization(t *testing.T) { + src := []byte(`using MyCrate = App.Crate; + +namespace App { + public interface IBoxStore { } + public class Crate { } + + public class IntBoxA : IBoxStore { } + public class IntBoxB : IBoxStore { } + public class IntBoxC : IBoxStore { } + + public class AliasStore : IBoxStore { } + + public class Flow { + private readonly IBoxStore _clr; + private readonly IBoxStore _kw; + private readonly IBoxStore _aliased; + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Alias.cs", src) + require.NoError(t, err) + + for _, cls := range []string{"IntBoxA", "IntBoxB", "IntBoxC"} { + m, ok := implementsMeta(result.Edges, "Alias.cs::"+cls, "IBoxStore") + require.True(t, ok, cls) + assert.Equal(t, "int", m["target_type_args"], + "%s: every BCL alias spelling folds to the keyword canonical form", cls) + } + + aliased, ok := implementsMeta(result.Edges, "Alias.cs::AliasStore", "IBoxStore") + require.True(t, ok) + assert.NotContains(t, aliased, "target_type_args", + "a using-alias argument is opaque - no stamp") + + clr := fieldMeta(result.Nodes, "Alias.cs::Flow._clr") + require.NotNil(t, clr) + assert.Equal(t, "int", clr["field_type_args"]) + kw := fieldMeta(result.Nodes, "Alias.cs::Flow._kw") + require.NotNil(t, kw) + assert.Equal(t, "int", kw["field_type_args"]) + al := fieldMeta(result.Nodes, "Alias.cs::Flow._aliased") + require.NotNil(t, al) + assert.NotContains(t, al, "field_type_args", + "a using-alias argument is opaque - no stamp") +} + +// fieldMeta returns the meta of the node with the given ID. +func fieldMeta(result_nodes []*graph.Node, id string) map[string]any { + for _, n := range result_nodes { + if n != nil && n.ID == id { + return n.Meta + } + } + return nil +} + +// Field and property nodes carry field_type_args for CLOSED generic +// declared types - the receiver half of the dispatch gate reads this +// stamp instead of re-parsing field_type text, so the open/closed rules +// live in exactly one place (extraction, where the enclosing-type chain +// is visible). +func TestCSharpExtractor_FieldTypeArgsStamp(t *testing.T) { + src := []byte(`namespace App { + public interface IBoxStore { } + public class Crate { } + + public class Flow { + private readonly IBoxStore _store; + public IBoxStore Prop { get; set; } + private Crate _plain; + } + + // The enclosing generic's parameter is open even one nesting level + // down - the field's owner itself declares no parameters. + public class Outer { + public class NestedFlow { + private IBoxStore _open; + } + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Flow.cs", src) + require.NoError(t, err) + + store := fieldMeta(result.Nodes, "Flow.cs::Flow._store") + require.NotNil(t, store) + assert.Equal(t, "Crate", store["field_type_args"]) + + prop := fieldMeta(result.Nodes, "Flow.cs::Flow.Prop") + require.NotNil(t, prop) + assert.Equal(t, "Crate", prop["field_type_args"]) + + plain := fieldMeta(result.Nodes, "Flow.cs::Flow._plain") + require.NotNil(t, plain) + assert.NotContains(t, plain, "field_type_args") + + open := fieldMeta(result.Nodes, "Flow.cs::NestedFlow._open") + require.NotNil(t, open) + assert.NotContains(t, open, "field_type_args", + "the enclosing generic type's parameter is open - no stamp") +} + +// RED 3: two same-named member calls on ONE line dedupe to a single +// unresolved companion edge carrying one arbitrary receiver_name - the +// receiver evidence is ambiguous and must say so, or the dispatch gate +// applies one receiver's arguments to the other call's fan-out. +func TestCSharpExtractor_SameLineSameNameCallsMarkReceiverAmbiguous(t *testing.T) { + src := []byte(`namespace App { + public class Store { public int Fetch(int id) { return id; } } + public class Flow { + private readonly Store _crates; + private readonly Store _widgets; + public int Pull() { return _crates.Fetch(_widgets.Fetch(1)); } + public int Single() { return _crates.Fetch(2); } + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Flow.cs", src) + require.NoError(t, err) + + var ambiguous, single []*graph.Edge + for _, ed := range result.Edges { + if ed == nil || ed.Kind != graph.EdgeCalls || ed.To != "unresolved::*.Fetch" { + continue + } + switch ed.From { + case "Flow.cs::Flow.Pull": + ambiguous = append(ambiguous, ed) + case "Flow.cs::Flow.Single": + single = append(single, ed) + } + } + require.NotEmpty(t, ambiguous) + for _, ed := range ambiguous { + require.NotNil(t, ed.Meta) + assert.Equal(t, true, ed.Meta["receiver_ambiguous"], + "two distinct receivers behind one (name,line) site must be marked") + } + require.NotEmpty(t, single) + for _, ed := range single { + if ed.Meta != nil { + assert.NotContains(t, ed.Meta, "receiver_ambiguous", + "a lone-receiver site stays unmarked") + } + } +} diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 26a13f93..21cc4ea1 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -3,6 +3,7 @@ package resolver import ( "sort" "strconv" + "strings" "github.com/zzet/gortex/internal/graph" ) @@ -135,6 +136,13 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) hierarchySources := g.GetNodesByIDs(hierarchySourceIDs) hierarchyByName := g.FindNodesByNames(hierarchyNames) children := map[string][]string{} + // Direct implementors' stamped CLOSED type arguments per interface + // (extractor: target_type_args on generic base-list entries) — the + // evidence half of the G9 gate: an IBoxStore receiver never + // dispatches into the IBoxStore implementor. Absent for + // non-generic bases, open generics, transitive descendants, and + // non-simple arguments — absence always means "do not filter". + implArgs := map[string]map[string]string{} for _, e := range hierarchyEdges { if e == nil || e.From == "" || e.To == "" { continue @@ -150,6 +158,16 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) } } children[toID] = append(children[toID], e.From) + if e.Meta != nil { + if args, _ := e.Meta["target_type_args"].(string); args != "" { + m := implArgs[e.From] + if m == nil { + m = map[string]string{} + implArgs[e.From] = m + } + m[toID] = args + } + } } if len(children) == 0 { return 0 @@ -249,8 +267,10 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) // Build families and the member → families index. type family struct { - ifaceID string - members []string + ifaceID string + ifaceName string // short interface name, for matching a receiver's declared field type + members []string + implArgs map[string]string // member method ID → its DIRECT implementor's stamped type args ("" absent = never filter) } var families []family famsOfMember := map[string][]int{} @@ -261,12 +281,14 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) for _, id := range ag.nodeIDs { anchorSet[id] = true } + memberArgs := map[string]string{} implCount := 0 for _, sub := range descendants(ag.ifaceID) { byName := membersByType[sub] if byName == nil { continue } + subArgs := implArgs[sub][ag.ifaceID] for _, m := range byName[ag.name] { if m == nil || anchorSet[m.ID] { continue @@ -276,6 +298,9 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) continue } memberIDs = append(memberIDs, m.ID) + if subArgs != "" { + memberArgs[m.ID] = subArgs + } implCount++ } } @@ -285,7 +310,10 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) continue } idx := len(families) - families = append(families, family{ifaceID: ag.ifaceID, members: memberIDs}) + families = append(families, family{ + ifaceID: ag.ifaceID, ifaceName: csharpShortTypeName(ag.ifaceID), + members: memberIDs, implArgs: memberArgs, + }) for _, id := range memberIDs { famsOfMember[id] = append(famsOfMember[id], idx) } @@ -326,6 +354,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) var batch []*graph.Edge seen := map[string]bool{} + receiverFieldTypes := map[string]string{} // per (caller,file,line) cache of the receiver field's declared type text for _, e := range callEdges { if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) { continue @@ -361,6 +390,18 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) continue } f := families[fi] + // Constructed-interface gate (G9): the source site's own type + // arguments — a sibling site bound to a stamped implementor + // carries that implementor's args; a through-interface site + // carries its receiver FIELD's declared args when the receiver + // evidence names one. "" means unknown — never filter. A + // family with no stamps at all (every non-generic interface, + // and the whole graph until a reindex) can never filter, so + // it never pays the receiver lookup either. + srcArgs := f.implArgs[e.To] + if srcArgs == "" && len(f.implArgs) > 0 { + srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceName, receiverFieldTypes) + } for _, member := range f.members { // Skip the member the call already reaches — and the CALLER // itself: a family member forwarding through its own @@ -371,6 +412,16 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) if member == e.To || member == e.From { continue } + // A site with known constructed args never fans into an + // implementor stamped with DIFFERENT args — they implement + // different constructed interfaces. Members without a stamp + // (the interface member itself, open generics, transitive + // implementors) always stay in. + if srcArgs != "" { + if ma := f.implArgs[member]; ma != "" && ma != srcArgs { + continue + } + } k := csharpCallSiteKey(e.From, member, e.FilePath, e.Line) if existing[k] || seen[k] { continue @@ -572,6 +623,134 @@ func containsInt(xs []int, v int) bool { return false } +// csharpShortTypeName reduces a type node ID to its bare type name: +// `file.cs::Ns.IBoxStore` → IBoxStore. +func csharpShortTypeName(id string) string { + if i := strings.LastIndex(id, "::"); i >= 0 { + id = id[i+2:] + } + if i := strings.LastIndex(id, "."); i >= 0 { + id = id[i+1:] + } + return id +} + +// csharpReceiverDeclaredArgs recovers the CLOSED type arguments a +// through-interface call site's receiver declares, for the G9 gate: +// `_store.Fetch(...)` on a field declared `IBoxStore _store` +// answers "Crate". Evidence-gated at every step — any absence answers "" +// (never filter): +// +// - the receiver name comes from the bound edge's own receiver_name +// meta, or from the extraction's unresolved companion edge for the +// SAME member name at the same site (the enrichment/LSP tiers bind a +// NEW edge and leave the companion, receiver evidence and all, +// alongside); a site the extractor marked receiver_ambiguous — two +// same-named calls on one line — contributes nothing; +// - the field is looked up on the caller's own type (bare receivers +// only — the exact shape the field-identifier emitter covers) and +// must actually be a field/constant node; +// - the arguments come from the extractor's field_type_args stamp, +// which already applied the open/closed rules (enclosing-chain type +// parameters, non-simple arguments) at the one place the full +// syntax context is visible — this pass never re-parses type text; +// - the field's declared type must still name THIS family's interface +// (short-name comparison against field_type — the one remaining +// name-based trust, see the caller). +// +// Typed LOCALS are a named remainder: the tenv strips generics before +// receiver_type is stamped, so local-receiver sites keep the full +// fan-out until the extractor carries local type arguments too. +func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceName string, cache map[string]string) string { + if e == nil || e.From == "" { + return "" + } + cacheKey := e.From + "\x00" + e.FilePath + "\x00" + strconv.Itoa(e.Line) + "\x00" + ifaceName + if v, ok := cache[cacheKey]; ok { + return v + } + args := "" + if field := csharpReceiverField(g, e); field != nil { + ft, _ := field.Meta["field_type"].(string) + prefix := strings.TrimSpace(ft) + if lt := strings.Index(prefix, "<"); lt > 0 { + prefix = prefix[:lt] + } + if i := strings.LastIndex(prefix, "."); i >= 0 { + prefix = prefix[i+1:] + } + if prefix == ifaceName { + args, _ = field.Meta["field_type_args"].(string) + } + } + cache[cacheKey] = args + return args +} + +// csharpReceiverField resolves the call site's receiver to a field (or +// constant) node of the caller's own type, or nil when the receiver is +// not an unambiguous bare same-type field. +func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { + name := "" + if e.Meta != nil { + if amb, _ := e.Meta["receiver_ambiguous"].(bool); amb { + return nil + } + name, _ = e.Meta["receiver_name"].(string) + } + if name == "" { + // The bound edge (enrichment/LSP tiers) carries no receiver + // evidence; the extraction's unresolved companion for the same + // member name at the same site does. Match the member name so a + // different call sharing the line can never lend its receiver. + memberName := csharpShortTypeName(e.To) + companionTo := "unresolved::*." + memberName + for _, out := range g.GetOutEdges(e.From) { + if out == nil || out.Kind != graph.EdgeCalls || out.To != companionTo { + continue + } + if out.FilePath != e.FilePath || out.Line != e.Line || out.Meta == nil { + continue + } + if amb, _ := out.Meta["receiver_ambiguous"].(bool); amb { + return nil + } + if rn, _ := out.Meta["receiver_name"].(string); rn != "" { + name = rn + break + } + } + } + if name == "" || strings.ContainsAny(name, ".(") { + return nil + } + ownerID := csharpEnclosingTypeID(e.From) + if ownerID == "" { + return nil + } + field := g.GetNodesByIDs([]string{ownerID + "." + name})[ownerID+"."+name] + if field == nil || field.Meta == nil || + (field.Kind != graph.KindField && field.Kind != graph.KindConstant) { + return nil + } + return field +} + +// csharpEnclosingTypeID strips the member segment off a method node ID: +// `file.cs::Flow.Pull` → `file.cs::Flow`. Empty when the ID carries no +// member segment after the symbol separator. +func csharpEnclosingTypeID(methodID string) string { + sep := strings.LastIndex(methodID, "::") + if sep < 0 { + return "" + } + dot := strings.LastIndex(methodID, ".") + if dot <= sep+2 { + return "" + } + return methodID[:dot] +} + func csharpResolveHierarchyTargetPrefetched(from *graph.Node, unresolvedTo string, byName map[string][]*graph.Node) string { name := graph.UnresolvedName(unresolvedTo) if name == "" { diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go new file mode 100644 index 00000000..9c0713c3 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -0,0 +1,360 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// Generic type arguments gate the interface-dispatch fan-out: a receiver +// declared IBoxStore can never dispatch into the class implementing +// IBoxStore, so the fan-out must not fabricate that usage. The +// filter is evidence-gated on BOTH sides — the receiver's declared field +// type and the implementor's stamped base-list arguments — and absence of +// either keeps today's full fan-out (precision only ever improves). +func TestResolveCSharpInterfaceDispatch_GenericTypeArgsGateFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + T Fetch(int id); + } + public class WidgetBoxStore : IBoxStore { + public Widget Fetch(int id) { return new Widget(); } + } + public class CrateBoxStore : IBoxStore { + public Crate Fetch(int id) { return new Crate(); } + } +}`, + "CrateFlow.cs": `namespace App { + public class CrateFlow { + private readonly IBoxStore _store; + public CrateFlow(IBoxStore store) { _store = store; } + public Crate Pull(int id) { + return _store.Fetch(id); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "CrateFlow.cs::CrateFlow.Pull" + bindFieldReceiverCall(t, g, callerID, "_store", "Stores.cs::IBoxStore.Fetch") + + ResolveCSharpInterfaceDispatch(g) + + var targets []string + for _, e := range g.GetOutEdges(callerID) { + if isIfaceDispatchEdge(e) { + targets = append(targets, e.To) + } + } + assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch", + "the type-compatible implementation still receives the fan-out") + assert.NotContains(t, targets, "Stores.cs::WidgetBoxStore.Fetch", + "an IBoxStore receiver can never dispatch to the IBoxStore impl") +} + +// bindFieldReceiverCall mirrors what the enrichment/LSP tiers do on a live +// store for a FIELD-receiver member call the core resolver leaves +// unresolved: a resolved call edge lands at the same site, while the +// extraction's own unresolved companion edge (carrying receiver_name) +// stays alongside it. The dispatch pass reads the receiver evidence from +// that companion - exactly the join a production store requires. +func bindFieldReceiverCall(t *testing.T, g graph.Store, callerID, receiver, target string) { + t.Helper() + var companion *graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && graph.IsUnresolvedTarget(e.To) && + e.Meta != nil && e.Meta["receiver_name"] == receiver { + companion = e + break + } + } + require.NotNil(t, companion, "fixture: the extraction must leave a receiver_name companion edge") + g.AddEdge(&graph.Edge{ + From: callerID, To: target, Kind: graph.EdgeCalls, + FilePath: companion.FilePath, Line: companion.Line, + Origin: graph.OriginASTResolved, Confidence: 0.95, + }) +} + +// The sibling mechanism is gated the same way: a call bound directly to the +// Widget implementation's own method must not fan into the Crate +// implementation — they implement different constructed interfaces — while +// the erased interface member itself (argument-less evidence) still +// receives the site. +func TestResolveCSharpInterfaceDispatch_SiblingFanoutRespectsTypeArgs(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + T Fetch(int id); + } + public class WidgetBoxStore : IBoxStore { + public Widget Fetch(int id) { return new Widget(); } + } + public class CrateBoxStore : IBoxStore { + public Crate Fetch(int id) { return new Crate(); } + } + public class WidgetUser { + public Widget Load(int id) { + WidgetBoxStore store = new WidgetBoxStore(); + return store.Fetch(id); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Stores.cs::WidgetUser.Load" + require.Contains(t, callTargetsFrom(g, callerID), "Stores.cs::WidgetBoxStore.Fetch", + "fixture: the typed-local call must bind to the Widget implementation") + + ResolveCSharpInterfaceDispatch(g) + + var targets []string + for _, e := range g.GetOutEdges(callerID) { + if isIfaceDispatchEdge(e) { + targets = append(targets, e.To) + } + } + assert.Contains(t, targets, "Stores.cs::IBoxStore.Fetch", + "the erased interface member still receives the sibling site") + assert.NotContains(t, targets, "Stores.cs::CrateBoxStore.Fetch", + "a Widget-impl site never fans into the Crate impl - different constructed interfaces") +} + +// An open-generic implementor (Relay : IBoxStore) carries no stamp and +// can bind ANY argument - it must stay in every fan-out. +func TestResolveCSharpInterfaceDispatch_OpenGenericImplStaysInFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + T Fetch(int id); + } + public class Relay : IBoxStore { + public T Fetch(int id) { return default(T); } + } + public class CrateBoxStore : IBoxStore { + public Crate Fetch(int id) { return new Crate(); } + } +}`, + "CrateFlow.cs": `namespace App { + public class CrateFlow { + private readonly IBoxStore _store; + public CrateFlow(IBoxStore store) { _store = store; } + public Crate Pull(int id) { + return _store.Fetch(id); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "CrateFlow.cs::CrateFlow.Pull" + bindFieldReceiverCall(t, g, callerID, "_store", "Stores.cs::IBoxStore.Fetch") + + ResolveCSharpInterfaceDispatch(g) + + var targets []string + for _, e := range g.GetOutEdges(callerID) { + if isIfaceDispatchEdge(e) { + targets = append(targets, e.To) + } + } + assert.Contains(t, targets, "Stores.cs::Relay.Fetch", + "an open-generic implementor can bind any argument and must stay in the fan-out") + assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch") +} + +// dispatchTargets returns the fan-out targets minted from callerID. +func dispatchTargets(g graph.Store, callerID string) []string { + var targets []string + for _, e := range g.GetOutEdges(callerID) { + if isIfaceDispatchEdge(e) { + targets = append(targets, e.To) + } + } + return targets +} + +// Review RED 1: a receiver field typed with an ENCLOSING generic type's +// parameter (Outer { class Flow { IBoxStore _store; } }) closes +// nothing - the site must keep the FULL fan-out, and the nested +// implementor's own bogus stamp must not survive either. +func TestResolveCSharpInterfaceDispatch_EnclosingTypeParamNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + T Fetch(int id); + } + public class WidgetBoxStore : IBoxStore { + public Widget Fetch(int id) { return new Widget(); } + } + public class CrateBoxStore : IBoxStore { + public Crate Fetch(int id) { return new Crate(); } + } +}`, + "Nested.cs": `namespace App { + public class Outer { + public class Flow { + private readonly IBoxStore _store; + public Flow(IBoxStore store) { _store = store; } + public T Pull(int id) { + return _store.Fetch(id); + } + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Nested.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_store", "Stores.cs::IBoxStore.Fetch") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch", + "an open receiver argument filters nothing") + assert.Contains(t, targets, "Stores.cs::WidgetBoxStore.Fetch", + "an open receiver argument filters nothing") +} + +// Review RED 3: two same-named member calls on one line share a single +// unresolved companion edge - the receiver evidence is ambiguous, so the +// site must keep the FULL fan-out rather than applying one receiver's +// arguments to both calls. +func TestResolveCSharpInterfaceDispatch_AmbiguousSameLineReceiverNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + int Fetch(int id); + } + public class WidgetBoxStore : IBoxStore { + public int Fetch(int id) { return 1; } + } + public class CrateBoxStore : IBoxStore { + public int Fetch(int id) { return 2; } + } + public class Flow { + private readonly IBoxStore _crates; + private readonly IBoxStore _widgets; + public Flow(IBoxStore c, IBoxStore w) { _crates = c; _widgets = w; } + public int Pull() { return _crates.Fetch(_widgets.Fetch(1)); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Stores.cs::Flow.Pull" + // One bound edge stands in for both same-line calls (same from/to/line). + var companion *graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && graph.IsUnresolvedTarget(e.To) { + companion = e + break + } + } + require.NotNil(t, companion) + g.AddEdge(&graph.Edge{ + From: callerID, To: "Stores.cs::IBoxStore.Fetch", Kind: graph.EdgeCalls, + FilePath: companion.FilePath, Line: companion.Line, + Origin: graph.OriginASTResolved, Confidence: 0.95, + }) + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch", + "ambiguous receiver evidence filters nothing") + assert.Contains(t, targets, "Stores.cs::WidgetBoxStore.Fetch", + "ambiguous receiver evidence filters nothing") +} + +// Multi-argument closures compare as one normalized list end-to-end - the +// string-equality contract between extractor stamp and receiver stamp is +// exactly what would regress silently without a pin. +func TestResolveCSharpInterfaceDispatch_MultiArgClosureGatesFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Pairs.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IPair { + int Fetch(int id); + } + public class WidgetPair : IPair { + public int Fetch(int id) { return 1; } + } + public class CratePair : IPair { + public int Fetch(int id) { return 2; } + } + public class Flow { + private readonly IPair _pairs; + public Flow(IPair p) { _pairs = p; } + public int Pull() { return _pairs.Fetch(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Pairs.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_pairs", "Pairs.cs::IPair.Fetch") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Pairs.cs::CratePair.Fetch") + assert.NotContains(t, targets, "Pairs.cs::WidgetPair.Fetch", + "an IPair receiver never dispatches to the IPair impl") +} + +// Codex review RED: `IBoxStore` and `IBoxStore` are the +// SAME constructed interface in different spellings — the gate must fold +// both to one canonical form and RETAIN the edge, never suppress it on a +// spelling mismatch. +func TestResolveCSharpInterfaceDispatch_BCLAliasSpellingsRetainTheEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Ints.cs": `namespace App { + public class Crate { } + public interface IBoxStore { + int Fetch(int id); + } + public class IntBox : IBoxStore { + public int Fetch(int id) { return 1; } + } + public class CrateBox : IBoxStore { + public int Fetch(int id) { return 2; } + } + public class Flow { + private readonly IBoxStore _ints; + public Flow(IBoxStore i) { _ints = i; } + public int Pull() { return _ints.Fetch(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Ints.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_ints", "Ints.cs::IBoxStore.Fetch") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Ints.cs::IntBox.Fetch", + "System.Int32 and int spell the same constructed interface - the edge stays") + assert.NotContains(t, targets, "Ints.cs::CrateBox.Fetch", + "the genuinely different closure still filters") +} From 3cb11cc630892c4a2448e46d3ff834576a300948 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:31:40 +0200 Subject: [PATCH 02/38] indexer: bump the C# extractor version for #668 and the type-arg stamps Two C# extraction changes need already-indexed .cs/.razor/.cshtml files to re-extract without a content change: the field-identifier read/write edges from #668 (where the bump was missed - caught in the #671 discussion: an in-place-upgraded store never re-extracts, so find_usages on a field stays empty even after the view fix) and this branch's own target_type_args/field_type_args/receiver_ambiguous stamps. One bump covers both; the salt test pin moves with it, proving the salt actually changes. --- internal/indexer/extractor_version.go | 2 +- internal/indexer/extractor_version_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/indexer/extractor_version.go b/internal/indexer/extractor_version.go index d0d7d788..3660b9b8 100644 --- a/internal/indexer/extractor_version.go +++ b/internal/indexer/extractor_version.go @@ -32,7 +32,7 @@ var extractorVersions = map[string]int{ // "go": 2, "c": generatedParserProjectionPolicyVersion, // generated parser projection covers all strictly detected table sizes "php": 2, // class/interface inheritance now emits typed structural edges - "csharp": 12, // receiverless calls carry arg_count / type_arg_count for #559 (was: params parameters emit complete shape and arity evidence) + "csharp": 13, // field identifiers emit read/write edges naming the field (#668, retroactive - the bump was missed there) + generic base-list/field type-argument stamps for dispatch gating (was: receiverless calls carry arg_count / type_arg_count for #559) "scala": 2, // explicitly instantiated generic calls emit call edges "go": 3, // generic instantiations are marked so indexing a func value cannot bind (was: generic calls emit call edges) "cpp": 2, // templated and namespace-qualified calls emit call edges diff --git a/internal/indexer/extractor_version_test.go b/internal/indexer/extractor_version_test.go index 023b56d8..519821cb 100644 --- a/internal/indexer/extractor_version_test.go +++ b/internal/indexer/extractor_version_test.go @@ -63,8 +63,8 @@ func TestStaleLangsDetection(t *testing.T) { t.Errorf("stored pre-params C# version = %v, want [csharp]", got) } for _, path := range []string{"src/Handler.cs", "Views/Page.razor", "Views/Page.cshtml"} { - if got := merkleSaltFor(path); got != "csharp@12" { - t.Errorf("C# extractor salt for %s = %q, want csharp@12", path, got) + if got := merkleSaltFor(path); got != "csharp@13" { + t.Errorf("C# extractor salt for %s = %q, want csharp@13", path, got) } } if got := merkleSaltFor("src/Handler.php"); got != "php@2" { From 2718f19ff5a349429681803005d0ee036b9581aa Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:05:28 +0200 Subject: [PATCH 03/38] resolver: receiver-args cache keys the member and full interface identity Two different member calls sharing a line each carry their own receiver companion, but the lookup cache keyed only caller/file/line/short-iface - the first call's declared arguments poisoned the second call's gate and dropped the other implementor's usage. The key now includes the resolved member and the full interface ID. --- internal/resolver/csharp_iface_dispatch.go | 11 ++-- .../csharp_iface_dispatch_generic_test.go | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 21cc4ea1..65e3ae5e 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -400,7 +400,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) // it never pays the receiver lookup either. srcArgs := f.implArgs[e.To] if srcArgs == "" && len(f.implArgs) > 0 { - srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceName, receiverFieldTypes) + srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceID, f.ifaceName, receiverFieldTypes) } for _, member := range f.members { // Skip the member the call already reaches — and the CALLER @@ -661,11 +661,16 @@ func csharpShortTypeName(id string) string { // Typed LOCALS are a named remainder: the tenv strips generics before // receiver_type is stamped, so local-receiver sites keep the full // fan-out until the extractor carries local type arguments too. -func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceName string, cache map[string]string) string { +func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName string, cache map[string]string) string { if e == nil || e.From == "" { return "" } - cacheKey := e.From + "\x00" + e.FilePath + "\x00" + strconv.Itoa(e.Line) + "\x00" + ifaceName + // The member (e.To) selects WHICH companion edge lends its receiver, so + // two different member calls sharing a line resolve different fields — + // a key without it lets the first call's arguments poison the second. + // The full interface ID keeps short-name twins from distinct families + // apart for the same reason. + cacheKey := e.From + "\x00" + e.To + "\x00" + e.FilePath + "\x00" + strconv.Itoa(e.Line) + "\x00" + ifaceID if v, ok := cache[cacheKey]; ok { return v } diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 9c0713c3..8b2d46b5 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -321,6 +321,56 @@ func TestResolveCSharpInterfaceDispatch_MultiArgClosureGatesFanout(t *testing.T) "an IPair receiver never dispatches to the IPair impl") } +// Review RED (revision 1): two DIFFERENT member calls on one line, each on +// its own field of the same generic interface closed with different +// arguments — `_crates.Fetch(1) + _widgets.Save(2)`. The receiver lookup is +// cached per call site, and a cache key without the member identity lets the +// first receiver's arguments poison the second call's gate: WidgetBoxStore.Save +// silently loses its usage. Each member call must gate on ITS OWN receiver. +func TestResolveCSharpInterfaceDispatch_SameLineDistinctMembersKeepOwnReceivers(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Stores.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBoxStore { + int Fetch(int id); + int Save(int id); + } + public class WidgetBoxStore : IBoxStore { + public int Fetch(int id) { return 1; } + public int Save(int id) { return 1; } + } + public class CrateBoxStore : IBoxStore { + public int Fetch(int id) { return 2; } + public int Save(int id) { return 2; } + } + public class Flow { + private readonly IBoxStore _crates; + private readonly IBoxStore _widgets; + public Flow(IBoxStore c, IBoxStore w) { _crates = c; _widgets = w; } + public int Pull() { return _crates.Fetch(1) + _widgets.Save(2); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Stores.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_crates", "Stores.cs::IBoxStore.Fetch") + bindFieldReceiverCall(t, g, callerID, "_widgets", "Stores.cs::IBoxStore.Save") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch", + "the Fetch call gates on _crates and keeps the Crate impl") + assert.NotContains(t, targets, "Stores.cs::WidgetBoxStore.Fetch", + "the Fetch call's receiver is IBoxStore") + assert.Contains(t, targets, "Stores.cs::WidgetBoxStore.Save", + "the Save call gates on _widgets - the Fetch lookup must not poison it") + assert.NotContains(t, targets, "Stores.cs::CrateBoxStore.Save", + "the Save call's receiver is IBoxStore") +} + // Codex review RED: `IBoxStore` and `IBoxStore` are the // SAME constructed interface in different spellings — the gate must fold // both to one canonical form and RETAIN the edge, never suppress it on a From 478b350ba3e31a99f99a1296eb5c1840bf1844ac Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:07:50 +0200 Subject: [PATCH 04/38] resolver: receiver-field lookup requires the extractor's field-read evidence A bare same-name lookup bound a shadowed receiver identifier to the field it shadows and gated the fan-out on the wrong declared arguments. The field-identifier emitter already refuses shadowed identifiers, so its EdgeReads at the exact call site is the binding proof the lookup was missing - without it the receiver stays unknown and the site keeps the full fan-out. --- internal/resolver/csharp_iface_dispatch.go | 26 +++++- .../csharp_iface_dispatch_generic_test.go | 84 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 65e3ae5e..b70070ec 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -733,7 +733,31 @@ func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { if ownerID == "" { return nil } - field := g.GetNodesByIDs([]string{ownerID + "." + name})[ownerID+"."+name] + fieldID := ownerID + "." + name + // Binding evidence: the field-identifier emitter refuses shadowed + // identifiers (a parameter or local with the field's name owns the + // identifier inside that method), so an EdgeReads at this exact site + // naming the field is proof the bare receiver really is the enclosing + // type's field. A name-only lookup would bind a shadowed identifier to + // the field it shadows and gate on the wrong declared arguments — + // without the read edge the receiver stays unknown (never filter). + fieldRead := false + for _, out := range g.GetOutEdges(e.From) { + if out == nil || out.Kind != graph.EdgeReads { + continue + } + if out.FilePath != e.FilePath || out.Line != e.Line { + continue + } + if out.To == "unresolved::*."+name || out.To == fieldID { + fieldRead = true + break + } + } + if !fieldRead { + return nil + } + field := g.GetNodesByIDs([]string{fieldID})[fieldID] if field == nil || field.Meta == nil || (field.Kind != graph.KindField && field.Kind != graph.KindConstant) { return nil diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 8b2d46b5..a8820c37 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -371,6 +371,90 @@ func TestResolveCSharpInterfaceDispatch_SameLineDistinctMembersKeepOwnReceivers( "the Save call's receiver is IBoxStore") } +// Review RED (revision 2a): a method parameter shadows the receiver field — +// `IBox _box` field, `IBox _box` parameter. The receiver +// identifier binds to the PARAMETER, but a bare text lookup finds the field +// and its Crate arguments suppress WidgetBox.Get. Without binding evidence +// the receiver is unknown and the site must keep the full fan-out. +func TestResolveCSharpInterfaceDispatch_ParameterShadowedReceiverNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Boxes.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBox { + int Get(int id); + } + public class WidgetBox : IBox { + public int Get(int id) { return 1; } + } + public class CrateBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull(IBox _box) { + return _box.Get(7); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Boxes.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Boxes.cs::WidgetBox.Get", + "the receiver is the shadowing IBox parameter - its impl must stay in") + assert.Contains(t, targets, "Boxes.cs::CrateBox.Get", + "an unknown receiver filters nothing") +} + +// Review RED (revision 2b): a `var` local shadows the receiver field the +// same way - the identifier binds to the local, not the field the text +// lookup finds. (An interface-TYPED local never reaches the receiver +// lookup: the tenv binds its call directly and leaves no companion.) +func TestResolveCSharpInterfaceDispatch_LocalShadowedReceiverNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Boxes.cs": `namespace App { + public class Widget { } + public class Crate { } + public interface IBox { + int Get(int id); + } + public class WidgetBox : IBox { + public int Get(int id) { return 1; } + } + public class CrateBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull(IBox source) { + var _box = source; + return _box.Get(7); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Boxes.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Boxes.cs::WidgetBox.Get", + "the receiver is the shadowing IBox local - its impl must stay in") + assert.Contains(t, targets, "Boxes.cs::CrateBox.Get", + "an unknown receiver filters nothing") +} + // Codex review RED: `IBoxStore` and `IBoxStore` are the // SAME constructed interface in different spellings — the gate must fold // both to one canonical form and RETAIN the edge, never suppress it on a From 00b55c7986e89f84db1e4f171544b2370eab4f05 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:11:26 +0200 Subject: [PATCH 05/38] resolver: variance-declaring interfaces disarm the type-argument gate ISource makes ISource assignable to an ISource receiver, so closed-and-unequal arguments do not prove the implementor is unreachable - the equality gate models invariant parameters only. The extractor now stamps variant_type_params on an interface whose parameter list declares in/out, and the family build skips arg stamping entirely for those interfaces: every site keeps the full fan-out. --- internal/parser/languages/csharp.go | 3 + .../parser/languages/csharp_base_type_args.go | 39 +++++++++ internal/resolver/csharp_iface_dispatch.go | 30 ++++++- .../csharp_iface_dispatch_generic_test.go | 81 +++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 5686263b..c1a3f753 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -857,6 +857,9 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK switch kind { case "iface": meta["type_flavor"] = "interface" + if csharpHasVariantTypeParams(def.Node) { + meta["variant_type_params"] = true + } case "struct": meta["type_flavor"] = "struct" case "enum": diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go index 6ce5d9ac..35e5ee9f 100644 --- a/internal/parser/languages/csharp_base_type_args.go +++ b/internal/parser/languages/csharp_base_type_args.go @@ -97,6 +97,45 @@ func csharpUnstampableArgNames(node *sitter.Node, src []byte) map[string]bool { return out } +// csharpHasVariantTypeParams reports whether decl's type-parameter list +// declares any `in`/`out` variance modifier. A variance-declaring +// interface makes differently-closed constructions assignable across the +// implements family (ISource satisfies an ISource receiver +// closed over Animal), so the dispatch gate — which models invariant +// parameters only — must never arm for it. +func csharpHasVariantTypeParams(decl *sitter.Node) bool { + if decl == nil { + return false + } + tparams := decl.ChildByFieldName("type_parameters") + if tparams == nil { + for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ { + c := decl.NamedChild(i) + if c != nil && c.Type() == "type_parameter_list" { + tparams = c + break + } + } + } + if tparams == nil { + return false + } + for i, _nc := 0, int(tparams.NamedChildCount()); i < _nc; i++ { + tp := tparams.NamedChild(i) + if tp == nil || tp.Type() != "type_parameter" { + continue + } + // The variance keyword is an anonymous token child of the + // type_parameter, before its identifier. + for j, _jc := 0, int(tp.ChildCount()); j < _jc; j++ { + if c := tp.Child(j); c != nil && (c.Type() == "in" || c.Type() == "out") { + return true + } + } + } + return false +} + // csharpUsingAliasName returns the alias identifier a using directive // introduces (`using MyCrate = App.Crate;` → "MyCrate"), or "" when the // node is not an alias directive. Grammar revisions differ — some wrap diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index b70070ec..9b657fdb 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -241,6 +241,30 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) return 0 } + // A variance-declaring interface (ISource / ISink) makes + // differently-closed constructions assignable across the family, so the + // closed-and-unequal equality gate — which models invariant parameters + // only — must never arm for it: its families carry no stamped args at + // all, and every site keeps the full fan-out. + ifaceIDs := make([]string, 0, len(anchorGroups)) + seenIfaceIDs := map[string]bool{} + for _, key := range anchorOrder { + id := anchorGroups[key].ifaceID + if !seenIfaceIDs[id] { + seenIfaceIDs[id] = true + ifaceIDs = append(ifaceIDs, id) + } + } + variantIface := map[string]bool{} + for id, n := range g.GetNodesByIDs(ifaceIDs) { + if n == nil || n.Meta == nil { + continue + } + if v, _ := n.Meta["variant_type_params"].(bool); v { + variantIface[id] = true + } + } + // Descendant closure per interface, computed once and shared across that // interface's anchors (one per member name). descCache := map[string][]string{} @@ -283,12 +307,16 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) } memberArgs := map[string]string{} implCount := 0 + variant := variantIface[ag.ifaceID] for _, sub := range descendants(ag.ifaceID) { byName := membersByType[sub] if byName == nil { continue } - subArgs := implArgs[sub][ag.ifaceID] + subArgs := "" + if !variant { + subArgs = implArgs[sub][ag.ifaceID] + } for _, m := range byName[ag.name] { if m == nil || anchorSet[m.ID] { continue diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index a8820c37..d9bea55d 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -455,6 +455,87 @@ func TestResolveCSharpInterfaceDispatch_LocalShadowedReceiverNeverFilters(t *tes "an unknown receiver filters nothing") } +// Review RED (revision 3a): a covariant interface (`ISource`) makes +// ISource assignable to an ISource receiver, so DogSource.Get +// is a real dispatch target at the site - the closed-and-unequal equality +// gate only models INVARIANT parameters and must stand down entirely when +// the interface declares any variance. +func TestResolveCSharpInterfaceDispatch_CovariantInterfaceNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Sources.cs": `namespace App { + public class Animal { } + public class Dog : Animal { } + public class Cat : Animal { } + public interface ISource { + T Get(); + } + public class DogSource : ISource { + public Dog Get() { return new Dog(); } + } + public class CatSource : ISource { + public Cat Get() { return new Cat(); } + } + public class Flow { + private readonly ISource _source; + public Flow(ISource s) { _source = s; } + public Animal Pull() { + return _source.Get(); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Sources.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_source", "Sources.cs::ISource.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Sources.cs::DogSource.Get", + "out T: ISource satisfies an ISource receiver - the impl must stay in") + assert.Contains(t, targets, "Sources.cs::CatSource.Get", + "out T: ISource satisfies an ISource receiver - the impl must stay in") +} + +// Review RED (revision 3b): the contravariant twin (`ISink`) - +// ISink is assignable to an ISink receiver. +func TestResolveCSharpInterfaceDispatch_ContravariantInterfaceNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Sinks.cs": `namespace App { + public class Animal { } + public class Dog : Animal { } + public interface ISink { + int Put(int item); + } + public class AnimalSink : ISink { + public int Put(int item) { return 1; } + } + public class DogSink : ISink { + public int Put(int item) { return 2; } + } + public class Flow { + private readonly ISink _sink; + public Flow(ISink s) { _sink = s; } + public int Push() { + return _sink.Put(3); + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Sinks.cs::Flow.Push" + bindFieldReceiverCall(t, g, callerID, "_sink", "Sinks.cs::ISink.Put") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Sinks.cs::AnimalSink.Put", + "in T: ISink satisfies an ISink receiver - the impl must stay in") + assert.Contains(t, targets, "Sinks.cs::DogSink.Put") +} + // Codex review RED: `IBoxStore` and `IBoxStore` are the // SAME constructed interface in different spellings — the gate must fold // both to one canonical form and RETAIN the edge, never suppress it on a From c560f6944697d950a55b3909db9fb0dab2fdf76b Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:13:06 +0200 Subject: [PATCH 06/38] indexer: type-argument stamps canonicalize qualifier, verbatim, and alias spellings Equivalent spellings of one constructed interface compared unequal and armed the closed-and-unequal gate against a real dispatch target: global::App.Crate vs Crate, @Crate vs Crate, IBox vs IBox, IBox vs IBox. The normalizer now reduces alias/global qualifiers to the final segment and strips the verbatim @ BEFORE the open-parameter check - so IBox<@T> reads as the open parameter T and stamps nothing - and the canonical fold adds dynamic->object and IntPtr/UIntPtr->nint/nuint. --- .../parser/languages/csharp_base_type_args.go | 19 +++ .../csharp_iface_dispatch_generic_test.go | 146 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go index 35e5ee9f..81376c8f 100644 --- a/internal/parser/languages/csharp_base_type_args.go +++ b/internal/parser/languages/csharp_base_type_args.go @@ -207,6 +207,14 @@ func csharpCanonicalTypeArg(t string) string { return "ulong" case "Object": return "object" + case "dynamic": + // dynamic erases to object — the two spellings construct over the + // same underlying type, and folding can only CREATE matches. + return "object" + case "IntPtr": + return "nint" + case "UIntPtr": + return "nuint" } return t } @@ -248,9 +256,20 @@ func csharpNormalizeSimpleArg(text string, openParams map[string]bool) string { // beyond one identifier chain — not comparable by string. return "" } + // Qualifiers reduce to the final segment — `global::App.Crate`, an + // extern-alias qualifier, and a dotted namespace all name the same + // last-segment type the resolver-side convention compares by. + if i := strings.LastIndex(text, "::"); i >= 0 { + text = text[i+2:] + } if dot := strings.LastIndex(text, "."); dot >= 0 { text = text[dot+1:] } + // A verbatim identifier (`@Crate`, `@T`) names the same symbol as its + // bare spelling. Strip BEFORE the open-parameter/alias check so + // `IBox<@T>` reads as the open parameter T — never as a closed type + // spelled "@T" that would gate the open implementor out. + text = strings.TrimPrefix(text, "@") if text == "" || openParams[text] { return "" } diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index d9bea55d..a5009654 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -536,6 +536,152 @@ func TestResolveCSharpInterfaceDispatch_ContravariantInterfaceNeverFilters(t *te assert.Contains(t, targets, "Sinks.cs::DogSink.Put") } +// Review RED (revision 4a): `IBox` and `IBox` construct +// over the same underlying type (dynamic erases to object) - the gate must +// fold them together and retain the edge. +func TestResolveCSharpInterfaceDispatch_DynamicObjectSpellingsRetainTheEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Dyn.cs": `namespace App { + public class Crate { } + public interface IBox { + int Get(int id); + } + public class DynBox : IBox { + public int Get(int id) { return 1; } + } + public class CrateBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _objects; + public Flow(IBox o) { _objects = o; } + public int Pull() { return _objects.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Dyn.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_objects", "Dyn.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Dyn.cs::DynBox.Get", + "dynamic and object spell the same constructed interface - the edge stays") + assert.NotContains(t, targets, "Dyn.cs::CrateBox.Get", + "the genuinely different closure still filters") +} + +// Review RED (revision 4b): `nint` IS System.IntPtr (and nuint UIntPtr) - +// the native-int keyword and the struct name spell one type. +func TestResolveCSharpInterfaceDispatch_NativeIntSpellingsRetainTheEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Native.cs": `namespace App { + public class Crate { } + public interface IBox { + int Get(int id); + } + public class PtrBox : IBox { + public int Get(int id) { return 1; } + } + public class CrateBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _ptrs; + public Flow(IBox p) { _ptrs = p; } + public int Pull() { return _ptrs.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Native.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_ptrs", "Native.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Native.cs::PtrBox.Get", + "nint and System.IntPtr spell the same constructed interface - the edge stays") + assert.NotContains(t, targets, "Native.cs::CrateBox.Get") +} + +// Review RED (revision 4c): a verbatim identifier (`@Crate`) and a +// global-qualified spelling (`global::App.Crate`) both name plain Crate. +func TestResolveCSharpInterfaceDispatch_VerbatimAndGlobalSpellingsRetainTheEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Spell.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class VerbatimBox : IBox<@Crate> { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _crates; + public Flow(IBox c) { _crates = c; } + public int Pull() { return _crates.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Spell.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_crates", "Spell.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Spell.cs::VerbatimBox.Get", + "@Crate and global::App.Crate both name Crate - the edge stays") + assert.NotContains(t, targets, "Spell.cs::WidgetBox.Get", + "the genuinely different closure still filters") +} + +// Review RED (revision 4d): `class Relay : IBox<@T>` closes NOTHING - +// the escaped spelling still names the open parameter T, and reading it as +// a closed type called "@T" would filter the open implementor out of every +// differently-closed receiver's fan-out. +func TestResolveCSharpInterfaceDispatch_EscapedOpenParamStaysInFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Relay.cs": `namespace App { + public class Crate { } + public interface IBox { + int Get(int id); + } + public class Relay : IBox<@T> { + public int Get(int id) { return 1; } + } + public class CrateBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _crates; + public Flow(IBox c) { _crates = c; } + public int Pull() { return _crates.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Relay.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_crates", "Relay.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Relay.cs::Relay.Get", + "an open-generic implementor spelled with a verbatim parameter stays in the fan-out") + assert.Contains(t, targets, "Relay.cs::CrateBox.Get") +} + // Codex review RED: `IBoxStore` and `IBoxStore` are the // SAME constructed interface in different spellings — the gate must fold // both to one canonical form and RETAIN the edge, never suppress it on a From c04c0c8945f9cdd97e237689be149b301b424aa1 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:17:04 +0200 Subject: [PATCH 07/38] resolver: project-global using aliases refuse the type-argument stamps that spell them A global using alias declared in one file renames a type in EVERY file of the project, but the stamp-time alias scan walks only the declaring file's ancestor chain - a receiver spelled IBox and an implementor spelled IBox stamped unequal spellings of one constructed interface and the gate suppressed the real target. The extractor now records global alias names on the file node (global_using_aliases), and the dispatch pass collects them once per run and refuses any stamp naming one - opaque spelling, never filter. --- internal/parser/languages/csharp.go | 19 ++++- internal/resolver/csharp_iface_dispatch.go | 42 ++++++++++ .../csharp_iface_dispatch_generic_test.go | 81 +++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index c1a3f753..76e41002 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -1594,10 +1594,11 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st // as Meta["scoped_usings"] ("scope|name", empty scope = compilation // unit). Additive: the flat keys keep their exact legacy shape. func stampCSharpUsings(root *sitter.Node, src []byte, fileNode *graph.Node) { - var usings, globals, statics, scoped, globalStatics []string + var usings, globals, statics, scoped, globalStatics, globalAliases []string seen := map[string]bool{} seenStatic := map[string]bool{} seenScoped := map[string]bool{} + seenGlobalAlias := map[string]bool{} walkNodes(root, func(n *sitter.Node) { if n.Type() != "using_directive" { return @@ -1612,6 +1613,17 @@ func stampCSharpUsings(root *sitter.Node, src []byte, fileNode *graph.Node) { case "static": isStatic = true case "name_equals", "=": + // An alias grants no bare-name namespace visibility, but a + // GLOBAL alias makes its identifier project-scoped and + // opaque to string-compared type-argument stamps in every + // OTHER file — record the name so the dispatch gate can + // refuse stamps that spell it. + if isGlobal { + if a := csharpUsingAliasName(n, src); a != "" && !seenGlobalAlias[a] { + seenGlobalAlias[a] = true + globalAliases = append(globalAliases, a) + } + } return case "identifier", "qualified_name": name = strings.TrimSpace(c.Content(src)) @@ -1647,12 +1659,15 @@ func stampCSharpUsings(root *sitter.Node, src []byte, fileNode *graph.Node) { scoped = append(scoped, key) } }) - if len(usings) == 0 && len(statics) == 0 { + if len(usings) == 0 && len(statics) == 0 && len(globalAliases) == 0 { return } if fileNode.Meta == nil { fileNode.Meta = map[string]any{} } + if len(globalAliases) > 0 { + fileNode.Meta["global_using_aliases"] = globalAliases + } if len(usings) > 0 { fileNode.Meta["usings"] = usings } diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 9b657fdb..fcffd685 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -173,6 +173,27 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) return 0 } + // Project-global using aliases (`global using Entity = App.Crate;`) + // make their identifier opaque to the string-compared stamps in EVERY + // file of the project — the extractor's ancestor scan only sees the + // declaring file, so a receiver spelled IBox and an implementor + // spelled IBox stamp unequal spellings of one constructed + // interface. Any stamp naming such an alias is refused (never filter). + // Collected once per pass from the file nodes' extractor stamps, and + // only when stamps exist to gate with; the union across repos is + // deliberate — over-refusing can only PRESERVE edges. + globalAliasNames := map[string]bool{} + if len(implArgs) > 0 { + for n := range graph.NodesByKindsSeq(g, graph.KindFile) { + if n == nil || n.Meta == nil { + continue + } + for _, a := range csharpMetaStrings(n.Meta["global_using_aliases"]) { + globalAliasNames[a] = true + } + } + } + // implementation/interface type node id → member name → method nodes. // Every overload matters: C# overloads mint one node each (Convert, // Convert_L39, ...) sharing the same Name, and real call sites bind to any @@ -316,6 +337,9 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) subArgs := "" if !variant { subArgs = implArgs[sub][ag.ifaceID] + if csharpArgsNameGlobalAlias(subArgs, globalAliasNames) { + subArgs = "" + } } for _, m := range byName[ag.name] { if m == nil || anchorSet[m.ID] { @@ -429,6 +453,9 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) srcArgs := f.implArgs[e.To] if srcArgs == "" && len(f.implArgs) > 0 { srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceID, f.ifaceName, receiverFieldTypes) + if csharpArgsNameGlobalAlias(srcArgs, globalAliasNames) { + srcArgs = "" + } } for _, member := range f.members { // Skip the member the call already reaches — and the CALLER @@ -640,6 +667,21 @@ func csharpMemberMethodsAllByTypeFromEdges(edges []*graph.Edge, nodes map[string return out } +// csharpArgsNameGlobalAlias reports whether any comma-separated argument in +// a type-argument stamp names a project-global using alias — a spelling the +// string comparison cannot resolve, so the stamp must be refused. +func csharpArgsNameGlobalAlias(args string, aliases map[string]bool) bool { + if args == "" || len(aliases) == 0 { + return false + } + for _, a := range strings.Split(args, ",") { + if aliases[a] { + return true + } + } + return false +} + // containsInt reports whether xs contains v. Family lists are tiny (a method // belongs to one or two families), so a linear scan beats a map. func containsInt(xs []int, v int) bool { diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index a5009654..85394e40 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -682,6 +682,87 @@ func TestResolveCSharpInterfaceDispatch_EscapedOpenParamStaysInFanout(t *testing assert.Contains(t, targets, "Relay.cs::CrateBox.Get") } +// Review RED (revision 5a): a PROJECT-WIDE alias (`global using Entity = +// App.Crate;` in another file) makes the spelling "Entity" opaque in every +// file - the receiver's IBox and the implementor's IBox are +// the same constructed interface, but the ancestor-only alias scan cannot +// see the cross-file directive and the stamps compare unequal. Any stamp +// naming a project-global alias must be refused (never filter). +func TestResolveCSharpInterfaceDispatch_GlobalUsingAliasReceiverNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Global.cs": `global using Entity = App.Crate; +`, + "Stores.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } +}`, + "Flow.cs": `namespace App { + public class Flow { + private readonly IBox _crates; + public Flow(IBox c) { _crates = c; } + public int Pull() { return _crates.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Flow.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_crates", "Stores.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBox.Get", + "Entity IS Crate through the project-global alias - the impl must stay in") + assert.Contains(t, targets, "Stores.cs::WidgetBox.Get", + "an alias-named receiver stamp is opaque and filters nothing") +} + +// Review RED (revision 5b): the implementor-side twin - a base list spelled +// through the project-global alias (`CrateBox : IBox`) must not +// stamp a closed argument the gate would compare against literal spellings. +func TestResolveCSharpInterfaceDispatch_GlobalUsingAliasImplementorStaysInFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Global.cs": `global using Entity = App.Crate; +`, + "Stores.cs": `namespace App { + public class Crate { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } +}`, + "Flow.cs": `namespace App { + public class Flow { + private readonly IBox _crates; + public Flow(IBox c) { _crates = c; } + public int Pull() { return _crates.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Flow.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_crates", "Stores.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBox.Get", + "the alias-spelled base list stamps nothing - the impl stays in the fan-out") +} + // Codex review RED: `IBoxStore` and `IBoxStore` are the // SAME constructed interface in different spellings — the gate must fold // both to one canonical form and RETAIN the edge, never suppress it on a From 1f06363faf88e23ab59e53950bd92f6549c8c35e Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:17:38 +0200 Subject: [PATCH 08/38] indexer: bump the C# extractor version for the revision's new stamps The variance stamp (variant_type_params), the global-using-alias stamp (global_using_aliases), and the widened argument canonicalization all change extraction output for unchanged files. A store already running this branch's version 13 would keep the old stamps and the gate would filter variant families forever - the bump forces the re-extract; the salt pin moves with it. --- internal/indexer/extractor_version.go | 2 +- internal/indexer/extractor_version_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/indexer/extractor_version.go b/internal/indexer/extractor_version.go index 3660b9b8..4c2d5351 100644 --- a/internal/indexer/extractor_version.go +++ b/internal/indexer/extractor_version.go @@ -32,7 +32,7 @@ var extractorVersions = map[string]int{ // "go": 2, "c": generatedParserProjectionPolicyVersion, // generated parser projection covers all strictly detected table sizes "php": 2, // class/interface inheritance now emits typed structural edges - "csharp": 13, // field identifiers emit read/write edges naming the field (#668, retroactive - the bump was missed there) + generic base-list/field type-argument stamps for dispatch gating (was: receiverless calls carry arg_count / type_arg_count for #559) + "csharp": 14, // interface variance + global-using-alias stamps and qualifier/verbatim canonicalization for the dispatch gate review revision (was: field-identifier read/write edges + type-argument stamps) "scala": 2, // explicitly instantiated generic calls emit call edges "go": 3, // generic instantiations are marked so indexing a func value cannot bind (was: generic calls emit call edges) "cpp": 2, // templated and namespace-qualified calls emit call edges diff --git a/internal/indexer/extractor_version_test.go b/internal/indexer/extractor_version_test.go index 519821cb..61f63d43 100644 --- a/internal/indexer/extractor_version_test.go +++ b/internal/indexer/extractor_version_test.go @@ -63,8 +63,8 @@ func TestStaleLangsDetection(t *testing.T) { t.Errorf("stored pre-params C# version = %v, want [csharp]", got) } for _, path := range []string{"src/Handler.cs", "Views/Page.razor", "Views/Page.cshtml"} { - if got := merkleSaltFor(path); got != "csharp@13" { - t.Errorf("C# extractor salt for %s = %q, want csharp@13", path, got) + if got := merkleSaltFor(path); got != "csharp@14" { + t.Errorf("C# extractor salt for %s = %q, want csharp@14", path, got) } } if got := merkleSaltFor("src/Handler.php"); got != "php@2" { From 12cc6affe799e0b2df1a2b8b8525f8f4b410db27 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:21:10 +0200 Subject: [PATCH 09/38] resolver: receiver lookups read each caller's adjacency once per pass Every receiver lookup re-read the caller's full out-edge list - the companion scan and the field-read evidence scan each paid one GetOutEdges per call site, ~2N reads for a method with N through-interface sites. A per-pass lookup context now reads each caller's adjacency once and serves every site from it, and caches resolved field nodes by ID. --- internal/resolver/csharp_iface_dispatch.go | 60 ++++++++++++++--- .../csharp_iface_dispatch_generic_test.go | 65 +++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index fcffd685..7e31125f 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -406,7 +406,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) var batch []*graph.Edge seen := map[string]bool{} - receiverFieldTypes := map[string]string{} // per (caller,file,line) cache of the receiver field's declared type text + receiverLookups := newCSharpReceiverLookupCtx() for _, e := range callEdges { if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) { continue @@ -452,7 +452,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) // it never pays the receiver lookup either. srcArgs := f.implArgs[e.To] if srcArgs == "" && len(f.implArgs) > 0 { - srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceID, f.ifaceName, receiverFieldTypes) + srcArgs = csharpReceiverDeclaredArgs(g, e, f.ifaceID, f.ifaceName, receiverLookups) if csharpArgsNameGlobalAlias(srcArgs, globalAliasNames) { srcArgs = "" } @@ -731,7 +731,47 @@ func csharpShortTypeName(id string) string { // Typed LOCALS are a named remainder: the tenv strips generics before // receiver_type is stamped, so local-receiver sites keep the full // fan-out until the extractor carries local type arguments too. -func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName string, cache map[string]string) string { +// csharpReceiverLookupCtx carries the per-pass receiver-evidence caches: +// declared args per (caller, member, site, interface), each caller's +// out-edge adjacency read ONCE and served to every site (the companion +// scan and the field-read evidence scan both consume it), and resolved +// field nodes by ID. +type csharpReceiverLookupCtx struct { + args map[string]string + outEdges map[string][]*graph.Edge + fields map[string]*graph.Node + fieldSeen map[string]bool +} + +func newCSharpReceiverLookupCtx() *csharpReceiverLookupCtx { + return &csharpReceiverLookupCtx{ + args: map[string]string{}, + outEdges: map[string][]*graph.Edge{}, + fields: map[string]*graph.Node{}, + fieldSeen: map[string]bool{}, + } +} + +func (c *csharpReceiverLookupCtx) callerOutEdges(g graph.Store, caller string) []*graph.Edge { + if es, ok := c.outEdges[caller]; ok { + return es + } + es := g.GetOutEdges(caller) + c.outEdges[caller] = es + return es +} + +func (c *csharpReceiverLookupCtx) fieldNode(g graph.Store, id string) *graph.Node { + if c.fieldSeen[id] { + return c.fields[id] + } + c.fieldSeen[id] = true + n := g.GetNodesByIDs([]string{id})[id] + c.fields[id] = n + return n +} + +func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName string, lookups *csharpReceiverLookupCtx) string { if e == nil || e.From == "" { return "" } @@ -741,11 +781,11 @@ func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName // The full interface ID keeps short-name twins from distinct families // apart for the same reason. cacheKey := e.From + "\x00" + e.To + "\x00" + e.FilePath + "\x00" + strconv.Itoa(e.Line) + "\x00" + ifaceID - if v, ok := cache[cacheKey]; ok { + if v, ok := lookups.args[cacheKey]; ok { return v } args := "" - if field := csharpReceiverField(g, e); field != nil { + if field := csharpReceiverField(g, e, lookups); field != nil { ft, _ := field.Meta["field_type"].(string) prefix := strings.TrimSpace(ft) if lt := strings.Index(prefix, "<"); lt > 0 { @@ -758,14 +798,14 @@ func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName args, _ = field.Meta["field_type_args"].(string) } } - cache[cacheKey] = args + lookups.args[cacheKey] = args return args } // csharpReceiverField resolves the call site's receiver to a field (or // constant) node of the caller's own type, or nil when the receiver is // not an unambiguous bare same-type field. -func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { +func csharpReceiverField(g graph.Store, e *graph.Edge, lookups *csharpReceiverLookupCtx) *graph.Node { name := "" if e.Meta != nil { if amb, _ := e.Meta["receiver_ambiguous"].(bool); amb { @@ -780,7 +820,7 @@ func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { // different call sharing the line can never lend its receiver. memberName := csharpShortTypeName(e.To) companionTo := "unresolved::*." + memberName - for _, out := range g.GetOutEdges(e.From) { + for _, out := range lookups.callerOutEdges(g, e.From) { if out == nil || out.Kind != graph.EdgeCalls || out.To != companionTo { continue } @@ -812,7 +852,7 @@ func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { // the field it shadows and gate on the wrong declared arguments — // without the read edge the receiver stays unknown (never filter). fieldRead := false - for _, out := range g.GetOutEdges(e.From) { + for _, out := range lookups.callerOutEdges(g, e.From) { if out == nil || out.Kind != graph.EdgeReads { continue } @@ -827,7 +867,7 @@ func csharpReceiverField(g graph.Store, e *graph.Edge) *graph.Node { if !fieldRead { return nil } - field := g.GetNodesByIDs([]string{fieldID})[fieldID] + field := lookups.fieldNode(g, fieldID) if field == nil || field.Meta == nil || (field.Kind != graph.KindField && field.Kind != graph.KindConstant) { return nil diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 85394e40..8ed39f5c 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -175,6 +175,71 @@ func TestResolveCSharpInterfaceDispatch_OpenGenericImplStaysInFanout(t *testing. assert.Contains(t, targets, "Stores.cs::CrateBoxStore.Fetch") } +// outEdgeCountingStore counts GetOutEdges reads per node ID. Plain +// interface embedding deliberately hides the optional projection +// capabilities the same way csharpProjectionlessStore does. +type outEdgeCountingStore struct { + graph.Store + outEdgeReads map[string]int +} + +func (s *outEdgeCountingStore) GetOutEdges(id string) []*graph.Edge { + s.outEdgeReads[id]++ + return s.Store.GetOutEdges(id) +} + +// Review RED (revision P2): every receiver lookup re-read the caller's full +// out-edge adjacency - one scan per call site per evidence pass, so a method +// with N through-interface sites paid ~2N GetOutEdges reads. The lookup must +// read a caller's adjacency once per pass and serve every site from it. +func TestResolveCSharpInterfaceDispatch_ReceiverLookupReadsCallerAdjacencyOnce(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Multi.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _a; + private readonly IBox _b; + private readonly IBox _c; + public Flow(IBox a, IBox b, IBox c) { _a = a; _b = b; _c = c; } + public int Pull() { + int x = _a.Get(1); + int y = _b.Get(2); + int z = _c.Get(3); + return x + y + z; + } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Multi.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_a", "Multi.cs::IBox.Get") + bindFieldReceiverCall(t, g, callerID, "_b", "Multi.cs::IBox.Get") + bindFieldReceiverCall(t, g, callerID, "_c", "Multi.cs::IBox.Get") + + counting := &outEdgeCountingStore{Store: g, outEdgeReads: map[string]int{}} + ResolveCSharpInterfaceDispatch(counting) + + if n := counting.outEdgeReads[callerID]; n > 1 { + t.Fatalf("receiver lookups read the caller's out edges %d times, want at most 1", n) + } + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Multi.cs::CrateBox.Get", + "the cached adjacency still yields the receiver evidence") + assert.NotContains(t, targets, "Multi.cs::WidgetBox.Get", + "the gate still filters on the cached evidence") +} + // dispatchTargets returns the fan-out targets minted from callerID. func dispatchTargets(g graph.Store, callerID string) []string { var targets []string From 2c8f60701d155ce73c58354fc0e8d5e162b082f3 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:25:29 +0200 Subject: [PATCH 10/38] indexer: collect using-alias names once per file for the type-argument stamps Every field, property, and base-list stamp re-walked its ancestor chain and rescanned the enclosing namespace's whole declaration list for alias directives - quadratic in sibling count (1.9s on the review's 2,000-sibling fixture, 0.37s after). The alias names are now collected in one walk per extraction and threaded to the stamp sites; the ancestor walk keeps only the cheap per-declaration type-parameter half. Scope widening is deliberate and recall-safe: treating every alias in the file as in scope everywhere can only refuse MORE stamps, which preserves fan-out edges. --- internal/parser/languages/csharp.go | 35 +++++++----- .../parser/languages/csharp_base_type_args.go | 55 ++++++++++--------- .../languages/csharp_base_type_args_test.go | 22 ++++++++ 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 76e41002..1fd9baa5 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -316,6 +316,11 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // shape so a locally-known interface always wins. localInterfaces := collectCSharpInterfaceNames(root, src) + // Using-alias names, collected once per file: the type-argument stamp + // sites consult them per declaration, and a per-declaration rescan of + // the enclosing namespace was quadratic in sibling count. + fileAliases := csharpFileAliasNames(root, src) + var calls []csharpDeferredCall var locals []csharpDeferredLocal var typeUses []csharpTypeUse @@ -329,19 +334,19 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex e.emitNamespace(m, filePath, fileID, result, seen) case m.Captures["class.def"] != nil: - e.emitContainer(m, "class", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) + e.emitContainer(m, "class", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) case m.Captures["iface.def"] != nil: - e.emitContainer(m, "iface", graph.KindInterface, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) + e.emitContainer(m, "iface", graph.KindInterface, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) case m.Captures["struct.def"] != nil: - e.emitContainer(m, "struct", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) + e.emitContainer(m, "struct", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) case m.Captures["record.def"] != nil: - e.emitContainer(m, "record", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) + e.emitContainer(m, "record", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) case m.Captures["enum.def"] != nil: - e.emitContainer(m, "enum", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) + e.emitContainer(m, "enum", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) case m.Captures["anon.def"] != nil: e.emitAnonymousType(m, filePath, fileID, result, seen) @@ -353,10 +358,10 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex e.emitConstructor(m, filePath, fileID, src, result, seen) case m.Captures["field.def"] != nil: - e.emitField(m, filePath, fileID, src, result, seen) + e.emitField(m, filePath, fileID, src, result, seen, fileAliases) case m.Captures["prop.def"] != nil: - e.emitProperty(m, filePath, fileID, src, result, seen) + e.emitProperty(m, filePath, fileID, src, result, seen, fileAliases) case m.Captures["using.def"] != nil: e.emitUsing(m, filePath, fileID, result) @@ -839,7 +844,7 @@ func (e *CSharpExtractor) emitNamespace(m parser.QueryResult, filePath, fileID s // emitContainer collapses the per-kind class/interface/struct/enum // node emission. The capture-name prefix selects which capture set to // read from (the legacy code repeated this body four times). -func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeKind graph.NodeKind, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, localInterfaces map[string]bool) { +func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeKind graph.NodeKind, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, localInterfaces, fileAliases map[string]bool) { name := m.Captures[kind+".name"].Text def := m.Captures[kind+".def"] id := filePath + "::" + name @@ -894,7 +899,7 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK // for structs and records, inheritance for interfaces). switch kind { case "class", "struct", "record", "iface": - emitCSharpBaseList(id, def.Node, src, filePath, localInterfaces, result) + emitCSharpBaseList(id, def.Node, src, filePath, localInterfaces, fileAliases, result) case "enum": e.emitCSharpEnumMembers(def.Node, src, filePath, id, name, result, seen) } @@ -1450,7 +1455,7 @@ func (e *CSharpExtractor) emitConstructor(m parser.QueryResult, filePath, fileID emitCSharpFunctionShape(id, def.Node, src, filePath, startLine1, result) } -func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { +func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool, fileAliases map[string]bool) { def := m.Captures["field.def"] owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration", "interface_declaration", "record_declaration") if owner.kind == "" { @@ -1483,7 +1488,7 @@ func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID strin meta["field_type"] = fieldTypeRaw // Closed generic arguments of the declared type — the dispatch // gate's receiver evidence (see csharp_base_type_args.go). - if args := csharpSimpleTypeArgsFromText(fieldTypeRaw, csharpUnstampableArgNames(def.Node, src)); args != "" { + if args := csharpSimpleTypeArgsFromText(fieldTypeRaw, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { meta["field_type_args"] = args } } @@ -1522,7 +1527,7 @@ func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID strin emitCSharpTypeUseEdges(id, fieldTypeRaw, filePath, def.StartLine+1, result) } -func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { +func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool, fileAliases map[string]bool) { def := m.Captures["prop.def"] owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration", "interface_declaration", "record_declaration") if owner.kind == "" { @@ -1554,7 +1559,7 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st meta["field_type"] = propTypeRaw // Same closed-generic-arguments stamp fields carry (dispatch // gate receiver evidence — csharp_base_type_args.go). - if args := csharpSimpleTypeArgsFromText(propTypeRaw, csharpUnstampableArgNames(def.Node, src)); args != "" { + if args := csharpSimpleTypeArgsFromText(propTypeRaw, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { meta["field_type_args"] = args } } @@ -1831,7 +1836,7 @@ func collectCSharpInterfaceNames(root *sitter.Node, src []byte) map[string]bool // the resolver binds them like every other C# reference. A base that // resolves to a same-file class still flows through unchanged — it is // neither a known interface nor I-prefixed, so it lands as EdgeExtends. -func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath string, localInterfaces map[string]bool, result *parser.ExtractionResult) { +func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath string, localInterfaces, fileAliases map[string]bool, result *parser.ExtractionResult) { if decl == nil { return } @@ -1862,7 +1867,7 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s // the declaring type AND every enclosing type (Relay : IBoxStore, // or a type nested inside a generic outer), plus in-scope using // aliases (opaque spellings). - declTypeParams := csharpUnstampableArgNames(decl, src) + declTypeParams := csharpUnstampableArgNames(decl, src, fileAliases) // A base list closing the SAME erased target twice // (Both : IBoxStore, IBoxStore) collapses to one stored // edge — identical (from, to, kind, file, line) — so a stamp would diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go index 81376c8f..1e6bb148 100644 --- a/internal/parser/languages/csharp_base_type_args.go +++ b/internal/parser/languages/csharp_base_type_args.go @@ -44,21 +44,40 @@ import ( // - a qualified base whose generic segment is not the FINAL one // (Outer.IInner) never stamps the outer segment's arguments. +// csharpFileAliasNames collects every using-alias identifier the FILE +// declares, in one walk, regardless of scope. An alias may spell any type, +// including one whose canonical form differs from the alias identifier, so +// it is opaque to a string comparison — and treating every alias as +// in-scope everywhere in the file over-refuses at worst, which only ever +// PRESERVES fan-out edges. Computed once per extraction and threaded to +// each stamp site: the previous per-declaration ancestor scan re-walked +// the enclosing namespace's whole declaration list, quadratic in sibling +// count (the review's 2,000-sibling fixture). +func csharpFileAliasNames(root *sitter.Node, src []byte) map[string]bool { + var out map[string]bool + walkNodes(root, func(n *sitter.Node) { + if name := csharpUsingAliasName(n, src); name != "" { + if out == nil { + out = map[string]bool{} + } + out[name] = true + } + }) + return out +} + // csharpUnstampableArgNames collects every identifier that must NOT be -// read as a closed concrete type argument at node's position, walking the -// ancestor chain once: +// read as a closed concrete type argument at node's position: // // - type parameters of every enclosing type declaration, the node's own // included — a nested type legitimately closes over its outer types' -// parameters, and every one of them is open; -// - using-alias names in scope (`using MyCrate = App.Crate;`, at file -// level or inside an enclosing namespace) — an alias may spell any -// type, including one whose canonical form differs from the alias -// identifier, so it is opaque to a string comparison. +// parameters, and every one of them is open (per-declaration ancestor +// walk, cheap); +// - the file's using-alias names, precollected by csharpFileAliasNames. // // Both categories mean the same thing to the caller: this spelling does // not denote a type the dispatch gate may compare by name. -func csharpUnstampableArgNames(node *sitter.Node, src []byte) map[string]bool { +func csharpUnstampableArgNames(node *sitter.Node, src []byte, fileAliases map[string]bool) map[string]bool { var out map[string]bool add := func(name string) { if name == "" { @@ -75,25 +94,11 @@ func csharpUnstampableArgNames(node *sitter.Node, src []byte) map[string]bool { for name := range csharpMethodTypeParamNames(n, src) { add(name) } - case "compilation_unit", "namespace_declaration", "file_scoped_namespace_declaration": - // Using directives are DIRECT children of the compilation - // unit or of a namespace declaration (a block namespace - // keeps its members one level down, in declaration_list) — - // all of which sit on the ancestor chain of any type, so - // this shallow scan sees every alias actually in scope, - // scoped usings included, without a whole-tree walk. - for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { - c := n.NamedChild(i) - if c != nil && c.Type() == "declaration_list" { - for j, _jc := 0, int(c.NamedChildCount()); j < _jc; j++ { - add(csharpUsingAliasName(c.NamedChild(j), src)) - } - continue - } - add(csharpUsingAliasName(c, src)) - } } } + for name := range fileAliases { + add(name) + } return out } diff --git a/internal/parser/languages/csharp_base_type_args_test.go b/internal/parser/languages/csharp_base_type_args_test.go index c5c1fc03..8491cf08 100644 --- a/internal/parser/languages/csharp_base_type_args_test.go +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -294,3 +294,25 @@ func TestCSharpExtractor_SameLineSameNameCallsMarkReceiverAmbiguous(t *testing.T } } } + +// The 2,000-sibling shape from the review: one namespace whose declaration +// list holds thousands of types, each with a generic base entry and a +// generic field. A per-declaration alias scan that re-walks the namespace's +// children makes stamping quadratic in sibling count; the per-file alias +// set must be collected once. +func BenchmarkCSharpExtractSiblingHeavyTypeArgStamps(b *testing.B) { + var sb []byte + sb = append(sb, []byte("using MyCrate = App.Crate;\nnamespace App {\n public interface IBoxStore { }\n public class Crate { }\n")...) + for i := 0; i < 2000; i++ { + n := []byte(" public class Store" + itoa(i) + " : IBoxStore {\n private readonly IBoxStore _store;\n }\n") + sb = append(sb, n...) + } + sb = append(sb, []byte("}\n")...) + e := NewCSharpExtractor() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := e.Extract("Siblings.cs", sb); err != nil { + b.Fatal(err) + } + } +} From 0a3cfe9b4c8f15e2461dee6141110822a4fccd15 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:27:44 +0200 Subject: [PATCH 11/38] resolver: pin the property-receiver, transitive-implementor, and nullable gate shapes Three sweep pins beyond the review findings, all already-correct behavior worth locking: a property receiver gates exactly like a field (properties mint KindField and ride the same read-edge evidence), a transitive implementor is unstamped against the root interface and never filtered, and a nullable-annotated spelling stamps nothing (folding Crate? to Crate would be right for reference types but wrong for value types). --- .../csharp_iface_dispatch_generic_test.go | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 8ed39f5c..3adabf17 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -240,6 +240,128 @@ func TestResolveCSharpInterfaceDispatch_ReceiverLookupReadsCallerAdjacencyOnce(t "the gate still filters on the cached evidence") } +// Sweep pin: a PROPERTY receiver rides the same evidence path as a field - +// properties mint KindField nodes, the field-identifier emitter covers +// their bare-identifier reads, and the declared-type stamp carries the +// closed arguments - so the gate must filter for property receivers +// exactly as it does for fields. +func TestResolveCSharpInterfaceDispatch_PropertyReceiverGatesFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Props.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private IBox Store { get; set; } + public int Pull() { return Store.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Props.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "Store", "Props.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Props.cs::CrateBox.Get", + "a property receiver's declared arguments gate exactly like a field's") + assert.NotContains(t, targets, "Props.cs::WidgetBox.Get", + "an IBox property receiver never dispatches to the Widget impl") +} + +// Sweep pin: an implementor reached TRANSITIVELY (class D : IDerived where +// IDerived : IBox) carries no stamp against the root interface - its +// base-list evidence names IDerived, not IBox - so it must stay in every +// fan-out, even one whose receiver closes over different arguments. The +// documented conservative rule, pinned so stamp inheritance can never +// silently flip it into filtering. +func TestResolveCSharpInterfaceDispatch_TransitiveImplementorStaysInFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Derived.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public interface IDerived : IBox { + } + public class DerivedBox : IDerived { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _widgets; + public Flow(IBox w) { _widgets = w; } + public int Pull() { return _widgets.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Derived.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_widgets", "Derived.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Derived.cs::WidgetBox.Get", + "the receiver's own closure keeps its matching impl") + assert.Contains(t, targets, "Derived.cs::DerivedBox.Get", + "a transitive implementor is unstamped and never filtered") +} + +// Sweep pin: a nullable-annotated receiver (`IBox`) is a non-simple +// spelling and stamps nothing - the site keeps the full fan-out. Pinned +// against a future fold treating Crate? as Crate: right for reference +// types, wrong for value types (int? is Nullable), so the refusal is +// the correct conservative rule. +func TestResolveCSharpInterfaceDispatch_NullableReceiverSpellingNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Nullable.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _maybe; + public Flow(IBox m) { _maybe = m; } + public int Pull() { return _maybe.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Nullable.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_maybe", "Nullable.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Nullable.cs::CrateBox.Get", + "a non-simple spelling stamps nothing and filters nothing") + assert.Contains(t, targets, "Nullable.cs::WidgetBox.Get", + "a non-simple spelling stamps nothing and filters nothing") +} + // dispatchTargets returns the fan-out targets minted from callerID. func dispatchTargets(g graph.Store, callerID string) []string { var targets []string From e26d3ecd10220b95add18a74296d34166b37bc20 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:12:31 +0200 Subject: [PATCH 12/38] resolver: test the dynamic/object fold through a compilable fixture DynBox : IBox is CS1966 - a class cannot implement a dynamic interface. The legal orientation (an IBox receiver consuming the IBox implementor) exercises the same fold. --- .../resolver/csharp_iface_dispatch_generic_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 3adabf17..53cf408f 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -725,7 +725,10 @@ func TestResolveCSharpInterfaceDispatch_ContravariantInterfaceNeverFilters(t *te // Review RED (revision 4a): `IBox` and `IBox` construct // over the same underlying type (dynamic erases to object) - the gate must -// fold them together and retain the edge. +// fold them together and retain the edge. Re-review fix: the original +// fixture implemented IBox directly, which the compiler refuses +// (CS1966 - a class cannot implement a dynamic interface); the legal +// orientation exercises the same fold from the receiver side. func TestResolveCSharpInterfaceDispatch_DynamicObjectSpellingsRetainTheEdge(t *testing.T) { g := buildCSharpResolverGraph(t, map[string]string{ "Dyn.cs": `namespace App { @@ -733,15 +736,15 @@ func TestResolveCSharpInterfaceDispatch_DynamicObjectSpellingsRetainTheEdge(t *t public interface IBox { int Get(int id); } - public class DynBox : IBox { + public class ObjectBox : IBox { public int Get(int id) { return 1; } } public class CrateBox : IBox { public int Get(int id) { return 2; } } public class Flow { - private readonly IBox _objects; - public Flow(IBox o) { _objects = o; } + private readonly IBox _objects; + public Flow(IBox o) { _objects = o; } public int Pull() { return _objects.Get(1); } } }`, @@ -754,7 +757,7 @@ func TestResolveCSharpInterfaceDispatch_DynamicObjectSpellingsRetainTheEdge(t *t ResolveCSharpInterfaceDispatch(g) targets := dispatchTargets(g, callerID) - assert.Contains(t, targets, "Dyn.cs::DynBox.Get", + assert.Contains(t, targets, "Dyn.cs::ObjectBox.Get", "dynamic and object spell the same constructed interface - the edge stays") assert.NotContains(t, targets, "Dyn.cs::CrateBox.Get", "the genuinely different closure still filters") From 48e5a691b4ebc8c091f35af81b3db4878831b627 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:23:51 +0200 Subject: [PATCH 13/38] indexer: derive field/property type arguments from the parsed type AST Comment trivia between the tokens of a declared type is legal C# and no part of type identity - the raw-text path stamped IBox as field_type_args="/**/Crate" and the dispatch gate then filtered the valid implementor (re-review RED). The arguments now come from the type AST node, the same source the base-list stamps already use, and the argument normalizer refuses raw text carrying trivia so a qualified spelling with an embedded comment stamps nothing. --- internal/parser/languages/csharp.go | 28 ++++++++--- .../parser/languages/csharp_base_type_args.go | 48 +++++++++++++------ .../languages/csharp_base_type_args_test.go | 31 ++++++++++++ 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 1fd9baa5..6ba65720 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -1483,12 +1483,16 @@ func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID strin // A field_declaration's type lives on its nested variable_declaration // (`field_declaration → variable_declaration[type] → variable_declarator`), // not as a direct `type` field of the field_declaration itself. - fieldTypeRaw := csharpFieldDeclType(def.Node, src) + fieldTypeNode := csharpFieldDeclTypeNode(def.Node) + fieldTypeRaw := "" + if fieldTypeNode != nil { + fieldTypeRaw = strings.TrimSpace(fieldTypeNode.Content(src)) + } if fieldTypeRaw != "" { meta["field_type"] = fieldTypeRaw // Closed generic arguments of the declared type — the dispatch // gate's receiver evidence (see csharp_base_type_args.go). - if args := csharpSimpleTypeArgsFromText(fieldTypeRaw, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { + if args := csharpTypeArgsFromTypeNode(fieldTypeNode, src, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { meta["field_type_args"] = args } } @@ -1559,7 +1563,7 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st meta["field_type"] = propTypeRaw // Same closed-generic-arguments stamp fields carry (dispatch // gate receiver evidence — csharp_base_type_args.go). - if args := csharpSimpleTypeArgsFromText(propTypeRaw, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { + if args := csharpTypeArgsFromTypeNode(t, src, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { meta["field_type_args"] = args } } @@ -2405,8 +2409,18 @@ func normalizeCSharpTypeName(t string) string { // variable_declaration node, not of the field_declaration itself, so a // direct ChildByFieldName("type") on the field_declaration is always nil. func csharpFieldDeclType(fieldDecl *sitter.Node, src []byte) string { + if t := csharpFieldDeclTypeNode(fieldDecl); t != nil { + return strings.TrimSpace(t.Content(src)) + } + return "" +} + +// csharpFieldDeclTypeNode is csharpFieldDeclType returning the type NODE — +// the type-argument stamp derives its arguments from the parsed tree, not +// from raw text, so trivia between tokens stays out of the identity. +func csharpFieldDeclTypeNode(fieldDecl *sitter.Node) *sitter.Node { if fieldDecl == nil { - return "" + return nil } for i, _nc := 0, int(fieldDecl.NamedChildCount()); i < _nc; i++ { c := fieldDecl.NamedChild(i) @@ -2414,17 +2428,17 @@ func csharpFieldDeclType(fieldDecl *sitter.Node, src []byte) string { continue } if t := c.ChildByFieldName("type"); t != nil { - return strings.TrimSpace(t.Content(src)) + return t } // Fallback: first named child of the variable_declaration is the // type in grammar revisions that don't tag the field. if c.NamedChildCount() > 0 { if first := c.NamedChild(0); first != nil && first.Type() != "variable_declarator" { - return strings.TrimSpace(first.Content(src)) + return first } } } - return "" + return nil } // inferTypeFromCSharpNew extracts the type name from a C# object_creation_expression. diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go index 1e6bb148..fa3fda19 100644 --- a/internal/parser/languages/csharp_base_type_args.go +++ b/internal/parser/languages/csharp_base_type_args.go @@ -261,6 +261,12 @@ func csharpNormalizeSimpleArg(text string, openParams map[string]bool) string { // beyond one identifier chain — not comparable by string. return "" } + if strings.ContainsAny(text, "/*") { + // Comment trivia is legal between the tokens of a qualified + // spelling (`App./**/Crate`) and no part of type identity — raw + // text carrying it must never be compared as a spelling. + return "" + } // Qualifiers reduce to the final segment — `global::App.Crate`, an // extern-alias qualifier, and a dotted namespace all name the same // last-segment type the resolver-side convention compares by. @@ -286,29 +292,43 @@ func csharpNormalizeSimpleArg(text string, openParams map[string]bool) string { return csharpCanonicalTypeArg(text) } -// csharpSimpleTypeArgsFromText is csharpBaseTypeArgs over a declared-type -// TEXT (a field/property's type spelling): "IBoxStore" → "Crate". -// "" when the text is not generic, the argument section is non-simple, -// or any argument is an open parameter. -func csharpSimpleTypeArgsFromText(text string, openParams map[string]bool) string { - text = strings.TrimSpace(text) - lt := strings.Index(text, "<") - if lt <= 0 || !strings.HasSuffix(text, ">") { +// csharpTypeArgsFromTypeNode is csharpBaseTypeArgs over a declared-type +// AST node (a field/property/positional-parameter type): +// "IBoxStore" → "Crate". The arguments come from the parsed tree, +// not from raw source text, so comment trivia between tokens +// (`IBox`) never becomes part of the compared spelling — the +// earlier text-based path stamped "/**/Crate" as an argument and the +// dispatch gate then filtered the valid implementor. "" when the type is +// not a plain generic name (wrapped forms — nullable, array — refuse the +// same way the text path did), or any argument is open or non-simple. +func csharpTypeArgsFromTypeNode(typeNode *sitter.Node, src []byte, openParams map[string]bool) string { + if typeNode == nil { + return "" + } + switch typeNode.Type() { + case "generic_name", "qualified_name": + default: return "" } - inner := text[lt+1 : len(text)-1] - if inner == "" || strings.ContainsAny(inner, "<[?(") { + argList := csharpEntryTypeArgumentList(typeNode) + if argList == nil { return "" } - rawArgs := strings.Split(inner, ",") - args := make([]string, 0, len(rawArgs)) - for _, a := range rawArgs { - norm := csharpNormalizeSimpleArg(a, openParams) + var args []string + for i, _nc := 0, int(argList.NamedChildCount()); i < _nc; i++ { + arg := argList.NamedChild(i) + if arg == nil || arg.Type() == "comment" { + continue + } + norm := csharpNormalizeSimpleArg(arg.Content(src), openParams) if norm == "" { return "" } args = append(args, norm) } + if len(args) == 0 { + return "" + } return strings.Join(args, ",") } diff --git a/internal/parser/languages/csharp_base_type_args_test.go b/internal/parser/languages/csharp_base_type_args_test.go index 8491cf08..9cc1c0fe 100644 --- a/internal/parser/languages/csharp_base_type_args_test.go +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -192,6 +192,37 @@ namespace App { "a using-alias argument is opaque - no stamp") } +// Re-review RED: comment trivia between the tokens of a declared type is +// legal C# and no part of type identity - `IBox` names the +// SAME constructed interface as `IBox`. The raw-text path stamped +// "/**/Crate" as an argument and the gate then filtered the valid +// implementor; deriving the arguments from the parsed type AST keeps +// trivia out of the compared spelling. +func TestCSharpExtractor_FieldTypeArgTriviaIsNotIdentity(t *testing.T) { + src := []byte(`namespace App { + public interface IBox { } + public class Crate { } + + public class Flow { + private readonly IBox _box; + public IBox Prop { get; set; } + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Trivia.cs", src) + require.NoError(t, err) + + box := fieldMeta(result.Nodes, "Trivia.cs::Flow._box") + require.NotNil(t, box) + assert.Equal(t, "Crate", box["field_type_args"], + "comment trivia inside the argument list is not part of the argument") + + prop := fieldMeta(result.Nodes, "Trivia.cs::Flow.Prop") + require.NotNil(t, prop) + assert.Equal(t, "Crate", prop["field_type_args"]) +} + // fieldMeta returns the meta of the node with the given ID. func fieldMeta(result_nodes []*graph.Node, id string) map[string]any { for _, n := range result_nodes { From 791cb74df4a6875a96f860cf6c4bad85f31b9d6f Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:28:53 +0200 Subject: [PATCH 14/38] indexer: canonicalize identifier spellings into one comparison domain Declaration sets and use-side arguments met in different normalization domains (re-review RED): class Outer<@T> stored raw "@T" in the open-parameter set while the use side stripped the verbatim prefix to "T", so the open field stamped as CLOSED over a type called T and dispatch returned no targets; the Unicode escape spelling of the same declaration failed identically, and 'using @Entity = App.Crate' stored "@Entity" in the alias sets the normalizer could never hit. csharpCanonicalIdentifier (verbatim prefix strip + \uXXXX/\UXXXXXXXX decode, refusal on malformed escapes) is now applied at every set insertion - enclosing type parameters, file alias names, the global-alias file stamp - and in the use-side normalizer, so every legal respelling of one identifier meets the sets in one domain. A decoded argument that is not a plain identifier refuses outright. --- .../parser/languages/csharp_base_type_args.go | 84 +++++++++++++++++-- .../languages/csharp_base_type_args_test.go | 83 ++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/internal/parser/languages/csharp_base_type_args.go b/internal/parser/languages/csharp_base_type_args.go index fa3fda19..a247f861 100644 --- a/internal/parser/languages/csharp_base_type_args.go +++ b/internal/parser/languages/csharp_base_type_args.go @@ -1,6 +1,7 @@ package languages import ( + "strconv" "strings" sitter "github.com/zzet/gortex/internal/parser/tsitter" @@ -80,6 +81,14 @@ func csharpFileAliasNames(root *sitter.Node, src []byte) map[string]bool { func csharpUnstampableArgNames(node *sitter.Node, src []byte, fileAliases map[string]bool) map[string]bool { var out map[string]bool add := func(name string) { + // The set must live in the same normalization domain the use side + // compares in: a declaration spelled `class Outer<@T>` (or with a + // Unicode escape) declares the same open parameter T that every + // use-side spelling normalizes to. A malformed escape keeps the + // raw spelling — the use side refuses those outright. + if c := csharpCanonicalIdentifier(name); c != "" { + name = c + } if name == "" { return } @@ -102,6 +111,50 @@ func csharpUnstampableArgNames(node *sitter.Node, src []byte, fileAliases map[st return out } +// csharpCanonicalIdentifier reduces one identifier SPELLING to the +// identifier it denotes: the verbatim prefix (`@T` → T) is dropped and +// C# Unicode escapes (`\u0054` → T, the 8-digit `\U` form too) are +// decoded — all legal respellings of one identifier, and the ONLY +// domain the open-parameter/alias sets and the use-side normalizer may +// meet in. "" when an escape is malformed (the compiler would refuse +// the source; refusing to canonicalize keeps every caller conservative). +func csharpCanonicalIdentifier(s string) string { + s = strings.TrimPrefix(strings.TrimSpace(s), "@") + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); { + if s[i] != '\\' { + b.WriteByte(s[i]) + i++ + continue + } + if i+1 >= len(s) { + return "" + } + hexLen := 0 + switch s[i+1] { + case 'u': + hexLen = 4 + case 'U': + hexLen = 8 + default: + return "" + } + if i+2+hexLen > len(s) { + return "" + } + v, err := strconv.ParseUint(s[i+2:i+2+hexLen], 16, 32) + if err != nil { + return "" + } + b.WriteRune(rune(v)) + i += 2 + hexLen + } + return b.String() +} + // csharpHasVariantTypeParams reports whether decl's type-parameter list // declares any `in`/`out` variance modifier. A variance-declaring // interface makes differently-closed constructions assignable across the @@ -146,11 +199,21 @@ func csharpHasVariantTypeParams(decl *sitter.Node) bool { // node is not an alias directive. Grammar revisions differ — some wrap // the alias in a name_equals node, others lay it out flat (identifier, // bare `=` token, target); stampCSharpUsings' skip branch matches the -// same pair. +// same pair. The returned name is CANONICAL (verbatim prefix stripped, +// escapes decoded) so the alias sets and the use-side normalizer meet in +// one domain — `using @Entity = ...` must catch both `IBox<@Entity>` and +// `IBox`. A malformed escape keeps the raw spelling; the use +// side refuses those outright. func csharpUsingAliasName(n *sitter.Node, src []byte) string { if n == nil || n.Type() != "using_directive" { return "" } + canonical := func(s string) string { + if c := csharpCanonicalIdentifier(s); c != "" { + return c + } + return s + } firstIdent := "" for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ { c := n.Child(i) @@ -161,11 +224,11 @@ func csharpUsingAliasName(n *sitter.Node, src []byte) string { case "name_equals": for j, _jc := 0, int(c.NamedChildCount()); j < _jc; j++ { if id := c.NamedChild(j); id != nil && id.Type() == "identifier" { - return strings.TrimSpace(id.Content(src)) + return canonical(strings.TrimSpace(id.Content(src))) } } case "=": - return firstIdent + return canonical(firstIdent) case "identifier": if firstIdent == "" { firstIdent = strings.TrimSpace(c.Content(src)) @@ -276,12 +339,15 @@ func csharpNormalizeSimpleArg(text string, openParams map[string]bool) string { if dot := strings.LastIndex(text, "."); dot >= 0 { text = text[dot+1:] } - // A verbatim identifier (`@Crate`, `@T`) names the same symbol as its - // bare spelling. Strip BEFORE the open-parameter/alias check so - // `IBox<@T>` reads as the open parameter T — never as a closed type - // spelled "@T" that would gate the open implementor out. - text = strings.TrimPrefix(text, "@") - if text == "" || openParams[text] { + // A verbatim identifier (`@Crate`, `@T`) or a Unicode-escaped one + // names the same symbol as its bare spelling. Canonicalize BEFORE the + // open-parameter/alias check so every spelling meets the set in one + // domain — `IBox<@T>` reads as the open parameter T, never as a + // closed type spelled "@T" that would gate the open implementor out. + // A malformed escape (or one decoding to a non-identifier character) + // refuses: the compiler would too, and refusal never filters. + text = csharpCanonicalIdentifier(text) + if text == "" || strings.ContainsAny(text, "<[?(,. :@/\\*") || openParams[text] { return "" } // Fold AFTER the open-name check: a type parameter or alias named diff --git a/internal/parser/languages/csharp_base_type_args_test.go b/internal/parser/languages/csharp_base_type_args_test.go index 9cc1c0fe..38f1f285 100644 --- a/internal/parser/languages/csharp_base_type_args_test.go +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -223,6 +223,89 @@ func TestCSharpExtractor_FieldTypeArgTriviaIsNotIdentity(t *testing.T) { assert.Equal(t, "Crate", prop["field_type_args"]) } +// Re-review RED: declaration-side identifier ESCAPES must land in the +// same normalization domain the use side compares in. `class Outer<@T>` +// declares the open parameter T, but the raw spelling "@T" in the +// open-parameter set never meets the use side's normalized "T" - the +// field stamps a closed type called "T" and dispatch filters everything. +// The Unicode escape spelling of the same declaration fails identically. +func TestCSharpExtractor_EscapedDeclarationTypeParamsStayOpen(t *testing.T) { + src := []byte(`namespace App { + public interface IBox { } + + public class Outer<@T> { + private readonly IBox<@T> _verbatim; + private readonly IBox _bare; + } + + public class Uni<\u0054> { + private readonly IBox _viaEscape; + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Esc.cs", src) + require.NoError(t, err) + + for _, id := range []string{"Esc.cs::Outer._verbatim", "Esc.cs::Outer._bare", "Esc.cs::Uni._viaEscape"} { + m := fieldMeta(result.Nodes, id) + require.NotNil(t, m, id) + assert.NotContains(t, m, "field_type_args", + "%s: every spelling of the open declaration parameter closes nothing", id) + } +} + +// Re-review RED: alias keys and use-side arguments compared in different +// normalization domains. The alias collector stored "@Entity" while the +// argument normalizer strips the verbatim prefix to "Entity" - the guard +// missed and the alias-opaque argument stamped as a closed type. +func TestCSharpExtractor_VerbatimAliasSpellingsAreOpaque(t *testing.T) { + src := []byte(`using @Entity = App.Crate; + +namespace App { + public interface IBox { } + public class Crate { } + + public class Flow { + private readonly IBox<@Entity> _verbatimUse; + private readonly IBox _bareUse; + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("VAlias.cs", src) + require.NoError(t, err) + + for _, id := range []string{"VAlias.cs::Flow._verbatimUse", "VAlias.cs::Flow._bareUse"} { + m := fieldMeta(result.Nodes, id) + require.NotNil(t, m, id) + assert.NotContains(t, m, "field_type_args", + "%s: every spelling denoting the alias is opaque - no stamp", id) + } +} + +// Re-review RED (metadata half): the project-global alias stamp must +// carry the CANONICAL identifier, or the resolver-side guard compares +// "@Entity" against normalized stamps and never refuses. +func TestCSharpExtractor_GlobalAliasMetaIsCanonical(t *testing.T) { + src := []byte(`global using @Entity = App.Crate; +`) + e := NewCSharpExtractor() + result, err := e.Extract("GAlias.cs", src) + require.NoError(t, err) + + var fileMeta map[string]any + for _, n := range result.Nodes { + if n != nil && n.Kind == graph.KindFile { + fileMeta = n.Meta + break + } + } + require.NotNil(t, fileMeta) + assert.Equal(t, []string{"Entity"}, fileMeta["global_using_aliases"], + "the alias identifier is stored canonically - verbatim prefix stripped") +} + // fieldMeta returns the meta of the node with the given ID. func fieldMeta(result_nodes []*graph.Node, id string) map[string]any { for _, n := range result_nodes { From 4a3f87ba0ae5d175b963fe9e3b06712837f1ba12 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:38:11 +0200 Subject: [PATCH 15/38] resolver: refuse stamps naming the FOLDED form of a global alias 'global using Int32 = App.Crate;' legally shadows a BCL name. The extractor cannot see the cross-file directive, so a receiver spelled IBox stamps the folded "int" - and the global-alias guard then compared "int" against the stored alias name "Int32" in a different domain and never refused (re-review RED). The guard now indexes every comparable form of each alias - canonical name plus its BCL keyword fold - so a stamp that MAY spell the alias refuses. A genuine int keyword can never denote the alias, so the over-refusal only preserves edges, and only in projects that actually shadow a BCL name project-wide. --- internal/resolver/csharp_iface_dispatch.go | 63 +++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 7e31125f..9d9d6de0 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -189,7 +189,9 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) continue } for _, a := range csharpMetaStrings(n.Meta["global_using_aliases"]) { - globalAliasNames[a] = true + for _, form := range csharpAliasComparableForms(a) { + globalAliasNames[form] = true + } } } } @@ -667,6 +669,65 @@ func csharpMemberMethodsAllByTypeFromEdges(edges []*graph.Edge, nodes map[string return out } +// csharpAliasComparableForms returns every form of a global-alias +// identifier a type-argument stamp could carry: the canonical name +// (verbatim prefix stripped — pre-normalization stores stamped it raw) +// and, for an alias legally shadowing a BCL type name, the keyword the +// extractor's canonicalization folds arguments onto. `global using +// Int32 = App.Crate` makes a stamped "int" ambiguous — it may spell the +// genuine keyword or the folded alias — and an ambiguous stamp must +// refuse (never filter). The fold mirrors the parser's +// csharpCanonicalTypeArg table; the packages stay independent, so keep +// the two in sync. +func csharpAliasComparableForms(alias string) []string { + name := strings.TrimPrefix(alias, "@") + if name == "" { + return nil + } + forms := []string{name} + folded := "" + switch name { + case "String": + folded = "string" + case "Boolean": + folded = "bool" + case "Byte": + folded = "byte" + case "SByte": + folded = "sbyte" + case "Char": + folded = "char" + case "Decimal": + folded = "decimal" + case "Double": + folded = "double" + case "Single": + folded = "float" + case "Int16": + folded = "short" + case "UInt16": + folded = "ushort" + case "Int32": + folded = "int" + case "UInt32": + folded = "uint" + case "Int64": + folded = "long" + case "UInt64": + folded = "ulong" + case "Object": + folded = "object" + case "IntPtr": + folded = "nint" + case "UIntPtr": + folded = "nuint" + } + if folded != "" { + forms = append(forms, folded) + } + return forms +} + // csharpArgsNameGlobalAlias reports whether any comma-separated argument in // a type-argument stamp names a project-global using alias — a spelling the // string comparison cannot resolve, so the stamp must be refused. From fd1bfed809d7de556c5b11c9cc01134c835af3f9 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:44:05 +0200 Subject: [PATCH 16/38] indexer: stamp type arguments on positional-record properties record Flow(IBox Store) synthesizes the Store property with field_type but no field_type_args, so dispatch through a positional property never narrowed (re-review P2). The positional emitter now applies the same AST-derived conservative stamp ordinary fields and properties carry - record's own type parameters open, aliases opaque - with the unstampable set computed once for the parameter list. --- internal/parser/languages/csharp.go | 13 +++++- .../languages/csharp_base_type_args_test.go | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 6ba65720..130eb37c 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -904,7 +904,7 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK e.emitCSharpEnumMembers(def.Node, src, filePath, id, name, result, seen) } if kind == "record" { - e.emitCSharpRecordPositionalProps(id, name, def.Node, src, filePath, fileID, result, seen) + e.emitCSharpRecordPositionalProps(id, name, def.Node, src, filePath, fileID, result, seen, fileAliases) } } @@ -916,7 +916,7 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK // in tree order: an explicit redeclaration of a positional property // (legal C# — it replaces the synthesized one) hits the seen guard and // stays a single node for the same logical member. -func (e *CSharpExtractor) emitCSharpRecordPositionalProps(ownerID, ownerName string, decl *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool) { +func (e *CSharpExtractor) emitCSharpRecordPositionalProps(ownerID, ownerName string, decl *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool, fileAliases map[string]bool) { // The record's parameter_list is an unnamed child in this grammar — // unlike method parameters, ChildByFieldName("parameters") finds // nothing, so scan the direct children by type. @@ -930,6 +930,9 @@ func (e *CSharpExtractor) emitCSharpRecordPositionalProps(ownerID, ownerName str if params == nil { return } + // One set for every positional property: the enclosing chain is the + // same for all of them (the record's own type parameters included). + unstampable := csharpUnstampableArgNames(decl, src, fileAliases) for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ { p := params.NamedChild(i) if p == nil || p.Type() != "parameter" { @@ -954,6 +957,12 @@ func (e *CSharpExtractor) emitCSharpRecordPositionalProps(ownerID, ownerName str } if t := p.ChildByFieldName("type"); t != nil { meta["field_type"] = strings.TrimSpace(t.Content(src)) + // Same closed-generic-arguments stamp ordinary fields and + // properties carry (dispatch gate receiver evidence) — a + // positional property is a first-class receiver. + if args := csharpTypeArgsFromTypeNode(t, src, unstampable); args != "" { + meta["field_type_args"] = args + } } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindField, Name: pname, diff --git a/internal/parser/languages/csharp_base_type_args_test.go b/internal/parser/languages/csharp_base_type_args_test.go index 38f1f285..6194c69d 100644 --- a/internal/parser/languages/csharp_base_type_args_test.go +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -306,6 +306,47 @@ func TestCSharpExtractor_GlobalAliasMetaIsCanonical(t *testing.T) { "the alias identifier is stored canonically - verbatim prefix stripped") } +// Re-review RED (P2): the synthesized positional-record property carries +// field_type but no field_type_args, so `record Flow(IBox Store)` +// never narrows Store's dispatch. The same conservative rules apply: the +// record's own type parameter is open, an alias is opaque. +func TestCSharpExtractor_RecordPositionalPropTypeArgs(t *testing.T) { + src := []byte(`using MyCrate = App.Crate; + +namespace App { + public interface IBox { } + public class Crate { } + + public record Flow(IBox Store, Crate Plain); + public record Open(IBox Store); + public record Aliased(IBox Store); +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("Rec.cs", src) + require.NoError(t, err) + + store := fieldMeta(result.Nodes, "Rec.cs::Flow.Store") + require.NotNil(t, store) + assert.Equal(t, "IBox", store["field_type"]) + assert.Equal(t, "Crate", store["field_type_args"], + "a positional property is a first-class receiver - same stamp as an ordinary property") + + plain := fieldMeta(result.Nodes, "Rec.cs::Flow.Plain") + require.NotNil(t, plain) + assert.NotContains(t, plain, "field_type_args") + + open := fieldMeta(result.Nodes, "Rec.cs::Open.Store") + require.NotNil(t, open) + assert.NotContains(t, open, "field_type_args", + "the record's own type parameter is open - no stamp") + + aliased := fieldMeta(result.Nodes, "Rec.cs::Aliased.Store") + require.NotNil(t, aliased) + assert.NotContains(t, aliased, "field_type_args", + "a using-alias argument is opaque - no stamp") +} + // fieldMeta returns the meta of the node with the given ID. func fieldMeta(result_nodes []*graph.Node, id string) map[string]any { for _, n := range result_nodes { From 080e9f6edcb86dccdf74123f25a7f44ba734f871 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:05:30 +0200 Subject: [PATCH 17/38] indexer: refuse receiver_name for parameter/local-shadowed bare receivers receiver_name's documented contract is "a receiver no local, param or builtin explains" - but parameters never ride the tenv, so a bare parameter receiver fell through to the stamp and the dispatch gate could bind it through the same-named field it shadows. The re-review RED makes it concrete: 'this._box.Save() + _box.Get()' on one line, where the field read emitted for this._box certified the shadowing parameter's receiver and filtered the valid WidgetBox.Get. The shadow indexes (parameter and declared-local names per owner) are now built before call emission and consulted at the stamp site, so a shadowed bare receiver stays unknown - the read-edge evidence can then only ever certify a receiver extraction says is field-eligible. The field-identifier emitter reuses the same indexes instead of rebuilding them. Ships with the full re-review e2e suite: same-line shadow, trivia, escaped declaration params, verbatim alias, foldable global alias, positional-record narrowing, and the member-companion bind helper the receiver-less cases need. --- internal/parser/languages/csharp.go | 66 ++-- .../languages/csharp_field_identifier.go | 3 +- .../csharp_iface_dispatch_generic_test.go | 304 +++++++++++++++++- 3 files changed, 343 insertions(+), 30 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 130eb37c..22ea3441 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -674,6 +674,27 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex } } + // Shadow indexes, built BEFORE the call emission so receiver_name can + // honor its contract ("a receiver no local, param or builtin + // explains"): a bare receiver naming a declared parameter or local is + // that value — never the enclosing type's same-named field, never a + // static class. Locals ride the tenv only when typed; the name sets + // cover every declaration. The field-identifier emitter reuses both. + paramsByOwner := csharpParamNamesByOwner(result) + localNamesByOwner := map[string]map[string]bool{} + for _, l := range locals { + owner := localOwner(l) + if owner == "" { + continue + } + m := localNamesByOwner[owner] + if m == nil { + m = map[string]bool{} + localNamesByOwner[owner] = m + } + m[l.name] = true + } + for _, c := range calls { callerID := funcRanges.enclosing(c.line) if callerID == "" { @@ -720,15 +741,20 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // filled, and lands on the wrong overload. edge.Meta = map[string]any{"receiver_name": c.receiver} } - } else if c.receiver != "" { - // A bare receiver nothing above could type. Its spelling - // is still evidence: reaching here means no local, param - // or builtin in scope carries that name, so a receiver - // that names a static class is the STATIC form of an - // extension call (`BagExt.Add(bag)`) — where the `this` - // slot is filled by the first argument, not the - // receiver. The extension binder needs that distinction - // before it can compare argument counts. + } else if c.receiver != "" && + !paramsByOwner[callerID][c.receiver] && + !localNamesByOwner[callerID][c.receiver] { + // A bare receiver nothing above could type, and no + // parameter or local declares its name. Its spelling is + // still evidence: a receiver that names a static class is + // the STATIC form of an extension call (`BagExt.Add(bag)`) + // — where the `this` slot is filled by the first argument, + // not the receiver — and a same-named field of the + // enclosing type is only bindable because nothing shadows + // it. A parameter or local DOES shadow (re-review RED: a + // shadowing parameter's call bound through the field it + // shadows on the strength of an unrelated same-line + // `this.`-qualified read); those receivers stay unknown. edge.Meta = map[string]any{"receiver_name": c.receiver} } // Stamped AFTER the receiver-evidence chain — every branch @@ -789,24 +815,12 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex emitCSharpMemberAccesses(accesses, src, filePath, funcRanges, tenvByOwner, builtinsByOwner, result) - // Field-identifier uses need the shadow indexes: every DECLARED - // local by name (typed or not — tenv alone holds only the typed - // ones), parameters, and builtin-typed locals. - localNamesByOwner := map[string]map[string]bool{} - for _, l := range locals { - owner := localOwner(l) - if owner == "" { - continue - } - m := localNamesByOwner[owner] - if m == nil { - m = map[string]bool{} - localNamesByOwner[owner] = m - } - m[l.name] = true - } + // Field-identifier uses reuse the same shadow indexes the receiver + // stamps consulted: every DECLARED local by name (typed or not — tenv + // alone holds only the typed ones), parameters, and builtin-typed + // locals. emitCSharpFieldIdentifierUses(calls, accesses, fieldAssigns, src, - filePath, funcRanges, localNamesByOwner, builtinsByOwner, result) + filePath, funcRanges, paramsByOwner, localNamesByOwner, builtinsByOwner, result) // .NET surfaces a symbol walk misses: DI registrations + COM // interop. Stamped onto the file node. diff --git a/internal/parser/languages/csharp_field_identifier.go b/internal/parser/languages/csharp_field_identifier.go index c92bce15..20b4f748 100644 --- a/internal/parser/languages/csharp_field_identifier.go +++ b/internal/parser/languages/csharp_field_identifier.go @@ -87,7 +87,7 @@ func emitCSharpFieldIdentifierUses( calls []csharpDeferredCall, accesses []csharpDeferredAccess, fieldAssigns []csharpDeferredFieldAssign, src []byte, filePath string, funcRanges *csharpFuncLookup, - localNamesByOwner map[string]map[string]bool, + paramsByOwner, localNamesByOwner map[string]map[string]bool, builtinsByOwner map[string]map[string]string, result *parser.ExtractionResult, ) { @@ -95,7 +95,6 @@ func emitCSharpFieldIdentifierUses( if len(fieldsByType) == 0 { return } - paramsByOwner := csharpParamNamesByOwner(result) // eligible resolves the enclosing owner and reports whether name is // an unshadowed field of the owner's type. diff --git a/internal/resolver/csharp_iface_dispatch_generic_test.go b/internal/resolver/csharp_iface_dispatch_generic_test.go index 53cf408f..ef5e976b 100644 --- a/internal/resolver/csharp_iface_dispatch_generic_test.go +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -589,7 +589,9 @@ func TestResolveCSharpInterfaceDispatch_ParameterShadowedReceiverNeverFilters(t New(g).ResolveAll() callerID := "Boxes.cs::Flow.Pull" - bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Get") + // The extractor now refuses receiver_name for a param-shadowed bare + // receiver, so the bind keys on the member companion alone. + bindMemberCall(t, g, callerID, "Get", "Boxes.cs::IBox.Get") ResolveCSharpInterfaceDispatch(g) @@ -631,7 +633,9 @@ func TestResolveCSharpInterfaceDispatch_LocalShadowedReceiverNeverFilters(t *tes New(g).ResolveAll() callerID := "Boxes.cs::Flow.Pull" - bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Get") + // The extractor now refuses receiver_name for a local-shadowed bare + // receiver, so the bind keys on the member companion alone. + bindMemberCall(t, g, callerID, "Get", "Boxes.cs::IBox.Get") ResolveCSharpInterfaceDispatch(g) @@ -990,3 +994,299 @@ func TestResolveCSharpInterfaceDispatch_BCLAliasSpellingsRetainTheEdge(t *testin assert.NotContains(t, targets, "Ints.cs::CrateBox.Fetch", "the genuinely different closure still filters") } + +// bindMemberCall binds the unresolved companion for the named member to the +// interface member regardless of receiver evidence - the enrichment/LSP +// tiers key on the site, not on extraction's receiver stamps, so a call +// whose receiver extraction could not certify still arrives bound. +func bindMemberCall(t *testing.T, g graph.Store, callerID, member, target string) { + t.Helper() + var companion *graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && e.To == "unresolved::*."+member { + companion = e + break + } + } + require.NotNil(t, companion, "fixture: extraction must leave the member companion edge") + g.AddEdge(&graph.Edge{ + From: callerID, To: target, Kind: graph.EdgeCalls, + FilePath: companion.FilePath, Line: companion.Line, + Origin: graph.OriginASTResolved, Confidence: 0.95, + }) +} + +// Re-review RED: comment trivia in the receiver's declared type is legal +// C# and no part of type identity - `IBox` must dispatch +// exactly like `IBox`, not carry "/**/Crate" into the compare and +// lose the valid implementor. +func TestResolveCSharpInterfaceDispatch_CommentTriviaInReceiverTypeRetainsTheEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Trivia.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull() { return _box.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Trivia.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Trivia.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Trivia.cs::CrateBox.Get", + "trivia-bearing and plain spellings name one constructed interface - the edge stays") + assert.NotContains(t, targets, "Trivia.cs::WidgetBox.Get", + "the argument still reads as Crate - the different closure still filters") +} + +// Re-review RED: a DECLARATION-side escaped type parameter (`class +// Outer<@T>`) is the same open parameter T every use-side spelling names. +// Storing raw "@T" in the open-parameter set while the use side +// normalizes to "T" stamps the open receiver as CLOSED over a type +// called "T" - and exact-head dispatch then returns no targets at all. +func TestResolveCSharpInterfaceDispatch_EscapedDeclarationParamKeepsFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Outer.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Outer<@T> { + private readonly IBox<@T> _box; + public Outer(IBox<@T> b) { _box = b; } + public int Pull() { return _box.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Outer.cs::Outer.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Outer.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Outer.cs::CrateBox.Get", + "an open receiver keeps the conservative full fan-out") + assert.Contains(t, targets, "Outer.cs::WidgetBox.Get", + "an open receiver keeps the conservative full fan-out") +} + +// Re-review RED (unicode twin): `class Outer<\u0054>` declares the same +// parameter T - the escape is a legal identifier spelling the decoder +// must fold before the open-parameter set is consulted. +func TestResolveCSharpInterfaceDispatch_UnicodeEscapedDeclarationParamKeepsFanout(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Uni.cs": `namespace App { + public class Crate { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class Outer<\u0054> { + private readonly IBox _box; + public Outer(IBox b) { _box = b; } + public int Pull() { return _box.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Uni.cs::Outer.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Uni.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Uni.cs::CrateBox.Get", + "the escaped declaration parameter is open - the fan-out survives") +} + +// Re-review RED: alias keys and arguments must live in ONE normalization +// domain. `using @Entity = App.Crate;` stores "@Entity" in the alias set +// while the argument normalizer strips the verbatim prefix to "Entity" - +// the alias guard misses and the valid implementor is filtered. +func TestResolveCSharpInterfaceDispatch_VerbatimAliasReceiverNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Alias.cs": `using @Entity = App.Crate; + +namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public class Flow { + private readonly IBox<@Entity> _box; + public Flow(IBox<@Entity> b) { _box = b; } + public int Pull() { return _box.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Alias.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Alias.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Alias.cs::CrateBox.Get", + "@Entity denotes the alias - the stamp must refuse and the fan-out survive") + assert.Contains(t, targets, "Alias.cs::WidgetBox.Get", + "an alias-opaque receiver keeps the conservative full fan-out") +} + +// Re-review RED: a project-global alias whose identifier the argument +// normalizer FOLDS (`global using Int32 = App.Crate;`) escapes the alias +// guard - the receiver argument is canonicalized to "int" while the +// global-alias metadata says "Int32", and the guard compares the two in +// different domains. The alias-comparable forms must meet the stamp in +// the stamp's own domain. +func TestResolveCSharpInterfaceDispatch_FoldableGlobalAliasNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Global.cs": `global using Int32 = App.Crate; +`, + "Stores.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } +}`, + "Flow.cs": `namespace App { + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull() { return _box.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Flow.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Stores.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Stores.cs::CrateBox.Get", + "Int32 legally denotes the project-global alias App.Crate - the stamp must refuse") +} + +// Re-review RED: same-line field-read evidence must not certify a +// DIFFERENT expression's receiver. `this._box.Save()` emits the field +// read for _box; the shadowing parameter's `_box.Get()` shares the +// physical line, and binding the parameter's call through Flow._box +// filtered the valid WidgetBox.Get out of the store. +func TestResolveCSharpInterfaceDispatch_SameLineFieldReadDoesNotBindShadowingParam(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Boxes.cs": `namespace App { + public sealed class Crate { } + public sealed class Widget { } + + public interface IBox { + int Get(); + int Save(); + } + + public sealed class CrateBox : IBox { + public int Get() { return 1; } + public int Save() { return 1; } + } + + public sealed class WidgetBox : IBox { + public int Get() { return 1; } + public int Save() { return 1; } + } + + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull(IBox _box) { return this._box.Save() + _box.Get(); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Boxes.cs::Flow.Pull" + bindMemberCall(t, g, callerID, "Get", "Boxes.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Boxes.cs::WidgetBox.Get", + "the parameter shadows the field - its receiver is IBox and the Widget impl must stay in") +} + +// Re-review RED (P2): a positional record property is a first-class +// receiver - `record Flow(IBox Store)` must narrow Store.Get() +// exactly like an ordinary property of the same declared type. +func TestResolveCSharpInterfaceDispatch_PositionalRecordPropertyNarrows(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Rec.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { + int Get(int id); + } + public class CrateBox : IBox { + public int Get(int id) { return 1; } + } + public class WidgetBox : IBox { + public int Get(int id) { return 2; } + } + public record Flow(IBox Store) { + public int Pull() { return Store.Get(1); } + } +}`, + }) + New(g).ResolveAll() + + callerID := "Rec.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "Store", "Rec.cs::IBox.Get") + + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + assert.Contains(t, targets, "Rec.cs::CrateBox.Get") + assert.NotContains(t, targets, "Rec.cs::WidgetBox.Get", + "the positional property's declared closure is IBox - the Widget impl filters") +} From 761b89c28c71143bc23b0ea1e38cecde685387a3 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:07:31 +0200 Subject: [PATCH 18/38] indexer: bump the C# extractor version for the canonical-stamp revision A store extracted at the previous revision keeps raw alias metadata, text-derived type arguments, unstamped positional-record properties, and receiver_name stamps on shadowed receivers - no content change would refresh them. --- internal/indexer/extractor_version.go | 2 +- internal/indexer/extractor_version_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/indexer/extractor_version.go b/internal/indexer/extractor_version.go index 4c2d5351..dcdee686 100644 --- a/internal/indexer/extractor_version.go +++ b/internal/indexer/extractor_version.go @@ -32,7 +32,7 @@ var extractorVersions = map[string]int{ // "go": 2, "c": generatedParserProjectionPolicyVersion, // generated parser projection covers all strictly detected table sizes "php": 2, // class/interface inheritance now emits typed structural edges - "csharp": 14, // interface variance + global-using-alias stamps and qualifier/verbatim canonicalization for the dispatch gate review revision (was: field-identifier read/write edges + type-argument stamps) + "csharp": 15, // canonical-identifier domain for the type-arg stamps (escapes, verbatim, alias meta), AST-derived field/property args, positional-record stamps, shadow-aware receiver_name (was: variance + global-alias stamps) "scala": 2, // explicitly instantiated generic calls emit call edges "go": 3, // generic instantiations are marked so indexing a func value cannot bind (was: generic calls emit call edges) "cpp": 2, // templated and namespace-qualified calls emit call edges diff --git a/internal/indexer/extractor_version_test.go b/internal/indexer/extractor_version_test.go index 61f63d43..fe996022 100644 --- a/internal/indexer/extractor_version_test.go +++ b/internal/indexer/extractor_version_test.go @@ -63,8 +63,8 @@ func TestStaleLangsDetection(t *testing.T) { t.Errorf("stored pre-params C# version = %v, want [csharp]", got) } for _, path := range []string{"src/Handler.cs", "Views/Page.razor", "Views/Page.cshtml"} { - if got := merkleSaltFor(path); got != "csharp@14" { - t.Errorf("C# extractor salt for %s = %q, want csharp@14", path, got) + if got := merkleSaltFor(path); got != "csharp@15" { + t.Errorf("C# extractor salt for %s = %q, want csharp@15", path, got) } } if got := merkleSaltFor("src/Handler.php"); got != "php@2" { From af81140a32707d52634355a51282cf8cacf41fec Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:54:09 +0200 Subject: [PATCH 19/38] parser: drop unused csharpFieldDeclType wrapper The revision that moved field/property type-argument stamps onto the parsed type node left the string-returning wrapper with no callers; golangci-lint (unused) flags it. Its grammar note about the nested variable_declaration moves onto csharpFieldDeclTypeNode. --- internal/parser/languages/csharp.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 22ea3441..70bee4c9 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -2427,20 +2427,12 @@ func normalizeCSharpTypeName(t string) string { return t } -// csharpFieldDeclType returns the verbatim declared type of a -// field_declaration. The type is a field of the nested -// variable_declaration node, not of the field_declaration itself, so a -// direct ChildByFieldName("type") on the field_declaration is always nil. -func csharpFieldDeclType(fieldDecl *sitter.Node, src []byte) string { - if t := csharpFieldDeclTypeNode(fieldDecl); t != nil { - return strings.TrimSpace(t.Content(src)) - } - return "" -} - -// csharpFieldDeclTypeNode is csharpFieldDeclType returning the type NODE — -// the type-argument stamp derives its arguments from the parsed tree, not -// from raw text, so trivia between tokens stays out of the identity. +// csharpFieldDeclTypeNode returns the declared type NODE of a +// field_declaration — the type-argument stamp derives its arguments from +// the parsed tree, not from raw text, so trivia between tokens stays out +// of the identity. The type is a field of the nested variable_declaration +// node, not of the field_declaration itself, so a direct +// ChildByFieldName("type") on the field_declaration is always nil. func csharpFieldDeclTypeNode(fieldDecl *sitter.Node) *sitter.Node { if fieldDecl == nil { return nil From 2e46d066f0292e743162e7efe71e441ef4a22487 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:05:40 +0200 Subject: [PATCH 20/38] parser: scope the receiver_name shadow refusal to the declaring block localNamesByOwner was keyed on the enclosing function, so a local declared in a nested block vetoed the receiver_name stamp for every call in the method - including calls placed after that block had closed, where the local cannot bind at all. That stamp is the only evidence telling the binder a call is the STATIC form of an extension call, where the first argument fills the `this` slot rather than the receiver. Without it the binder reads the call as extension form, subtracts a slot the argument list had actually filled, and lands one parameter too wide. Nothing about this involves generics or interface dispatch. Locals now carry the byte extent of the block that declares them and calls carry their start offset, so the refusal asks whether a name is bound at this site rather than somewhere in this function. A declaration with no enclosing block keeps an unbounded extent, so an unrecognized shape can only keep the old refusal, never lose one. The field-identifier emitter shares the index and deliberately keeps asking the function-wide question: its input buffers do not all carry a byte offset, and answering a narrower question without a real coordinate would open a hole rather than close one. --- internal/parser/languages/csharp.go | 74 ++++++++++++++++--- .../languages/csharp_field_identifier.go | 9 ++- .../csharp_extension_block_scope_test.go | 58 +++++++++++++++ 3 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 internal/resolver/csharp_extension_block_scope_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 70bee4c9..6ca22d18 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -186,6 +186,11 @@ type csharpDeferredCall struct { // keyword itself never appears in any tenv. recvType string line int + // offset is the call expression's start byte. A line number cannot + // say which side of a block boundary a call falls on, and cannot + // separate two sites that share one physical line; a scope test + // needs a coordinate that can. + offset int isMember bool // returnUsage is how the call site consumes the return value // (graph.ReturnUsage* label), classified at capture time and @@ -207,6 +212,9 @@ type csharpDeferredCall struct { func withCSharpCallArity(c csharpDeferredCall, inv *sitter.Node) csharpDeferredCall { c.argCount, c.argKnown = csharpCallArgCount(inv) c.typeArgCount, c.typeArgKnown = csharpCallTypeArgCount(inv) + if inv != nil { + c.offset = int(inv.StartByte()) + } return c } @@ -221,6 +229,53 @@ type csharpDeferredLocal struct { defNode *sitter.Node } +// csharpLocalScope is one local declaration's binding extent: the byte +// range of the block that declares it. A local shadows a same-named +// field only inside that range — a name declared in a nested block that +// has already closed binds nothing at a later call site, and refusing +// evidence there costs the site its receiver spelling for no reason. +type csharpLocalScope struct { + start, end int +} + +// csharpLocalScopes indexes each function's declared local names by the +// extents that declare them. It replaces a flat name set: the set could +// only answer "declared somewhere in this function", which is not the +// question a shadow test asks. +type csharpLocalScopes map[string]map[string][]csharpLocalScope + +// shadows reports whether a local declared in owner and named name is in +// scope at offset. +func (s csharpLocalScopes) shadows(owner, name string, offset int) bool { + for _, sc := range s[owner][name] { + if offset >= sc.start && offset < sc.end { + return true + } + } + return false +} + +// shadowsAnywhere is the function-wide question, for consumers whose +// sites carry no byte offset. It is the pre-extent behavior, kept +// deliberately: answering a narrower question without a real coordinate +// would open a hole rather than close one. +func (s csharpLocalScopes) shadowsAnywhere(owner, name string) bool { + return len(s[owner][name]) > 0 +} + +// csharpLocalScopeOf returns the extent of the block declaring a local. +// A declaration with no enclosing block gets an unbounded extent, which +// keeps its refusal function-wide — exactly what every local had before +// extents existed, so an unrecognized shape can never lose a refusal. +func csharpLocalScopeOf(n *sitter.Node) csharpLocalScope { + for cur := n; cur != nil; cur = cur.Parent() { + if cur.Type() == "block" { + return csharpLocalScope{start: int(cur.StartByte()), end: int(cur.EndByte())} + } + } + return csharpLocalScope{start: 0, end: math.MaxInt} +} + // csharpTypeUse buffers a type referenced only in a local-variable // annotation (`HttpResponse resp = Get();`) so the post-pass can emit an // EdgeTypedAs from the enclosing function once funcRanges are built. @@ -678,21 +733,22 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // honor its contract ("a receiver no local, param or builtin // explains"): a bare receiver naming a declared parameter or local is // that value — never the enclosing type's same-named field, never a - // static class. Locals ride the tenv only when typed; the name sets - // cover every declaration. The field-identifier emitter reuses both. + // static class. Locals ride the tenv only when typed; the scope index + // covers every declaration, and covers it where it actually binds. + // The field-identifier emitter reuses both. paramsByOwner := csharpParamNamesByOwner(result) - localNamesByOwner := map[string]map[string]bool{} + localScopes := csharpLocalScopes{} for _, l := range locals { owner := localOwner(l) if owner == "" { continue } - m := localNamesByOwner[owner] + m := localScopes[owner] if m == nil { - m = map[string]bool{} - localNamesByOwner[owner] = m + m = map[string][]csharpLocalScope{} + localScopes[owner] = m } - m[l.name] = true + m[l.name] = append(m[l.name], csharpLocalScopeOf(l.defNode)) } for _, c := range calls { @@ -743,7 +799,7 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex } } else if c.receiver != "" && !paramsByOwner[callerID][c.receiver] && - !localNamesByOwner[callerID][c.receiver] { + !localScopes.shadows(callerID, c.receiver, c.offset) { // A bare receiver nothing above could type, and no // parameter or local declares its name. Its spelling is // still evidence: a receiver that names a static class is @@ -820,7 +876,7 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // alone holds only the typed ones), parameters, and builtin-typed // locals. emitCSharpFieldIdentifierUses(calls, accesses, fieldAssigns, src, - filePath, funcRanges, paramsByOwner, localNamesByOwner, builtinsByOwner, result) + filePath, funcRanges, paramsByOwner, localScopes, builtinsByOwner, result) // .NET surfaces a symbol walk misses: DI registrations + COM // interop. Stamped onto the file node. diff --git a/internal/parser/languages/csharp_field_identifier.go b/internal/parser/languages/csharp_field_identifier.go index 20b4f748..caa02738 100644 --- a/internal/parser/languages/csharp_field_identifier.go +++ b/internal/parser/languages/csharp_field_identifier.go @@ -87,7 +87,8 @@ func emitCSharpFieldIdentifierUses( calls []csharpDeferredCall, accesses []csharpDeferredAccess, fieldAssigns []csharpDeferredFieldAssign, src []byte, filePath string, funcRanges *csharpFuncLookup, - paramsByOwner, localNamesByOwner map[string]map[string]bool, + paramsByOwner map[string]map[string]bool, + localScopes csharpLocalScopes, builtinsByOwner map[string]map[string]string, result *parser.ExtractionResult, ) { @@ -107,7 +108,11 @@ func emitCSharpFieldIdentifierUses( if ownerType == "" || !fieldsByType[ownerType][name] { return "", "", false } - if paramsByOwner[owner][name] || localNamesByOwner[owner][name] || + // This emitter's three input buffers do not all carry a byte + // offset, so it asks the function-wide question. That is the + // pre-extent behavior and stays conservative: it can only + // withhold a read edge, never invent one. + if paramsByOwner[owner][name] || localScopes.shadowsAnywhere(owner, name) || builtinsByOwner[owner][name] != "" { return "", "", false } diff --git a/internal/resolver/csharp_extension_block_scope_test.go b/internal/resolver/csharp_extension_block_scope_test.go new file mode 100644 index 00000000..946b7c22 --- /dev/null +++ b/internal/resolver/csharp_extension_block_scope_test.go @@ -0,0 +1,58 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// `receiver_name` is the only evidence that tells the binder a call is the +// STATIC form of an extension call — where the first argument fills the +// `this` slot rather than the receiver. The extractor refuses that stamp +// when a parameter or local declares the receiver's name, which is right: +// such a name is the local, not a static class. +// +// The refusal's SCOPE is wrong. `localNamesByOwner` is keyed on the +// enclosing function, so a local buried in a nested block vetoes the stamp +// for every call in the method — including calls the local cannot possibly +// bind at, because its block has already closed. The evidence vanishes, the +// binder reads the call as extension form, subtracts a `this` slot the +// argument list had actually filled, and the arity window lands one +// parameter too wide. +// +// Nothing here is generic or dispatch-related: this is the extension +// binder reading a two-argument call as three. +func TestResolveCSharpExtension_NestedBlockLocalKeepsStaticForm(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Ext.cs": `namespace Lib { + public class Bag { } + public static class BagExt { + public static void Add(this Bag b, int x) { } + public static void Add(this Bag b, int x, int y) { } + } +}`, + "Caller.cs": `using Lib; +namespace App { + public class Use { + public void Shadowed(Bag bag) { + if (bag != null) { var BagExt = 1; System.Console.WriteLine(BagExt); } + BagExt.Add(bag, 5); + } + public void Control(Bag bag) { + BagExt.Add(bag, 5); + } + } +}`, + }) + New(g).ResolveAll() + + // Ext.cs:4 takes (this Bag, int) — two parameters, which is what a + // static-form `BagExt.Add(bag, 5)` fills. Ext.cs:5 takes three. + const twoParam = "Ext.cs::BagExt.Add" + const threeParam = "Ext.cs::BagExt.Add_L5" + + assert.Equal(t, twoParam, namedCallTarget(t, g, "Caller.cs::Use.Control", "Add"), + "control: with no local anywhere in the method the static form already binds correctly") + assert.Equal(t, twoParam, namedCallTarget(t, g, "Caller.cs::Use.Shadowed", "Add"), + "a local in a closed nested block shadows nothing at the call site and must not cost the call its static-form evidence") +} From ee8255ec5fb53a1dd41050975b9ce39cb6b50a35 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:09:07 +0200 Subject: [PATCH 21/38] parser: descend a qualified base name to its final segment csharpBaseTypeName scanned only a qualified_name entry's direct identifier children and took the last one. That is correct for System.Object, but a qualified name whose final segment is itself generic spells that segment `generic_name`, not `identifier` - so the scan walked past it and returned the penultimate segment, the namespace. `App.IBox` extracted as "App". Two consequences, both pre-existing. The I-prefix discrimination sees a namespace rather than an interface name, so the entry lands on the wrong edge kind. And csharpEntryTypeArgumentList descends correctly and pulls "Crate", so the two functions disagree about what the base entry names - which matters now that a duplicate guard keys on one and a filter consumes the other. The suite had "generic interface strips type arguments" and "qualified base name reduced to simple name" as separate cases and never crossed them; the new subtest is that crossing. --- internal/parser/languages/csharp.go | 13 ++++++++++++- internal/parser/languages/csharp_test.go | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 6ca22d18..9b5a4a5c 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -2118,7 +2118,18 @@ func csharpBaseTypeName(entry *sitter.Node, src []byte) (string, bool) { } } case "qualified_name": - // System.Object → Object (the last identifier). + // System.Object → Object, App.IBox → IBox. The final + // segment is not always an identifier: a constructed generic + // spells it `generic_name`, and a nested qualification spells it + // `qualified_name`. Scanning only direct identifier children + // walked past those and returned the PENULTIMATE segment — the + // namespace — which then disagreed with the type-argument + // extractor about what the entry even names. + if name := entry.ChildByFieldName("name"); name != nil { + if n, _ := csharpBaseTypeName(name, src); n != "" { + return n, false + } + } var last string for i, _nc := 0, int(entry.ChildCount()); i < _nc; i++ { if c := entry.Child(i); c != nil && c.Type() == "identifier" { diff --git a/internal/parser/languages/csharp_test.go b/internal/parser/languages/csharp_test.go index c3ffed78..c1540d55 100644 --- a/internal/parser/languages/csharp_test.go +++ b/internal/parser/languages/csharp_test.go @@ -714,6 +714,22 @@ class Panel : Widget {}`) edgeTargetNames(result.Edges, "Outer.cs::Outer", graph.EdgeImplements)) }) + // The two cases above cross here: a qualified name whose FINAL + // segment is itself generic. That segment parses as a `generic_name`, + // not an `identifier`, so scanning a qualified_name's direct + // identifier children walked straight past it and returned the + // penultimate segment - the namespace - as the base's name. + t.Run("qualified generic base reduces to its final segment", func(t *testing.T) { + src := []byte(`class Dual : App.Base, App.IBox {}`) + result, err := e.Extract("Dual.cs", src) + require.NoError(t, err) + + assert.Equal(t, []string{"Base"}, + edgeTargetNames(result.Edges, "Dual.cs::Dual", graph.EdgeExtends)) + assert.Equal(t, []string{"IBox"}, + edgeTargetNames(result.Edges, "Dual.cs::Dual", graph.EdgeImplements)) + }) + t.Run("record extends base and implements interface", func(t *testing.T) { src := []byte(`record Rec(int X) : Base(X), IThing {}`) result, err := e.Extract("Rec.cs", src) From 1e7c59fdc55ef5719261c3a9f95d3b8ce5fa9ad0 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:16:41 +0200 Subject: [PATCH 22/38] parser: evaluate interface variance before the duplicate-ID return emitContainer keys a type node on filePath + "::" + name, so a generic interface and a non-generic twin of the same name collide and the second declaration is dropped. The variance stamp was evaluated after that `seen[id]` return, so when the generic twin lost the race its `out`/`in` parameters were never recorded anywhere. Variance is what disarms the closed-and-unequal equality gate. Losing it re-arms an invariant-only filter over a covariant family and drops the covariant implementor - the same class of false negative the variance guard was added to prevent, reached through a different door. The IEnumerable / IEnumerable pairing makes this an ordinary shape rather than a corner case. Variance is now evaluated before the return and ORed onto whichever node survives. Union is the conservative merge: the stamp only ever widens a fan-out, so a declaration carrying it can never make the result narrower than it would have been without the collision. The test carries the control - the same source with the twin removed - because that control passing is what proves this is a node-identity collision rather than a variance bug. It asserts the EXACT target set rather than membership: a gate fails by removing a valid target, and a membership assertion stays green while the set shrinks around the one element it names. --- internal/parser/languages/csharp.go | 30 +++++++ .../csharp_iface_dispatch_collision_test.go | 86 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 internal/resolver/csharp_iface_dispatch_collision_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 9b5a4a5c..46eb63f0 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -911,6 +911,26 @@ func (e *CSharpExtractor) emitNamespace(m parser.QueryResult, filePath, fileID s }) } +// csharpMarkVariantTypeParams ORs the variance stamp onto an already +// emitted type node. Type node IDs carry no arity, so a generic +// interface can collide with a non-generic twin and be dropped whole - +// but variance is a REFUSAL signal, and a refusal that only one +// colliding declaration carries has to survive the collision. Union is +// therefore the conservative merge: it can only widen a fan-out. +func csharpMarkVariantTypeParams(result *parser.ExtractionResult, id string) { + for i := len(result.Nodes) - 1; i >= 0; i-- { + n := result.Nodes[i] + if n == nil || n.ID != id { + continue + } + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta["variant_type_params"] = true + return + } +} + // emitContainer collapses the per-kind class/interface/struct/enum // node emission. The capture-name prefix selects which capture set to // read from (the legacy code repeated this body four times). @@ -919,6 +939,16 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK def := m.Captures[kind+".def"] id := filePath + "::" + name if seen[id] { + // A second declaration on an ID already taken: the arity pair + // (ISource / ISource, Result / Result), same-file + // partial parts, or two namespaces in one file. The node is + // dropped, but variance must not be dropped with it - the gate + // reads that stamp off whichever node survives, and evaluating + // it behind this return meant a bare-named sibling could delete + // a covariant family's only protection. + if kind == "iface" && csharpHasVariantTypeParams(def.Node) { + csharpMarkVariantTypeParams(result, id) + } return } seen[id] = true diff --git a/internal/resolver/csharp_iface_dispatch_collision_test.go b/internal/resolver/csharp_iface_dispatch_collision_test.go new file mode 100644 index 00000000..c08ef6ad --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -0,0 +1,86 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// C# type node IDs are filePath + "::" + name: no namespace, no generic +// arity. Two declarations that differ only in those dimensions collide, +// and emitContainer drops the second. That was a harmless +// over-approximation while the dispatch fan-out was unfiltered - the two +// declarations merged and every target stayed. Once a gate reads +// evidence off the surviving node, the loser's evidence is gone and the +// gate filters on the winner's. +// +// This file covers that axis: every way two declarations can land on one +// ID, crossed with a gated dispatch site. + +// The IEnumerable / IEnumerable idiom - a non-generic interface +// declared beside its generic twin. Both mint `Src.cs::ISource`, the +// second is dropped, and the variance stamp rides on the DROPPED one: +// `seen[id]` returns before the stamp is ever evaluated. +// +// Variance is the signal that disarms the equality gate, so losing it +// re-arms an invariant-only filter over a covariant family and drops the +// covariant implementor - the exact P1 the variance guard was added to +// prevent, reached through a different door. +func TestResolveCSharpInterfaceDispatch_NonGenericTwinKeepsVarianceStamp(t *testing.T) { + const withTwin = `namespace App { + public class Animal { } + public class Dog : Animal { } + public interface ISource { void Reset(); } + public interface ISource { T Get(); } + public class DogSource : ISource { public Dog Get() { return null; } } + public class AnimalSource : ISource { public Animal Get() { return null; } } + public class Flow { + private readonly ISource _src; + public Flow(ISource s) { _src = s; } + public Animal Pull() { return _src.Get(); } + } +}` + + // The control is the same source with the non-generic twin removed. + // It already passed before this fix, which is what makes the twin + // case a collision problem rather than a variance problem. + const withoutTwin = `namespace App { + public class Animal { } + public class Dog : Animal { } + public interface ISource { T Get(); } + public class DogSource : ISource { public Dog Get() { return null; } } + public class AnimalSource : ISource { public Animal Get() { return null; } } + public class Flow { + private readonly ISource _src; + public Flow(ISource s) { _src = s; } + public Animal Pull() { return _src.Get(); } + } +}` + + for _, tc := range []struct { + name string + src string + }{ + {"control: variant interface alone", withoutTwin}, + {"non-generic twin present", withTwin}, + } { + t.Run(tc.name, func(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{"Src.cs": tc.src}) + New(g).ResolveAll() + + const callerID = "Src.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_src", "Src.cs::ISource.Get") + ResolveCSharpInterfaceDispatch(g) + + // The EXACT set, not membership. A gate's failure mode is + // removing a valid target, and an assertion that only asks + // whether one good target is present cannot observe a + // removal - it stays green while the set shrinks around it. + assert.ElementsMatch(t, []string{ + "Src.cs::AnimalSource.Get", + "Src.cs::DogSource.Get", + }, dispatchTargets(g, callerID), + "ISource makes an ISource assignable to an ISource slot, so both implementors stay reachable") + }) + } +} From fcdc563d5546d4ae0d3eb744b529d97232f085e5 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:34:36 +0200 Subject: [PATCH 23/38] parser: count duplicate base targets per type ID, not per base list The duplicate-target guard counted base-list entries within a single declaration. Same-file partial parts are two declarations of one type: each base list reads as unambiguous, both stamp, and because the type node ID carries no arity and no namespace the second declaration is dropped at the seen[id] return - so only the first closure ever reaches the graph. The gate then paints that one closure onto every same-named member of the type and filters the overloads implementing the other closure. The whole fan-out disappears, and which closure survives depends on source order, so the result is unstable under reordering as well as wrong. The count is now a file-level prescan keyed by type node ID, so every declaration sharing an ID contributes. The prescan walks base_list nodes and reads the parent declaration name rather than enumerating declaration node types, which differ across grammar revisions. A type ID the prescan cannot attribute counts 0 and stamps nothing, so an unrecognized shape keeps the full fan-out. The arity twin (Result / Result) and the two-namespaces-in-one-file shape collapse through the same door and are fixed by the same count. --- internal/parser/languages/csharp.go | 88 +++++++++++++++---- .../csharp_iface_dispatch_collision_test.go | 44 ++++++++++ 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 46eb63f0..5c04b9a0 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -370,6 +370,10 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // (emitCSharpBaseList) checks this set before falling back to name // shape so a locally-known interface always wins. localInterfaces := collectCSharpInterfaceNames(root, src) + // Per type node ID, across every declaration in the file — see + // csharpBaseNameCounts for why one declaration's own base list is not + // a sufficient ambiguity check. + baseNameCounts := csharpBaseNameCounts(root, src, filePath) // Using-alias names, collected once per file: the type-argument stamp // sites consult them per declaration, and a per-declaration rescan of @@ -389,19 +393,19 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex e.emitNamespace(m, filePath, fileID, result, seen) case m.Captures["class.def"] != nil: - e.emitContainer(m, "class", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) + e.emitContainer(m, "class", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases, baseNameCounts) case m.Captures["iface.def"] != nil: - e.emitContainer(m, "iface", graph.KindInterface, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) + e.emitContainer(m, "iface", graph.KindInterface, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases, baseNameCounts) case m.Captures["struct.def"] != nil: - e.emitContainer(m, "struct", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) + e.emitContainer(m, "struct", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases, baseNameCounts) case m.Captures["record.def"] != nil: - e.emitContainer(m, "record", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) + e.emitContainer(m, "record", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases, baseNameCounts) case m.Captures["enum.def"] != nil: - e.emitContainer(m, "enum", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases) + e.emitContainer(m, "enum", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces, fileAliases, baseNameCounts) case m.Captures["anon.def"] != nil: e.emitAnonymousType(m, filePath, fileID, result, seen) @@ -934,7 +938,7 @@ func csharpMarkVariantTypeParams(result *parser.ExtractionResult, id string) { // emitContainer collapses the per-kind class/interface/struct/enum // node emission. The capture-name prefix selects which capture set to // read from (the legacy code repeated this body four times). -func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeKind graph.NodeKind, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, localInterfaces, fileAliases map[string]bool) { +func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeKind graph.NodeKind, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, localInterfaces, fileAliases map[string]bool, baseNameCounts map[string]map[string]int) { name := m.Captures[kind+".name"].Text def := m.Captures[kind+".def"] id := filePath + "::" + name @@ -999,7 +1003,7 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK // for structs and records, inheritance for interfaces). switch kind { case "class", "struct", "record", "iface": - emitCSharpBaseList(id, def.Node, src, filePath, localInterfaces, fileAliases, result) + emitCSharpBaseList(id, def.Node, src, filePath, localInterfaces, fileAliases, baseNameCounts, result) case "enum": e.emitCSharpEnumMembers(def.Node, src, filePath, id, name, result, seen) } @@ -1909,6 +1913,54 @@ func csharpDirectMemberOwner(member *sitter.Node, src []byte, allowed ...string) // heuristic consults this set first: a base type that names a // locally-declared interface is unambiguously an interface, regardless // of whether its name follows the `I`-prefix convention. +// csharpBaseNameCounts counts, per type node ID, how many base-list +// entries across EVERY declaration of that type in the file name the +// same erased base. +// +// A type node ID carries neither arity nor namespace, so same-file +// partial parts and arity twins share one ID. Each part's own base list +// reads as unambiguous while the type AS A WHOLE closes one interface +// twice, and only the first declaration reaches the graph - so a stamp +// taken from it describes one closure and is then applied to members +// implementing the other. Counting across declarations is what makes +// that ambiguity visible at the one place still able to refuse it. +// +// Walking base_list nodes and reading the parent's name avoids +// enumerating declaration node types, which differ across grammar +// revisions. +func csharpBaseNameCounts(root *sitter.Node, src []byte, filePath string) map[string]map[string]int { + counts := map[string]map[string]int{} + walkNodes(root, func(n *sitter.Node) { + if n.Type() != "base_list" { + return + } + decl := n.Parent() + if decl == nil { + return + } + nameNode := decl.ChildByFieldName("name") + if nameNode == nil { + return + } + id := filePath + "::" + nameNode.Content(src) + m := counts[id] + if m == nil { + m = map[string]int{} + counts[id] = m + } + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + entry := n.NamedChild(i) + if entry == nil { + continue + } + if name, _ := csharpBaseTypeName(entry, src); name != "" { + m[name]++ + } + } + }) + return counts +} + func collectCSharpInterfaceNames(root *sitter.Node, src []byte) map[string]bool { names := make(map[string]bool) walkNodes(root, func(n *sitter.Node) { @@ -1949,7 +2001,7 @@ func collectCSharpInterfaceNames(root *sitter.Node, src []byte) map[string]bool // the resolver binds them like every other C# reference. A base that // resolves to a same-file class still flows through unchanged — it is // neither a known interface nor I-prefixed, so it lands as EdgeExtends. -func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath string, localInterfaces, fileAliases map[string]bool, result *parser.ExtractionResult) { +func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath string, localInterfaces, fileAliases map[string]bool, baseNameCounts map[string]map[string]int, result *parser.ExtractionResult) { if decl == nil { return } @@ -1981,19 +2033,19 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s // or a type nested inside a generic outer), plus in-scope using // aliases (opaque spellings). declTypeParams := csharpUnstampableArgNames(decl, src, fileAliases) - // A base list closing the SAME erased target twice + // A type closing the SAME erased target twice // (Both : IBoxStore, IBoxStore) collapses to one stored // edge — identical (from, to, kind, file, line) — so a stamp would // arbitrarily keep one closure and suppress the other's implementors - // downstream. Count targets first; a repeated one stamps nothing. - baseNameCount := map[string]int{} - for i, _nc := 0, int(baseList.NamedChildCount()); i < _nc; i++ { - if entry := baseList.NamedChild(i); entry != nil { - if name, _ := csharpBaseTypeName(entry, src); name != "" { - baseNameCount[name]++ - } - } - } + // downstream. A repeated target stamps nothing. + // + // The count spans every declaration sharing this type's node ID, not + // just this base list: same-file partial parts and arity twins each + // look unambiguous alone while the type as a whole is not, and the + // part that loses the ID race never reaches the graph to contradict + // the winner's stamp. An absent entry counts as 0 and stamps nothing, + // so a shape the prescan cannot attribute keeps the full fan-out. + baseNameCount := baseNameCounts[typeID] extendsTaken := false for i, _nc := 0, int(baseList.NamedChildCount()); i < _nc; i++ { entry := baseList.NamedChild(i) diff --git a/internal/resolver/csharp_iface_dispatch_collision_test.go b/internal/resolver/csharp_iface_dispatch_collision_test.go index c08ef6ad..2cc5f5a8 100644 --- a/internal/resolver/csharp_iface_dispatch_collision_test.go +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -84,3 +84,47 @@ func TestResolveCSharpInterfaceDispatch_NonGenericTwinKeepsVarianceStamp(t *test }) } } + +// Same-file partial parts. Each part is its own declaration with its own +// base list, so the duplicate guard - which counts base entries within +// ONE base list - sees an unambiguous single IBox in each and lets both +// stamp. They are the same type: both mint `Boxes.cs::Store`, the second +// declaration is dropped whole, and the one surviving implements edge +// carries whichever closure was written first. +// +// The gate then paints that single closure onto every same-named member +// of the type and filters both Put overloads against it. Which closure +// wins is source-order dependent, so the fan-out is not merely wrong, it +// is unstable under reordering. +func TestResolveCSharpInterfaceDispatch_SameFilePartialPartsKeepBothOverloads(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Boxes.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public partial class Store : IBox { public void Put(Widget w) { } } + public partial class Store : IBox { public void Put(Crate c) { } } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Boxes.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + // One type reaching IBox through two closures cannot be filtered on + // either of them, so both overloads stay. The declaration that lost + // the ID race is exactly the evidence that would have been needed to + // filter correctly - which is why its absence has to disarm the gate + // rather than license it. + assert.ElementsMatch(t, []string{ + "Boxes.cs::Store.Put", + "Boxes.cs::Store.Put_L6", + }, dispatchTargets(g, callerID), + "a type whose parts close IBox twice must keep its whole fan-out") +} From 71b275d4df8fd85768e4327980fb26b9cda10f3a Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:43:13 +0200 Subject: [PATCH 24/38] resolver: refuse a closure when a type reaches an interface twice The stamp was read from the implementor's own direct base-list edge and then painted onto every same-named member of the type. A type may legally implement several constructions of one erased interface - `class C : IEnumerable, IEnumerable` compiles whenever the arguments cannot unify - and the constructions need not arrive through the type own base list at all: an inherited interface (ICrateBox : IBox) or a base class carries one just as well. Those paths were invisible, so the members implementing them were filtered against a closure they do not have. In the base-class case the kept target is the base method and the dropped one is the override that actually executes, which is the worst possible orientation for a virtual-dispatch answer. The hierarchy is now walked for every path from the implementor to the interface. The rule is deliberately asymmetric: only the implementor own direct closure can QUALIFY it for filtering, while a disagreeing closure anywhere up the hierarchy - including an unstamped one, which means a construction we cannot read - DISQUALIFIES it. A transitive descendant was never filterable and still is not. The precise guarantee is that the TARGET filter is monotonically weakened: a member's stamp is either the same string as before or refused. A site whose bound member loses its stamp then falls back to receiver-declared evidence on the source side, so the end-to-end edge set is not a strict superset of the old one - in the shapes measured the fallback verdict is the more correct one, because the old source-side read painted one arbitrary closure over a multi-closure type, the same defect this commit fixes on the target side. An earlier draft let transitive evidence qualify as well, which started filtering transitive implementors that the documented conservative rule had always preserved; the existing pin for that rule caught it. Unstamped hierarchy edges are recorded alongside stamped ones, since absence of a closure has to be distinguishable from absence of an edge. --- internal/resolver/csharp_iface_dispatch.go | 124 +++++++++++++++--- ...csharp_iface_dispatch_multiclosure_test.go | 102 ++++++++++++++ 2 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 internal/resolver/csharp_iface_dispatch_multiclosure_test.go diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 9d9d6de0..e4820760 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -136,13 +136,22 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) hierarchySources := g.GetNodesByIDs(hierarchySourceIDs) hierarchyByName := g.FindNodesByNames(hierarchyNames) children := map[string][]string{} - // Direct implementors' stamped CLOSED type arguments per interface - // (extractor: target_type_args on generic base-list entries) — the - // evidence half of the G9 gate: an IBoxStore receiver never - // dispatches into the IBoxStore implementor. Absent for - // non-generic bases, open generics, transitive descendants, and - // non-simple arguments — absence always means "do not filter". - implArgs := map[string]map[string]string{} + // Every hierarchy edge, carrying whatever CLOSED type arguments the + // extractor stamped on it (target_type_args on generic base-list + // entries) — the evidence half of the G9 gate: an IBoxStore + // receiver never dispatches into the IBoxStore implementor. + // Absent for non-generic bases, open generics and non-simple + // arguments — absence always means "do not filter". + // + // UNSTAMPED edges are recorded too, as the empty string. A type can + // implement several constructions of one erased interface, and those + // constructions can arrive through an inherited interface or a base + // class rather than the type's own base list. Deciding whether one + // closure describes a type means walking every path it has to that + // interface, and a path carrying no closure is exactly as + // disqualifying as two different ones. + implEdges := map[string]map[string][]string{} + anyStamps := false for _, e := range hierarchyEdges { if e == nil || e.From == "" || e.To == "" { continue @@ -158,16 +167,19 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) } } children[toID] = append(children[toID], e.From) + args := "" if e.Meta != nil { - if args, _ := e.Meta["target_type_args"].(string); args != "" { - m := implArgs[e.From] - if m == nil { - m = map[string]string{} - implArgs[e.From] = m - } - m[toID] = args - } + args, _ = e.Meta["target_type_args"].(string) + } + if args != "" { + anyStamps = true } + m := implEdges[e.From] + if m == nil { + m = map[string][]string{} + implEdges[e.From] = m + } + m[toID] = append(m[toID], args) } if len(children) == 0 { return 0 @@ -183,7 +195,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) // only when stamps exist to gate with; the union across repos is // deliberate — over-refusing can only PRESERVE edges. globalAliasNames := map[string]bool{} - if len(implArgs) > 0 { + if anyStamps { for n := range graph.NodesByKindsSeq(g, graph.KindFile) { if n == nil || n.Meta == nil { continue @@ -312,6 +324,23 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) return out } + // The single closure by which each descendant reaches an interface, + // or "" where that is not unique. Computed once per interface and + // shared across its anchors, like descCache — an interface with many + // members would otherwise re-walk the same hierarchy per member. + closureCache := map[string]map[string]string{} + closuresFor := func(ifaceID string) map[string]string { + if c, ok := closureCache[ifaceID]; ok { + return c + } + out := map[string]string{} + for _, sub := range descendants(ifaceID) { + out[sub] = csharpUniqueClosureToIface(sub, ifaceID, implEdges) + } + closureCache[ifaceID] = out + return out + } + // Build families and the member → families index. type family struct { ifaceID string @@ -338,7 +367,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) } subArgs := "" if !variant { - subArgs = implArgs[sub][ag.ifaceID] + subArgs = closuresFor(ag.ifaceID)[sub] if csharpArgsNameGlobalAlias(subArgs, globalAliasNames) { subArgs = "" } @@ -731,6 +760,67 @@ func csharpAliasComparableForms(alias string) []string { // csharpArgsNameGlobalAlias reports whether any comma-separated argument in // a type-argument stamp names a project-global using alias — a spelling the // string comparison cannot resolve, so the stamp must be refused. +// csharpUniqueClosureToIface returns the closed type arguments by which +// sub reaches ifaceID, or "" when that is not a single known closure. +// +// A stamp describes one construction, but the gate applies it to every +// same-named member of the implementor, so it is only sound when the +// implementor reaches the interface exactly one way. C# permits several: +// `class C : IEnumerable, IEnumerable` is legal whenever +// the arguments cannot unify (CS0695 fires only when they could), and a +// construction can also arrive through an inherited interface or a base +// class instead of the type's own base list. +// +// The rule is deliberately asymmetric: only the implementor's OWN direct +// closure can qualify it for filtering, while evidence found anywhere up +// the hierarchy can disqualify it. A transitive descendant has never +// been filterable — the closure belongs to an intermediate type, not to +// this one — and this walk does not change that. What it adds is the +// ability to NOTICE a second construction arriving through an inherited +// interface or a base class, and to refuse on it. +// +// So the walk can only ever remove filtering power, never add it. That +// keeps the existing conservative rules intact and makes every outcome +// change here a preserved edge rather than a dropped one. +func csharpUniqueClosureToIface(sub, ifaceID string, implEdges map[string]map[string][]string) string { + // The implementor's own base list. Absent, ambiguous, or unstamped + // means there is nothing to filter on, exactly as before. + direct := "" + for _, c := range implEdges[sub][ifaceID] { + if c == "" || (direct != "" && direct != c) { + return "" + } + direct = c + } + if direct == "" { + return "" + } + + // Any OTHER path to the same interface that disagrees — including one + // carrying no closure at all, which means a construction we cannot + // read — proves no single closure describes this type. + visited := map[string]bool{sub: true} + queue := []string{sub} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for to, closures := range implEdges[cur] { + if to == ifaceID && cur != sub { + for _, c := range closures { + if c != direct { + return "" + } + } + } + if !visited[to] { + visited[to] = true + queue = append(queue, to) + } + } + } + return direct +} + func csharpArgsNameGlobalAlias(args string, aliases map[string]bool) bool { if args == "" || len(aliases) == 0 { return false diff --git a/internal/resolver/csharp_iface_dispatch_multiclosure_test.go b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go new file mode 100644 index 00000000..f7a68a12 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go @@ -0,0 +1,102 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// A stamp is recorded per (type -> erased interface), but a type can +// legally implement several CONSTRUCTIONS of one generic interface. +// `class C : IEnumerable, IEnumerable` is the canonical +// form: CS0695 only fires when the type arguments contain type +// parameters that could unify, so distinct concrete types are legal. +// +// Only the type's own DIRECT base-list edge is recorded, and that single +// closure is then painted onto every same-named member of the type. A +// second construction reaching the type through an inherited interface +// or a base class is invisible, so the members implementing it are +// filtered against a closure they do not have. +// +// The rule these tests pin: a stamp is usable only when the implementor +// reaches the interface by exactly ONE closure. Two distinct closures, +// or any path that carries no closure at all, must disarm the filter for +// that implementor - while leaving it armed for everyone else. + +// 2a: the second closure arrives through an inherited interface. +func TestResolveCSharpInterfaceDispatch_SecondClosureViaInheritedInterface(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Dual.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public interface ICrateBox : IBox { } + public class CrateBox : IBox { public void Put(Crate c) { } } + public class WidgetBox : IBox { public void Put(Widget w) { } } + public class Store : ICrateBox, IBox { + public void Put(Crate c) { } + public void Put(Widget w) { } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Dual.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Dual.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + // WidgetBox reaches IBox by exactly one closure and that closure is + // type-impossible for an IBox receiver, so the gate keeps + // doing its job there. Store reaches IBox by two - IBox via + // ICrateBox and IBox directly - so no single closure + // describes it and both overloads stay. + assert.ElementsMatch(t, []string{ + "Dual.cs::CrateBox.Put", + "Dual.cs::Store.Put", + "Dual.cs::Store.Put_L10", + }, dispatchTargets(g, callerID), + "an ambiguous implementor keeps its fan-out; an unambiguous type-impossible one is still filtered") +} + +// 2b: the second closure arrives through a base class, and the member +// dropped is the `override` that actually executes at runtime. +func TestResolveCSharpInterfaceDispatch_SecondClosureViaBaseClass(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Derived.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public class CrateBox : IBox { public virtual void Put(Crate c) { } } + public class WidgetBox : IBox { public void Put(Widget w) { } } + public class Store : CrateBox, IBox { + public override void Put(Crate c) { } + public void Put(Widget w) { } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Derived.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Derived.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + // Keeping only the base method while dropping the override is the + // worst possible orientation for a virtual-dispatch answer: the + // surviving target is the one that does NOT run. + assert.ElementsMatch(t, []string{ + "Derived.cs::CrateBox.Put", + "Derived.cs::Store.Put", + "Derived.cs::Store.Put_L9", + }, dispatchTargets(g, callerID), + "a closure inherited through a base class is still a second closure") +} From 4d42becab554ff40d9a458eef7c1662b1879fb71 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:44:34 +0200 Subject: [PATCH 25/38] resolver: pin the qualified-spelling duplicate base end to end Closed by the extractor descending a qualified base name to its final segment, not by new behavior here - with the name right, the per-base-list duplicate count already fires, since both spellings of the reported shape sit in one base list. Pinned end to end because the extractor unit test alone does not prove the gate stops filtering. `class Dual : App.IBox, IBox` lists two constructions of one interface in its own base list. The guard counts entries by name and the name extractor returned "App" for the qualified entry, so the counts read App=1, IBox=1 and both entries stamped. The control with both entries spelled bare is kept alongside, so a future regression in the qualified path stays distinguishable from one in the guard itself. --- ...csharp_iface_dispatch_multiclosure_test.go | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/internal/resolver/csharp_iface_dispatch_multiclosure_test.go b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go index f7a68a12..98e3b92c 100644 --- a/internal/resolver/csharp_iface_dispatch_multiclosure_test.go +++ b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go @@ -100,3 +100,59 @@ func TestResolveCSharpInterfaceDispatch_SecondClosureViaBaseClass(t *testing.T) }, dispatchTargets(g, callerID), "a closure inherited through a base class is still a second closure") } + +// 2c: both constructions are in the type's OWN base list, but one is +// spelled namespace-qualified. This is the duplicate guard's blind spot +// rather than the hierarchy walk's: the guard counts base entries by +// name, and the name extractor returned the namespace segment for a +// qualified entry whose final segment is generic. `App.IBox` and +// `IBox` therefore counted as App=1 and IBox=1, both entries +// stamped, and the type looked unambiguous. +// +// Two commits close it - the extractor descending to the final segment, +// and the count spanning a whole type ID - so this is the end-to-end +// pin that the two together actually cover the reported shape. +func TestResolveCSharpInterfaceDispatch_QualifiedSpellingCountsAsDuplicate(t *testing.T) { + for _, tc := range []struct { + name string + bases string + }{ + // zzet's control: with both entries spelled bare, the guard + // already fired. Keeping it here makes a future regression in + // the qualified path distinguishable from one in the guard. + {"control: both bases bare", "IBox, IBox"}, + {"one base namespace-qualified", "App.IBox, IBox"}, + } { + t.Run(tc.name, func(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Qual.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public class PlainCrateBox : IBox { public void Put(Crate c) { } } + public class Dual : ` + tc.bases + ` { + public void Put(Crate c) { } + public void Put(Widget w) { } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Qual.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Qual.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + assert.ElementsMatch(t, []string{ + "Qual.cs::PlainCrateBox.Put", + "Qual.cs::Dual.Put", + "Qual.cs::Dual.Put_L8", + }, dispatchTargets(g, callerID), + "a qualified spelling names the same interface, so the entry is still a duplicate") + }) + } +} From 52b72c11f87ae56eaed8fd8804f4f644ba876a6c Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:06:35 +0200 Subject: [PATCH 26/38] resolver: prove field ownership before gating on field_type_args The receiver lookup assembled the field ID from the caller enclosing type ID plus the receiver name. Type node IDs carry no arity and no namespace, so an arity twin (Result / Result) or a same-file namespace twin mints the same field ID for two different fields, and only one field node survives extraction. A caller in the other declaration then gated on the survivor field_type_args - a foreign type declared closure - and the implementor it kept was precisely the type-impossible one, while the one its own receiver could hold was dropped. The lookup now requires the caller method StartLine and the field StartLine to both fall inside the owner type node line span, refusing on any mismatch or a missing span. The surviving type node spans one declaration, so a caller or a field contributed by the twin fails the check from either side; refusal keeps the receiver unknown and the site keeps its full fan-out. This is the cheapest sound check that needs no ID change. The real fix is namespace and arity in the node ID itself, which is a bigger change than this PR should carry. Cross-file partials are unaffected: field IDs embed the file path, so those lookups already missed. fieldNode/fields/fieldSeen become nodeByID/nodes/nodeSeen - the cache now serves field, owner-type and caller-method fetches alike. --- internal/resolver/csharp_iface_dispatch.go | 53 +++++++++++++------ .../csharp_iface_dispatch_collision_test.go | 45 ++++++++++++++++ 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index e4820760..63f9bc5b 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -885,21 +885,22 @@ func csharpShortTypeName(id string) string { // csharpReceiverLookupCtx carries the per-pass receiver-evidence caches: // declared args per (caller, member, site, interface), each caller's // out-edge adjacency read ONCE and served to every site (the companion -// scan and the field-read evidence scan both consume it), and resolved -// field nodes by ID. +// scan and the field-read evidence scan both consume it), and nodes by +// ID - fields, their owner types, and caller methods, since the +// ownership span check reads all three. type csharpReceiverLookupCtx struct { - args map[string]string - outEdges map[string][]*graph.Edge - fields map[string]*graph.Node - fieldSeen map[string]bool + args map[string]string + outEdges map[string][]*graph.Edge + nodes map[string]*graph.Node + nodeSeen map[string]bool } func newCSharpReceiverLookupCtx() *csharpReceiverLookupCtx { return &csharpReceiverLookupCtx{ - args: map[string]string{}, - outEdges: map[string][]*graph.Edge{}, - fields: map[string]*graph.Node{}, - fieldSeen: map[string]bool{}, + args: map[string]string{}, + outEdges: map[string][]*graph.Edge{}, + nodes: map[string]*graph.Node{}, + nodeSeen: map[string]bool{}, } } @@ -912,13 +913,13 @@ func (c *csharpReceiverLookupCtx) callerOutEdges(g graph.Store, caller string) [ return es } -func (c *csharpReceiverLookupCtx) fieldNode(g graph.Store, id string) *graph.Node { - if c.fieldSeen[id] { - return c.fields[id] +func (c *csharpReceiverLookupCtx) nodeByID(g graph.Store, id string) *graph.Node { + if c.nodeSeen[id] { + return c.nodes[id] } - c.fieldSeen[id] = true + c.nodeSeen[id] = true n := g.GetNodesByIDs([]string{id})[id] - c.fields[id] = n + c.nodes[id] = n return n } @@ -1018,11 +1019,31 @@ func csharpReceiverField(g graph.Store, e *graph.Edge, lookups *csharpReceiverLo if !fieldRead { return nil } - field := lookups.fieldNode(g, fieldID) + field := lookups.nodeByID(g, fieldID) if field == nil || field.Meta == nil || (field.Kind != graph.KindField && field.Kind != graph.KindConstant) { return nil } + // Ownership proof. The field ID was assembled from the caller's type + // ID, but type node IDs carry no arity and no namespace, so an arity + // twin (Result / Result) or a same-file namespace twin mints the + // same field ID and only one field node survives - possibly the OTHER + // declaration's, whose declared type says nothing about this caller's + // receiver. Require the caller and the field to both sit inside the + // owner type node's line span; any mismatch, or a missing span, means + // the ownership cannot be proven and the receiver stays unknown. + // (The surviving type node spans one declaration, so a caller in the + // twin fails the check from either side - as does a field the twin + // contributed. Refusal only ever preserves fan-out.) + owner := lookups.nodeByID(g, ownerID) + caller := lookups.nodeByID(g, e.From) + if owner == nil || caller == nil || + (owner.Kind != graph.KindType && owner.Kind != graph.KindInterface) || + owner.StartLine <= 0 || owner.EndLine < owner.StartLine || + caller.StartLine < owner.StartLine || caller.StartLine > owner.EndLine || + field.StartLine < owner.StartLine || field.StartLine > owner.EndLine { + return nil + } return field } diff --git a/internal/resolver/csharp_iface_dispatch_collision_test.go b/internal/resolver/csharp_iface_dispatch_collision_test.go index 2cc5f5a8..c89cf6e2 100644 --- a/internal/resolver/csharp_iface_dispatch_collision_test.go +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -128,3 +128,48 @@ func TestResolveCSharpInterfaceDispatch_SameFilePartialPartsKeepBothOverloads(t }, dispatchTargets(g, callerID), "a type whose parts close IBox twice must keep its whole fan-out") } + +// Field node IDs collide through the same door: `ownerID + "." + name` +// inherits the type ID's missing arity, so the Result / Result pair +// mints one `Result.cs::Result._source` and only the first declaration's +// field node survives. A caller in the OTHER declaration then gates on +// the survivor's field_type_args - a foreign type's evidence - and the +// implementor it keeps is the type-impossible one, while the one its +// own receiver could actually hold is dropped. +// +// The receiver lookup must prove the field it resolved belongs to the +// caller's own declaration, and refuse when it cannot. +func TestResolveCSharpInterfaceDispatch_ArityTwinFieldCollisionNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Result.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class CrateBox : IBox { public int Get(int id) { return 1; } } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class Result { + protected readonly IBox _source; + public Result(IBox source) { _source = source; } + } + public class Result { + protected readonly IBox _source; + public Result(IBox source) { _source = source; } + public int Load(int id) { return _source.Get(id); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Result.cs::Result.Load" + bindFieldReceiverCall(t, g, callerID, "_source", "Result.cs::IBox.Get") + ResolveCSharpInterfaceDispatch(g) + + // The caller's receiver is IBox; the surviving field node says + // Widget. Filtering on either would be wrong for one of the twins, so + // the only sound answer is the full fan-out. + assert.ElementsMatch(t, []string{ + "Result.cs::CrateBox.Get", + "Result.cs::WidgetBox.Get", + }, dispatchTargets(g, callerID), + "a field ID shared by an arity twin is not evidence about this caller's receiver") +} From 7c4af491504acc5e1bc5b7be48a7210e25a58444 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:21:40 +0200 Subject: [PATCH 27/38] parser: teach the shadow index C#'s other binding forms The shadow indexes were fed by exactly two binding forms - the local_declaration_statement capture and emitted KindParam nodes. C# binds names five more ways that matter here: foreach variables, declaration patterns (`o is T x`, `o is var x`), out-var declaration expressions, lambda parameters, and the parenthesized `using (var x = ...)` resource. (`using var x = ...;` is an ordinary local declaration and was already covered.) A name bound by any of them was invisible to the refusal. When it coincided with a field name, the site stamped receiver_name AND the field-identifier emitter minted the read-edge evidence off the same index - both layers of the two-layer guard failing together, since a two-layer guard whose layers share one index is one layer. The gate then filtered on the field's closure at a site whose receiver is the bound name, not the field. These forms are idiomatic modern C#, and the collision fires exactly on injected-repository-style field names (`repo`, `store`, `handler`) - the shape the gate exists for. Extents follow the language: a declaration pattern and an out-var escape to the enclosing block (definite-assignment scoping), while a foreach variable, lambda parameter, and using resource bind only over their own statement or lambda - so a call after the statement is back on the field and keeps its evidence. The walk reads the grammar's field names (left/name/parameters) verified against tree-sitter-c-sharp v0.23.5 node-types, rather than extending the prepared query - a wrong node type in the query would panic extractor construction, while an unmatched type here simply collects nothing. --- internal/parser/languages/csharp.go | 4 + .../parser/languages/csharp_binding_scopes.go | 105 +++++++++++++++++ ...sharp_iface_dispatch_binding_forms_test.go | 111 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 internal/parser/languages/csharp_binding_scopes.go create mode 100644 internal/resolver/csharp_iface_dispatch_binding_forms_test.go diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 5c04b9a0..16877026 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -754,6 +754,10 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex } m[l.name] = append(m[l.name], csharpLocalScopeOf(l.defNode)) } + // The lvar capture sees only local_declaration_statement; C#'s other + // binding forms (foreach, patterns, out var, lambda parameters, + // parenthesized using) shadow a field exactly the same way. + csharpCollectExtraBindingScopes(root, src, funcRanges, localScopes) for _, c := range calls { callerID := funcRanges.enclosing(c.line) diff --git a/internal/parser/languages/csharp_binding_scopes.go b/internal/parser/languages/csharp_binding_scopes.go new file mode 100644 index 00000000..158c7232 --- /dev/null +++ b/internal/parser/languages/csharp_binding_scopes.go @@ -0,0 +1,105 @@ +package languages + +import ( + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// csharpCollectExtraBindingScopes adds the binding forms the lvar +// capture cannot see to the local-scope index: foreach variables, +// declaration patterns (`o is T x`, `o is var x`), out-var declaration +// expressions, lambda parameters, and the parenthesized +// `using (var x = ...)` resource. (`using var x = ...;` IS a +// local_declaration_statement and needs nothing here.) +// +// The index answers "does a local bind this name at this site" for the +// receiver_name shadow refusal, and function-wide for the +// field-identifier emitter. A name bound by any of these forms shadows a +// same-named field exactly like a declared local does, and these forms +// are idiomatic modern C# - the miss fires precisely when such a name +// coincides with an injected-repository-style field (`repo`, `store`, +// `handler`), which is the shape the dispatch gate exists for. +// +// Extents: a declaration pattern and an out-var escape to the enclosing +// block (C# definite-assignment scoping) - the same extent an ordinary +// local gets. A foreach variable, a lambda parameter, and a using +// resource bind only over their own statement or lambda, so they carry +// that node's span rather than the enclosing block's; a call after the +// statement is back on the field and keeps its evidence. +func csharpCollectExtraBindingScopes(root *sitter.Node, src []byte, funcRanges *csharpFuncLookup, scopes csharpLocalScopes) { + add := func(nameNode *sitter.Node, sc csharpLocalScope) { + if nameNode == nil { + return + } + name := nameNode.Content(src) + if name == "" { + return + } + owner := funcRanges.enclosing(int(nameNode.StartPoint().Row) + 1) + if owner == "" { + return + } + m := scopes[owner] + if m == nil { + m = map[string][]csharpLocalScope{} + scopes[owner] = m + } + m[name] = append(m[name], sc) + } + spanOf := func(n *sitter.Node) csharpLocalScope { + return csharpLocalScope{start: int(n.StartByte()), end: int(n.EndByte())} + } + walkNodes(root, func(n *sitter.Node) { + switch n.Type() { + case "foreach_statement": + // left is the loop variable - an identifier, or a tuple + // pattern whose every identifier binds. All of them scope + // over the statement. + if left := n.ChildByFieldName("left"); left != nil { + if left.Type() == "identifier" { + add(left, spanOf(n)) + } else { + walkNodes(left, func(c *sitter.Node) { + if c.Type() == "identifier" { + add(c, spanOf(n)) + } + }) + } + } + case "declaration_pattern", "var_pattern", "declaration_expression": + add(n.ChildByFieldName("name"), csharpLocalScopeOf(n)) + case "lambda_expression": + sc := spanOf(n) + params := n.ChildByFieldName("parameters") + if params == nil { + return + } + switch params.Type() { + case "implicit_parameter", "identifier": + // `x => ...` - the parameters node IS the name. + add(params, sc) + case "parameter_list": + for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ { + if p := params.NamedChild(i); p != nil && p.Type() == "parameter" { + add(p.ChildByFieldName("name"), sc) + } + } + } + case "using_statement": + // Only the parenthesized resource form carries a + // variable_declaration child here. + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + c := n.NamedChild(i) + if c == nil || c.Type() != "variable_declaration" { + continue + } + walkNodes(c, func(d *sitter.Node) { + if d.Type() == "variable_declarator" { + if name := d.ChildByFieldName("name"); name != nil && name.Type() == "identifier" { + add(name, spanOf(n)) + } + } + }) + } + } + }) +} diff --git a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go new file mode 100644 index 00000000..bb18d8a0 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go @@ -0,0 +1,111 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// bindMemberCallAtLine mirrors the enrichment/LSP binding for a member +// call whose extraction companion carries NO receiver evidence - the +// exact state a shadow-refused site is in. bindFieldReceiverCall finds +// the companion by its receiver_name stamp, which such a site does not +// have; here the companion is found by member name alone. +func bindMemberCallAtLine(t *testing.T, g graph.Store, callerID, memberName, target string) { + t.Helper() + var companion *graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && e.To == "unresolved::*."+memberName { + companion = e + break + } + } + require.NotNil(t, companion, "fixture: the extraction must leave an unresolved companion for the member call") + g.AddEdge(&graph.Edge{ + From: callerID, To: target, Kind: graph.EdgeCalls, + FilePath: companion.FilePath, Line: companion.Line, + Origin: graph.OriginASTResolved, Confidence: 0.95, + }) +} + +// The shadow indexes were fed by exactly two binding forms - the +// local_declaration_statement capture and emitted KindParam nodes. C# +// has more: foreach variables, declaration patterns (`o is T x`), +// out-var declaration expressions, lambda parameters, and the +// parenthesized `using (var x = ...)` resource. A name bound by any of +// them was invisible to the refusal, so when it coincided with a field +// name the site stamped receiver_name, the field-identifier emitter +// minted the read-edge evidence off the SAME index, and the gate +// filtered on the field's closure - a receiver the call site does not +// have. Both layers of the two-layer guard failed together, because a +// two-layer guard whose layers share one index is one layer. +// +// Every fixture binds `_box` (an IBox) over a call site while +// the enclosing type declares `IBox _box`. The receiver is the +// bound name, not the field, so no closure is provable and the site +// must keep the full fan-out. +func TestResolveCSharpInterfaceDispatch_BindingFormsShadowFieldReceivers(t *testing.T) { + for _, tc := range []struct { + name string + caller string + body string + }{ + {"foreach variable", "Flow.Sum", ` + public int Sum(IBox[] all) { + int t = 0; + foreach (var _box in all) { t += _box.Get(7); } + return t; + }`}, + {"declaration pattern", "Flow.Check", ` + public int Check(object o) { + if (o is IBox _box) { return _box.Get(7); } + return 0; + }`}, + {"out var", "Flow.Pull", ` + private bool TryMake(out IBox made) { made = null; return false; } + public int Pull() { + if (TryMake(out var _box)) { return _box.Get(7); } + return 0; + }`}, + {"lambda parameter", "Flow.Total", ` + public int Total(IBox[] all) { + return all.Sum(_box => _box.Get(7)); + }`}, + {"parenthesized using", "Flow.Use", ` + private IBox Make(IBox s) { return s; } + public int Use(IBox src) { + using (var _box = Make(src)) { return _box.Get(7); } + }`}, + } { + t.Run(tc.name, func(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "F.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class CrateBox : IBox { public int Get(int id) { return 1; } } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } +` + tc.body + ` + } +}`, + }) + New(g).ResolveAll() + + callerID := "F.cs::" + tc.caller + bindMemberCallAtLine(t, g, callerID, "Get", "F.cs::IBox.Get") + ResolveCSharpInterfaceDispatch(g) + + assert.ElementsMatch(t, []string{ + "F.cs::CrateBox.Get", + "F.cs::WidgetBox.Get", + }, dispatchTargets(g, callerID), + "the receiver is the bound name, not the field - no closure is provable, so the full fan-out stays") + }) + } +} From cee8cb267195d218ae33335f385fa96332c79017 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:27:01 +0200 Subject: [PATCH 28/38] parser: seed the site-ambiguity index from receiverless calls too The index that marks unattributable call sites skipped every call without a spelled receiver. A receiverless call is a call on `this`, and `return Get(1) + _widgets.Get(2);` puts one on the same line as a field-receiver call of the same member name. The bare call's bound edge carries no receiver meta, so the receiver join falls back to the line's only `unresolved::*.Get` companion - the sibling's - and adopts a Widget closure for a call whose real receiver is the enclosing type, an IBox. The actual callee is dropped and the type-impossible implementor is the only survivor. Every call now seeds the index, the receiverless form as the empty receiver: an empty receiver differing from a spelled one is exactly as disqualifying as two spelled receivers differing. The stamp itself still lands only on member companions, so nothing new is emitted - sites that used to lend evidence across calls now refuse it. The `this.`-qualified spelling of the same line needed nothing here: it is captured with `this` as a spelled receiver, so those calls already seeded the index and already marked the line ambiguous. --- internal/parser/languages/csharp.go | 20 ++++---- ...sharp_iface_dispatch_binding_forms_test.go | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 16877026..004294df 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -708,11 +708,18 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // already covered by emitCSharpBaseList, so it is not re-emitted here. emitCSharpReferenceForms(root, src, filePath, fileID, result) - // Two same-named member calls on ONE line (`_a.Fetch(_b.Fetch(1))`) - // dedupe to a single stored edge — identical (from, to, kind, file, - // line) — carrying one arbitrary receiver's evidence. Mark those sites - // so no downstream consumer applies one receiver's typing to the other - // call's edge (the dispatch gate's receiver evidence in particular). + // Two same-named calls on ONE line whose receivers differ make the + // line unattributable: member companions dedupe to a single stored + // edge (`_a.Fetch(_b.Fetch(1))`), and a receiverless implicit-this + // call (`Get(1) + _widgets.Get(2)`) leaves the line's only + // `unresolved::*.Get` companion as the join target for a bound edge + // that is not its call. Mark those sites so no downstream consumer + // applies one receiver's typing to the other call's edge (the + // dispatch gate's receiver evidence in particular). + // + // EVERY call seeds the index — the receiverless form as the empty + // receiver — because an empty receiver differing from a spelled one + // is exactly as disqualifying as two spelled receivers differing. type csharpCallSite struct { name string line int @@ -720,9 +727,6 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex memberSiteReceiver := map[csharpCallSite]string{} memberSiteAmbiguous := map[csharpCallSite]bool{} for _, c := range calls { - if !c.isMember || c.receiver == "" { - continue - } key := csharpCallSite{c.name, c.line} if prev, ok := memberSiteReceiver[key]; ok { if prev != c.receiver { diff --git a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go index bb18d8a0..9310cb8f 100644 --- a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go +++ b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go @@ -109,3 +109,51 @@ func TestResolveCSharpInterfaceDispatch_BindingFormsShadowFieldReceivers(t *test }) } } + +// A receiverless call is a call on `this`, and it can share a line with +// a field-receiver call of the same member name: +// +// return Get(1) + _widgets.Get(2); +// +// The bare call's bound edge carries no receiver meta, so the receiver +// join falls back to the only `unresolved::*.Get` companion on the line +// - the SIBLING's - and adopts its Widget closure for a call whose real +// receiver is a Flow (an IBox). The actual callee is dropped and +// the type-impossible implementor is the only survivor. +// +// The site-ambiguity index skipped receiverless calls entirely, so the +// steal went unmarked. An empty receiver differing from `_widgets` is +// exactly as disqualifying as a different spelled one. +func TestResolveCSharpInterfaceDispatch_ImplicitThisNeverStealsSiblingReceiver(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "F.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class Mid : IBox { public int Get(int id) { return 1; } } + public class Flow : Mid { + private readonly IBox _widgets; + public Flow(IBox w) { _widgets = w; } + public int Pull() { return Get(1) + _widgets.Get(2); } + } +}`, + }) + New(g).ResolveAll() + + // The bare Get(1) binds through the interface family like any + // member call the enrichment tier resolves; its bound edge carries + // no receiver evidence of its own. + const callerID = "F.cs::Flow.Pull" + bindMemberCallAtLine(t, g, callerID, "Get", "F.cs::IBox.Get") + ResolveCSharpInterfaceDispatch(g) + + // With the line marked ambiguous nothing at the site may filter, so + // the fan-out keeps every implementor - the actual callee Mid.Get + // included. + assert.ElementsMatch(t, []string{ + "F.cs::Mid.Get", + "F.cs::WidgetBox.Get", + }, dispatchTargets(g, callerID), + "a receiverless implicit-this call marks its line ambiguous rather than borrowing the sibling's receiver") +} From f98686ea1d522702e233b47fce4f7aa43ea75b09 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:10:32 +0200 Subject: [PATCH 29/38] parser: refuse receiver evidence where two members tie on one line Two members declared on one source line tie for the innermost func range, and enclosing() breaks the tie by extraction order - so a call inside the second member is attributed to the first. The shadow refusal then consults the wrong member's parameter set: for `public int B() { return 0; } public int A(IBox _store, ...)` the call on A's parameter checked B's empty set, receiver_name stamped, and the gate filtered on the same-named FIELD's closure. The misattribution predates the gate; what is new is that it removes a target. The fix rides the primitive already built for unattributable sites: ambiguousAt reports an equal-span tie at the call's line, and the site is stamped receiver_ambiguous - the same verdict the same-line same-name receiver conflict gets, arrived at from the other side. There the receivers are unattributable to calls; here the caller is unattributable among members. Nested shapes (a local function inside a method) are ties of coverage, not of span, and stay unambiguous - the innermost genuinely owns the call, so nothing there changes. --- internal/parser/languages/csharp.go | 38 +++++++++++++++++- ...sharp_iface_dispatch_binding_forms_test.go | 40 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 004294df..97352dc1 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -826,8 +826,13 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex edge.Meta = map[string]any{"receiver_name": c.receiver} } // Stamped AFTER the receiver-evidence chain — every branch - // above assigns a fresh Meta map and would clobber it. - if memberSiteAmbiguous[csharpCallSite{c.name, c.line}] { + // above assigns a fresh Meta map and would clobber it. A + // two-members-on-one-line tie is the same verdict from the + // other side: there the RECEIVERS are attributable but the + // CALLER is not, and the shadow refusal above consulted the + // tie-break winner's parameter set — possibly the wrong + // member's. + if memberSiteAmbiguous[csharpCallSite{c.name, c.line}] || funcRanges.ambiguousAt(c.line) { if edge.Meta == nil { edge.Meta = map[string]any{} } @@ -1881,6 +1886,35 @@ func (l *csharpFuncLookup) enclosing(line int) string { return best } +// ambiguousAt reports whether two different functions tie for the +// innermost range covering line - two members declared on one source +// line. enclosing() breaks that tie by extraction order, which is +// deterministic but arbitrary: a line-keyed attribution cannot say +// which member owns a call there, so evidence keyed on the attribution +// (the shadow refusal consulting the attributed member's parameter set +// in particular) must refuse at such a line. Nested shapes - a local +// function inside a method - are ties of COVERAGE, not of span, and +// stay unambiguous: the innermost is genuinely the owner. +func (l *csharpFuncLookup) ambiguousAt(line int) bool { + i := sort.Search(len(l.ranges), func(j int) bool { return l.ranges[j].startLine > line }) - 1 + bestSpan := math.MaxInt + ties := 0 + for ; i >= 0; i-- { + if l.maxEnd[i] < line { + break + } + if r := l.ranges[i]; line <= r.endLine { + switch span := r.endLine - r.startLine; { + case span < bestSpan: + bestSpan, ties = span, 1 + case span == bestSpan: + ties++ + } + } + } + return ties > 1 +} + type csharpOwner struct { kind string // class_declaration / struct_declaration / interface_declaration name string diff --git a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go index 9310cb8f..48fa2503 100644 --- a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go +++ b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go @@ -157,3 +157,43 @@ func TestResolveCSharpInterfaceDispatch_ImplicitThisNeverStealsSiblingReceiver(t }, dispatchTargets(g, callerID), "a receiverless implicit-this call marks its line ambiguous rather than borrowing the sibling's receiver") } + +// Two members declared on one source line tie for the innermost func +// range, and the tie-break is extraction order - so the call inside A +// is attributed to B. The shadow refusal then consults B's EMPTY +// parameter set, receiver_name stamps for what is actually A's +// parameter, and the gate filters on the same-named FIELD's closure +// instead of the parameter's. +// +// The misattribution itself predates the gate; what is new is that it +// removes a target. A line two members tie on is unattributable, and +// evidence keyed on that attribution must refuse. +func TestResolveCSharpInterfaceDispatch_TwoMembersOnOneLineRefuseReceiverEvidence(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "F.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class CrateBox : IBox { public int Get(int id) { return 1; } } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class Flow { + private readonly IBox _store; + public Flow(IBox s) { _store = s; } + public int B() { return 0; } public int A(IBox _store, int id) { return _store.Get(id); } + } +}`, + }) + New(g).ResolveAll() + + // The line-keyed attribution hands the call to B - the production + // state this pins, not an artifact of the test setup. + const callerID = "F.cs::Flow.B" + bindMemberCallAtLine(t, g, callerID, "Get", "F.cs::IBox.Get") + ResolveCSharpInterfaceDispatch(g) + + assert.ElementsMatch(t, []string{ + "F.cs::CrateBox.Get", + "F.cs::WidgetBox.Get", + }, dispatchTargets(g, callerID), + "a line two members tie on carries no usable receiver evidence, so the full fan-out stays") +} From 5aab0f8f5754c87b1f339e50d88cdeb787bc66be Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:15:55 +0200 Subject: [PATCH 30/38] resolver: bucket receiver evidence by site once per caller The round-1 P2 fix cached the caller's adjacency READ, but every call site still rescanned the whole cached slice twice - once for the companion join, once for the field-read proof - and the args cache keys on the site, so no site ever reused another's scan. With S sites and ~3S out-edges per caller the dispatch pass was quadratic in the caller's site count. The caller's evidence is now bucketed by exact (target, file, line) once, so each site's consultation is two map probes plus a walk of its own companion bucket, which a real store holds at one edge (same-site same-name companions dedupe on the stored key). Bucket order preserves adjacency order, so the companion walk sees edges exactly as the slice scan did and the semantics are unchanged - the full suite agrees. One caller, S through-interface sites, min of 3, this machine: S before after 200 401 us 361 us 800 3,602 us 1,391 us 3200 36,931 us 5,685 us Before grows 9-10x per 4x sites; after grows 3.9-4.1x - linear. The benchmark pins the growth-curve shape, table-driven over S. Allocs rise ~18% from the bucket maps; the trade is the curve. buildCSharpResolverGraph widens to testing.TB so the benchmark builds the same extractor-produced shape the tests do. --- internal/resolver/csharp_iface_dispatch.go | 79 ++++++++++++------- .../csharp_iface_dispatch_bench_test.go | 74 +++++++++++++++++ .../resolver/csharp_iface_dispatch_test.go | 5 +- 3 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 internal/resolver/csharp_iface_dispatch_bench_test.go diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 63f9bc5b..7f1449db 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -890,7 +890,7 @@ func csharpShortTypeName(id string) string { // ownership span check reads all three. type csharpReceiverLookupCtx struct { args map[string]string - outEdges map[string][]*graph.Edge + evidence map[string]*csharpCallerEvidence nodes map[string]*graph.Node nodeSeen map[string]bool } @@ -898,19 +898,55 @@ type csharpReceiverLookupCtx struct { func newCSharpReceiverLookupCtx() *csharpReceiverLookupCtx { return &csharpReceiverLookupCtx{ args: map[string]string{}, - outEdges: map[string][]*graph.Edge{}, + evidence: map[string]*csharpCallerEvidence{}, nodes: map[string]*graph.Node{}, nodeSeen: map[string]bool{}, } } -func (c *csharpReceiverLookupCtx) callerOutEdges(g graph.Store, caller string) []*graph.Edge { - if es, ok := c.outEdges[caller]; ok { - return es +// csharpEvidenceSite addresses one piece of a caller's evidence: an edge +// target at an exact file position. +type csharpEvidenceSite struct { + to string + file string + line int +} + +// csharpCallerEvidence is one caller's out-edge evidence bucketed by +// exact site. Caching the adjacency READ per caller still left every +// site rescanning the whole slice - twice, for the companion join and +// the field-read proof - which is quadratic in the caller's site count. +// Bucketing once makes each site's consultation two map probes plus a +// walk of its own (almost always single-edge) companion bucket. +type csharpCallerEvidence struct { + calls map[csharpEvidenceSite][]*graph.Edge + reads map[csharpEvidenceSite]bool +} + +func (c *csharpReceiverLookupCtx) siteEvidence(g graph.Store, caller string) *csharpCallerEvidence { + if ev, ok := c.evidence[caller]; ok { + return ev + } + ev := &csharpCallerEvidence{ + calls: map[csharpEvidenceSite][]*graph.Edge{}, + reads: map[csharpEvidenceSite]bool{}, + } + for _, out := range g.GetOutEdges(caller) { + if out == nil { + continue + } + key := csharpEvidenceSite{out.To, out.FilePath, out.Line} + switch out.Kind { + case graph.EdgeCalls: + // Adjacency order is preserved within a bucket, so the + // companion walk sees edges exactly as the slice scan did. + ev.calls[key] = append(ev.calls[key], out) + case graph.EdgeReads: + ev.reads[key] = true + } } - es := g.GetOutEdges(caller) - c.outEdges[caller] = es - return es + c.evidence[caller] = ev + return ev } func (c *csharpReceiverLookupCtx) nodeByID(g graph.Store, id string) *graph.Node { @@ -965,18 +1001,15 @@ func csharpReceiverField(g graph.Store, e *graph.Edge, lookups *csharpReceiverLo } name, _ = e.Meta["receiver_name"].(string) } + ev := lookups.siteEvidence(g, e.From) if name == "" { // The bound edge (enrichment/LSP tiers) carries no receiver // evidence; the extraction's unresolved companion for the same // member name at the same site does. Match the member name so a // different call sharing the line can never lend its receiver. - memberName := csharpShortTypeName(e.To) - companionTo := "unresolved::*." + memberName - for _, out := range lookups.callerOutEdges(g, e.From) { - if out == nil || out.Kind != graph.EdgeCalls || out.To != companionTo { - continue - } - if out.FilePath != e.FilePath || out.Line != e.Line || out.Meta == nil { + companionTo := "unresolved::*." + csharpShortTypeName(e.To) + for _, out := range ev.calls[csharpEvidenceSite{companionTo, e.FilePath, e.Line}] { + if out.Meta == nil { continue } if amb, _ := out.Meta["receiver_ambiguous"].(bool); amb { @@ -1003,20 +1036,8 @@ func csharpReceiverField(g graph.Store, e *graph.Edge, lookups *csharpReceiverLo // type's field. A name-only lookup would bind a shadowed identifier to // the field it shadows and gate on the wrong declared arguments — // without the read edge the receiver stays unknown (never filter). - fieldRead := false - for _, out := range lookups.callerOutEdges(g, e.From) { - if out == nil || out.Kind != graph.EdgeReads { - continue - } - if out.FilePath != e.FilePath || out.Line != e.Line { - continue - } - if out.To == "unresolved::*."+name || out.To == fieldID { - fieldRead = true - break - } - } - if !fieldRead { + if !ev.reads[csharpEvidenceSite{"unresolved::*." + name, e.FilePath, e.Line}] && + !ev.reads[csharpEvidenceSite{fieldID, e.FilePath, e.Line}] { return nil } field := lookups.nodeByID(g, fieldID) diff --git a/internal/resolver/csharp_iface_dispatch_bench_test.go b/internal/resolver/csharp_iface_dispatch_bench_test.go new file mode 100644 index 00000000..9e8efd37 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_bench_test.go @@ -0,0 +1,74 @@ +package resolver + +import ( + "fmt" + "strings" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// One caller with many through-interface sites. Each bound site consults +// the caller's out-edge evidence twice - the companion join and the +// field-read proof - and a per-site rescan of the caller's adjacency +// makes the dispatch pass quadratic in the caller's site count. The +// shape is unrealistic in the high hundreds, but the growth curve is +// the regression this benchmark pins. +func BenchmarkCSharpIfaceDispatchManySitesOneCaller(b *testing.B) { + for _, sites := range []int{200, 800, 3200} { + b.Run(fmt.Sprintf("sites=%d", sites), func(b *testing.B) { + var body strings.Builder + for i := 0; i < sites; i++ { + fmt.Fprintf(&body, " t += _box.Get(%d);\n", i) + } + files := map[string]string{ + "Many.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class CrateBox : IBox { public int Get(int id) { return 1; } } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public int Pull() { + int t = 0; +` + body.String() + ` return t; + } + } +}`, + } + + const callerID = "Many.cs::Flow.Pull" + bindEverySite := func(g graph.Store) { + var companions []*graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && e.To == "unresolved::*.Get" { + companions = append(companions, e) + } + } + if len(companions) != sites { + b.Fatalf("fixture: %d companions, want %d", len(companions), sites) + } + for _, c := range companions { + g.AddEdge(&graph.Edge{ + From: callerID, To: "Many.cs::IBox.Get", Kind: graph.EdgeCalls, + FilePath: c.FilePath, Line: c.Line, + Origin: graph.OriginASTResolved, Confidence: 0.95, + }) + } + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + g := buildCSharpResolverGraph(b, files) + New(g).ResolveAll() + bindEverySite(g) + b.StartTimer() + ResolveCSharpInterfaceDispatch(g) + } + }) + } +} diff --git a/internal/resolver/csharp_iface_dispatch_test.go b/internal/resolver/csharp_iface_dispatch_test.go index 295aa7c2..b7b28a8c 100644 --- a/internal/resolver/csharp_iface_dispatch_test.go +++ b/internal/resolver/csharp_iface_dispatch_test.go @@ -21,8 +21,9 @@ func isIfaceDispatchEdge(e *graph.Edge) bool { // buildCSharpResolverGraph extracts each C# fixture with the real extractor and // loads its nodes/edges into a fresh graph — the same unresolved shape a live -// index produces, ready for New(g).ResolveAll(). -func buildCSharpResolverGraph(t *testing.T, files map[string]string) graph.Store { +// index produces, ready for New(g).ResolveAll(). testing.TB so benchmarks +// can build the same shape tests do. +func buildCSharpResolverGraph(t testing.TB, files map[string]string) graph.Store { t.Helper() g := graph.New() e := languages.NewCSharpExtractor() From 72c6691c4c299791c723ddecb9d09c86af41077a Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:26:49 +0200 Subject: [PATCH 31/38] parser: stop the extent climb at scope-forming ancestors, not blocks csharpLocalScopeOf climbed to the nearest `block`. A binder in a scope the grammar does not spell as a block - a switch section, a switch-expression arm, a loop condition, an expression-bodied lambda - therefore got the whole METHOD BODY as its extent, and the shadow refusal fired at calls its name can never bind. That is the receiver_name over-refusal shape again, re-introduced for exactly the binding forms the previous commit added to the index; the victim is the extension binder's static-form evidence, same as before. The climb now stops at the first scope-forming ancestor. `if_statement` is deliberately not one: a pattern variable declared in an `if` condition escapes to the enclosing block (definite-assignment scoping), so the block extent is its correct one - which is why `block` remains the default stop. Loop headers do not leak their pattern variables past the statement, and a switch section is its own declaration space. One of the six pinned shapes - a local declared in a switch section - predates the index widening: C# scopes it to the switch block, and the block climb had walked past that too. --- internal/parser/languages/csharp.go | 42 +++++++++-- .../csharp_extension_block_scope_test.go | 71 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 97352dc1..974d7638 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -263,13 +263,45 @@ func (s csharpLocalScopes) shadowsAnywhere(owner, name string) bool { return len(s[owner][name]) > 0 } -// csharpLocalScopeOf returns the extent of the block declaring a local. -// A declaration with no enclosing block gets an unbounded extent, which -// keeps its refusal function-wide — exactly what every local had before -// extents existed, so an unrecognized shape can never lose a refusal. +// csharpScopeFormers are the ancestors that bound a local binding's +// extent. `block` alone is not enough: a switch section, a +// switch-expression arm, a loop header, or an expression-bodied lambda +// each form a scope the grammar does not spell as a block, and climbing +// past them hands the binding a method-wide extent — which turns the +// shadow refusal back into the function-wide question the extent +// machinery exists to replace, and costs unrelated calls their +// static-form evidence. +// +// `if_statement` is deliberately absent: a pattern variable declared in +// an `if` condition escapes to the ENCLOSING block (definite-assignment +// scoping), so stopping at the `if` would under-refuse. Loop headers do +// not leak their pattern variables past the statement, and a switch +// section is its own declaration space. +var csharpScopeFormers = map[string]bool{ + "block": true, + "switch_section": true, + "switch_expression_arm": true, + "lambda_expression": true, + "anonymous_method_expression": true, + "local_function_statement": true, + "arrow_expression_clause": true, + "while_statement": true, + "do_statement": true, + "for_statement": true, + "foreach_statement": true, + "using_statement": true, + "lock_statement": true, + "fixed_statement": true, +} + +// csharpLocalScopeOf returns the extent of the scope declaring a local. +// A declaration with no scope-forming ancestor gets an unbounded +// extent, which keeps its refusal function-wide — exactly what every +// local had before extents existed, so an unrecognized shape can never +// lose a refusal. func csharpLocalScopeOf(n *sitter.Node) csharpLocalScope { for cur := n; cur != nil; cur = cur.Parent() { - if cur.Type() == "block" { + if csharpScopeFormers[cur.Type()] { return csharpLocalScope{start: int(cur.StartByte()), end: int(cur.EndByte())} } } diff --git a/internal/resolver/csharp_extension_block_scope_test.go b/internal/resolver/csharp_extension_block_scope_test.go index 946b7c22..a0187339 100644 --- a/internal/resolver/csharp_extension_block_scope_test.go +++ b/internal/resolver/csharp_extension_block_scope_test.go @@ -56,3 +56,74 @@ namespace App { assert.Equal(t, twoParam, namedCallTarget(t, g, "Caller.cs::Use.Shadowed", "Add"), "a local in a closed nested block shadows nothing at the call site and must not cost the call its static-form evidence") } + +// The extent recorded for a binding decides where the shadow refusal +// fires, and "the nearest block ancestor" over-widens for every binder +// that sits in a scope the grammar does not spell as a block: a switch +// section, a switch-expression arm, a loop condition, an expression +// lambda. Each of these bodies binds `BagExt` somewhere the call at the +// end can never see, so the static-form evidence must survive. +// +// An if-condition pattern is deliberately NOT here: `if (o is int x)` +// genuinely escapes to the enclosing block (definite-assignment +// scoping), so the block extent is its correct one. +func TestResolveCSharpExtension_NonBlockScopesKeepStaticForm(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"switch-section pattern variable", ` + public void M(Bag bag, object o) { + switch (o) { case int BagExt: System.Console.WriteLine(BagExt); break; } + BagExt.Add(bag, 5); + }`}, + {"switch-expression arm", ` + public void M(Bag bag, object o) { + var q = o switch { int BagExt => BagExt, _ => 0 }; + BagExt.Add(bag, 5); + }`}, + {"out var inside an expression lambda", ` + public void M(Bag bag, int[] xs, System.Collections.Generic.Dictionary map) { + var ok = System.Linq.Enumerable.Any(xs, x => map.TryGetValue(x, out var BagExt)); + BagExt.Add(bag, 5); + }`}, + {"declaration pattern inside an expression lambda", ` + public void M(Bag bag, int[] xs) { + var ok = System.Linq.Enumerable.Any(xs, x => ((object)x) is int BagExt); + BagExt.Add(bag, 5); + }`}, + {"pattern variable in a while condition", ` + public void M(Bag bag, object o) { + while (o is int BagExt) { System.Console.WriteLine(BagExt); break; } + BagExt.Add(bag, 5); + }`}, + {"switch-section local declaration", ` + public void M(Bag bag, object o) { + switch (((object)1)) { default: var BagExt = 1; System.Console.WriteLine(BagExt); break; } + BagExt.Add(bag, 5); + }`}, + } { + t.Run(tc.name, func(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Ext.cs": `namespace Lib { + public class Bag { } + public static class BagExt { + public static void Add(this Bag b, int x) { } + public static void Add(this Bag b, int x, int y) { } + } +}`, + "Caller.cs": `using Lib; +namespace App { + public class Use { +` + tc.body + ` + } +}`, + }) + New(g).ResolveAll() + + assert.Equal(t, "Ext.cs::BagExt.Add", + namedCallTarget(t, g, "Caller.cs::Use.M", "Add"), + "the binder's scope has closed before the call, so the static form keeps its two-parameter overload") + }) + } +} From c958f20d3a61207be6b3946f3a76d816af081209 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:56:12 +0200 Subject: [PATCH 32/38] parser: nine more binding forms join the shadow index The previous widening covered five forms; C# binds names more ways than that, and each missed one reproduces the same false filter when its name coincides with a field: catch variables, for-initializer declarations (a bare variable_declaration the lvar capture cannot see), anonymous-method parameters, query range variables (from/let/ join), local function parameters (a local function mints no function node, so paramsByOwner is blind to them), recursive-pattern designations, parenthesized designations, and deconstruction declarations. Two of the forms do not parse the way the grammar's node-types suggest, so the collectors follow the actual trees: - `var (a, b) = t;` is a variable_declarator carrying a tuple_pattern; the names live inside the pattern, and the declarator's only direct identifier child is the INITIALIZER. - `o is var (a, b)` misparses as an invocation of the is_expression - `(o is var)(a, b)` - so the designation's names land in an argument list. The collector recognizes exactly that shape. Extents follow the language again: catch variables bind over their catch clause, for-initializer names over the statement, delegate and local-function parameters over their bodies, query range variables over the whole query expression, and the pattern designations escape to the enclosing scope. --- .../parser/languages/csharp_binding_scopes.go | 136 ++++++++++++++++-- ...sharp_iface_dispatch_binding_forms_test.go | 47 ++++++ 2 files changed, 170 insertions(+), 13 deletions(-) diff --git a/internal/parser/languages/csharp_binding_scopes.go b/internal/parser/languages/csharp_binding_scopes.go index 158c7232..32370c0e 100644 --- a/internal/parser/languages/csharp_binding_scopes.go +++ b/internal/parser/languages/csharp_binding_scopes.go @@ -48,6 +48,32 @@ func csharpCollectExtraBindingScopes(root *sitter.Node, src []byte, funcRanges * spanOf := func(n *sitter.Node) csharpLocalScope { return csharpLocalScope{start: int(n.StartByte()), end: int(n.EndByte())} } + addParams := func(params *sitter.Node, sc csharpLocalScope) { + if params == nil { + return + } + switch params.Type() { + case "implicit_parameter", "identifier": + // `x => ...` - the parameters node IS the name. + add(params, sc) + case "parameter_list": + for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ { + if p := params.NamedChild(i); p != nil && p.Type() == "parameter" { + add(p.ChildByFieldName("name"), sc) + } + } + } + } + // A query's range variables bind across the query's later clauses; + // their extent is the whole query expression. + queryScope := func(n *sitter.Node) csharpLocalScope { + for cur := n; cur != nil; cur = cur.Parent() { + if cur.Type() == "query_expression" { + return spanOf(cur) + } + } + return csharpLocalScopeOf(n) + } walkNodes(root, func(n *sitter.Node) { switch n.Type() { case "foreach_statement": @@ -65,25 +91,109 @@ func csharpCollectExtraBindingScopes(root *sitter.Node, src []byte, funcRanges * }) } } - case "declaration_pattern", "var_pattern", "declaration_expression": - add(n.ChildByFieldName("name"), csharpLocalScopeOf(n)) + case "declaration_pattern", "var_pattern", "declaration_expression", + "recursive_pattern", "list_pattern": + // All of these escape to the enclosing scope (definite- + // assignment scoping), like an ordinary local. + sc := csharpLocalScopeOf(n) + add(n.ChildByFieldName("name"), sc) + // A parenthesized designation (`var (a, b)`) hangs off the + // pattern as an unfielded child; every identifier inside it + // binds. This is also how a deconstruction declaration + // (`var (a, b) = t;`) spells its names. + walkNodes(n, func(c *sitter.Node) { + if c.Type() != "parenthesized_variable_designation" { + return + } + walkNodes(c, func(d *sitter.Node) { + if d.Type() == "identifier" { + add(d, sc) + } + }) + }) case "lambda_expression": + addParams(n.ChildByFieldName("parameters"), spanOf(n)) + case "anonymous_method_expression": + // The C# 1 spelling of a lambda: `delegate(T x) { ... }`. + addParams(n.ChildByFieldName("parameters"), spanOf(n)) + case "local_function_statement": + // A local function mints no function node, so its + // parameters are invisible to paramsByOwner - this index is + // the only place they can refuse anything. + addParams(n.ChildByFieldName("parameters"), spanOf(n)) + case "catch_declaration": + // The catch variable binds over its catch clause. sc := spanOf(n) - params := n.ChildByFieldName("parameters") - if params == nil { - return + if p := n.Parent(); p != nil && p.Type() == "catch_clause" { + sc = spanOf(p) } - switch params.Type() { - case "implicit_parameter", "identifier": - // `x => ...` - the parameters node IS the name. - add(params, sc) - case "parameter_list": - for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ { - if p := params.NamedChild(i); p != nil && p.Type() == "parameter" { - add(p.ChildByFieldName("name"), sc) + add(n.ChildByFieldName("name"), sc) + case "for_statement": + // `for (var x = ...; ...)` - the initializer is a bare + // variable_declaration, not a local_declaration_statement, + // so the lvar capture cannot see it. Scopes over the + // statement. + if init := n.ChildByFieldName("initializer"); init != nil && init.Type() == "variable_declaration" { + sc := spanOf(n) + walkNodes(init, func(d *sitter.Node) { + if d.Type() == "variable_declarator" { + if name := d.ChildByFieldName("name"); name != nil && name.Type() == "identifier" { + add(name, sc) + } } + }) + } + case "from_clause": + add(n.ChildByFieldName("name"), queryScope(n)) + case "let_clause", "join_clause": + // The introduced name is an unfielded child: the FIRST + // identifier (`let x = expr` / `join x in ...`). Later + // identifier children belong to the expression side and + // must not be collected. + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + if c := n.NamedChild(i); c != nil && c.Type() == "identifier" { + add(c, queryScope(n)) + break } } + case "tuple_pattern": + // A deconstruction declaration (`var (a, b) = t;`) parses + // as a variable_declarator carrying a tuple_pattern - the + // names live inside the pattern, invisible to the lvar + // capture's direct-identifier match. They escape to the + // enclosing scope like any local. + if p := n.Parent(); p != nil && p.Type() == "variable_declarator" { + sc := csharpLocalScopeOf(n) + walkNodes(n, func(d *sitter.Node) { + if d.Type() == "identifier" { + add(d, sc) + } + }) + } + case "invocation_expression": + // The grammar misparses `o is var (a, b)` as an invocation + // of the is_expression - `(o is var)(a, b)` - so the + // designation's names land in the ARGUMENT list. Recognize + // exactly that shape (an is_expression whose pattern is the + // bare implicit type) and index the argument identifiers; + // they escape to the enclosing scope like a declaration + // pattern's name. + fn := n.ChildByFieldName("function") + if fn == nil || fn.Type() != "is_expression" { + return + } + last := fn.NamedChild(int(fn.NamedChildCount()) - 1) + if last == nil || last.Type() != "implicit_type" { + return + } + if args := n.ChildByFieldName("arguments"); args != nil { + sc := csharpLocalScopeOf(n) + walkNodes(args, func(d *sitter.Node) { + if d.Type() == "identifier" { + add(d, sc) + } + }) + } case "using_statement": // Only the parenthesized resource form carries a // variable_declaration child here. diff --git a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go index 48fa2503..bf340f65 100644 --- a/internal/resolver/csharp_iface_dispatch_binding_forms_test.go +++ b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go @@ -78,6 +78,50 @@ func TestResolveCSharpInterfaceDispatch_BindingFormsShadowFieldReceivers(t *test private IBox Make(IBox s) { return s; } public int Use(IBox src) { using (var _box = Make(src)) { return _box.Get(7); } + }`}, + {"catch variable", "Flow.Guard", ` + public int Guard() { + try { return 0; } + catch (BoxError _box) { return _box.Get(7); } + }`}, + {"for initializer", "Flow.Loop", ` + private IBox Make(IBox s) { return s; } + public int Loop(IBox src) { + for (var _box = Make(src); _box != null; ) { return _box.Get(7); } + return 0; + }`}, + {"anonymous method parameter", "Flow.Old", ` + public int Old(IBox src) { + System.Func, int> f = delegate(IBox _box) { return _box.Get(7); }; + return f(src); + }`}, + {"query from variable", "Flow.Query", ` + public int Query(IBox[] all) { + return System.Linq.Enumerable.Sum(from _box in all select _box.Get(7)); + }`}, + {"query let variable", "Flow.Bind", ` + public int Bind(IBox[] all) { + return System.Linq.Enumerable.Sum(from w in all let _box = w select _box.Get(7)); + }`}, + {"local function parameter", "Flow.Host", ` + public int Host() { + int Inner(IBox _box) { return _box.Get(7); } + return Inner(null); + }`}, + {"recursive pattern designation", "Flow.Probe", ` + public int Probe(object o) { + if (o is IBox { } _box) { return _box.Get(7); } + return 0; + }`}, + {"parenthesized designation", "Flow.Split", ` + public int Split(object o) { + if (o is var (_box, n)) { return _box.Get(7); } + return 0; + }`}, + {"deconstruction declaration", "Flow.Take", ` + public int Take((IBox, int) t) { + var (_box, n) = t; + return _box.Get(7); }`}, } { t.Run(tc.name, func(t *testing.T) { @@ -88,6 +132,9 @@ func TestResolveCSharpInterfaceDispatch_BindingFormsShadowFieldReceivers(t *test public interface IBox { int Get(int id); } public class CrateBox : IBox { public int Get(int id) { return 1; } } public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class BoxError : System.Exception { + public int Get(int id) { return 0; } + } public class Flow { private readonly IBox _box; public Flow(IBox b) { _box = b; } From 82426ef610be5b933dbaa8f8708e91b8bdac6d97 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:59:19 +0200 Subject: [PATCH 33/38] parser: an alias-spelled base entry suppresses the whole type's stamps `using BX = App.IBox;` then `class Dual : BX, IBox` is the alias spelling of the duplicate-base shape. The name extractor sees "BX", the duplicate count reads BX=1, IBox=1, and the bare entry stamps its closure onto a type that in truth closes the interface twice. The multi-closure walk cannot catch it either: unresolved::BX never resolves to the interface, so only one path is visible. An alias is an opaque spelling of some type - possibly a construction of the very interface a sibling entry closes - so a base list that contains one can never prove any entry's target unique. Such an entry now counts under a NUL-prefixed sentinel and the stamp site refuses every entry of that type. Refusal only ever preserves fan-out. Known boundary, deliberate: a GLOBAL using alias declared in another file is invisible at extraction time, so that spelling of the same shape still stamps. It shares the fate of the other cross-file alias domains: the resolver-side global-alias refusal covers stamped ARGUMENTS naming such aliases, but a base ENTRY spelled as one is out of reach until base targets resolve before stamping. --- internal/parser/languages/csharp.go | 32 +++++++++++---- ...csharp_iface_dispatch_multiclosure_test.go | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 974d7638..8c9e56cd 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -402,16 +402,17 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex // (emitCSharpBaseList) checks this set before falling back to name // shape so a locally-known interface always wins. localInterfaces := collectCSharpInterfaceNames(root, src) - // Per type node ID, across every declaration in the file — see - // csharpBaseNameCounts for why one declaration's own base list is not - // a sufficient ambiguity check. - baseNameCounts := csharpBaseNameCounts(root, src, filePath) // Using-alias names, collected once per file: the type-argument stamp // sites consult them per declaration, and a per-declaration rescan of // the enclosing namespace was quadratic in sibling count. fileAliases := csharpFileAliasNames(root, src) + // Per type node ID, across every declaration in the file — see + // csharpBaseNameCounts for why one declaration's own base list is not + // a sufficient ambiguity check. + baseNameCounts := csharpBaseNameCounts(root, src, filePath, fileAliases) + var calls []csharpDeferredCall var locals []csharpDeferredLocal var typeUses []csharpTypeUse @@ -2002,7 +2003,16 @@ func csharpDirectMemberOwner(member *sitter.Node, src []byte, allowed ...string) // Walking base_list nodes and reading the parent's name avoids // enumerating declaration node types, which differ across grammar // revisions. -func csharpBaseNameCounts(root *sitter.Node, src []byte, filePath string) map[string]map[string]int { +// +// A base entry that names a using alias is recorded under the alias +// sentinel rather than its own spelling. An alias is an opaque spelling +// of some type - possibly a construction of the very interface a +// sibling entry closes - so a base list containing one can never prove +// its target unique, and the stamp site refuses the whole type. The +// sentinel key contains a NUL so no real base name can collide with it. +const csharpAliasBaseSentinel = "\x00alias-base" + +func csharpBaseNameCounts(root *sitter.Node, src []byte, filePath string, fileAliases map[string]bool) map[string]map[string]int { counts := map[string]map[string]int{} walkNodes(root, func(n *sitter.Node) { if n.Type() != "base_list" { @@ -2028,6 +2038,10 @@ func csharpBaseNameCounts(root *sitter.Node, src []byte, filePath string) map[st continue } if name, _ := csharpBaseTypeName(entry, src); name != "" { + if fileAliases[name] { + m[csharpAliasBaseSentinel]++ + continue + } m[name]++ } } @@ -2162,8 +2176,12 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s } // Closed generic arguments ride the edge so the dispatch fan-out // can exclude type-impossible implementors — see the package doc - // in csharp_base_type_args.go for the conservative rules. - if baseNameCount[name] == 1 { + // in csharp_base_type_args.go for the conservative rules. A base + // list that spells any entry as a using alias stamps nothing at + // all: the alias is an opaque spelling that may construct the + // same interface a sibling entry closes, so no entry's target + // can be proven unique. + if baseNameCount[csharpAliasBaseSentinel] == 0 && baseNameCount[name] == 1 { if args := csharpBaseTypeArgs(entry, src, declTypeParams); args != "" { if edge.Meta == nil { edge.Meta = map[string]any{} diff --git a/internal/resolver/csharp_iface_dispatch_multiclosure_test.go b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go index 98e3b92c..aa1035c4 100644 --- a/internal/resolver/csharp_iface_dispatch_multiclosure_test.go +++ b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go @@ -112,6 +112,47 @@ func TestResolveCSharpInterfaceDispatch_SecondClosureViaBaseClass(t *testing.T) // Two commits close it - the extractor descending to the final segment, // and the count spanning a whole type ID - so this is the end-to-end // pin that the two together actually cover the reported shape. +// The alias spelling of the same duplicate: `using BX = App.IBox;` +// then `Dual : BX, IBox`. The name extractor sees "BX", so the +// duplicate count reads BX=1, IBox=1 and the bare entry stamps its +// closure onto the whole type - and the multi-closure walk cannot help, +// because `unresolved::BX` never resolves to the interface, leaving only +// one visible path. An alias is an opaque spelling: a base list that +// contains one cannot prove it names anything OTHER than the interface, +// so no entry of that type may stamp. +func TestResolveCSharpInterfaceDispatch_AliasSpelledBaseSuppressesStamps(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "Alias.cs": `using BX = App.IBox; +namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public class PlainCrateBox : IBox { public void Put(Crate c) { } } + public class Dual : BX, IBox { + public void Put(Crate c) { } + public void Put(Widget w) { } + } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "Alias.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Alias.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + assert.ElementsMatch(t, []string{ + "Alias.cs::PlainCrateBox.Put", + "Alias.cs::Dual.Put", + "Alias.cs::Dual.Put_L9", + }, dispatchTargets(g, callerID), + "a type whose base list spells an alias keeps its whole fan-out") +} + func TestResolveCSharpInterfaceDispatch_QualifiedSpellingCountsAsDuplicate(t *testing.T) { for _, tc := range []struct { name string From 30c74861e36476f62cf0deaf4458505105063676 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:04:38 +0200 Subject: [PATCH 34/38] parser: the read emitter asks the shadow question at its own site emitCSharpFieldIdentifierUses consulted the widened scope index with the function-wide question, so a lambda parameter or foreach variable ANYWHERE in a method deleted every read edge for a coinciding field name - a genuine `_box.Touch()` before the binder included. That is a find_usages recall loss on the field-read feature this PR's extractor version bump retroactively covers, and it fires precisely on the injected-repository field names the widened index now knows about. Call receivers already carry their byte offset and access receivers carry their node, so both now ask "shadowed at THIS site". Only the assignment buffer has no coordinate and keeps the function-wide question - which can only withhold a write edge, never invent one. Block-scoped binders (declaration patterns, out vars) are deliberately not in the survival pins: their name is in scope for the whole block, so a bare use of it before them is CS0841 and refusal stays correct. --- .../languages/csharp_field_identifier.go | 30 ++++++++----- .../languages/csharp_field_identifier_test.go | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/internal/parser/languages/csharp_field_identifier.go b/internal/parser/languages/csharp_field_identifier.go index caa02738..9f39a75a 100644 --- a/internal/parser/languages/csharp_field_identifier.go +++ b/internal/parser/languages/csharp_field_identifier.go @@ -98,8 +98,14 @@ func emitCSharpFieldIdentifierUses( } // eligible resolves the enclosing owner and reports whether name is - // an unshadowed field of the owner's type. - eligible := func(line int, name string) (owner, ownerType string, ok bool) { + // an unshadowed field of the owner's type. Sites carrying a byte + // offset ask the shadow question at their own coordinate — a lambda + // parameter or foreach variable elsewhere in the method must not + // delete a genuine field read outside its extent. A site with no + // coordinate (offset < 0: the assignment buffer) keeps the + // function-wide question, which can only withhold a read, never + // invent one. + eligible := func(line, offset int, name string) (owner, ownerType string, ok bool) { owner = funcRanges.enclosing(line) if owner == "" { return "", "", false @@ -108,11 +114,11 @@ func emitCSharpFieldIdentifierUses( if ownerType == "" || !fieldsByType[ownerType][name] { return "", "", false } - // This emitter's three input buffers do not all carry a byte - // offset, so it asks the function-wide question. That is the - // pre-extent behavior and stays conservative: it can only - // withhold a read edge, never invent one. - if paramsByOwner[owner][name] || localScopes.shadowsAnywhere(owner, name) || + shadowed := localScopes.shadowsAnywhere(owner, name) + if offset >= 0 { + shadowed = localScopes.shadows(owner, name, offset) + } + if paramsByOwner[owner][name] || shadowed || builtinsByOwner[owner][name] != "" { return "", "", false } @@ -129,8 +135,8 @@ func emitCSharpFieldIdentifierUses( kind graph.EdgeKind } seen := map[siteKey]bool{} - emit := func(line int, name string, kind graph.EdgeKind) { - owner, ownerType, ok := eligible(line, name) + emit := func(line, offset int, name string, kind graph.EdgeKind) { + owner, ownerType, ok := eligible(line, offset, name) if !ok { return } @@ -152,7 +158,7 @@ func emitCSharpFieldIdentifierUses( if !c.isMember || c.recvType != "" || !csharpBareIdentifier(c.receiver) { continue } - emit(c.line, c.receiver, graph.EdgeReads) + emit(c.line, c.offset, c.receiver, graph.EdgeReads) } for _, a := range accesses { @@ -171,10 +177,10 @@ func emitCSharpFieldIdentifierUses( if csharpAccessInCallPosition(a.node) { continue } - emit(a.line, recv.Content(src), graph.EdgeReads) + emit(a.line, int(a.node.StartByte()), recv.Content(src), graph.EdgeReads) } for _, fa := range fieldAssigns { - emit(fa.line, fa.name, graph.EdgeWrites) + emit(fa.line, -1, fa.name, graph.EdgeWrites) } } diff --git a/internal/parser/languages/csharp_field_identifier_test.go b/internal/parser/languages/csharp_field_identifier_test.go index 7e9f0755..94a7a181 100644 --- a/internal/parser/languages/csharp_field_identifier_test.go +++ b/internal/parser/languages/csharp_field_identifier_test.go @@ -213,3 +213,47 @@ func TestCSharpExtractor_FieldIdentifierUsesAcrossCtorShapes(t *testing.T) { require.Len(t, prim, 1, "primary-ctor class: method call-receiver read") assert.Equal(t, graph.EdgeReads, prim[0].Kind) } + +// The shadow refusal must not delete a GENUINE field read because a +// coinciding binder exists somewhere else in the method. Every body +// below reads the field `_box` as a call receiver first, then binds a +// different `_box` in a scope the read never touches - the read edge +// has to survive. (The pre-existing declared-local shape stays +// function-wide by design: the emitter's other input buffers carry no +// byte coordinate, so the local case keeps the conservative answer.) +func TestCSharpExtractor_FieldReadSurvivesLaterCoincidingBinder(t *testing.T) { + for _, tc := range []struct { + name string + binder string + }{ + // Block-scoped binders (declaration patterns, out vars) are NOT + // here: their name is in scope for the whole block, so a bare + // `_box` before them is CS0841 and refusing it is correct. + {"foreach variable", `foreach (var _box in xs) { System.Console.WriteLine(_box); }`}, + {"expression lambda parameter", `System.Linq.Enumerable.Any(xs, _box => _box > 0);`}, + {"parenthesized using", `using (var _box = System.IO.File.OpenRead("x")) { }`}, + } { + t.Run(tc.name, func(t *testing.T) { + src := []byte(`namespace App { + public class Bag { public void Touch() { } } + public class Flow { + private readonly Bag _box; + public Flow(Bag b) { _box = b; } + public void M(int[] xs, System.Collections.Generic.Dictionary map) { + _box.Touch(); + ` + tc.binder + ` + } + } +} +`) + e := NewCSharpExtractor() + result, err := e.Extract("F.cs", src) + require.NoError(t, err) + + reads := accessEdges(result.Edges, "F.cs::Flow.M", "_box") + require.Len(t, reads, 1, + "the pre-binder call-receiver read of the field must survive") + assert.Equal(t, graph.EdgeReads, reads[0].Kind) + }) + } +} From 072a859684f2408df617c119ae927ff9ac89dd9c Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:07:57 +0200 Subject: [PATCH 35/38] parser: stamp duplicate_decl on collided type IDs; refuse such owners The span check proves field ownership only when the colliding declarations occupy disjoint spans. A same-named type nested INSIDE its twin is legal C# (CS0542 bars only the immediate enclosing type's name) and places the dropped declaration's lines inside the survivor's span - so the caller and the foreign field both pass the check, and the gate filters on the wrong declaration's closure, keeping exactly the type-impossible implementor. No span heuristic can close that: the missing fact is the collision itself. emitContainer now ORs duplicate_decl onto the surviving type node whenever a second declaration lands on its ID - the same OR-onto-the-survivor move the variance stamp uses, folded into one helper - and the field-receiver lookup refuses any owner carrying it. A refusal covers every collision shape at once: arity twins, same-file partials, nested twins, namespace twins, and whatever shape arrives next through the same door. The span check stays: it also guards stores extracted before this stamp existed, where the flag is absent but disjoint spans still catch the common shapes. --- internal/parser/languages/csharp.go | 38 +++++++++------ internal/resolver/csharp_iface_dispatch.go | 9 ++++ .../csharp_iface_dispatch_collision_test.go | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 8c9e56cd..9e520264 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -961,13 +961,13 @@ func (e *CSharpExtractor) emitNamespace(m parser.QueryResult, filePath, fileID s }) } -// csharpMarkVariantTypeParams ORs the variance stamp onto an already -// emitted type node. Type node IDs carry no arity, so a generic -// interface can collide with a non-generic twin and be dropped whole - -// but variance is a REFUSAL signal, and a refusal that only one +// csharpOrTypeMeta ORs a boolean stamp onto an already emitted type +// node. Type node IDs carry no arity and no namespace, so declarations +// can collide and be dropped whole - but a REFUSAL signal that only one // colliding declaration carries has to survive the collision. Union is -// therefore the conservative merge: it can only widen a fan-out. -func csharpMarkVariantTypeParams(result *parser.ExtractionResult, id string) { +// the conservative merge: every consumer of these stamps can only +// widen a fan-out or refuse evidence on seeing one. +func csharpOrTypeMeta(result *parser.ExtractionResult, id, key string) { for i := len(result.Nodes) - 1; i >= 0; i-- { n := result.Nodes[i] if n == nil || n.ID != id { @@ -976,7 +976,7 @@ func csharpMarkVariantTypeParams(result *parser.ExtractionResult, id string) { if n.Meta == nil { n.Meta = map[string]any{} } - n.Meta["variant_type_params"] = true + n.Meta[key] = true return } } @@ -991,14 +991,24 @@ func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeK if seen[id] { // A second declaration on an ID already taken: the arity pair // (ISource / ISource, Result / Result), same-file - // partial parts, or two namespaces in one file. The node is - // dropped, but variance must not be dropped with it - the gate - // reads that stamp off whichever node survives, and evaluating - // it behind this return meant a bare-named sibling could delete - // a covariant family's only protection. + // partial parts, nested same-named types, or two namespaces in + // one file. The node is dropped, but two refusal signals must + // not be dropped with it. + // + // Variance: the gate reads that stamp off whichever node + // survives, and evaluating it behind this return meant a + // bare-named sibling could delete a covariant family's only + // protection. if kind == "iface" && csharpHasVariantTypeParams(def.Node) { - csharpMarkVariantTypeParams(result, id) - } + csharpOrTypeMeta(result, id, "variant_type_params") + } + // The collision itself: a consumer that assembles evidence from + // this ID (the field-receiver lookup in particular) cannot know + // WHICH declaration's evidence survived, and no span heuristic + // can prove it - a same-named type nested inside its twin puts + // the dropped declaration's lines inside the survivor's span. + // The positive signal closes every collision shape at once. + csharpOrTypeMeta(result, id, "duplicate_decl") return } seen[id] = true diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 7f1449db..679f0c94 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -1065,6 +1065,15 @@ func csharpReceiverField(g graph.Store, e *graph.Edge, lookups *csharpReceiverLo field.StartLine < owner.StartLine || field.StartLine > owner.EndLine { return nil } + // The positive collision signal: the extractor stamps duplicate_decl + // on a type node whose ID a second declaration collided with. The + // span check above cannot prove ownership for a twin declared INSIDE + // the survivor (a nested same-named type), and no span heuristic + // can - whichever declaration's evidence survived, a collided owner + // proves nothing about which field the receiver names. + if dup, _ := owner.Meta["duplicate_decl"].(bool); dup { + return nil + } return field } diff --git a/internal/resolver/csharp_iface_dispatch_collision_test.go b/internal/resolver/csharp_iface_dispatch_collision_test.go index c89cf6e2..c7cbc92d 100644 --- a/internal/resolver/csharp_iface_dispatch_collision_test.go +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -173,3 +173,50 @@ func TestResolveCSharpInterfaceDispatch_ArityTwinFieldCollisionNeverFilters(t *t }, dispatchTargets(g, callerID), "a field ID shared by an arity twin is not evidence about this caller's receiver") } + +// The span check proves ownership only when the colliding declarations +// have disjoint spans. A same-named type nested INSIDE its twin is +// legal (CS0542 bars only the immediate enclosing type's name) and puts +// the dropped declaration's lines inside the survivor's span, so both +// the caller and the foreign field pass the check and the gate filters +// on evidence from the wrong declaration - keeping precisely the +// type-impossible implementor. +// +// A span cannot close this shape; a positive collision signal can. The +// extractor now stamps duplicate_decl on a type node whose ID a second +// declaration collided with - the same OR-onto-the-survivor move the +// variance stamp uses - and the receiver lookup refuses any owner so +// stamped. Refusal covers every collision shape at once. +func TestResolveCSharpInterfaceDispatch_NestedTwinFieldCollisionNeverFilters(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "B.cs": `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { int Get(int id); } + public class CrateBox : IBox { public int Get(int id) { return 1; } } + public class WidgetBox : IBox { public int Get(int id) { return 2; } } + public class A { + private readonly IBox _box; + public A(IBox b) { _box = b; } + public class B { + public class A { + private readonly IBox _box; + public A(IBox b) { _box = b; } + public int Load(int id) { return _box.Get(id); } + } + } + } +}`, + }) + New(g).ResolveAll() + + const callerID = "B.cs::A.Load" + bindFieldReceiverCall(t, g, callerID, "_box", "B.cs::IBox.Get") + ResolveCSharpInterfaceDispatch(g) + + assert.ElementsMatch(t, []string{ + "B.cs::CrateBox.Get", + "B.cs::WidgetBox.Get", + }, dispatchTargets(g, callerID), + "a collided owner ID proves nothing about which declaration's field the receiver is") +} From b226eee405f1630d592805816af120ad0e02acf2 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:09:20 +0200 Subject: [PATCH 36/38] resolver: the closure walk never crosses the interface itself Two hardenings from this round's adversarial review pass. The BFS enqueued every edge target including the interface being walked to, then kept walking upward past it. An edge from one of the interface's own supertypes back to the interface is by definition a cycle - unreachable from compiling source, but reachable through the node-ID collision channel (two same-short-named interfaces in one file merging onto one ID) - and it read as a disagreeing second path, disqualifying every descendant's stamp. The walk now stops at the interface: its supertypes are not paths to it. The function's doc claimed the walk "can only ever remove filtering power, never add it". That is true of the TARGET filter this function feeds - the return value is either the old direct-stamp string or "" - but not of the end-to-end edge set: the family loop also reads a bound member's stamp as the SOURCE side's receiver construction, and a refused stamp sends those sites to the receiver-declared fallback, whose verdict can differ. In the shapes measured the fallback is the more correct one (the old source-side read painted one arbitrary closure over a multi-closure type). The comment now states the precise property instead of the flattering one. --- internal/resolver/csharp_iface_dispatch.go | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 679f0c94..8a1b72e0 100644 --- a/internal/resolver/csharp_iface_dispatch.go +++ b/internal/resolver/csharp_iface_dispatch.go @@ -779,9 +779,16 @@ func csharpAliasComparableForms(alias string) []string { // ability to NOTICE a second construction arriving through an inherited // interface or a base class, and to refuse on it. // -// So the walk can only ever remove filtering power, never add it. That -// keeps the existing conservative rules intact and makes every outcome -// change here a preserved edge rather than a dropped one. +// The precise guarantee is that the TARGET filter is monotonically +// weakened: this function returns either the same string the old +// direct-stamp read produced or "". The END-TO-END edge set is not a +// strict superset, because the family loop also reads a bound member's +// stamp as the SOURCE side's receiver construction — a member whose +// stamp is refused here sends its sites to the receiver-declared +// fallback instead, whose verdict can differ. In the shapes measured +// the fallback is the more correct one: the old source-side read +// painted one arbitrary closure over a multi-closure type, the same +// defect this walk fixes on the target side. func csharpUniqueClosureToIface(sub, ifaceID string, implEdges map[string]map[string][]string) string { // The implementor's own base list. Absent, ambiguous, or unstamped // means there is nothing to filter on, exactly as before. @@ -805,12 +812,18 @@ func csharpUniqueClosureToIface(sub, ifaceID string, implEdges map[string]map[st cur := queue[0] queue = queue[1:] for to, closures := range implEdges[cur] { - if to == ifaceID && cur != sub { - for _, c := range closures { - if c != direct { - return "" + if to == ifaceID { + if cur != sub { + for _, c := range closures { + if c != direct { + return "" + } } } + // Never walk THROUGH the interface: its supertypes are + // not paths to it, and a collision-merged cycle above it + // would otherwise read as a disagreeing second path. + continue } if !visited[to] { visited[to] = true From 269fa3210f1f55ffc05c22358679a97d976678fb Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:12:46 +0200 Subject: [PATCH 37/38] resolver: pin the arity-twin and namespace-twin collision shapes Both were fixed by the per-type-ID duplicate count and verified working, but only the partial-parts shape carried a test. A future narrowing of the count's key - arity or namespace, which is exactly the deferred "real fix" for type identity - would silently reopen these two while the partial pin stayed green. The assertion counts the colliding type's surviving members rather than naming overload-suffix IDs, since the suffix depends on fixture line numbers. --- .../csharp_iface_dispatch_collision_test.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/resolver/csharp_iface_dispatch_collision_test.go b/internal/resolver/csharp_iface_dispatch_collision_test.go index c7cbc92d..cfcf97bc 100644 --- a/internal/resolver/csharp_iface_dispatch_collision_test.go +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -1,6 +1,7 @@ package resolver import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -129,6 +130,70 @@ func TestResolveCSharpInterfaceDispatch_SameFilePartialPartsKeepBothOverloads(t "a type whose parts close IBox twice must keep its whole fan-out") } +// The other two declaration shapes that collapse onto one type ID and +// close the interface twice - the arity twin (Result / Result) and +// two namespaces in one file. Both flow through the same per-type-ID +// count as the partial parts above; pinned separately because a future +// narrowing of the count's key (arity, namespace - the deferred "real +// fix") would silently reopen exactly these while the partial pin +// stayed green. +func TestResolveCSharpInterfaceDispatch_TypeIDCollisionShapesKeepFanout(t *testing.T) { + for _, tc := range []struct { + name string + src string + }{ + {"arity twin", `namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public class Store : IBox { public void Put(Widget w) { } } + public class Store : IBox { public void Put(Crate c) { } } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`}, + {"two namespaces in one file", `namespace A { + public class Store : IBox { public void Put(App.Widget w) { } } +} +namespace App { + public class Crate { } + public class Widget { } + public interface IBox { void Put(T item); } + public class Store : IBox { public void Put(Crate c) { } } + public class Flow { + private readonly IBox _box; + public Flow(IBox b) { _box = b; } + public void Pull(Crate c) { _box.Put(c); } + } +}`}, + } { + t.Run(tc.name, func(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{"Boxes.cs": tc.src}) + New(g).ResolveAll() + + const callerID = "Boxes.cs::Flow.Pull" + bindFieldReceiverCall(t, g, callerID, "_box", "Boxes.cs::IBox.Put") + ResolveCSharpInterfaceDispatch(g) + + targets := dispatchTargets(g, callerID) + // The colliding declarations' member IDs differ only in the + // overload suffix, which depends on each fixture's line + // numbers - assert the invariant that matters: BOTH Put + // declarations survive, so the set holds two Store members. + storePuts := 0 + for _, tgt := range targets { + if strings.HasPrefix(tgt, "Boxes.cs::Store.Put") { + storePuts++ + } + } + assert.Equal(t, 2, storePuts, + "a type ID closing IBox twice keeps both members; targets: %v", targets) + }) + } +} + // Field node IDs collide through the same door: `ownerID + "." + name` // inherits the type ID's missing arity, so the Result / Result pair // mints one `Result.cs::Result._source` and only the first declaration's From 1f43e0eabd9c1221c52920e56a29191621567edf Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:01:22 +0200 Subject: [PATCH 38/38] resolver: drop an unused constant from the extension block-scope test --- internal/resolver/csharp_extension_block_scope_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/resolver/csharp_extension_block_scope_test.go b/internal/resolver/csharp_extension_block_scope_test.go index a0187339..991db842 100644 --- a/internal/resolver/csharp_extension_block_scope_test.go +++ b/internal/resolver/csharp_extension_block_scope_test.go @@ -49,7 +49,6 @@ namespace App { // Ext.cs:4 takes (this Bag, int) — two parameters, which is what a // static-form `BagExt.Add(bag, 5)` fills. Ext.cs:5 takes three. const twoParam = "Ext.cs::BagExt.Add" - const threeParam = "Ext.cs::BagExt.Add_L5" assert.Equal(t, twoParam, namedCallTarget(t, g, "Caller.cs::Use.Control", "Add"), "control: with no local anywhere in the method the static form already binds correctly")