Skip to content

C#: file-scoped receiver typing drops true calls edges in unrelated methods (regression in 0.9.27) #2299

Closed

Description

@JensD-git

Regression window: introduced in 0.9.27 (#1609 namespace-aware member-call resolution, adapted from #1620).
Last unaffected version: 0.9.26. Still present in 0.9.29.
Language: C#. Platform: Windows 11, Python 3.12, graphifyy from PyPI, tree-sitter-c-sharp 0.23.5 (identical parser in both versions tested).

Summary

_resolve_csharp_member_calls types a receiver from a per-file name -> Type table and poisons the
entry when the same name is bound to conflicting/untypable expressions. Because the table is keyed per
file rather than per method/block scope, a single binding that the extractor cannot type — e.g.
var x = list[i]; in one method — removes the calls edges for every use of that name in the file,
including methods where the receiver is an explicitly typed parameter and C# scoping is unambiguous.

The docstring describes the poisoning as protecting against "a shadowing local of a different type".
That intent is right, but C# shadowing is scoped: two methods that each declare their own local named
item do not shadow one another, so no poisoning should occur between them.

Minimal reproducer

Two files, one namespace, no external dependencies:

Item.cs

namespace Demo { public class Item { public void Handle() { } } }

Runner.cs

using System.Collections.Generic;

namespace Demo
{
    public class Runner
    {
        public void RunOne(Item item)          // explicitly typed parameter
        {
            item.Handle();                     // EXPECTED: calls Item.Handle
        }

        public void RunIndexed(List<Item> items, int i)
        {
            var item = items[i];               // element type not inferred by the extractor
            item.Handle();                     // EXPECTED: calls Item.Handle
        }
    }
}
graphify extract ./src --out ./out --code-only
Variant 0.9.26 calls edges 0.9.29 calls edges
V1 — only RunOne RunOne -> Handle RunOne -> Handle
V2 — RunOne + RunIndexed (both use the name item) RunOne -> Handle, RunIndexed -> Handle none
V3 — as V2, but the second local is named other RunOne -> Handle RunOne -> Handle
V4 — as V2, plus an unrelated RunOther(Item single) all three only RunOther -> Handle

V2 vs. V3 isolates the cause: the code is semantically identical; only the reuse of the name
differs. V4 shows the blast radius is the name, not the file.

RunOne's receiver is an explicitly typed parameter — the type is stated in the signature, in a
different method, with no shadowing involved. Losing that edge is not a soundness trade-off; the
information is unambiguous in the source.

A related over-generation in the same table (0.9.26 and earlier)

The file-scoped table also mis-binds in the other direction, so this is not simply "0.9.26 was better":

// HtmlWriter.cs / TextWriter.cs each declare: public string Render()
public class Report
{
    public string AsHtml() { var writer = new HtmlWriter(); return writer.Render(); }
    public string AsText() { var writer = new TextWriter(); return writer.Render(); }
}

0.9.26 emits two edges, but both point at TextWriter.Render — AsHtml's edge is wrong.
0.9.29 emits neither. Per-scope typing would give the one correct edge for each method, so both the
old over-generation and the new silence appear to have the same root cause.

Suggested fix direction

Key the receiver type table by (file, enclosing method/block) rather than by file alone — the extractor
already tracks an enclosing-declaration context for the caller node (caller_nid) and a scope_chain
for the type-reference pass. Poisoning would then only trigger on a genuine same-scope rebinding.

Impact observed on a real corpus

Measured on eight private C# repositories (10 313 .cs files, 150 574 nodes), 0.9.26 vs. 0.9.29 on
identical source, plain graphify extract output. Connections are counted as
(file, symbol) -> (file, symbol) pairs so that the 0.9.29 node-id canonicalization (#2231, #2243)
does not distort the comparison — comparing raw node ids would report unchanged edges as lost:

0.9.26 0.9.29
calls connections 46 936 48 031
lost vs. baseline — 1 093 (2.3 %)
newly gained — 2 188

Per repository the loss ranges from 0.0 % to 3.0 %; the gains outweigh it everywhere except one
repository where 660 were lost and 119 gained.

So 0.9.29 is a net gain overall — the new base.M() and namespace-aware tiers add more than they
remove, and we have upgraded. This report is only about the subset that is lost without need: edges of
the V2 shape above, where the receiver's type is stated explicitly in its own method's signature.

Spot-checked losses (verified by reading the source) are all the same shape: a receiver name that is
also bound elsewhere in the file via an indexer, a foreach over a generic collection, or a
Dictionary.Values enumeration — i.e. in a different method, with no shadowing of the typed binding.

Activity

  1. safishamsi commented on Jul 30, 2026

    @safishamsi
    Member

    Fixed in v0.9.31. C# receiver typing is now per-method (was per-file), so an untypable local like var item = items[i] in one method no longer poisons the name file-wide and drops the true calls edge in another method where the same name is a typed parameter. The namespace resolver and no-wrong-edge guards are unchanged. https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.31

  2. JensD-git commented on Jul 31, 2026

    @JensD-git
    ContributorAuthor

    Verified on the corpus from the original report — thanks for the quick turnaround.

    Eight private C# repositories (~10.3k .cs files), rebuilt from scratch with 0.9.29 and 0.9.31 against
    identical sources. Comparing calls edges as (source_file, label) pairs rather than node ids (ids are
    not stable across versions):

    0.9.29 0.9.31
    calls edges 48,031 48,875
    gained — +962
    lost — 118

    That recovers roughly 90% of what file-scoped typing had cost us. The 0.9.29 baseline matches our
    earlier measurement exactly, so the corpus is unchanged.

    The minimal repro from the report now resolves fully, and notably AsHtml/AsText resolve to
    different types — 0.9.26 emitted two edges here but pointed both at the same (last-seen) type, so one
    was wrong. 0.9.31 is the first version that gets this case right rather than guessing or staying silent.

    We traced a sample of the 118 lost edges back to source: in each case 0.9.29 only produced the edge
    because the file-wide table leaked a type in from an unrelated method and happened to be right. With
    per-method scoping that accidental source is gone — correctly so. We consider this the right trade and
    are not asking for it back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions