Skip to content

resolver: generic type arguments gate the C# interface-dispatch fan-out - #677

Open
pbednarcik wants to merge 39 commits into
zzet:mainfrom
pbednarcik:fix/csharp-generic-dispatch-args
Open

resolver: generic type arguments gate the C# interface-dispatch fan-out#677
pbednarcik wants to merge 39 commits into
zzet:mainfrom
pbednarcik:fix/csharp-generic-dispatch-args

Conversation

@pbednarcik

Copy link
Copy Markdown
Contributor

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.

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.
@pbednarcik

Copy link
Copy Markdown
Contributor Author

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 zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@pbednarcik

Copy link
Copy Markdown
Contributor Author

All five P1 findings and both P2s are addressed, one commit per finding on
top of the reviewed head, each with your repro shape as the RED fixture.
Point by point:

  1. Receiver-cache poisoning. The lookup cache key now includes the resolved
    member (e.To) and the full interface ID, so two different member calls
    sharing a line each resolve their own receiver. Your _crates.Fetch() + _widgets.Save() shape is the pinned fixture; before the fix it reproduced
    in both directions (whichever call was processed first poisoned the other).

  2. Shadowing. The receiver-field lookup now requires binding evidence
    instead of trusting a bare name match: the field-identifier emitter already
    refuses shadowed identifiers (a parameter or local with the field's name
    owns it inside that method), so its EdgeReads at the exact call site is
    proof the bare receiver really is the enclosing type's field. No read edge,
    no gate: the receiver stays unknown and the site keeps the full fan-out.
    Two fixtures, parameter shadow and var local shadow. (An interface-typed
    local never reaches this lookup at all: the tenv binds its call directly
    and leaves no companion, so the typed-local remainder is unchanged.)

  3. Variance. The extractor stamps variant_type_params on any interface
    whose parameter list declares in/out, and the family build skips arg
    stamping entirely for those interfaces, so the equality gate can never arm.
    The gate models invariant parameters only; ISource<out T> makes
    differently-closed constructions assignable across the family, so
    closed-and-unequal proves nothing. Covariant and contravariant fixtures
    both reproduce total or partial fan-out loss before the fix.

  4. Type-identity canonicalization. csharpNormalizeSimpleArg now reduces
    global::/alias qualifiers to the final segment and strips the verbatim
    @ BEFORE the open-parameter and alias-set checks, so IBox<@T> reads as
    the open parameter T and stamps nothing, and @Crate / global::App.Crate
    compare equal to Crate. The canonical fold adds dynamic->object and
    IntPtr/UIntPtr->nint/nuint. All folds only ever CREATE matches, and a
    match always keeps the edge.

  5. Project-wide global usings. The extractor now records global alias names
    on the file node (global_using_aliases, alias directives were previously
    skipped entirely by the usings stamper), and the dispatch pass collects
    them once per run and refuses any stamp whose argument list names one:
    opaque spelling, never filter. This is deliberately the conservative
    option we discussed; resolving the alias to its target at stamp time can
    come later as a precision follow-up without touching the contract. Your
    three-file regression is the fixture, plus the implementor-side twin
    (base list spelled through the alias).

Extractor version bumps 13->14 in the same change: the variance and alias
stamps and the widened canonicalization all change extraction output for
unchanged files, and a store already running this branch's v13 would
otherwise keep filtering variant families forever.

The two P2s:

  1. Quadratic alias scans. Alias names are now collected in ONE walk per
    file and threaded to the stamp sites; the per-declaration ancestor walk
    keeps only the cheap type-parameter half. Your 2,000-sibling fixture is
    now a committed benchmark: 1.90s per extract before, 0.37s after on my
    machine. The scope widening (every alias in the file treated as in scope
    everywhere in it) is recall-safe: over-refusing a stamp can only preserve
    fan-out edges.

  2. Companion-edge indexing. A per-pass lookup context reads each caller's
    out-edge adjacency once and serves every call site from it (the companion
    scan and the new field-read evidence scan both consume it), and caches
    resolved field nodes by ID. A counting-store test pins the 2N-reads-per-
    caller shape to exactly one.

Beyond the findings, one sweep commit pins three adjacent shapes that were
already correct so they cannot silently flip: property receivers gate
exactly like fields (properties mint field nodes and ride the same
read-edge evidence), transitive implementors stay unstamped and unfiltered,
and nullable-annotated spellings stamp nothing (folding Crate? to Crate
would be right for reference types and wrong for value types).

