Repository navigation
C#: file-scoped receiver typing drops true calls edges in unrelated methods (regression in 0.9.27) #2299
Description
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
fix(csharp): per-method receiver typing so cross-method name reuse st…
chore: bump to 0.9.31; changelog for #2308/#2299/#2324/#2309/#2266/#2313
safishamsi commentedon Jul 30, 2026
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.31JensD-git commentedon Jul 31, 2026
Verified on the corpus from the original report — thanks for the quick turnaround.
Eight private C# repositories (~10.3k
.csfiles), rebuilt from scratch with 0.9.29 and 0.9.31 againstidentical sources. Comparing
callsedges as(source_file, label)pairs rather than node ids (ids arenot stable across versions):
callsedgesThat 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/AsTextresolve todifferent 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.
fix(csharp): scope receiver types per declaration so an untypeable re…
fix(csharp): per-method receiver typing so cross-method name reuse st…
chore: bump to 0.9.31; changelog for Graphify-Labs#2308/Graphify-Labs…
fix(csharp): scope receiver types per declaration so an untypeable re…
merge: backport upstream C# receiver-typing fixes (Graphify-Labs#1609 G…