Skip to content

C#: primary-constructor parameters are never walked, so constructor-injected dependencies produce no edges #2829

Description

@brobl2008

Summary

extract_csharp never walks a class's primary constructor parameter list, so
dependencies declared as class Foo(IBar bar) — the idiomatic C# 12 / .NET 8+ DI
pattern — are invisible to the graph. No references edge is emitted for the
parameter's type, and because the name is never registered as a class-scoped
receiver binding, every call through it (bar.Baz()) also loses its calls edge.

This is the C# twin of #2063 (Kotlin primary_constructor/class_parameters).
Scala already has the equivalent handling in the same file; C# never got it.

Not a duplicate of #2624. That issue is about explicit type arguments at a call
site (X.M<T>()) folding into the lookup key. The repro below contains no generics,
and #2676 states it "does not involve primary constructor parameter resolution."
The two also differ in severity — see the isolation table.

Root cause

Two C# member forms populate csharp_field_types and emit a references[field]
edge, in graphify/extractors/engine.py:

  • field_declarationengine.py:3566 (records types at :3588, edge at :3608)
  • property_declarationengine.py:3612 (records types at :3631)

There is no third branch for a class_declaration's parameter_list. Because
csharp_field_types[class_nid] is what _csharp_method_receiver_types
(engine.py:1609) takes as its field_types base scope, an unregistered name can
never be typed at a call site, so no calls edge is stamped either.

Note the tree-sitter shape — the parameter list is an unnamed child, so
child_by_field_name("parameters") returns None and the node must be found by
iterating children (exactly as the Scala class_parameters branch at engine.py:3426
already does):

>>> cls = parse(b"public class CaseA(IDep dep) { }").root_node.children[0]
>>> cls.type
'class_declaration'
>>> [c.type for c in cls.children]
['modifier', 'class', 'identifier', 'parameter_list', 'declaration_list']
>>> cls.child_by_field_name("parameters")
None

Each parameter child exposes the same name / type fields that
bind_parameter() (engine.py:1651) already consumes:

>>> [(p.child_by_field_name("name").text, p.child_by_field_name("type").text)
...  for p in parameter_list.children if p.type == "parameter"]
[(b'dep', b'IDep')]

Minimal reproduction

namespace Iso;

public interface IDep
{
    string Plain(int id);
    T Generic<T>(string key);
}

public class Dep : IDep
{
    public string Plain(int id) => "x";
    public T Generic<T>(string key) => default!;
}

// CASE A - primary constructor parameter
public class CaseA(IDep dep)
{
    public string Run(int id) => dep.Plain(id);
}

// CASE B - classic constructor + readonly field (control)
public class CaseB
{
    private readonly IDep _dep;
    public CaseB(IDep dep) { _dep = dep; }
    public string Run(int id) => _dep.Plain(id);
}

// CASE C - generic call site (#2624, for contrast)
public class CaseC
{
    private readonly IDep _dep;
    public CaseC(IDep dep) { _dep = dep; }
    public string Run() => _dep.Generic<string>("k");
}

// CASE D - local variable (control)
public class CaseD
{
    public string Run(int id)
    {
        var dep = new Dep();
        return dep.Plain(id);
    }
}
$ graphify extract ./src --code-only --force
$ graphify affected "IDep"

Affected nodes for IDep
- CaseB [references] Cases.cs:L24
- CaseC [references] Cases.cs:L32
- Dep   [implements] Cases.cs:L9
- .Run() [calls]     Cases.cs:L26
Case Pattern calls edge ref to IDep found by affected
A class CaseA(IDep dep)dep.Plain(id) ✗ invisible
B ctor + field → _dep.Plain(id)
C field → _dep.Generic<string>("k") ✗ (#2624)
D local new Dep()dep.Plain(id) n/a n/a

Case B is the control and passes, so constructor injection generally is fine —
it is primary constructors specifically that are dropped.

The severity difference against #2624 is the last column: Case C loses the calls
edge but keeps references[field], so the class is still discoverable by
affected. Case A has no edge of any kind and disappears from impact analysis
entirely — affected returns a confident, short, wrong answer rather than an error.

Positional record parameters are affected identically:

public record RecCase(IDep Dep)          // no edge to IDep
{
    public string Run(int id) => Dep.Plain(id);
}

Impact

Every class using this DI pattern loses its dependency edges entirely, and none of them
are reachable via affected on the interface they depend on. The losses concentrate on
service and data-access classes — the nodes impact-analysis traversals care about most.

Since C# 12 made primary constructors available to plain classes and not just records,
they are increasingly the default in new .NET code, so this affects a growing share of
C# graphs. A codebase that adopts the pattern as a documented convention loses its DI
edges wholesale.

Suggested fix

Add a class_declaration / record_declaration branch alongside the existing
field_declaration and property_declaration handlers that, for each parameter
in the unnamed parameter_list child:

  1. records name → type into csharp_field_types[class_nid], so the existing
    _csharp_method_receiver_types scoping resolves calls through it; and
  2. emits a references edge with context="field", matching engine.py:3608.

That mirrors #2063's Kotlin fix and the Scala class_parameters branch already at
engine.py:3426. Happy to open a PR if the approach looks right.

Tested on graphifyy 0.9.45, Python 3.14, macOS (arm64).

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