Skip to content

C#: generic type arguments are dropped in field and call-site position (captured in property/return/parameter) #2911

Description

@brobl2008

Summary

The C# extractor walks generic type arguments in property, return and parameter position, but drops them in field and call-site position. The field/property split is the surprising one — they are semantically adjacent, and one works while the other does not.

Version: 0.9.48. Reproduced with --code-only (pure AST, no LLM).

Reproduction

Types.cs

public interface IAlpha { }
public interface IBeta { }
public interface IGamma { }
public interface IDelta { }
public interface IEpsilon { }
public interface IZeta { }
public class Box<T> { }
public class Registry { public void Do<T>() { } }
public interface IServiceCollection { }
public static class Ext
{
    public static void AddScoped<TService, TImpl>(this IServiceCollection s) { }
}

Probe.cs

public class Probe
{
    private Box<IAlpha> _field = null!;                    // 1. field
    public Box<IBeta> Prop { get; set; } = null!;          // 2. property
    public Box<IGamma> Ret() => null!;                     // 3. return
    public void Param(Box<IDelta> p) { }                   // 4. parameter
    public void Call(Registry r) => r.Do<IEpsilon>();      // 5. call site
    public void Di(IServiceCollection s) => s.AddScoped<IZeta, Box<IZeta>>();  // 6. DI registration
}
graphify extract proj --code-only --force --out out

Result

Edges originating from Probe or its members:

position type arg edge emitted
field IAlpha none
property IBeta references[generic_arg]
return IGamma references[generic_arg]
parameter IDelta references[generic_arg]
call site IEpsilon none
DI registration IZeta none

Expected: all six emit a references edge to the inner type argument, as properties, returns and parameters already do.

Why it matters

Both gaps erase dependency edges that impact analysis depends on, and they erase them silently — affected returns a confident, smaller answer rather than an error or a warning.

Field position. Classic constructor injection stores its dependency in a field, so the dependency's concrete type is invisible whenever it appears as a type argument:

private readonly IDbContextFactory<SomeDbContext> _factory;   // SomeDbContext: no edge

The same applies to essentially every mocking-based unit test (private readonly Mock<IThing> _mock; loses IThing). In a codebase I checked this against, six of six sampled real fields of this shape produced no edge to the inner type.

Call-site position. This is the standard .NET dependency-injection registration:

services.AddScoped<IThing, Thing>();
services.TryAddScoped<IThing, Thing>();

Neither IThing nor Thing gets an edge, so the interface→implementation binding — the thing that makes the registration meaningful — is absent from the graph. Microsoft.Extensions.DependencyInjection is close to universal in modern .NET, so this affects most C# projects with a composition root.

Relationship to #2624 / #2676

Related but not the same, and I do not think #2676 closes this.

#2624 is about the calls edge to the invoked method being lost when the call site carries explicit type arguments — X.M<T>(...) normalises to a key that cannot match the stored member label. #2676 fixes that by stripping the type arguments before matching.

This issue is about the type arguments themselves never becoming references edges. Stripping them to repair the callee match discards them, so #2676 would restore the edge to AddScoped while still leaving IThing and Thing unlinked. The two fixes are complementary: one repairs the callee edge, the other emits the argument references.

Field position is outside #2624 entirely — there is no call site involved.

Suggested direction

The property/return/parameter handlers already resolve this correctly, so the missing cases look like unvisited positions rather than a new mechanism:

  • field: walk the declared type's type-argument list in the field_declaration handler, mirroring the property handler directly beside it.
  • call site: emit a references[generic_arg] edge per argument in the invocation's type-argument list. If fix(csharp): strip call-site type arguments from generic calls #2676 strips them for callee matching, emitting the references before the strip keeps both behaviours.

Happy to put up a PR for either or both if the direction looks right — I sent #2836 for #2829 and can follow the same shape.

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