Skip to content

C#: an untypeable declaration poisons an already-typed receiver name within the same method #2472

Closed

Description

@JensD-git

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 both

Note 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

  1. safishamsi commented on Aug 5, 2026

    @safishamsi
    Member

    Fixed in v0.9.34 (graphifyy==0.9.34 on PyPI). C# receiver types are now tracked per lexical declaration scope and resolved by the call position, so a typed static local-function parameter keeps resolving even when an out var reuses 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 the out var receiver itself stays untyped (typing it from the callee out signature is a separate pre-existing gap). Thanks for the clean bisect and repro.

  2. JensD-git commented on Aug 6, 2026

    @JensD-git
    ContributorAuthor

    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 var receiver itself, which stays
    untyped 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.

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