diff --git a/internal/indexer/extractor_version.go b/internal/indexer/extractor_version.go index 76742b4d..8e3c43c4 100644 --- a/internal/indexer/extractor_version.go +++ b/internal/indexer/extractor_version.go @@ -40,7 +40,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, // EF Core ORM facts: [Table] models_table edges, ef_config_* and ef_fluent stamps (was: receiverless calls carry arg_count / type_arg_count for #559) + "csharp": 16, // 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: EF Core ORM facts) "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 19e70e0b..b86c7f67 100644 --- a/internal/indexer/extractor_version_test.go +++ b/internal/indexer/extractor_version_test.go @@ -221,7 +221,7 @@ func TestStaleLangsDetection(t *testing.T) { } for _, path := range []string{"src/Handler.cs", "Views/Page.razor", "Views/Page.cshtml"} { - want := policySalt + "|csharp@13" + want := policySalt + "|csharp@16" if got := merkleSaltFor(path); got != want { t.Errorf("C# extractor salt for %s = %q, want %q", path, got, want) } diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index b7436370..31abe408 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,85 @@ 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 +} + +// 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 csharpScopeFormers[cur.Type()] { + 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. @@ -317,6 +404,16 @@ 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) + + // 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 @@ -330,19 +427,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, baseNameCounts) 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, baseNameCounts) 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, baseNameCounts) 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, baseNameCounts) 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, baseNameCounts) case m.Captures["anon.def"] != nil: e.emitAnonymousType(m, filePath, fileID, result, seen) @@ -354,10 +451,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) @@ -645,6 +742,61 @@ 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 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 + } + memberSiteReceiver := map[csharpCallSite]string{} + memberSiteAmbiguous := map[csharpCallSite]bool{} + for _, c := range calls { + key := csharpCallSite{c.name, c.line} + if prev, ok := memberSiteReceiver[key]; ok { + if prev != c.receiver { + memberSiteAmbiguous[key] = true + } + } else { + memberSiteReceiver[key] = c.receiver + } + } + + // 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 scope index + // covers every declaration, and covers it where it actually binds. + // The field-identifier emitter reuses both. + paramsByOwner := csharpParamNamesByOwner(result) + localScopes := csharpLocalScopes{} + for _, l := range locals { + owner := localOwner(l) + if owner == "" { + continue + } + m := localScopes[owner] + if m == nil { + m = map[string][]csharpLocalScope{} + localScopes[owner] = m + } + 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) if callerID == "" { @@ -691,17 +843,35 @@ 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] && + !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 + // 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 + // 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{} + } + 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. @@ -752,24 +922,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, localScopes, builtinsByOwner, result) // .NET surfaces a symbol walk misses: DI registrations + COM // interop. Stamped onto the file node. @@ -804,14 +962,54 @@ func (e *CSharpExtractor) emitNamespace(m parser.QueryResult, filePath, fileID s }) } +// 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 +// 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 { + continue + } + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta[key] = 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). -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, baseNameCounts map[string]map[string]int) { name := m.Captures[kind+".name"].Text 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, 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) { + 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 @@ -825,6 +1023,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": @@ -871,12 +1072,12 @@ 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, baseNameCounts, result) case "enum": 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) } } @@ -888,7 +1089,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. @@ -902,6 +1103,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" { @@ -926,6 +1130,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, @@ -1427,7 +1637,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 == "" { @@ -1455,9 +1665,18 @@ 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 := csharpTypeArgsFromTypeNode(fieldTypeNode, src, csharpUnstampableArgNames(def.Node, src, fileAliases)); 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 @@ -1494,7 +1713,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 == "" { @@ -1524,6 +1743,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 := csharpTypeArgsFromTypeNode(t, src, csharpUnstampableArgNames(def.Node, src, fileAliases)); args != "" { + meta["field_type_args"] = args + } } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc @@ -1561,10 +1785,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 @@ -1579,6 +1804,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)) @@ -1614,12 +1850,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 } @@ -1703,6 +1942,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 @@ -1743,6 +2011,67 @@ 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. +// +// 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" { + 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 != "" { + if fileAliases[name] { + m[csharpAliasBaseSentinel]++ + continue + } + 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) { @@ -1783,7 +2112,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, baseNameCounts map[string]map[string]int, result *parser.ExtractionResult) { if decl == nil { return } @@ -1810,6 +2139,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, fileAliases) + // 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. 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) @@ -1850,6 +2197,21 @@ 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. 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{} + } + edge.Meta["target_type_args"] = args + } + } result.Edges = append(result.Edges, edge) } } @@ -1953,7 +2315,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" { @@ -2318,13 +2691,15 @@ 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 { +// 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 "" + return nil } for i, _nc := 0, int(fieldDecl.NamedChildCount()); i < _nc; i++ { c := fieldDecl.NamedChild(i) @@ -2332,17 +2707,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 new file mode 100644 index 00000000..a247f861 --- /dev/null +++ b/internal/parser/languages/csharp_base_type_args.go @@ -0,0 +1,448 @@ +package languages + +import ( + "strconv" + "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. + +// 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: +// +// - 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 (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, 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 + } + 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) + } + } + } + for name := range fileAliases { + add(name) + } + 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 +// 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 +// the alias in a name_equals node, others lay it out flat (identifier, +// bare `=` token, target); stampCSharpUsings' skip branch matches the +// 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) + 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 canonical(strings.TrimSpace(id.Content(src))) + } + } + case "=": + return canonical(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" + 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 +} + +// 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 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. + 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`) 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 + // 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) +} + +// 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 "" + } + argList := csharpEntryTypeArgumentList(typeNode) + if argList == nil { + return "" + } + 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, ",") +} + +// 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..6194c69d --- /dev/null +++ b/internal/parser/languages/csharp_base_type_args_test.go @@ -0,0 +1,473 @@ +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") +} + +// 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"]) +} + +// 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") +} + +// 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 { + 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") + } + } +} + +// 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) + } + } +} diff --git a/internal/parser/languages/csharp_binding_scopes.go b/internal/parser/languages/csharp_binding_scopes.go new file mode 100644 index 00000000..32370c0e --- /dev/null +++ b/internal/parser/languages/csharp_binding_scopes.go @@ -0,0 +1,215 @@ +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())} + } + 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": + // 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", + "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) + if p := n.Parent(); p != nil && p.Type() == "catch_clause" { + sc = spanOf(p) + } + 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. + 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/parser/languages/csharp_field_identifier.go b/internal/parser/languages/csharp_field_identifier.go index c92bce15..9f39a75a 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, - localNamesByOwner map[string]map[string]bool, + paramsByOwner map[string]map[string]bool, + localScopes csharpLocalScopes, builtinsByOwner map[string]map[string]string, result *parser.ExtractionResult, ) { @@ -95,11 +96,16 @@ 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. - 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,7 +114,11 @@ func emitCSharpFieldIdentifierUses( if ownerType == "" || !fieldsByType[ownerType][name] { return "", "", false } - if paramsByOwner[owner][name] || localNamesByOwner[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 } @@ -125,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 } @@ -148,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 { @@ -167,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) + }) + } +} 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) 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..991db842 --- /dev/null +++ b/internal/resolver/csharp_extension_block_scope_test.go @@ -0,0 +1,128 @@ +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" + + 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") +} + +// 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") + }) + } +} diff --git a/internal/resolver/csharp_iface_dispatch.go b/internal/resolver/csharp_iface_dispatch.go index 26a13f93..8a1b72e0 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,22 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) hierarchySources := g.GetNodesByIDs(hierarchySourceIDs) hierarchyByName := g.FindNodesByNames(hierarchyNames) children := 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 @@ -150,11 +167,47 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) } } children[toID] = append(children[toID], e.From) + args := "" + if e.Meta != nil { + 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 } + // 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 anyStamps { + for n := range graph.NodesByKindsSeq(g, graph.KindFile) { + if n == nil || n.Meta == nil { + continue + } + for _, a := range csharpMetaStrings(n.Meta["global_using_aliases"]) { + for _, form := range csharpAliasComparableForms(a) { + globalAliasNames[form] = 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 @@ -223,6 +276,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{} @@ -247,10 +324,29 @@ 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 - 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 +357,21 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) for _, id := range ag.nodeIDs { anchorSet[id] = true } + memberArgs := map[string]string{} implCount := 0 + variant := variantIface[ag.ifaceID] for _, sub := range descendants(ag.ifaceID) { byName := membersByType[sub] if byName == nil { continue } + subArgs := "" + if !variant { + subArgs = closuresFor(ag.ifaceID)[sub] + if csharpArgsNameGlobalAlias(subArgs, globalAliasNames) { + subArgs = "" + } + } for _, m := range byName[ag.name] { if m == nil || anchorSet[m.ID] { continue @@ -276,6 +381,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 +393,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 +437,7 @@ func ResolveCSharpInterfaceDispatchScoped(g graph.Store, scope map[string]bool) var batch []*graph.Edge seen := map[string]bool{} + receiverLookups := newCSharpReceiverLookupCtx() for _, e := range callEdges { if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) { continue @@ -361,6 +473,21 @@ 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.ifaceID, f.ifaceName, receiverLookups) + if csharpArgsNameGlobalAlias(srcArgs, globalAliasNames) { + srcArgs = "" + } + } 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 +498,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 @@ -561,6 +698,154 @@ 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. +// 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. +// +// 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. + 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 { + 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 + queue = append(queue, to) + } + } + } + return direct +} + +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 { @@ -572,6 +857,254 @@ 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. +// 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 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 + evidence map[string]*csharpCallerEvidence + nodes map[string]*graph.Node + nodeSeen map[string]bool +} + +func newCSharpReceiverLookupCtx() *csharpReceiverLookupCtx { + return &csharpReceiverLookupCtx{ + args: map[string]string{}, + evidence: map[string]*csharpCallerEvidence{}, + nodes: map[string]*graph.Node{}, + nodeSeen: map[string]bool{}, + } +} + +// 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 + } + } + c.evidence[caller] = ev + return ev +} + +func (c *csharpReceiverLookupCtx) nodeByID(g graph.Store, id string) *graph.Node { + if c.nodeSeen[id] { + return c.nodes[id] + } + c.nodeSeen[id] = true + n := g.GetNodesByIDs([]string{id})[id] + c.nodes[id] = n + return n +} + +func csharpReceiverDeclaredArgs(g graph.Store, e *graph.Edge, ifaceID, ifaceName string, lookups *csharpReceiverLookupCtx) string { + if e == nil || e.From == "" { + return "" + } + // 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 := lookups.args[cacheKey]; ok { + return v + } + args := "" + 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 { + prefix = prefix[:lt] + } + if i := strings.LastIndex(prefix, "."); i >= 0 { + prefix = prefix[i+1:] + } + if prefix == ifaceName { + args, _ = field.Meta["field_type_args"].(string) + } + } + 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, lookups *csharpReceiverLookupCtx) *graph.Node { + name := "" + if e.Meta != nil { + if amb, _ := e.Meta["receiver_ambiguous"].(bool); amb { + return nil + } + 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. + 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 { + 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 + } + 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). + 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) + 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 + } + // 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 +} + +// 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_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_binding_forms_test.go b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go new file mode 100644 index 00000000..bf340f65 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_binding_forms_test.go @@ -0,0 +1,246 @@ +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); } + }`}, + {"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) { + 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 BoxError : System.Exception { + public int Get(int id) { return 0; } + } + 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") + }) + } +} + +// 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") +} + +// 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") +} 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..cfcf97bc --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_collision_test.go @@ -0,0 +1,287 @@ +package resolver + +import ( + "strings" + "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") + }) + } +} + +// 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") +} + +// 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 +// 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") +} + +// 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") +} 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..ef5e976b --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_generic_test.go @@ -0,0 +1,1292 @@ +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") +} + +// 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") +} + +// 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 + 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") +} + +// 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") +} + +// 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" + // 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) + + 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" + // 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) + + 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") +} + +// 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") +} + +// 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. 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 { + public class Crate { } + public interface IBox { + int Get(int id); + } + 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; } + 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::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") +} + +// 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") +} + +// 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 +// 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") +} + +// 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") +} 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..aa1035c4 --- /dev/null +++ b/internal/resolver/csharp_iface_dispatch_multiclosure_test.go @@ -0,0 +1,199 @@ +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") +} + +// 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. +// 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 + 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") + }) + } +} 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()