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_declaration → engine.py:3566 (records types at :3588, edge at :3608)
property_declaration → engine.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:
- records
name → type into csharp_field_types[class_nid], so the existing
_csharp_method_receiver_types scoping resolves calls through it; and
- 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).
Summary
extract_csharpnever walks a class's primary constructor parameter list, sodependencies declared as
class Foo(IBar bar)— the idiomatic C# 12 / .NET 8+ DIpattern — are invisible to the graph. No
referencesedge is emitted for theparameter's type, and because the name is never registered as a class-scoped
receiver binding, every call through it (
bar.Baz()) also loses itscallsedge.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_typesand emit areferences[field]edge, in
graphify/extractors/engine.py:field_declaration→engine.py:3566(records types at:3588, edge at:3608)property_declaration→engine.py:3612(records types at:3631)There is no third branch for a
class_declaration'sparameter_list. Becausecsharp_field_types[class_nid]is what_csharp_method_receiver_types(
engine.py:1609) takes as itsfield_typesbase scope, an unregistered name cannever be typed at a call site, so no
callsedge is stamped either.Note the tree-sitter shape — the parameter list is an unnamed child, so
child_by_field_name("parameters")returnsNoneand the node must be found byiterating children (exactly as the Scala
class_parametersbranch atengine.py:3426already does):
Each
parameterchild exposes the samename/typefields thatbind_parameter()(engine.py:1651) already consumes:Minimal reproduction
callsedgeIDepaffectedclass CaseA(IDep dep)→dep.Plain(id)_dep.Plain(id)_dep.Generic<string>("k")new Dep()→dep.Plain(id)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
callsedge but keeps
references[field], so the class is still discoverable byaffected. Case A has no edge of any kind and disappears from impact analysisentirely —
affectedreturns a confident, short, wrong answer rather than an error.Positional
recordparameters are affected identically:Impact
Every class using this DI pattern loses its dependency edges entirely, and none of them
are reachable via
affectedon the interface they depend on. The losses concentrate onservice 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_declarationbranch alongside the existingfield_declarationandproperty_declarationhandlers that, for eachparameterin the unnamed
parameter_listchild:name → typeintocsharp_field_types[class_nid], so the existing_csharp_method_receiver_typesscoping resolves calls through it; andreferencesedge withcontext="field", matchingengine.py:3608.That mirrors #2063's Kotlin fix and the Scala
class_parametersbranch already atengine.py:3426. Happy to open a PR if the approach looks right.Tested on graphifyy 0.9.45, Python 3.14, macOS (arm64).