Repository navigation
C#: an untypeable declaration poisons an already-typed receiver name within the same method #2472
Description
Version: present in 0.9.32 and 0.9.33; last good version 0.9.31
Language: C#
Bisected on the reproducer below: 0.9.31 resolves 3/3, 0.9.32 resolves 0/3, 0.9.33
resolves 0/3. So it arrived with 0.9.32 — possibly as a side effect of the
pattern-based-declaration work released in that version (#2346).
Summary
When one method contains two declarations of the same identifier — one that graphify
can type and one that it cannot — 0.9.33 drops the typing for all uses of that
name, including the uses that belong to the typed declaration. 0.9.31 resolved them.
This is the method-scoped analogue of #2299 (which was the file-scoped version of the
same problem, fixed in 0.9.31).
Minimal reproducer
Probe2.cs (single project, net8.0):
namespace Repro2
{
public class Target2
{
public void Alpha() { }
public void Beta() { }
public void Gamma() { }
}
public static class Factory2
{
public static void Make(out Target2 created) { created = new Target2(); }
}
public class Caller2
{
public void Outer()
{
// (1) parameter of a *static local function* -- explicitly typed.
// C# allows a static local function to shadow enclosing locals.
static void LocalFunction(Target2 shared)
{
shared.Alpha();
shared.Beta();
}
// (2) same identifier, declared via `out var` -- type only available
// from the callee signature (out Target2 created)
Factory2.Make(out var shared);
shared.Gamma();
LocalFunction(shared);
}
}
}Expected vs. actual
calls edges into Target2:
| version | Alpha | Beta | Gamma | total |
|---|---|---|---|---|
| expected | yes | yes | yes | 3 |
| 0.9.31 | yes | yes | yes | 3 |
| 0.9.32 | no | no | no | 0 |
| 0.9.33 | no | no | no | 0 |
Both graph.raw.json and the final graph show the same numbers.
The two declarations are in different scopes: shared at (1) is the local
function's parameter, shared at (2) is a local of the enclosing body. They are
distinct variables and C# permits this precisely because the local function is
static. Resolving Alpha/Beta requires nothing but the parameter list a few
lines above the call.
Control case
A file with the same constructs but without name reuse resolves identically in
both versions, so neither local functions nor out-parameters are the trigger on
their own — the trigger is the reused name:
static void LocalFunction(Target viaLocalFunctionParameter) { viaLocalFunctionParameter.Alpha(); ... }
Factory.Make(out var viaOutVar);
viaOutVar.Gamma(); // unresolved in 0.9.31 AND 0.9.33 (separate, pre-existing gap)
Target viaExplicitLocal = new Target();
viaExplicitLocal.Delta(); // resolved in bothNote the side observation: with distinct names, out var is unresolved in both
versions. So in the reused-name case 0.9.31's Gamma edge was arguably a lucky hit
produced by the same over-broad scoping. The regression is nevertheless real, because
Alpha and Beta are unambiguously typed by the parameter list and are lost.
Suggested direction
Track receiver types per declaration scope rather than per method, and let an
untypeable declaration invalidate only its own scope instead of the whole name within
the method. A cheaper interim fix that would already restore this case: when the same
identifier carries several declarations in a method, keep the typed ones instead of
discarding all of them.
Separately (pre-existing, lower priority): out var x could take its type from the
callee's out parameter when that method is part of the corpus.
Real-world impact
Found while evaluating 0.9.33 on an 8-graph C# portfolio (~72 600 calls edges after
post-processing). It cost 5 edges in a single ASP.NET middleware method, where a
static local function parameter and an out var share the name sessionContext.
Small in absolute terms, but it silently removes calls that a reader of the source
would consider obvious.
Everything else in 0.9.33 checked out: the #2411 partial-class fix holds (0
cross-assembly collapses across all 8 graphs, previously 249) and there were no
rerouted edges.
Activity
fix(csharp): scope receiver types per declaration so an untypeable re…
safishamsi commentedon Aug 5, 2026
Fixed in v0.9.34 (
graphifyy==0.9.34on PyPI). C# receiver types are now tracked per lexical declaration scope and resolved by the call position, so a typedstaticlocal-function parameter keeps resolving even when anout varreuses the name in the enclosing body. This was a regression from 0.9.32 (#2346); cross-method independence (#2299) and field-conflict poisoning are unchanged. Note theout varreceiver itself stays untyped (typing it from the calleeoutsignature is a separate pre-existing gap). Thanks for the clean bisect and repro.JensD-git commentedon Aug 6, 2026
Confirmed fixed in 0.9.34 — thanks for the fast turnaround.
The reproducer goes from 0/3 to 2/3 resolved calls: the shadowed typed parameter resolves at
both of its call positions again. The third call is the
out varreceiver itself, which staysuntyped as you described — expected, and the real-world middleware case is resolved.
I also re-ran the full portfolio against 0.9.33 to check for fallout: no lost edges, no rerouted
edges, no duplicates, and the #2411 partial-class assembly keying still holds. Promoted to
production.
fix(csharp): scope receiver types per declaration so an untypeable re…
merge: backport upstream C# receiver-typing fixes (Graphify-Labs#1609 G…
feat(kernel): C# and C native walkers on the shared engine