Verification: the full race suite's failure set is byte-identical to my
Windows baseline at the merge base; every fixture above ran RED on the
reviewed head first. I will file the csharpBaseTypeName qualified
generic-base observation as a separate issue as agreed.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-102
  • internal/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-175
  • internal/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.
@pbednarcik

Copy link
Copy Markdown
Contributor Author

All four P1 paths, the P2, and the fixture issue are addressed at exact
head, each RED-first with your reproduction as the fixture. The common
root you identified - type identity read from raw source text and
compared across mismatched normalization domains - drove the shape of
the fixes: one canonicalization function, applied at every set
insertion and every use-side read, and AST-derived arguments wherever a
declared type becomes a compared spelling.

  1. Same-line field-read evidence. The root cause sat one layer below
    the evidence check: receiver_name documents itself as "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 read
    emitted for this._box could then certify it. 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 and the read-edge evidence can only ever certify
    a receiver extraction says is field-eligible. Your one-line repro is
    the regression test (this._box.Save() + _box.Get(); WidgetBox.Get
    retained). The two earlier shadow tests now bind through the member
    companion, since no receiver stamp exists for them to key on.

  2. Declaration-side escapes. csharpCanonicalIdentifier strips the
    verbatim prefix and decodes \uXXXX / \UXXXXXXXX (a malformed escape
    refuses), and it is applied at every set insertion - enclosing type
    parameters, file alias names, the global-alias file stamp - and in the
    use-side normalizer. Extractor and end-to-end tests cover class
    Outer<@t> and the \u0054 spelling; both keep the conservative fan-out.

  3. Alias normalization domains. Alias names are canonicalized at
    collection, so using @Entity = ... catches both IBox<@entity> and
    IBox (extractor and e2e tests for both spellings). For the
    foldable cross-file case, the resolver-side guard now indexes every
    comparable form of each global alias - the canonical name plus its BCL
    keyword fold - so a stamped "int" that may legally denote global using Int32 = App.Crate 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. Three-file e2e test
    included.

  4. Comment trivia. Field, property, and positional-record type
    arguments now derive from the parsed type AST (the same source the
    base-list stamps already used); the raw-text path is deleted, and the
    argument normalizer additionally refuses any raw spelling carrying
    trivia or an undecoded escape. IBox</**/Crate> stamps "Crate" and
    dispatches identically to the plain spelling (extractor + e2e tests).

  5. Positional records. emitCSharpRecordPositionalProps applies the same
    conservative AST-derived stamp ordinary fields carry, with the
    unstampable set computed once per parameter list. record Flow(IBox
    Store) narrows Store.Get() end-to-end; the record's own type parameter
    stays open and an alias stays opaque (both pinned).

  6. The dynamic fixture now uses the compilable orientation - ObjectBox :
    IBox consumed through an IBox receiver - and still
    exercises the fold.

    The C# extractor bumps to 15 within this branch (stores extracted at
    the previous revision keep raw alias metadata, text-derived arguments,
    unstamped positional properties, and receiver stamps on shadowed
    receivers). The cross-branch version-number note from the previous
    round still applies; merge order reconciles.

    My counted acceptance fixtures gained a ten-cell spelling-variant
    battery over one interface family - verbatim and escaped declaration
    parameters, verbatim and foldable aliases, comment trivia, dynamic and
    object, nint and IntPtr, BCL spellings, the same-line shadow shape, and
    the positional record - so the next normalization change trips a wire
    there before it reaches review.

    I also validated the revision against a live store: an isolated scratch
    daemon on this branch's binary cold-indexed my production C# codebase
    plus the acceptance fixtures. Every spelling-variant cell lands its
    predicted shape in one run. At production scale, 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), extension-method binding is byte-identical, unresolved counts
    are byte-identical, and 41,849 receiver_name stamps (-10.7%) are
    refused - each one a bare receiver that names a parameter or local,
    i.e. evidence the old code stamped in violation of its own contract.
    The bound-call delta equals the dispatch delta exactly, so the refusals
    cost no recall anywhere else. One detail worth noting: my production
    codebase turns out to contain a real project-global using alias, so the
    global-alias guard is exercised outside fixtures too.

    Full race suite on Windows: fail-name set identical to my baseline at
    the merge base except the known Swift timing flake and one rapid
    property-test failure in the graph package whose persisted counterexample
    replays identically at the merge base itself - a pre-existing test-model
    issue (the model keeps a duplicate generated node ID twice where the
    store upserts), which I will raise separately; this diff does not touch
    that package.

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 zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-880if 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_name shadow 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 csharpHasVariantTypeParams are correct and independently useful.
  • csharpBaseTypeName descending 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/required fields, 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. csharpFieldDeclTypeNode is a clean refactor.
  • The usings metadata is unchanged and global_using_aliases is 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 named Int32 only 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 the seen[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 and required properties, 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 EdgeReads all 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.
@pbednarcik

Copy link
Copy Markdown
Contributor Author

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 269fa321. Every finding was reproduced RED-first from my own fixture before any fix (your tables matched in every case, including which target survived), and every new dispatch test asserts the exact target set, for a reason covered at the end.

Root cause 1 - ID collisions

1b (1e7c59fd) - variance is now evaluated before the seen[id] return and ORed onto whichever node survives. Union is the conservative merge: the stamp only ever widens a fan-out, so a colliding declaration can never make the result narrower than the collision-free result. The test carries your control (twin removed) so a future regression in the collision path stays distinguishable from a variance bug.

1a (fcdc563d) - the duplicate-target 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's name rather than enumerating declaration node types, which differ across grammar revisions. A type ID it cannot attribute counts 0 and stamps nothing. All three shapes you named are pinned: partial parts exactly, and the arity twin and namespace twin in 269fa321 - separately, because a future narrowing of the count's key (arity or namespace, exactly the deferred "real fix") would reopen those two while the partial pin stayed green.

1c (52b72c11, hardened in 072a8596) - first the span check you proposed, as proposed: caller and field StartLine must both fall inside the owner type node's span, any mismatch or missing span keeps the receiver unknown. My review pass then found the span's residual door: a same-named type nested INSIDE its twin (legal - CS0542 bars only the immediate enclosing type's name) puts the dropped declaration's lines inside the survivor's span, and the gate filtered on foreign evidence again. No span heuristic closes that, so 072a8596 adds the positive signal: the extractor ORs duplicate_decl onto a type node whose ID a second declaration collided with (the same move as the variance stamp), and the receiver lookup refuses any owner carrying it. That covers every collision shape at once; the span check stays for stores extracted before the stamp existed. Cross-file partials were never at risk (field IDs embed the file path, so those lookups already missed). Agreed that the real fix is namespace and arity in the node ID and that it does not belong in this PR.

Root cause 2 - multiple constructions of one erased interface

2a + 2b (71b275d4) - the walk you proposed, with one deliberate narrowing. Unstamped hierarchy edges are recorded alongside stamped ones, and every implements/extends path from an implementor to the interface is walked. Only the implementor's OWN direct closure can qualify it for filtering; a disagreeing closure anywhere up the hierarchy - including an unstamped path, which means a construction we cannot read - disqualifies it.

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:

  • The monotonicity claim needs stating precisely: the TARGET filter is monotonically weakened (a member's stamp is either the old string or refused), but the end-to-end edge set is not a strict superset, because a bound member's stamp is also read 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 same defect this commit fixes on the target side.
  • The conservative rule has a precision cost on the gate's own home turf: a shared open-generic base that restates the interface (OrderRepo : RepoBase<Order>, IRepo<Order> with RepoBase<T> : IRepo<T>) now disarms, because the path through the open base is unstamped and unstamped disqualifies. Soundly narrowing that needs type substitution, which is the same deferred identity work. It means your -240 control number is stale in the direction that weakens the gate's case - if you re-run the gate-on/gate-off control at this head I will take whatever number comes out as the basis for the merge question.

b226eee4 hardens the walk from the same pass: it no longer walks THROUGH the interface (a collision-merged cycle above it read as a disagreeing second path), and the code comment now states the precise monotonicity property above.

Your 2a fixture now keeps Store.Put and Store.Put_L10 while still filtering the unambiguous WidgetBox.Put; 2b keeps the override that actually executes.

2c (ee8255ec, pinned end-to-end in 4d42beca) - csharpBaseTypeName descends a qualified_name to its final segment (the segment is a generic_name for a constructed generic, which the direct-identifier scan walked past, returning the namespace). That alone closes the reported shape - both spellings sit in one base list, so the pre-existing per-list count fires once the name is right - and it also fixes the extraction bug you flagged in round 1. My review pass found the spelling family's third door: a using ALIAS as a base entry (using BX = App.IBox<Crate>; then Dual : BX, IBox<Widget>) slips the count and the walk both, since unresolved::BX never resolves. 82426ef6 closes it: an alias-spelled base entry is an opaque spelling that may construct the sibling's interface, so it suppresses the whole type's stamps. Known boundary, disclosed: a GLOBAL using alias declared in another file is invisible at extraction time, so that spelling still stamps - it joins the other cross-file alias domains until base targets resolve before stamping.

Root cause 3 - site attribution

3a (7c4af491 + c958f20d) - the shadow index now covers fourteen binding forms beyond locals and parameters: your five (foreach variables, declaration patterns, out-var declaration expressions, lambda parameters, parenthesized using resources) and nine more my review pass found by walking the grammar - catch variables, for-initializer declarations (a bare variable_declaration the local capture cannot see), anonymous-method parameters, query range variables (from/let/join), local function parameters (a local function mints no function node, so the parameter index is blind to them), recursive-pattern designations, parenthesized designations, and deconstruction declarations. All fourteen are pinned in one table test with exact target sets.

Two 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 declarator's only direct identifier child is the INITIALIZER), and o is var (a, b) misparses as an invocation of the is_expression, landing the names in an argument list.

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 (2e46d066 + 72c6691c) - the block-scoping fix, landed first since you would want it regardless of the gate. Locals carry the byte extent of their declaring scope and calls carry their start offset, so the refusal asks "bound at this site" instead of "declared in this function"; a declaration with no scope-forming ancestor keeps an unbounded extent, so an unrecognized shape can only keep the old refusal, never lose one.

72c6691c is the review pass keeping me honest here: my own index widening had re-introduced 3d for every binder in a scope the grammar does not spell as a block - switch sections, switch-expression arms, loop conditions, expression lambdas got a method-wide extent, and five of six failing shapes were new at my own commit. The extent climb now stops at the first scope-forming ancestor; if_statement is deliberately not one, since a pattern variable in an if condition genuinely escapes to the enclosing block. A sixth pinned shape (a local declared in a switch section) predates the widening.

30c74861 - the same review pass caught the read-edge emitter consuming the widened index with the function-wide question, deleting a GENUINE field read because a lambda parameter or foreach variable existed elsewhere in the method - a find_usages recall loss on the field-read feature this PR's extractor bump retroactively covers. Call and access receivers now ask the shadow question at their own byte coordinate; only the assignment buffer, which carries no coordinate, keeps the function-wide question. Block-scoped binders (patterns, out vars) are deliberately not in the survival pins: their name is in scope for the whole block, so a bare use before them is CS0841 and refusal stays correct.

3b (cee8cb26) - every call now seeds the site-ambiguity index, the receiverless implicit-this form as the empty receiver, exactly per your suggestion. An empty receiver differing from a spelled one marks the site the same way two spelled receivers differing always did. (The this.-qualified spelling of the same line needed nothing: this is a spelled receiver, so those calls already seeded the index.)

3c (f98686ea) - rides the primitive you pointed at: a new ambiguousAt on the func lookup reports an equal-span tie at the call's line, and such sites are stamped receiver_ambiguous. Nested shapes (a local function inside a method) are ties of coverage, not of span, and stay unambiguous. The misattribution itself is untouched, as you noted it predates the PR; what is fixed is that it can no longer remove a target.

P2 (5aab0f8f)

Bucketed exactly as you suggested: the caller's evidence is indexed by (target, file, line) once, so each site's consultation is three map probes plus a walk of its own companion bucket. One caller, S through-interface sites, min of 3, my machine - "before" is this branch immediately prior to the bucketing commit, "after" is with it, both on the committed benchmark:

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 base 3eb51c6d on this Windows box (14 resolver JS/TS-import fails + 6 TestSubprocessExtractor_* needing sh, both verified present at the merge base).
  • go test -race across internal/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-existing unsafe.Pointer note in grammar_load_windows.go.
  • golangci-lint is not installed on this box; relying on the hosted check.

@zzet

zzet commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Hey @pbednarcik. Can you please resolve the merge conflicts?

@pbednarcik

Copy link
Copy Markdown
Contributor Author

@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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants