Skip to content

csharp: call attribution, repeated partial base classes, and sibling var typing regress on three shapes #725

Description

@zzet

Follow-up from the review of #677. Three shapes where the merged head is less correct than the previous head. None has a verified patch yet — each needs a design decision, not a one-liner.

Revisions referenced: f9e3f442 (merge base), 1a939e45 (previous head), 786a3929 (merged head).


1. The byte-interval fallback attributes a call to a member that does not contain it

enclosingAt (internal/parser/languages/csharp.go, ~line 2129) now ends with return l.enclosing(line) where it previously refused. Its comment justifies this as letting "a member kind without them (property, indexer, field initializer) sharing a line with one that has them" degrade to line attribution rather than lose the call.

That mechanism does not exist. buildFuncRanges selects only KindFunction / KindMethod, and both emitMethod and emitConstructor record a funcBytes entry unconditionally. So every candidate in l.ranges always has an extent, properties and indexers are never in ranges at all, and the fallback cannot return the intended member — it can only return a different one.

namespace App {
    public class Bag { public int Tally(){return 1;} public void Clear(){} }
    public class Box {
        private readonly Bag _bag = new Bag();
        public int Count => _bag.Tally(); public void Reset() { _bag.Clear(); }
    }
}
revision call edges from Box.Reset
f9e3f442 *.Tally (false — lives in Count), *.Clear
1a939e45 *.Clear only
786a3929 *.Tally (false, back), *.Clear

It also crosses class boundaries:

public class A { public void M() { Helper.Stop(); } } public class B { public int P => Helper.Go(); }

gives A.M -> *.Go at the merge base and merged head, nothing at the previous head. ambiguousAt does not mark these (only one range covers the line), so the false edge carries unmarked, confident receiver evidence.

For the record: this fallback was my own review recommendation in the previous round, and it was wrong — I diagnosed the dropped calls without checking that the members losing them were never attribution candidates. The author implemented it faithfully.

Two honest options: record byte extents for property / indexer / field-initializer members so they become real attribution targets, or restore the refusal.


2. A legally repeated base class across partial parts becomes implements from a class to a class

C# permits every partial part to repeat the base class as long as the parts agree. The shared extends budget threaded through emitCSharpBaseList is a bare boolean that never inspects the base target, so the second and later fragments get demoted to EdgeImplements.

namespace App {
    public class BaseA {}
    public interface IA {}
    public interface IB {}
    public partial class Box : BaseA, IA {}
    public partial class Box : BaseA, IB {}
}
revision base edges from Box
f9e3f442 extends BaseA, implements IA (second fragment dropped)
1a939e45 extends BaseA, extends BaseA, implements IA, implements IB
786a3929 extends BaseA, implements BaseA, implements IA, implements IB

A three-part version yields two false implements BaseA edges. This feeds interface-implementor fan-out and class-hierarchy walks with a class posing as an interface. The repeat should be dropped, not demoted — it names the same base class the budget already spent.


3. Sibling var redeclaration flipped the function-wide type env from first-wins to last-wins

Swapping the tier-1/tier-2 var guards from the function-wide map to typedRecordAt correctly mints the per-scope offset records, but it also means every sibling redeclaration now runs setLocalType, whose first line writes env[l.name] = typeName on the function-wide tenvByOwner. Offset-aware records are consulted only for a bare receiver; a chained receiver falls through to resolveChainType(..., tenvByOwner[callerID], ...) and an awaited local to csharpAwaitedCallType(..., tenvByOwner[owner], ...), both offset-blind.

public sealed class ChainFlow {
    public void Run() {
        { var h = new MakerA(); h.Make().Ping(); }   // Ping receiver should be Widget
        { var h = new MakerB(); h.Make().Ping(); }   // Ping receiver should be Gadget
    }
}

receiver_type on the two Ping calls:

revision first site second site
f9e3f442 Widget ✅ Widget ❌
1a939e45 Widget ✅ Widget ❌
786a3929 Gadget ❌ Gadget ✅

So this is first-wins → last-wins, not a clean regression: every revision is wrong on one of the two sites, and the merged head moves which one. It is still a regression for the first site, and the wrongness is confident rather than absent — the resolver binds on it. The real fix is to make the chained and awaited paths offset-aware like the bare-receiver path, rather than to pick a different wrong winner.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions