resolver: generic type arguments gate the C# interface-dispatch fan-out - #677
resolver: generic type arguments gate the C# interface-dispatch fan-out#677pbednarcik wants to merge 39 commits into
Conversation
The dispatch synthesizer builds implements-families over erased interface targets, so IBoxStore<Crate> and IBoxStore<Widget> implementors land in one family and every through-interface call fans out to both. On a per-entity-repository codebase that inflates every data-access usage answer (189 implementors of one repository interface; single methods carrying 40-70 distinct fan-out targets). The extractor now stamps CLOSED generic arguments as evidence: base-list entries on the implements/extends edge (target_type_args) and field/property declared types on the node (field_type_args). The rules live in one place, at extraction, where the full syntax context is visible: arguments naming a type parameter of the declaring type or ANY enclosing type stamp nothing; arguments matching an in-scope using alias stamp nothing (an alias may spell any type - opaque to a string comparison); non-simple arguments stamp nothing; a base list closing the same erased target twice stamps neither (the entries collapse to one stored edge); a qualified base whose generic segment is not the final one stamps nothing. Arguments compare by type identity, not spelling: BCL alias forms fold to the keyword canonical (System.Int32, Int32 and int all stamp "int") - folding can only create matches, and a match always keeps the edge, so the fold is recall-safe. Sites where two same-named member calls share one line are marked receiver_ambiguous, since they dedupe to a single edge carrying one arbitrary receiver's evidence. The dispatch pass derives each source site's constructed arguments (a sibling site from its bound implementor's stamp; a through-interface site from its receiver field's stamp, located via receiver_name or the same-member companion edge, refused on ambiguous sites) and skips fan-out members stamped with DIFFERENT arguments. Absence of evidence anywhere keeps the full fan-out, and a family with no stamps at all never pays the receiver lookup. Typed-local receivers are a named remainder: the tenv strips generics before receiver_type is stamped, so local-receiver sites keep the full fan-out until local type arguments are carried too. Every stamp rule and seven end-to-end dispatch behaviors are test-pinned, each watched fail first - including the four shapes two adversarial review rounds reproduced as silent false suppression (enclosing type parameter, double closure, same-line receiver ambiguity, BCL alias spelling).
…stamps Two C# extraction changes need already-indexed .cs/.razor/.cshtml files to re-extract without a content change: the field-identifier read/write edges from zzet#668 (where the bump was missed - caught in the zzet#671 discussion: an in-place-upgraded store never re-extracts, so find_usages on a field stays empty even after the view fix) and this branch's own target_type_args/field_type_args/receiver_ambiguous stamps. One bump covers both; the salt test pin moves with it, proving the salt actually changes.
|
Live validation on my production C# codebase, from a full store rebuild on this branch merged onto current main. The dispatch synthesis pass now emits 94,858 csharp-iface-dispatch edges where every previously recorded full pass on the same store family landed between 230k and 243k, so the gate removes roughly 60 percent of the synthesized fan-out. The per-method view is the one that matters for usage answers. Counting distinct fan-out targets for the shared repository member per calling method: before the fix single methods carried 40 to 70 distinct targets, after it the maximum across all 1,534 calling methods is 16, the mean is 3.7, and no method is above 40 anymore. The family has 213 implementors on this store, so the erased-family blowup is simply gone rather than shifted. My counted test fixture confirms the semantics at cell level: the through-interface call site retains the interface member and the type-correct implementor, and the type-impossible implementor is filtered. Three runs on the settled store, identical verdicts, no other cell regressed. One expected-behavior note from the same rebuild: the extractor version bump in the last commit did its job, the freshly stamped store carried the type-argument evidence from the first cold pass with no manual reindex. |
zzet
left a comment
There was a problem hiding this comment.
Thanks for the substantial work here—the direction is valuable and the existing tests are thoughtful. I need to request changes because exact-head fixtures at 3cb11cc demonstrate recall-breaking false negatives. The hosted checks and affected-package race tests are green, but they do not cover the cases below.
[P1] Preserve dispatch for variant generic interfaces
Location: internal/resolver/csharp_iface_dispatch.go:421
Reproduction:
class Animal {}
class Dog : Animal {}
interface ISource<out T> { T Get(); }
class DogSource : ISource<Dog> {
public Dog Get() => new();
}
class Flow {
ISource<Animal> _source = new DogSource();
Animal Pull() => _source.Get();
}This compiles, but the exact-head resolver drops DogSource.Get because Dog != Animal. The analogous ISink contravariant case also fails.
Proposed solution: extract generic-parameter variance on interface nodes. Until subtype-aware assignability exists, apply the equality gate only when all relevant parameters are explicitly invariant; preserve full fan-out for interfaces containing in or out. Add covariance and contravariance end-to-end tests.
[P1] Do not share receiver arguments between different calls on one line
Location: internal/resolver/csharp_iface_dispatch.go:668
Reproduction:
interface IBoxStore<T> { int Fetch(); int Save(); }
class Flow {
IBoxStore<Crate> _crates;
IBoxStore<Widget> _widgets;
int Pull() => _crates.Fetch() + _widgets.Save();
}After binding both interface calls, the PR synthesized CrateBoxStore.Fetch and CrateBoxStore.Save; WidgetBoxStore.Save was lost. The cache key contains caller/file/line/short interface name, so the first lookup poisons the second. The same-name ambiguity marker cannot catch Fetch versus Save.
Proposed solution: include the resolved member (e.To), full interface ID, and preferably receiver identity in the cache key, or remove this cache. Add this different-member/same-line fixture beside the existing same-name ambiguity test.
[P1] Do not infer a field through a shadowing parameter or local
Location: internal/resolver/csharp_iface_dispatch.go:693-733
Reproduction:
class Flow {
readonly IBox<Crate> _box;
int Pull(IBox<Widget> _box) => _box.Get();
}The extractor emits a companion with receiver_name="_box"; csharpReceiverField then finds Flow._box by text even though the parameter shadows it. The exact-head result retained only CrateBox.Get and dropped the valid WidgetBox.Get.
Proposed solution: require binding evidence that the receiver resolves to that field—ideally a stamped field node ID, or at least an exact same-site field-read edge. A same-name node lookup alone must return unknown when a local or parameter may shadow it. Add parameter- and local-shadow regression tests.
[P1] Canonicalize C# type identity before declaring an argument closed
Locations: internal/parser/languages/csharp_base_type_args.go:139-173 and 205-223
Reproductions that compile but lose valid dispatch targets:
IBox<dynamic> a = new ObjectBox(); // ObjectBox : IBox<object>
IBox<nint> b = new IntPtrBox(); // IntPtrBox : IBox<System.IntPtr>
IBox<@Crate> c = new CrateBox(); // CrateBox : IBox<Crate>
IBox<global::Crate> d = new CrateBox(); // same Crate type
class Relay<T> : IBox<@T> {
public T Get() => default!;
}dynamic/object, native-integer spellings, verbatim identifiers, and global:: qualification are type-identical forms. IBox<@t> is also open, but the PR stamps @t as a closed argument and can exclude Relay from valid fan-out.
Proposed solution: normalize verbatim identifiers and global:: or alias qualifiers before consulting the open-parameter and using-alias sets, then canonicalize dynamic/object, nint/IntPtr, and nuint/UIntPtr. Add both equivalent-concrete and escaped-open-parameter tests.
[P1] Account for project-wide global aliases
Location: internal/parser/languages/csharp_base_type_args.go:61-98
Reproduction:
// Global.cs
global using Entity = App.Crate;
// Store.cs
class CrateBox : IBox<Crate> {
public Crate Get() => new();
}
// Flow.cs
class Flow {
IBox<Entity> _box = new CrateBox();
Entity Pull() => _box.Get();
}The alias is legal in Flow.cs, but the new ancestor-only scan cannot see aliases declared in another compilation unit. It stamps Entity versus Crate and suppresses CrateBox.Get.
Proposed solution: preserve project-global alias metadata and consult it before filtering, either canonicalizing the alias target or conservatively refusing the stamp. Add a three-file regression test.
[P2] Stamp positional-record properties too
Location: internal/parser/languages/csharp.go:948
Reproduction:
record Flow(IBox<Crate> Store) {
Crate Pull() => Store.Get();
}The synthesized positional property gets field_type but no field_type_args, so both Crate and Widget implementations remain in the fan-out.
Proposed solution: reuse csharpSimpleTypeArgsFromText when emitting positional-record properties and add extractor plus end-to-end coverage.
[P2] Avoid repeated whole-scope alias scans
Locations: internal/parser/languages/csharp_base_type_args.go:61-98; callers in csharp.go:1483, 1554, and 1847
Reproduction: generate one namespace containing 2,000 sibling classes, each with one IBox field, then time extraction. On the same machine and fixture, the base took about 1.18 seconds and this head took 2.94–3.30 seconds. Each field, property, and base rescans all compilation-unit or namespace siblings, giving quadratic growth on generated C# files.
Proposed solution: cache alias sets once per compilation unit or namespace and merge only the enclosing type-parameter names per declaration. Add a scaling benchmark.
[P2] Index companion edges once per caller
Location: internal/resolver/csharp_iface_dispatch.go:708
Reproduction: a counting graph store with one caller containing 20 through-interface sites observed 20 full GetOutEdges(caller) scans, followed by one point node lookup per site. With caller out-degree growing with the number of sites, this path is quadratic.
Proposed solution: build a per-caller companion index keyed by (file,line,member) once and cache receiver-field results by (owner,name). Add a counting-store regression or benchmark.
Non-blocking surrounding-code follow-up
Location: internal/parser/languages/csharp.go:1999-2022
class Store1 : App.IBox<Crate> {}
class Store2 : global::App.IBox<Crate> {}The first currently emits unresolved::App with target_fqn=App.IBox; the second emits no base edge. This predates the PR, but limits the qualified generic-base coverage the new stamping code relies on.
Proposed solution: make csharpBaseTypeName descend to the final generic name and handle alias-qualified and global-qualified names, with an end-to-end hierarchy test. I am fine with tracking this separately because it was not introduced here.
The C# extractor-version bump and migration tests look correct. I found no security, secret-handling, authorization, or new dead-code issue. Once the P1 false-negative paths are fixed, I will be happy to re-review.
…tity Two different member calls sharing a line each carry their own receiver companion, but the lookup cache keyed only caller/file/line/short-iface - the first call's declared arguments poisoned the second call's gate and dropped the other implementor's usage. The key now includes the resolved member and the full interface ID.
…vidence A bare same-name lookup bound a shadowed receiver identifier to the field it shadows and gated the fan-out on the wrong declared arguments. The field-identifier emitter already refuses shadowed identifiers, so its EdgeReads at the exact call site is the binding proof the lookup was missing - without it the receiver stays unknown and the site keeps the full fan-out.
ISource<out T> makes ISource<Dog> assignable to an ISource<Animal> receiver, so closed-and-unequal arguments do not prove the implementor is unreachable - the equality gate models invariant parameters only. The extractor now stamps variant_type_params on an interface whose parameter list declares in/out, and the family build skips arg stamping entirely for those interfaces: every site keeps the full fan-out.
…lias spellings Equivalent spellings of one constructed interface compared unequal and armed the closed-and-unequal gate against a real dispatch target: global::App.Crate vs Crate, @crate vs Crate, IBox<dynamic> vs IBox<object>, IBox<nint> vs IBox<System.IntPtr>. The normalizer now reduces alias/global qualifiers to the final segment and strips the verbatim @ BEFORE the open-parameter check - so IBox<@t> reads as the open parameter T and stamps nothing - and the canonical fold adds dynamic->object and IntPtr/UIntPtr->nint/nuint.
…s that spell them A global using alias declared in one file renames a type in EVERY file of the project, but the stamp-time alias scan walks only the declaring file's ancestor chain - a receiver spelled IBox<Entity> and an implementor spelled IBox<Crate> stamped unequal spellings of one constructed interface and the gate suppressed the real target. The extractor now records global alias names on the file node (global_using_aliases), and the dispatch pass collects them once per run and refuses any stamp naming one - opaque spelling, never filter.
The variance stamp (variant_type_params), the global-using-alias stamp (global_using_aliases), and the widened argument canonicalization all change extraction output for unchanged files. A store already running this branch's version 13 would keep the old stamps and the gate would filter variant families forever - the bump forces the re-extract; the salt pin moves with it.
Every receiver lookup re-read the caller's full out-edge list - the companion scan and the field-read evidence scan each paid one GetOutEdges per call site, ~2N reads for a method with N through-interface sites. A per-pass lookup context now reads each caller's adjacency once and serves every site from it, and caches resolved field nodes by ID.
…t stamps Every field, property, and base-list stamp re-walked its ancestor chain and rescanned the enclosing namespace's whole declaration list for alias directives - quadratic in sibling count (1.9s on the review's 2,000-sibling fixture, 0.37s after). The alias names are now collected in one walk per extraction and threaded to the stamp sites; the ancestor walk keeps only the cheap per-declaration type-parameter half. Scope widening is deliberate and recall-safe: treating every alias in the file as in scope everywhere can only refuse MORE stamps, which preserves fan-out edges.
…able gate shapes Three sweep pins beyond the review findings, all already-correct behavior worth locking: a property receiver gates exactly like a field (properties mint KindField and ride the same read-edge evidence), a transitive implementor is unstamped against the root interface and never filtered, and a nullable-annotated spelling stamps nothing (folding Crate? to Crate would be right for reference types but wrong for value types).
|
All five P1 findings and both P2s are addressed, one commit per finding on
Extractor version bumps 13->14 in the same change: the variance and alias The two P2s:
Beyond the findings, one sweep commit pins three adjacent shapes that were Verification: the full race suite's failure set is byte-identical to my |
zzet
left a comment
There was a problem hiding this comment.
Thanks for the substantial follow-up. I re-reviewed exact head 0a3cfe9b4c8f15e2461dee6141110822a4fccd15. The earlier variance, different-member cache, ordinary parameter/local shadowing, standard type-spelling, ordinary global-alias, and scaling findings now appear fixed.
I still need to request changes because four compiling C# cases reproducibly remove valid dispatch targets. Since this optimization reduces an existing conservative fan-out, false negatives here are recall regressions.
[P1] Same-line field-read evidence can bind the wrong receiver
Location: internal/resolver/csharp_iface_dispatch.go:823-864
Reproduction
Add this source to the existing C# extractor → bind → interface-dispatch test harness:
sealed class Crate {}
sealed class Widget {}
interface IBox<T> {
int Get();
int Save();
}
sealed class CrateBox : IBox<Crate> {
public int Get() => 1;
public int Save() => 1;
}
sealed class WidgetBox : IBox<Widget> {
public int Get() => 1;
public int Save() => 1;
}
class Flow {
readonly IBox<Crate> _box = new CrateBox();
int Pull(IBox<Widget> _box) =>
this._box.Save() + _box.Get();
}The extractor emits the field read for this._box.Save() and the call for the shadowing parameter _box.Get() on the same line. csharpReceiverField accepts that unrelated read as proof that the parameter receiver is Flow._box.
Expected target for _box.Get(): WidgetBox.Get.
Actual synthesized target: CrateBox.Get; WidgetBox.Get is lost.
Proposed fix
The evidence needs to identify the exact receiver expression, not only file, line, and field ID. Prefer stamping a resolved receiver-field ID or shared AST site/span on the companion/call edge. Until such evidence exists, return unknown when same-line evidence is ambiguous and retain the conservative full fan-out.
Add a regression test with both this._box and a shadowing _box on one physical line.
[P1] Declaration-side escaped generic parameters are stamped as closed arguments
Locations:
internal/parser/languages/csharp_base_type_args.go:80-102internal/parser/languages/csharp.go:2332-2342
Reproduction
class Outer<@T> {
readonly IBox<@T> _box;
Outer(IBox<@T> box) => _box = box;
int Pull() => _box.Get();
}With CrateBox : IBox<Crate> and WidgetBox : IBox<Widget>, the receiver is open and must retain the conservative fan-out. Instead, the declaration set contains raw @T, use-side normalization produces T, and the extractor stamps field_type_args="T" as if it were closed. Exact-head dispatch returns no targets.
The equivalent declaration using a Unicode escape, class Outer<\u0054>, reproduces the same problem.
Proposed fix
Normalize declaration identifiers before inserting them into the unstampable/open-parameter set. At minimum strip the verbatim @ prefix and decode C# Unicode escapes. If decoding cannot be guaranteed, conservatively refuse to stamp any escape-bearing identifier.
Add extractor and end-to-end tests for declaration-side <@T> and <\u0054>; the current tests cover use-side spelling but not declaration-side spelling.
[P1] Alias keys and arguments are compared in different normalization domains
Locations:
internal/parser/languages/csharp_base_type_args.go:144-175internal/parser/languages/csharp.go:1626-1674
Reproduction A: verbatim alias
using @Entity = App.Crate;
// ...
class Flow {
readonly IBox<@Entity> _box;
int Pull() => _box.Get();
}This compiles, but the alias collector stores @Entity while the argument normalizer strips @ to Entity. The alias guard misses and CrateBox.Get is removed. The same failure occurs with global using @Entity = App.Crate;.
Reproduction B: cross-file alias with a foldable name
// Global.cs
global using Int32 = App.Crate;
// Flow.cs
class Flow {
readonly IBox<Int32> _box;
int Pull() => _box.Get();
}The receiver argument is canonicalized to int, while project-global alias metadata remains Int32. Exact-head dispatch again returns no target instead of CrateBox.Get.
Proposed fix
Use one canonical identifier representation for local alias sets, global-alias metadata, and resolver lookup. Perform alias recognition before assuming a name is a BCL synonym; aliases can legally shadow names such as Int32. Add local and cross-file tests for @Entity and a foldable alias name.
[P1] Comment trivia becomes part of generic type identity
Location: internal/parser/languages/csharp_base_type_args.go:289-312
Reproduction
class Flow {
readonly IBox</**/Crate> _box;
int Pull() => _box.Get();
}This is valid C#. The raw-text parser stamps field_type_args="/**/Crate"; comparison with CrateBox : IBox<Crate> therefore removes the valid CrateBox.Get target.
Proposed fix
Derive field/property type arguments from the parsed type AST, as is done for base-list arguments, rather than using raw source text as identity. A safe interim behavior is to refuse stamping whenever the raw argument contains comment/trivia or escapes. Add an extractor plus end-to-end comment-trivia regression.
[P2] Positional-record properties still omit field_type_args
Location: internal/parser/languages/csharp.go:919-967
Reproduction
record Flow(IBox<Crate> Store) {
int Pull() => Store.Get();
}After extraction, Flow.Store.Meta["field_type_args"] is absent. Dispatch consequently retains both Crate and Widget implementations instead of narrowing to the Crate implementation.
Proposed fix
Pass the file-alias/open-parameter context into emitCSharpRecordPositionalProps and apply the same conservative type-argument helper used by ordinary fields/properties. Add both a metadata assertion and an end-to-end dispatch test.
Test-quality issue: the dynamic fixture is not valid C#
Location: internal/resolver/csharp_iface_dispatch_generic_test.go:729-758
Reproduction
Put the test's implementation shape into a temporary .NET project and run dotnet build:
class DynBox : IBox<dynamic> {
public dynamic Get() => new object();
}The compiler reports CS1966: a class cannot implement a dynamic interface.
Proposed fix
Test the legal orientation:
class ObjectBox : IBox<object> {
public object Get() => new();
}
class Flow {
IBox<dynamic> _box = new ObjectBox();
object Pull() => _box.Get();
}This still validates the intended dynamic/object normalization using compilable source.
All ten hosted checks, focused package tests, affected-package race tests, and vet are green, but they do not cover the cases above. I found no security/authentication, hardcoded-secret, or new dead-code issue.
The qualified generic-base extraction limitation from the previous review also remains, but it predates this PR and is still non-blocking follow-up work.
DynBox : IBox<dynamic> is CS1966 - a class cannot implement a dynamic interface. The legal orientation (an IBox<dynamic> receiver consuming the IBox<object> implementor) exercises the same fold.
Comment trivia between the tokens of a declared type is legal C# and no part of type identity - the raw-text path stamped IBox</**/Crate> as field_type_args="/**/Crate" and the dispatch gate then filtered the valid implementor (re-review RED). The arguments now come from the type AST node, the same source the base-list stamps already use, and the argument normalizer refuses raw text carrying trivia so a qualified spelling with an embedded comment stamps nothing.
Declaration sets and use-side arguments met in different normalization domains (re-review RED): class Outer<@t> stored raw "@t" in the open-parameter set while the use side stripped the verbatim prefix to "T", so the open field stamped as CLOSED over a type called T and dispatch returned no targets; the Unicode escape spelling of the same declaration failed identically, and 'using @entity = App.Crate' stored "@entity" in the alias sets the normalizer could never hit. csharpCanonicalIdentifier (verbatim prefix strip + \uXXXX/\UXXXXXXXX decode, refusal on malformed escapes) is now applied at every set insertion - enclosing type parameters, file alias names, the global-alias file stamp - and in the use-side normalizer, so every legal respelling of one identifier meets the sets in one domain. A decoded argument that is not a plain identifier refuses outright.
'global using Int32 = App.Crate;' legally shadows a BCL name. The extractor cannot see the cross-file directive, so a receiver spelled IBox<Int32> stamps the folded "int" - and the global-alias guard then compared "int" against the stored alias name "Int32" in a different domain and never refused (re-review RED). The guard now indexes every comparable form of each alias - canonical name plus its BCL keyword fold - so a stamp that MAY spell the alias refuses. A genuine int keyword can never denote the alias, so the over-refusal only preserves edges, and only in projects that actually shadow a BCL name project-wide.
record Flow(IBox<Crate> Store) synthesizes the Store property with field_type but no field_type_args, so dispatch through a positional property never narrowed (re-review P2). The positional emitter now applies the same AST-derived conservative stamp ordinary fields and properties carry - record's own type parameters open, aliases opaque - with the unstampable set computed once for the parameter list.
…vers receiver_name's documented contract is "a receiver no local, param or builtin explains" - but parameters never ride the tenv, so a bare parameter receiver fell through to the stamp and the dispatch gate could bind it through the same-named field it shadows. The re-review RED makes it concrete: 'this._box.Save() + _box.Get()' on one line, where the field read emitted for this._box certified the shadowing parameter's receiver and filtered the valid WidgetBox.Get. The shadow indexes (parameter and declared-local names per owner) are now built before call emission and consulted at the stamp site, so a shadowed bare receiver stays unknown - the read-edge evidence can then only ever certify a receiver extraction says is field-eligible. The field-identifier emitter reuses the same indexes instead of rebuilding them. Ships with the full re-review e2e suite: same-line shadow, trivia, escaped declaration params, verbatim alias, foldable global alias, positional-record narrowing, and the member-companion bind helper the receiver-less cases need.
A store extracted at the previous revision keeps raw alias metadata, text-derived type arguments, unstamped positional-record properties, and receiver_name stamps on shadowed receivers - no content change would refresh them.
|
All four P1 paths, the P2, and the fixture issue are addressed at exact
|
The revision that moved field/property type-argument stamps onto the parsed type node left the string-returning wrapper with no callers; golangci-lint (unused) flags it. Its grammar note about the nested variable_declaration moves onto csharpFieldDeclTypeNode.
zzet
left a comment
There was a problem hiding this comment.
Re-reviewed exact head af81140a32707d52634355a51282cf8cacf41fec.
The four P1 paths from the last round — same-line field-read evidence, declaration-side escapes, alias normalization domains, comment trivia — and the positional-record P2 all verify fixed. Race suite on internal/parser/languages + internal/resolver + internal/indexer is green, golangci-lint clean. The round-1 quadratic alias scan is genuinely gone (measured below).
I still have to request changes. I ran a broad differential audit at head against merge base 3eb51c6d, and nine compiling C# shapes remove valid dispatch targets. I reproduced every one myself; the table under each finding is my own run, not a report.
The important thing is that they are not nine bugs. They are three root causes, and I think the third one is a signal about the design rather than a list of patches.
Root cause 1 — C# type node IDs are not unique, and the gate turns every collision into a filter
Type node IDs are filePath + "::" + name (csharp.go:863): no namespace, no generic arity. emitContainer's seen[id] guard (csharp.go:865) then silently drops the second declaration that lands on an existing ID. Before this PR that was a harmless over-approximation — two things merged, the fan-out stayed wide. Now every collision decides which evidence the gate filters on, and the loser's members are removed.
[P1] 1a. Same-file partial class parts collapse to one implements edge; the first closure gates both parts
Anchor: internal/parser/languages/csharp.go:1954 (if baseNameCount[name] == 1) — the duplicate guard is scoped to ONE declaration's base list, so it cannot see the sibling part. Consumed at internal/resolver/csharp_iface_dispatch.go:341/:356.
namespace App {
public class Crate { } public class Widget { }
public interface IBox<T> { void Put(T item); }
public partial class Store : IBox<Widget> { public void Put(Widget w) { } }
public partial class Store : IBox<Crate> { public void Put(Crate c) { } }
public class Flow {
private readonly IBox<Crate> _box;
public Flow(IBox<Crate> b) { _box = b; }
public void Pull(Crate c) { _box.Put(c); }
}
}Both parts mint Boxes.cs::Store; both Store -> IBox edges collapse to one, and only the first declaration's stamp survives (target_type_args:Widget).
| merge base | head | |
|---|---|---|
| targets | [Store.Put, Store.Put_L9] |
[] — empty |
The whole fan-out is gone, and which closure wins is source-order dependent. The same-name/different-arity twin (Store<T> / Store<T1,T2>, the Result/Result<T> idiom) collapses identically.
[P1] 1b. seen[id] returns before the variance stamp, disarming the variance guard
Anchor: internal/parser/languages/csharp.go:865-880 — if seen[id] { return } precedes meta["variant_type_params"] = true. Consumed at csharp_iface_dispatch.go:281-289.
This is the IEnumerable / IEnumerable<T> idiom — a non-generic interface beside its generic twin:
namespace App {
public class Animal { } public class Dog : Animal { }
public interface ISource { void Reset(); }
public interface ISource<out T> { T Get(); }
public class DogSource : ISource<Dog> { public Dog Get() { return null; } }
public class AnimalSource : ISource<Animal> { public Animal Get() { return null; } }
public class Flow {
private readonly ISource<Animal> _src; // may legally hold a DogSource
public Flow(ISource<Animal> s) { _src = s; }
public Animal Pull() { return _src.Get(); }
}
}| merge base | head | |
|---|---|---|
| non-generic twin present | [AnimalSource.Get, DogSource.Get] |
[AnimalSource.Get] |
| variant interface alone (control) | [AnimalSource.Get, DogSource.Get] |
[AnimalSource.Get, DogSource.Get] |
The control is the point: your variance guard is correct and works — a same-short-named sibling declaration silently deletes it, and the invariant equality gate then drops the covariant implementor. This is exactly the P1 you raised in round 1, reachable again through a different door.
[P1] 1c. Field node IDs collide the same way, so the gate reads a foreign type's field_type_args
Anchor: internal/resolver/csharp_iface_dispatch.go:907 (fieldID := ownerID + "." + name, ownerID from csharpEnclosingTypeID at :903).
// Result.cs — the Result/Result<T> (or Task/Task<T>) arity pair in one file
namespace App {
public class Result {
protected readonly IBox<Widget> _source;
public Result(IBox<Widget> source) { _source = source; }
}
public class Result<T> {
protected readonly IBox<Crate> _source;
public Result(IBox<Crate> source) { _source = source; }
public int Load(int id) { return _source.Get(id); } // receiver is IBox<Crate>
}
}| merge base | head | |
|---|---|---|
| targets | [WidgetBox.Get, CrateBox.Get] |
[WidgetBox.Get] |
The surviving target is the type-impossible one. Two types in two different namespaces in one file, or a nested type shadowing a top-level one, do the same.
Proposed fix for root 1. Two of these are patchable in place — move the variance evaluation out from behind seen[id] and OR it onto the existing node (variance is a refusal signal, so union is the conservative merge); make baseNameCount a per-node-ID prescan across every declaration in the file rather than per base list. 1c is not patchable that way: csharpReceiverField must prove the field it resolved belongs to the caller's own declaration. The cheapest sound check that needs no ID change is a span check — require the caller method's StartLine and the field's StartLine to fall inside the same type node's [StartLine, EndLine], and answer "" on any mismatch. The real fix is putting namespace and arity into the node ID, which is a bigger change than this PR should carry.
Root cause 2 — the stamp is per (type → erased interface), but a type can implement several constructions of one erased interface
implArgs[sub][ifaceID] (csharp_iface_dispatch.go:341) records only the closure on the type's own direct base-list edge, and :356 then paints that single closure onto every same-named member of the type. A class that legally implements two closed constructions of one generic interface loses the implementation of whichever construction did not stamp.
class C : IEnumerable<int>, IEnumerable<string> is the canonical legal form of this. CS0695 only fires when the type arguments contain type parameters that could unify; with distinct concrete types there is no conflict.
[P1] 2a. Second closure arrives through an inherited interface
namespace App {
public class Crate { } public class Widget { }
public interface IBox<T> { void Put(T item); }
public interface ICrateBox : IBox<Crate> { }
public class CrateBox : IBox<Crate> { public void Put(Crate c) { } }
public class WidgetBox : IBox<Widget> { public void Put(Widget w) { } }
public class Store : ICrateBox, IBox<Widget> {
public void Put(Crate c) { } // implements IBox<Crate>.Put
public void Put(Widget w) { } // implements IBox<Widget>.Put
}
public class Flow {
private readonly IBox<Crate> _box;
public Flow(IBox<Crate> b) { _box = b; }
public void Pull(Crate c) { _box.Put(c); }
}
}Only Store -implements-> IBox meta=[target_type_args:Widget] is recorded; the IBox<Crate> closure reached via ICrateBox is invisible, so both Put overloads are painted "Widget" and both are dropped.
| merge base | head | |
|---|---|---|
| targets | [CrateBox.Put, WidgetBox.Put, Store.Put, Store.Put_L10] |
[CrateBox.Put] |
[P1] 2b. Second closure arrives through a base class — the override that actually runs is dropped
Same shape with public class Store : CrateBox, IBox<Widget> where CrateBox : IBox<Crate> declares virtual void Put(Crate) and Store declares override void Put(Crate).
| merge base | head | |
|---|---|---|
| targets | [CrateBox.Put, WidgetBox.Put, Store.Put, Store.Put_L9] |
[CrateBox.Put] |
The kept target is the base method; the dropped one is the override that actually executes. For a virtual-dispatch answer that is the worst possible orientation.
[P1] 2c. A namespace-qualified spelling slips past the new duplicate guard
Anchor: internal/parser/languages/csharp.go:1954, root cause at csharpBaseTypeName's qualified_name branch (csharp.go:2064-2072).
public class Dual : App.IBox<Crate>, IBox<Widget> {
public void Put(Crate c) { }
public void Put(Widget w) { }
}
public class PlainCrateBox : IBox<Crate> { public void Put(Crate c) { } }csharpBaseTypeName scans only direct identifier children, so for App.IBox<Crate> — whose final segment is a generic_name — it returns the penultimate segment "App". baseNameCount therefore sees App=1, IBox=1, both entries stamp, and the guard never fires. csharpEntryTypeArgumentList descends correctly and pulls "Crate", so the two functions disagree about what the entry names.
| merge base | head | |
|---|---|---|
App.IBox<Crate>, IBox<Widget> |
[PlainCrateBox.Put, Dual.Put, Dual.Put_L7] |
[PlainCrateBox.Put] |
IBox<Crate>, IBox<Widget> (control) |
[PlainCrateBox.Put, Dual.Put, Dual.Put_L7] |
[PlainCrateBox.Put, Dual.Put, Dual.Put_L7] |
The control shows your guard works when both spellings are bare. This is also the qualified-generic-base extraction limitation you flagged as non-blocking in round 1 — it stopped being non-blocking the moment a filter started depending on it.
Proposed fix for root 2. A stamp is only usable when the implementor reaches the interface by exactly one closure. Walk every implements/extends path from sub to ag.ifaceID, collect target_type_args from each; if more than one distinct non-empty closure is found, or any path carries no stamp at all, refuse the stamp for that sub entirely. And fix csharpBaseTypeName to descend a qualified_name to its final segment.
Root cause 3 — site attribution is keyed on (file, line, name), and that key is not a site
Every step of the receiver join — receiver_name, the companion match, the EdgeReads evidence, the shadow refusal — is keyed on a physical line, a bare name, and function-scoped name sets. None of those identify a call site. Four shapes fall through, and one of them lands outside the gate entirely.
[P1] 3a. The shadow indexes know only two of C#'s binding forms
Anchors: csharp.go:690-706 (indexes), csharp.go:744-746 (the receiver_name gate), csharp_field_identifier.go:110-112 (same indexes as read-edge evidence).
localNamesByOwner comes only from the lvar.def capture (csharp.go:139-142), which matches local_declaration_statement alone; paramsByOwner (csharp_member_access.go:161) only from emitted KindParam nodes. Because the field-identifier emitter consults the same indexes, it also mints the EdgeReads that csharpReceiverField accepts as binding evidence — so both layers of the two-layer guard fail together. A two-layer guard whose layers share one index is one layer.
With readonly IBox<Crate> _box; on the enclosing type, all five of these drop the valid WidgetBox.Get and keep only CrateBox.Get:
| shape | merge base | head |
|---|---|---|
foreach (var _box in all) |
[CrateBox.Get, WidgetBox.Get] |
[CrateBox.Get] |
if (o is IBox<Widget> _box) |
[CrateBox.Get, WidgetBox.Get] |
[CrateBox.Get] |
map.TryGetValue(1, out var _box) |
[CrateBox.Get, WidgetBox.Get] |
[CrateBox.Get] |
all.Sum(_box => _box.Get(7)) |
[CrateBox.Get, WidgetBox.Get] |
[CrateBox.Get] |
using (var _box = Make(src)) |
[CrateBox.Get, WidgetBox.Get] |
[CrateBox.Get] |
(using var x = ...; IS covered — the parenthesized form is not a local_declaration_statement.) Pattern variables, out var and lambda parameters are idiomatic modern C#, and this fires whenever such a name coincides with a field name — precisely the injected-repository shape the PR targets (repo, store, handler, logger).
[P1] 3b. A receiverless implicit-this call steals a same-line field receiver's arguments
Anchor: csharp_iface_dispatch.go:882 (companion matched on file+line+member short name), paired with csharp.go:664 (if !c.isMember || c.receiver == "" { continue } — the receiver_ambiguous index skips receiverless calls).
public class Mid : IBox<Crate> { public int Get(int id) { return 1; } }
public class Flow : Mid {
private readonly IBox<Widget> _widgets;
public Flow(IBox<Widget> w) { _widgets = w; }
public int Pull() { return Get(1) + _widgets.Get(2); }
}The bare Get(1) is a call on this (a Flow, i.e. an IBox<Crate>). It carries no receiver meta, so the join matches the only unresolved::*.Get companion on that line — the sibling _widgets.Get(2) — and adopts its Widget closure.
| merge base | head | |
|---|---|---|
| targets | [Mid.Get, WidgetBox.Get] |
[WidgetBox.Get] |
Mid.Get — the actual callee — is dropped and the type-impossible implementor is the only survivor. Fix: seed the ambiguity index from every call at a line, keying on {c.name, c.line} with recv := c.receiver ("" for receiverless), so a differing receiver — the empty one included — marks the site.
[P1] 3c. Two members declared on one source line
Anchor: csharp_iface_dispatch.go:903-928; evidence produced at csharp.go:745-746.
public int B() { return 0; } public int A(IBox<Widget> _store, int id) { return _store.Get(id); }funcRanges.enclosing() ties the call to B, so the shadow check consults B's (empty) parameter set, stamps receiver_name:_store, and the gate filters on Flow._store's Crate closure instead of A's IBox<Widget> parameter.
| merge base | head | |
|---|---|---|
| targets on the attributed caller | [WidgetBox.Get, CrateBox.Get] |
[CrateBox.Get] |
The misattribution itself predates the PR; what is new is that it now removes a target. You already have the right primitive for this — receiver_ambiguous. Refuse receiver_name when more than one equal-span func range covers the line.
[P1] 3d. Outside the gate: the shadow refusal flips an extension-method overload
Anchor: csharp.go:746; the victim is internal/resolver/csharp_applicability.go:205 csharpCallIsStaticForm.
localNamesByOwner is function-wide, so a local in a nested block that does not shadow anything at the call site still vetoes receiver_name. Its other consumer then loses the evidence that distinguishes the static form of an extension call from the instance form, and the arity window slides by one this slot:
static class BagExt {
public static void Add(this Bag b, int x) { }
public static void Add(this Bag b, int x, int y) { }
}
class Use {
public void M(Bag bag) {
if (bag != null) { var BagExt = 1; Console.WriteLine(BagExt); }
BagExt.Add(bag, 5); // static form: must bind Add(this Bag, int)
}
}| merge base | head | |
|---|---|---|
| nested-block local present | F.cs::BagExt.Add |
F.cs::BagExt.Add_L6 |
| no local (control) | F.cs::BagExt.Add |
F.cs::BagExt.Add |
Head binds the three-parameter overload for a two-argument call. This one has nothing to do with generics or dispatch — it is a resolution regression in a neighbouring subsystem, and it is the finding I would most want fixed regardless of what happens to the gate.
Proposed fix for root 3. The refusal is right; the scope is wrong. Record each deferred local's enclosing block byte range and refuse only when the call offset falls inside it; attribute locals declared in lambda / local-function bodies to that body. That fixes 3d directly and narrows 3a. But 3a, 3b and 3c all really want the same thing: the receiver stamped as a resolved field node ID at extraction, where the binding scope is actually visible — rather than a spelling re-joined later on (file, line, name).
[P2] The dispatch pass is now quadratic in per-caller call-site count
Anchor: csharp_iface_dispatch.go:884 and :916.
Your round-1 P2 fix cached the caller's adjacency read (callerOutEdges), but each call site still linearly rescans that cached slice twice — once for the companion, once for the field-read evidence — and csharpReceiverDeclaredArgs's cache key includes e.Line, so no site ever reuses another's work. With S sites a caller has ~3S out-edges, giving O(S²).
Measured, one caller with S through-interface sites, dispatch pass only, min of 3:
| S | base ns/op | head ns/op | head allocs |
|---|---|---|---|
| 200 | 2,153,500 | 1,359,917 | 5,733 |
| 400 | 3,911,070 | 3,152,209 | 11,576 |
| 800 | 12,568,917 | 8,184,667 | 23,226 |
| 1600 | 15,531,264 | 23,326,403 | 46,526 |
| 3200 | 32,093,513 | 77,986,889 | 93,087 |
Head is faster below the crossover (~S 400–800) because the gate mints roughly half the edges — head allocations are 93k against base's 160k at S=3200. Above it, head grows 3.3× per doubling against base's 2.1× while doing half the allocation work; the excess is scanning. In fairness, S in the high hundreds inside a single method is not a realistic shape, so the practical impact is small — I raise it because it is the same P2 from round 1 and the fix addressed the store read rather than the scan. Bucketing the caller's evidence by exact site once per caller closes it.
The number this is being merged for
The two live measurements in this thread disagree by two orders of magnitude, and I would like to reconcile them before taking on this much surface area:
- Round 1 (
3cb11cc): 94,858 dispatch edges "where every previously recorded full pass on the same store family landed between 230k and 243k, so the gate removes roughly 60 percent"; per-method distinct targets 40–70 → max 16. - Round 3 (head): "compared with an ungated control store built the same way: the dispatch fan-out goes 95,073 to 94,833 (the gate's entire net effect, -240 type-impossible edges)."
The round-3 number is the controlled one — same build, gate on versus off — and it puts the gate's effect at −0.25%. The round-1 comparison was against historical passes on a differently-built store and does not isolate the gate. Reading those two together with what this review found, the most plausible explanation is that the round-1 −60% was largely produced by the then-unsound bare-name receiver lookup that round 2 required to be evidence-gated: most of that reduction was false negatives, and −240 is the honest remainder.
If that reading is right, the trade being offered is roughly 2,800 lines and three rounds of subtle type-identity code for −240 edges, against nine recall regressions that keep arriving through new doors. I do not think the idea is wrong — a per-entity-repository codebase genuinely should not fan IRepo<Order> into IRepo<Customer>. But the evidence the gate needs (unique type identity, per-construction implements edges, resolved receiver bindings) does not exist in the graph yet, and every round has been spent approximating it with spellings and line numbers.
My suggestion, and it is a suggestion rather than a request: land the parts that stand on their own now, and let the gate wait for the evidence.
- The
receiver_nameshadow contract fix is a real correction — 41,849 stamps violated the function's own documented contract. It needs the block-scoping fix in 3d first. - The extractor version bump is worth landing on its own: it retroactively covers #668's field-identifier edges, where an in-place-upgraded store never re-extracted.
- The variance stamp and
csharpHasVariantTypeParamsare correct and independently useful. csharpBaseTypeNamedescending to the final generic segment fixes a real pre-existing extraction bug.
Then the gate returns when a receiver can carry a resolved field node ID and an implements edge can carry its own construction — at which point most of this review stops applying, rather than being patched around.
I want to be clear that I would also accept the other answer. If you would rather fix all nine and keep the gate in this PR, that is a legitimate call and I will review it again on the same terms.
What held up
Worth recording, because a lot of this round's work was verifying things that turned out to be correct:
- The round-1 quadratic alias scan fix holds. Across five generated fixture families (N sibling classes at 250/500/1000/2000/4000, N fields in one class, aliases 1/50/200 crossed with fields, nested generic declarations, wide base lists), head tracks base within a few percent with an identical growth curve — 81.8/215.8/648.2 ms head against 76.6/206.1/681.8 ms base at N=250/500/1000. The superlinear shape present in both trees is pre-existing
ExtractDocAbove(48–71% of extraction in every profile), not this PR. The new helpers total ~1.8–3.8%. Meta["field_type"]is byte-identical to base across every field shape I could construct — multiple declarators, arrays, jagged and multi-dimensional, pointers, fixed buffers, function pointers,ref/requiredfields, nullable, tuples, nested generics, attributes and modifiers and comments split across lines, and the grammar-revision fallback branch — plus 390 C# fixtures harvested from the repo's own test files.csharpFieldDeclTypeNodeis a clean refactor.- The usings metadata is unchanged and
global_using_aliasesis purely additive. - The spelling normalizations are sound. The BCL fold table is reflexive on both sides of the gate (
int/Int32/System.Int32,string/String,nint/IntPtr,dynamic/object); a user type namedInt32only ever creates matches; qualified,global::and nested-type arguments reduce to the same last segment on both sides; alias-qualified bases and self-nested arguments refuse symmetrically. - The variance stamp itself is correct across 15 legal spellings (
out/in/mixed parameter lists) — it is only theseen[id]ordering in 1b that deletes it. - Most of the declaration surface is clean and conservative: C# 12 primary constructors on classes mint no field node so the gate never arms; record / record-struct / record-class positional properties, interface default-implementation members, expression-bodied,
init-only andrequiredproperties, static classes and fields, overloaded and static constructors, explicit interface implementations, local functions, base-class fields, partial classes across different files,new-hiding fields, nested types inside generic outers, and generic constraint clauses all behave correctly or refuse to filter. - The multi-line-receiver hypothesis does not reproduce — the call edge, the companion and the
EdgeReadsall agree on the line.
No security, secret-handling, authorization, or new dead-code issue.
Every fixture above ran at head and at merge base 3eb51c6d; the tables are observed output.
localNamesByOwner was keyed on the enclosing function, so a local declared in a nested block vetoed the receiver_name stamp for every call in the method - including calls placed after that block had closed, where the local cannot bind at all. That stamp is the only evidence telling the binder a call is the STATIC form of an extension call, where the first argument fills the `this` slot rather than the receiver. Without it the binder reads the call as extension form, subtracts a slot the argument list had actually filled, and lands one parameter too wide. Nothing about this involves generics or interface dispatch. Locals now carry the byte extent of the block that declares them and calls carry their start offset, so the refusal asks whether a name is bound at this site rather than somewhere in this function. A declaration with no enclosing block keeps an unbounded extent, so an unrecognized shape can only keep the old refusal, never lose one. The field-identifier emitter shares the index and deliberately keeps asking the function-wide question: its input buffers do not all carry a byte offset, and answering a narrower question without a real coordinate would open a hole rather than close one.
csharpBaseTypeName scanned only a qualified_name entry's direct identifier children and took the last one. That is correct for System.Object, but a qualified name whose final segment is itself generic spells that segment `generic_name`, not `identifier` - so the scan walked past it and returned the penultimate segment, the namespace. `App.IBox<Crate>` extracted as "App". Two consequences, both pre-existing. The I-prefix discrimination sees a namespace rather than an interface name, so the entry lands on the wrong edge kind. And csharpEntryTypeArgumentList descends correctly and pulls "Crate", so the two functions disagree about what the base entry names - which matters now that a duplicate guard keys on one and a filter consumes the other. The suite had "generic interface strips type arguments" and "qualified base name reduced to simple name" as separate cases and never crossed them; the new subtest is that crossing.
emitContainer keys a type node on filePath + "::" + name, so a generic interface and a non-generic twin of the same name collide and the second declaration is dropped. The variance stamp was evaluated after that `seen[id]` return, so when the generic twin lost the race its `out`/`in` parameters were never recorded anywhere. Variance is what disarms the closed-and-unequal equality gate. Losing it re-arms an invariant-only filter over a covariant family and drops the covariant implementor - the same class of false negative the variance guard was added to prevent, reached through a different door. The IEnumerable / IEnumerable<T> pairing makes this an ordinary shape rather than a corner case. Variance is now evaluated before the return and ORed onto whichever node survives. Union is the conservative merge: the stamp only ever widens a fan-out, so a declaration carrying it can never make the result narrower than it would have been without the collision. The test carries the control - the same source with the twin removed - because that control passing is what proves this is a node-identity collision rather than a variance bug. It asserts the EXACT target set rather than membership: a gate fails by removing a valid target, and a membership assertion stays green while the set shrinks around the one element it names.
The duplicate-target guard counted base-list entries within a single declaration. Same-file partial parts are two declarations of one type: each base list reads as unambiguous, both stamp, and because the type node ID carries no arity and no namespace the second declaration is dropped at the seen[id] return - so only the first closure ever reaches the graph. The gate then paints that one closure onto every same-named member of the type and filters the overloads implementing the other closure. The whole fan-out disappears, and which closure survives depends on source order, so the result is unstable under reordering as well as wrong. The count is now a file-level prescan keyed by type node ID, so every declaration sharing an ID contributes. The prescan walks base_list nodes and reads the parent declaration name rather than enumerating declaration node types, which differ across grammar revisions. A type ID the prescan cannot attribute counts 0 and stamps nothing, so an unrecognized shape keeps the full fan-out. The arity twin (Result / Result<T>) and the two-namespaces-in-one-file shape collapse through the same door and are fixed by the same count.
The stamp was read from the implementor's own direct base-list edge and then painted onto every same-named member of the type. A type may legally implement several constructions of one erased interface - `class C : IEnumerable<int>, IEnumerable<string>` compiles whenever the arguments cannot unify - and the constructions need not arrive through the type own base list at all: an inherited interface (ICrateBox : IBox<Crate>) or a base class carries one just as well. Those paths were invisible, so the members implementing them were filtered against a closure they do not have. In the base-class case the kept target is the base method and the dropped one is the override that actually executes, which is the worst possible orientation for a virtual-dispatch answer. The hierarchy is now walked for every path from the implementor to the interface. The rule is deliberately asymmetric: only the implementor own direct closure can QUALIFY it for filtering, while a disagreeing closure anywhere up the hierarchy - including an unstamped one, which means a construction we cannot read - DISQUALIFIES it. A transitive descendant was never filterable and still is not. The precise guarantee is that the TARGET filter is monotonically weakened: a member's stamp is either the same string as before or refused. A site whose bound member loses its stamp then falls back to receiver-declared evidence on the source side, so the end-to-end edge set is not a strict superset of the old one - in the shapes measured the fallback verdict is the more correct one, because the old source-side read painted one arbitrary closure over a multi-closure type, the same defect this commit fixes on the target side. An earlier draft let transitive evidence qualify as well, which started filtering transitive implementors that the documented conservative rule had always preserved; the existing pin for that rule caught it. Unstamped hierarchy edges are recorded alongside stamped ones, since absence of a closure has to be distinguishable from absence of an edge.
Closed by the extractor descending a qualified base name to its final segment, not by new behavior here - with the name right, the per-base-list duplicate count already fires, since both spellings of the reported shape sit in one base list. Pinned end to end because the extractor unit test alone does not prove the gate stops filtering. `class Dual : App.IBox<Crate>, IBox<Widget>` lists two constructions of one interface in its own base list. The guard counts entries by name and the name extractor returned "App" for the qualified entry, so the counts read App=1, IBox=1 and both entries stamped. The control with both entries spelled bare is kept alongside, so a future regression in the qualified path stays distinguishable from one in the guard itself.
The receiver lookup assembled the field ID from the caller enclosing type ID plus the receiver name. Type node IDs carry no arity and no namespace, so an arity twin (Result / Result<T>) or a same-file namespace twin mints the same field ID for two different fields, and only one field node survives extraction. A caller in the other declaration then gated on the survivor field_type_args - a foreign type declared closure - and the implementor it kept was precisely the type-impossible one, while the one its own receiver could hold was dropped. The lookup now requires the caller method StartLine and the field StartLine to both fall inside the owner type node line span, refusing on any mismatch or a missing span. The surviving type node spans one declaration, so a caller or a field contributed by the twin fails the check from either side; refusal keeps the receiver unknown and the site keeps its full fan-out. This is the cheapest sound check that needs no ID change. The real fix is namespace and arity in the node ID itself, which is a bigger change than this PR should carry. Cross-file partials are unaffected: field IDs embed the file path, so those lookups already missed. fieldNode/fields/fieldSeen become nodeByID/nodes/nodeSeen - the cache now serves field, owner-type and caller-method fetches alike.
The shadow indexes were fed by exactly two binding forms - the local_declaration_statement capture and emitted KindParam nodes. C# binds names five more ways that matter here: foreach variables, declaration patterns (`o is T x`, `o is var x`), out-var declaration expressions, lambda parameters, and the parenthesized `using (var x = ...)` resource. (`using var x = ...;` is an ordinary local declaration and was already covered.) A name bound by any of them was invisible to the refusal. When it coincided with a field name, the site stamped receiver_name AND the field-identifier emitter minted the read-edge evidence off the same index - both layers of the two-layer guard failing together, since a two-layer guard whose layers share one index is one layer. The gate then filtered on the field's closure at a site whose receiver is the bound name, not the field. These forms are idiomatic modern C#, and the collision fires exactly on injected-repository-style field names (`repo`, `store`, `handler`) - the shape the gate exists for. Extents follow the language: a declaration pattern and an out-var escape to the enclosing block (definite-assignment scoping), while a foreach variable, lambda parameter, and using resource bind only over their own statement or lambda - so a call after the statement is back on the field and keeps its evidence. The walk reads the grammar's field names (left/name/parameters) verified against tree-sitter-c-sharp v0.23.5 node-types, rather than extending the prepared query - a wrong node type in the query would panic extractor construction, while an unmatched type here simply collects nothing.
The index that marks unattributable call sites skipped every call without a spelled receiver. A receiverless call is a call on `this`, and `return Get(1) + _widgets.Get(2);` puts one on the same line as a field-receiver call of the same member name. The bare call's bound edge carries no receiver meta, so the receiver join falls back to the line's only `unresolved::*.Get` companion - the sibling's - and adopts a Widget closure for a call whose real receiver is the enclosing type, an IBox<Crate>. The actual callee is dropped and the type-impossible implementor is the only survivor. Every call now seeds the index, the receiverless form as the empty receiver: an empty receiver differing from a spelled one is exactly as disqualifying as two spelled receivers differing. The stamp itself still lands only on member companions, so nothing new is emitted - sites that used to lend evidence across calls now refuse it. The `this.`-qualified spelling of the same line needed nothing here: it is captured with `this` as a spelled receiver, so those calls already seeded the index and already marked the line ambiguous.
Two members declared on one source line tie for the innermost func
range, and enclosing() breaks the tie by extraction order - so a call
inside the second member is attributed to the first. The shadow
refusal then consults the wrong member's parameter set: for
`public int B() { return 0; } public int A(IBox<Widget> _store, ...)`
the call on A's parameter checked B's empty set, receiver_name
stamped, and the gate filtered on the same-named FIELD's closure.
The misattribution predates the gate; what is new is that it removes
a target. The fix rides the primitive already built for
unattributable sites: ambiguousAt reports an equal-span tie at the
call's line, and the site is stamped receiver_ambiguous - the same
verdict the same-line same-name receiver conflict gets, arrived at
from the other side. There the receivers are unattributable to calls;
here the caller is unattributable among members.
Nested shapes (a local function inside a method) are ties of coverage,
not of span, and stay unambiguous - the innermost genuinely owns the
call, so nothing there changes.
The round-1 P2 fix cached the caller's adjacency READ, but every call site still rescanned the whole cached slice twice - once for the companion join, once for the field-read proof - and the args cache keys on the site, so no site ever reused another's scan. With S sites and ~3S out-edges per caller the dispatch pass was quadratic in the caller's site count. The caller's evidence is now bucketed by exact (target, file, line) once, so each site's consultation is two map probes plus a walk of its own companion bucket, which a real store holds at one edge (same-site same-name companions dedupe on the stored key). Bucket order preserves adjacency order, so the companion walk sees edges exactly as the slice scan did and the semantics are unchanged - the full suite agrees. One caller, S through-interface sites, min of 3, this machine: S before after 200 401 us 361 us 800 3,602 us 1,391 us 3200 36,931 us 5,685 us Before grows 9-10x per 4x sites; after grows 3.9-4.1x - linear. The benchmark pins the growth-curve shape, table-driven over S. Allocs rise ~18% from the bucket maps; the trade is the curve. buildCSharpResolverGraph widens to testing.TB so the benchmark builds the same extractor-produced shape the tests do.
csharpLocalScopeOf climbed to the nearest `block`. A binder in a scope the grammar does not spell as a block - a switch section, a switch-expression arm, a loop condition, an expression-bodied lambda - therefore got the whole METHOD BODY as its extent, and the shadow refusal fired at calls its name can never bind. That is the receiver_name over-refusal shape again, re-introduced for exactly the binding forms the previous commit added to the index; the victim is the extension binder's static-form evidence, same as before. The climb now stops at the first scope-forming ancestor. `if_statement` is deliberately not one: a pattern variable declared in an `if` condition escapes to the enclosing block (definite-assignment scoping), so the block extent is its correct one - which is why `block` remains the default stop. Loop headers do not leak their pattern variables past the statement, and a switch section is its own declaration space. One of the six pinned shapes - a local declared in a switch section - predates the index widening: C# scopes it to the switch block, and the block climb had walked past that too.
The previous widening covered five forms; C# binds names more ways
than that, and each missed one reproduces the same false filter when
its name coincides with a field: catch variables, for-initializer
declarations (a bare variable_declaration the lvar capture cannot
see), anonymous-method parameters, query range variables (from/let/
join), local function parameters (a local function mints no function
node, so paramsByOwner is blind to them), recursive-pattern
designations, parenthesized designations, and deconstruction
declarations.
Two of the forms do not parse the way the grammar's node-types
suggest, so the collectors follow the actual trees:
- `var (a, b) = t;` is a variable_declarator carrying a
tuple_pattern; the names live inside the pattern, and the
declarator's only direct identifier child is the INITIALIZER.
- `o is var (a, b)` misparses as an invocation of the is_expression
- `(o is var)(a, b)` - so the designation's names land in an
argument list. The collector recognizes exactly that shape.
Extents follow the language again: catch variables bind over their
catch clause, for-initializer names over the statement, delegate and
local-function parameters over their bodies, query range variables
over the whole query expression, and the pattern designations escape
to the enclosing scope.
`using BX = App.IBox<Crate>;` then `class Dual : BX, IBox<Widget>` is the alias spelling of the duplicate-base shape. The name extractor sees "BX", the duplicate count reads BX=1, IBox=1, and the bare entry stamps its closure onto a type that in truth closes the interface twice. The multi-closure walk cannot catch it either: unresolved::BX never resolves to the interface, so only one path is visible. An alias is an opaque spelling of some type - possibly a construction of the very interface a sibling entry closes - so a base list that contains one can never prove any entry's target unique. Such an entry now counts under a NUL-prefixed sentinel and the stamp site refuses every entry of that type. Refusal only ever preserves fan-out. Known boundary, deliberate: a GLOBAL using alias declared in another file is invisible at extraction time, so that spelling of the same shape still stamps. It shares the fate of the other cross-file alias domains: the resolver-side global-alias refusal covers stamped ARGUMENTS naming such aliases, but a base ENTRY spelled as one is out of reach until base targets resolve before stamping.
emitCSharpFieldIdentifierUses consulted the widened scope index with the function-wide question, so a lambda parameter or foreach variable ANYWHERE in a method deleted every read edge for a coinciding field name - a genuine `_box.Touch()` before the binder included. That is a find_usages recall loss on the field-read feature this PR's extractor version bump retroactively covers, and it fires precisely on the injected-repository field names the widened index now knows about. Call receivers already carry their byte offset and access receivers carry their node, so both now ask "shadowed at THIS site". Only the assignment buffer has no coordinate and keeps the function-wide question - which can only withhold a write edge, never invent one. Block-scoped binders (declaration patterns, out vars) are deliberately not in the survival pins: their name is in scope for the whole block, so a bare use of it before them is CS0841 and refusal stays correct.
The span check proves field ownership only when the colliding declarations occupy disjoint spans. A same-named type nested INSIDE its twin is legal C# (CS0542 bars only the immediate enclosing type's name) and places the dropped declaration's lines inside the survivor's span - so the caller and the foreign field both pass the check, and the gate filters on the wrong declaration's closure, keeping exactly the type-impossible implementor. No span heuristic can close that: the missing fact is the collision itself. emitContainer now ORs duplicate_decl onto the surviving type node whenever a second declaration lands on its ID - the same OR-onto-the-survivor move the variance stamp uses, folded into one helper - and the field-receiver lookup refuses any owner carrying it. A refusal covers every collision shape at once: arity twins, same-file partials, nested twins, namespace twins, and whatever shape arrives next through the same door. The span check stays: it also guards stores extracted before this stamp existed, where the flag is absent but disjoint spans still catch the common shapes.
Two hardenings from this round's adversarial review pass. The BFS enqueued every edge target including the interface being walked to, then kept walking upward past it. An edge from one of the interface's own supertypes back to the interface is by definition a cycle - unreachable from compiling source, but reachable through the node-ID collision channel (two same-short-named interfaces in one file merging onto one ID) - and it read as a disagreeing second path, disqualifying every descendant's stamp. The walk now stops at the interface: its supertypes are not paths to it. The function's doc claimed the walk "can only ever remove filtering power, never add it". That is true of the TARGET filter this function feeds - the return value is either the old direct-stamp string or "" - but not of the end-to-end edge set: the family loop also reads a bound member's stamp as the SOURCE side's receiver construction, and a refused stamp sends those sites to the receiver-declared fallback, whose verdict can differ. In the shapes measured the fallback is the more correct one (the old source-side read painted one arbitrary closure over a multi-closure type). The comment now states the precise property instead of the flattering one.
Both were fixed by the per-type-ID duplicate count and verified working, but only the partial-parts shape carried a test. A future narrowing of the count's key - arity or namespace, which is exactly the deferred "real fix" for type identity - would silently reopen these two while the partial pin stayed green. The assertion counts the colliding type's surviving members rather than naming overload-suffix IDs, since the suffix depends on fixture line numbers.
|
Taking the second answer: fix all nine and keep the gate. All nine are fixed, plus the P2, and then a three-reviewer adversarial pass over my own revision found five more defects of the same families, which are fixed as well - eighteen commits total at head Root cause 1 - ID collisions1b ( 1a ( 1c ( Root cause 2 - multiple constructions of one erased interface2a + 2b ( The narrowing is the "only its own direct closure qualifies" half. A first draft let transitively-derived closures qualify too, which is semantically sound on a complete graph but started filtering transitive implementors - and hierarchy targets settle across several resolver passes, so the graph a scoped pass sees is not always complete. The existing pin for the transitive-implementors-never-filter rule caught that draft immediately. Two honest limits on this commit, both from my review pass:
Your 2a fixture now keeps 2c ( Root cause 3 - site attribution3a ( Two forms do not parse the way the grammar's node-types suggest, so the collectors follow the actual trees: Your observation that the two guard layers shared one index was the operative fact: the widened index feeds both layers, so the receiver stamp and the read-edge evidence now refuse together instead of failing together. 3d (
3b ( 3c ( P2 (
|
| S | before (pre-bucketing) | after |
|---|---|---|
| 200 | 401 us | 361 us |
| 800 | 3,602 us | 1,391 us |
| 3200 | 36,931 us | 5,685 us |
Before grows 9-10x per 4x sites; after grows 3.9-4.1x. My reviewer's independent re-run reproduced the same growth families (the tight per-run ratios wobble; the superlinear-to-linear shape does not). The benchmark is committed and table-driven over S, so the curve is reproducible on demand - a Go benchmark asserts nothing, so "pinned" would be the wrong word. Allocations rise ~18% in count and ~32% in bytes from the bucket maps. One cost the benchmark's small fixture does not measure: the closure walk records unstamped hierarchy edges too, so wide hierarchies pay more per-pass allocation than the stamped-only map did - stated here so it is on the record.
The adversarial pass
Before filing this round I ran three independent reviewers over my own revision with instructions to break it - the same treatment your reviews give it, applied early. Beyond the fixes above (72c6691c, c958f20d, 82426ef6, 30c74861, 072a8596, b226eee4), the pass mutation-tested every fix: each one reverted in isolation turns its test RED with exactly your reported surviving target, and no test stays green with its fix reverted. It also caught two claims in my own commit messages that were wrong - a fix asserted for the this.-qualified steal variant that was never broken, and a commit credited for 2c that is not load-bearing - which are corrected in the history you are reading rather than left for you to find.
The number, and why the tests missed nine shapes you found
Your reconciliation reads right to me: the round-1 comparison was against differently-built stores and the -60% was largely the unsound bare-name lookup's false negatives; -240 / -0.25% from the controlled run is the honest figure - now stale in the gate-weakening direction per the open-generic-base note above, so it needs re-measuring at this head before it carries any merge argument.
On why my tests kept passing while your differential audits kept finding drops: I measured the assertion styles. The round's own test file held 43 assert.Contains and 13 assert.NotContains, and no exact-target-set assertion exists anywhere in the dispatch tests. Every membership assertion bounds the target set from one side, on elements it names; a gate fails by REMOVING targets nobody named, and that is invisible to one-sided bounds by construction. Your audits compared whole target sets at base and head, which is exactly the missing assertion, executed by hand. The precise version of the claim matters: a Contains does catch removal of the specific element it names - one of the old pins caught my own first draft of the closure walk that way - so the blindness is exactly the unnamed elements. Every test added in this round asserts the full expected target set. Retrofitting the existing membership assertions is real scope and I have left it out of this PR; I would file it as a follow-up unless you want it in here.
Verification
go test ./internal/parser/languages/ ./internal/resolver/after every commit: identical failure set to merge base3eb51c6don this Windows box (14 resolver JS/TS-import fails + 6TestSubprocessExtractor_*needingsh, both verified present at the merge base).go test -raceacrossinternal/parser/languages,internal/resolver,internal/indexer: zero data races; failure set identical to this box's pre-existing Windows baseline (32 indexer + 14 resolver + 6 subprocess-extractor).go vet: only the pre-existingunsafe.Pointernote ingrammar_load_windows.go.- golangci-lint is not installed on this box; relying on the hosted check.
|
Hey @pbednarcik. Can you please resolve the merge conflicts? |
|
@zzet - yes, on it |
Both sides bumped the csharp extractor (EF Core facts to 13 on main, the dispatch-revision stamps to 15 here); the merged extractor carries both change sets, so the version resolves to 16 and the salt test follows the policy-salt scheme main introduced.
The C# interface-dispatch synthesizer builds its implements-families over
erased interface targets, so a class implementing IBoxStore and a
class implementing IBoxStore land in ONE family and every
through-interface call fans out to both. On a per-entity-repository
codebase that inflates every data-access usage answer: my production C#
codebase has 189 implementors of one generic repository interface, and
single methods carry 40 to 70 distinct fan-out targets for their
repository calls, most of them type-impossible.
The fix threads the type arguments through as evidence, conservatively
gated on both sides so precision only ever improves - absence of
evidence anywhere keeps today's full fan-out:
The extractor stamps a generic base-list entry's CLOSED type arguments
on the implements/extends edge (Meta target_type_args) and a field or
property node's CLOSED declared-type arguments as field_type_args. The
open/closed rules live in one place, at extraction, where the full
syntax context is visible: an argument naming a type parameter of the
declaring type OR of any enclosing type stamps nothing (a type nested
in a generic outer closes nothing); an argument matching an in-scope
using alias stamps nothing (an alias may spell any type, so it is
opaque to a string comparison); a non-simple argument (nested generic,
array, nullable, tuple) stamps nothing; a base list closing the SAME
erased target twice (Both : IBoxStore, IBoxStore)
stamps neither, because the two entries collapse to one stored edge
and the ambiguity would be invisible downstream; a qualified base
whose generic segment is not the final one (Outer.IInner) stamps
nothing. Arguments compare by type identity, not spelling: the BCL
alias forms fold to the keyword canonical (System.Int32, Int32 and
int all stamp "int"), mirroring the resolver's existing suffix-trim
fold - folding can only create matches, and a match always keeps the
edge, so the fold is recall-safe.
The extractor also marks call sites where two same-named member calls
share one line (receiver_ambiguous): they dedupe to a single stored
edge carrying one arbitrary receiver's evidence, so no consumer may
apply that receiver's typing to the site.
The dispatch pass records the stamps per direct implementor while
building families, and derives the SOURCE site's own arguments: a
sibling site bound to a stamped implementor carries that implementor's
arguments; a through-interface site carries its receiver FIELD's
field_type_args stamp, located via receiver_name on the bound edge or
the extraction's unresolved companion edge for the same member name at
the same site, refused outright on ambiguous-marked sites, and only
accepted when the field's declared type names this family's
interface. A family with no stamps at all - every non-generic
interface, and the whole graph until a reindex - never pays the
receiver lookup and can never filter.
At fan-out, a site with known constructed arguments never fans into an
implementor stamped with DIFFERENT arguments - they implement
different constructed interfaces. Members without a stamp (the
interface member itself, open-generic implementors, transitive
implementors through a base class) always stay in.
The last commit bumps the C# extractor version so already-indexed files
re-extract and gain the new stamps - and it retroactively covers #668's
field-identifier edges, where the bump was missed (as caught in the #671
discussion): an in-place-upgraded store never re-extracted, so
find_usages on a field stayed empty there even after the view fix. One
bump re-extracts once for both changes.
Typed-local receivers are a named remainder: the extractor's tenv strips
generics before receiver_type is stamped, so local-receiver sites keep
the full fan-out until local type arguments are carried too. Field
receivers are the dominant production shape (injected repositories).
Tests, all watched fail first: extractor pins for every stamp rule
(closed, open via own or enclosing type parameter, using-alias
opacity, BCL alias folding across all three spellings, double closure,
qualified non-final generic segment, nested, multi-argument,
non-generic) plus field/property stamps and the same-line ambiguity
marker; seven end-to-end resolver tests driving the real extractor and
pipeline - through-interface filtering, sibling-site filtering,
open-generic implementors staying in, enclosing-type-parameter receivers
never filtering, ambiguous same-line sites never filtering,
multi-argument closures compared as one normalized list, and
System.Int32/int spellings retaining the edge while a genuinely
different closure still filters.
Full test suite on Windows matches my recorded baseline exactly, no new
failures.