From d8b84893c4c0522fb542e09b11163e9b3b053fbd Mon Sep 17 00:00:00 2001 From: Peter Bednarcik Date: Wed, 29 Jul 2026 09:37:53 +0200 Subject: [PATCH] Emit receiver-typed spawn edges for deferred-lambda registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecurringJob.AddOrUpdate(x => x.M()) names the lambda receiver's type only in the generic argument, which extraction discarded — the call bound by name heuristics at best and jobs read as dead code. A registry of deferred-lambda clients (Hangfire first; per-API because a generic arg typing the lambda is a signature fact) emits EdgeSpawns with the *.M target + receiver_type hint resolveMethodCall already binds exactly. Inline Enqueue(() => M()) gains the spawn edge too. --- internal/graph/edge.go | 9 +- .../parser/languages/csharp_function_shape.go | 302 +++++++++++++++--- .../parser/languages/csharp_hangfire_test.go | 203 ++++++++++++ .../resolver/csharp_hangfire_spawn_test.go | 35 ++ 4 files changed, 494 insertions(+), 55 deletions(-) create mode 100644 internal/parser/languages/csharp_hangfire_test.go create mode 100644 internal/resolver/csharp_hangfire_spawn_test.go diff --git a/internal/graph/edge.go b/internal/graph/edge.go index 8cf3a641a..6f0418f83 100644 --- a/internal/graph/edge.go +++ b/internal/graph/edge.go @@ -138,10 +138,11 @@ const ( // over. EdgeCaptures EdgeKind = "captures" // EdgeSpawns links a caller to a function it launches - // asynchronously (goroutine, async/await, Promise, worker pool). - // Emitted in addition to the corresponding EdgeCalls so synchronous - // reachability queries can scope by edge kind. Meta["mode"] ∈ - // goroutine|async|promise|worker_pool. + // asynchronously (goroutine, async/await, Promise, worker pool, + // background scheduler). Emitted in addition to the corresponding + // EdgeCalls so synchronous reachability queries can scope by edge + // kind. Meta["mode"] ∈ goroutine|async|promise|worker_pool|task| + // thread|parallel|background. EdgeSpawns EdgeKind = "spawns" // EdgeSends / EdgeRecvs link a function to a channel-typed // variable for channel I/O. The channel's element type is reachable diff --git a/internal/parser/languages/csharp_function_shape.go b/internal/parser/languages/csharp_function_shape.go index b8485ff32..eb97f7588 100644 --- a/internal/parser/languages/csharp_function_shape.go +++ b/internal/parser/languages/csharp_function_shape.go @@ -1,6 +1,7 @@ package languages import ( + "bytes" "strconv" "strings" @@ -257,10 +258,11 @@ func emitCSharpTypeUseEdges(ownerID, typeText, filePath string, line int, result }) } -// emitCSharpAsyncSpawns walks a C# method body for `await` expressions -// and Task.Run / Task.Factory.StartNew / ThreadPool.QueueUserWorkItem -// calls. Mode is "async" for await, "task" for Task.Run, "thread" for -// thread-pool queues. +// emitCSharpAsyncSpawns walks a C# method body for `await` expressions, +// Task.Run / Task.Factory.StartNew / ThreadPool.QueueUserWorkItem +// calls, and deferred-lambda registrations (csharpDeferredLambdaClients, +// mode "background"). Mode is "async" for await, "task" for Task.Run, +// "thread" for thread-pool queues. func emitCSharpAsyncSpawns(ownerID string, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) { if body == nil { return @@ -287,63 +289,261 @@ func emitCSharpAsyncSpawns(ownerID string, body *sitter.Node, src []byte, filePa }, }) } - walkCSharpNodes(body, func(n *sitter.Node) bool { - switch n.Type() { - case "method_declaration", "lambda_expression", "anonymous_method_expression", - "local_function_statement": - return false - case "await_expression": - line := int(n.StartPoint().Row) + 1 - // Look for an inner invocation_expression to grab the - // callee name. - for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { - c := n.NamedChild(i) - if c == nil { - continue + // Lambdas are walked (registrations live inside configuration lambdas + // — services.AddHangfireServer(opts => { RecurringJob.AddOrUpdate… })) + // but only the deferred-client detection fires there; task/async + // spawns keep their original named-body-only attribution. + var walk func(n *sitter.Node, inLambda bool) + walk = func(n *sitter.Node, inLambda bool) { + if n == nil { + return + } + if !visitCSharpSpawnNode(n, inLambda, src, ownerID, filePath, seen, result, emit) { + return + } + if n.Type() == "lambda_expression" || n.Type() == "anonymous_method_expression" { + inLambda = true + } + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + walk(n.NamedChild(i), inLambda) + } + } + walk(body, false) +} + +// visitCSharpSpawnNode handles one node of the spawn walk; reports +// whether to descend into it. +func visitCSharpSpawnNode(n *sitter.Node, inLambda bool, src []byte, ownerID, filePath string, seen map[string]bool, result *parser.ExtractionResult, emit func(string, string, int)) bool { + switch n.Type() { + case "method_declaration", "local_function_statement": + return false + case "await_expression": + if inLambda { + return true + } + line := int(n.StartPoint().Row) + 1 + // Look for an inner invocation_expression to grab the + // callee name. + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + c := n.NamedChild(i) + if c == nil { + continue + } + if c.Type() == "invocation_expression" { + if name := csharpInvocationTargetName(c, src); name != "" { + emit(name, "async", line) + } + } + } + case "invocation_expression": + fn := n.ChildByFieldName("function") + if fn == nil { + return true + } + line := int(n.StartPoint().Row) + 1 + if fn.Type() == "member_access_expression" { + expr := fn.ChildByFieldName("expression") + name := fn.ChildByFieldName("name") + if expr != nil && name != nil { + // Byte-slice map index (no allocation — this path runs + // for every member call inside every lambda). + b := src[expr.StartByte():expr.EndByte()] + if mm := csharpDeferredLambdaClients[string(b[bytes.LastIndexByte(b, '.')+1:])]; mm != nil { + emitCSharpDeferredLambdaSpawn(n, name, mm, src, ownerID, filePath, line, seen, result) + } + if inLambda { + return true } - if c.Type() == "invocation_expression" { - if name := csharpInvocationTargetName(c, src); name != "" { - emit(name, "async", line) + obj := expr.Content(src) + meth := name.Content(src) + switch obj { + case "Task": + switch meth { + case "Run", "Factory": + emit("Task."+meth, "task", line) + } + case "Task.Factory": + if meth == "StartNew" { + emit("Task.Factory.StartNew", "task", line) + } + case "ThreadPool": + if meth == "QueueUserWorkItem" { + emit("ThreadPool.QueueUserWorkItem", "thread", line) + } + case "Parallel": + switch meth { + case "ForEach", "For", "Invoke": + emit("Parallel."+meth, "parallel", line) } } } - case "invocation_expression": - fn := n.ChildByFieldName("function") - if fn == nil { - return true + } + } + return true +} + +// csharpDeferredLambdaClients registers static-client methods whose +// lambda argument a framework executes later, on a receiver typed by +// the generic argument (Hangfire first). Per-API by necessity: whether +// a generic arg types the lambda is a signature fact — List.ConvertAll's +// TOutput, for one, does not. +var csharpDeferredLambdaClients = map[string]map[string]bool{ + "RecurringJob": {"AddOrUpdate": true}, + "BackgroundJob": {"Enqueue": true, "Schedule": true}, +} + +// emitCSharpDeferredLambdaSpawn emits the spawn for a registered +// client's lambda: the framework runs it later, and its receiver type +// exists only in the generic argument — carried as the receiver_type +// hint the resolver's *.-target path binds exactly. Origin is left for +// the resolver to stamp at the tier the binding actually earns. +func emitCSharpDeferredLambdaSpawn(inv, nameNode *sitter.Node, methods map[string]bool, src []byte, ownerID, filePath string, line int, seen map[string]bool, result *parser.ExtractionResult) { + meth := nameNode.Content(src) + recv := "" + if nameNode.Type() == "generic_name" { + meth, recv = csharpGenericNameParts(nameNode, src) + } + if !methods[meth] { + return + } + // A type-parameter receiver (Enqueue inside a generic wrapper) + // names no indexed type; a hint would only steer the resolver's + // fallback onto an arbitrary same-named method. Emit nothing. + if recv != "" && csharpRecvIsTypeParam(inv, recv, src) { + return + } + target, memberCall := csharpLambdaInvocationTarget(inv, src) + if target == "" { + return + } + // A captured-receiver call (() => job.Run()) without a generic hint + // carries no type evidence either — same fallback hazard, same skip. + if recv == "" && memberCall { + return + } + to := target + meta := map[string]any{"mode": "background"} + if recv != "" { + to = "*." + target + meta["receiver_type"] = recv + } + key := "background\x00" + recv + "\x00" + to + if seen[key] { + return + } + seen[key] = true + result.Edges = append(result.Edges, &graph.Edge{ + From: ownerID, + To: "unresolved::" + to, + Kind: graph.EdgeSpawns, + FilePath: filePath, + Line: line, + Meta: meta, + }) +} + +// csharpRecvIsTypeParam reports whether recv names a type parameter of +// an enclosing method, local function, or type declaration — an AST +// fact, not a naming-convention guess (TVShow is a class, TEntity in a +// generic wrapper is not). +func csharpRecvIsTypeParam(n *sitter.Node, recv string, src []byte) bool { + for p := n; p != nil; p = p.Parent() { + switch p.Type() { + case "method_declaration", "local_function_statement", "class_declaration", + "struct_declaration", "record_declaration", "interface_declaration": + default: + continue + } + tparams := p.ChildByFieldName("type_parameters") + if tparams == nil { + for i, _nc := 0, int(p.NamedChildCount()); i < _nc; i++ { + if c := p.NamedChild(i); c != nil && c.Type() == "type_parameter_list" { + tparams = c + break + } } - line := int(n.StartPoint().Row) + 1 - if fn.Type() == "member_access_expression" { - expr := fn.ChildByFieldName("expression") - name := fn.ChildByFieldName("name") - if expr != nil && name != nil { - obj := expr.Content(src) - meth := name.Content(src) - switch obj { - case "Task": - switch meth { - case "Run", "Factory": - emit("Task."+meth, "task", line) - } - case "Task.Factory": - if meth == "StartNew" { - emit("Task.Factory.StartNew", "task", line) - } - case "ThreadPool": - if meth == "QueueUserWorkItem" { - emit("ThreadPool.QueueUserWorkItem", "thread", line) - } - case "Parallel": - switch meth { - case "ForEach", "For", "Invoke": - emit("Parallel."+meth, "parallel", line) - } - } + } + if tparams == nil { + continue + } + for i, _nc := 0, int(tparams.NamedChildCount()); i < _nc; i++ { + tp := tparams.NamedChild(i) + if tp == nil || tp.Type() != "type_parameter" { + continue + } + for j, _jc := 0, int(tp.NamedChildCount()); j < _jc; j++ { + if c := tp.NamedChild(j); c != nil && c.Type() == "identifier" && c.Content(src) == recv { + return true } } } + } + return false +} + +// csharpGenericNameParts splits `Enqueue` into the bare +// method name and the first type argument, shortened to the simple type +// name the graph indexes. +func csharpGenericNameParts(n *sitter.Node, src []byte) (name, firstTypeArg string) { + for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ { + c := n.NamedChild(i) + if c == nil { + continue + } + switch c.Type() { + case "identifier": + name = c.Content(src) + case "type_argument_list": + if c.NamedChildCount() > 0 && c.NamedChild(0) != nil { + arg := c.NamedChild(0).Content(src) + if i := strings.IndexByte(arg, '<'); i >= 0 { + arg = arg[:i] + } + firstTypeArg = diShortName(arg) + } + } + } + return name, firstTypeArg +} + +// csharpLambdaInvocationTarget returns the callee name of the first +// invocation inside the FIRST lambda argument — the method the deferred +// lambda runs — and whether that callee is member-qualified. Later +// lambdas (a cron callback, say) never substitute for an invocationless +// first one. +func csharpLambdaInvocationTarget(inv *sitter.Node, src []byte) (target string, memberCall bool) { + args := inv.ChildByFieldName("arguments") + if args == nil { + return "", false + } + var lambda *sitter.Node + walkCSharpNodes(args, func(n *sitter.Node) bool { + if lambda != nil { + return false + } + if n.Type() == "lambda_expression" { + lambda = n + return false + } + return true + }) + if lambda == nil { + return "", false + } + walkCSharpNodes(lambda, func(b *sitter.Node) bool { + if target != "" { + return false + } + if b.Type() == "invocation_expression" { + target = csharpInvocationTargetName(b, src) + if fn := b.ChildByFieldName("function"); fn != nil && fn.Type() == "member_access_expression" { + memberCall = true + } + return false + } return true }) + return target, memberCall } func walkCSharpNodes(n *sitter.Node, visit func(*sitter.Node) bool) { diff --git a/internal/parser/languages/csharp_hangfire_test.go b/internal/parser/languages/csharp_hangfire_test.go new file mode 100644 index 000000000..1f9ff23d8 --- /dev/null +++ b/internal/parser/languages/csharp_hangfire_test.go @@ -0,0 +1,203 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func findSpawn(edges []*graph.Edge, to string) *graph.Edge { + for _, e := range edges { + if e.Kind == graph.EdgeSpawns && e.To == to { + return e + } + } + return nil +} + +// TestCSharpExtractor_HangfireSpawns: Hangfire registrations launch a +// method on a type named only by the generic argument — the lambda +// receiver is otherwise untyped, so without this the call binds (at +// best) by name heuristics. The extractor must emit a receiver-typed +// EdgeSpawns the resolver can land exactly. +func TestCSharpExtractor_HangfireSpawns(t *testing.T) { + src := []byte(`public class JobSetup { + public void Configure(string cron, TimeSpan delay, InvoiceDto dto) { + RecurringJob.AddOrUpdate("sync users", x => x.SynchronizeAsync(), cron); + BackgroundJob.Enqueue(m => m.SendAsync(1)); + BackgroundJob.Schedule(c => c.Sweep(), delay); + Hangfire.BackgroundJob.Enqueue(() => Process(dto)); + } + + public void Process(InvoiceDto dto) { } +} +`) + result, err := NewCSharpExtractor().Extract("JobSetup.cs", src) + require.NoError(t, err) + + // Generic forms: receiver-typed targets the resolver binds exactly. + for _, want := range []struct{ to, recv string }{ + {"unresolved::*.SynchronizeAsync", "SyncService"}, + {"unresolved::*.SendAsync", "Mailer"}, + {"unresolved::*.Sweep", "Cleaner"}, + } { + e := findSpawn(result.Edges, want.to) + require.NotNil(t, e, "missing spawn edge %s", want.to) + assert.Equal(t, "background", e.Meta["mode"]) + assert.Equal(t, want.recv, e.Meta["receiver_type"]) + assert.Equal(t, "JobSetup.cs::JobSetup.Configure", e.From) + } + + // Inline lambda: bare target, no receiver hint needed (same-class + // call; the ordinary EdgeCalls already exists alongside). + inline := findSpawn(result.Edges, "unresolved::Process") + require.NotNil(t, inline, "inline Enqueue(() => Process(...)) must spawn") + assert.Equal(t, "background", inline.Meta["mode"]) + assert.Nil(t, inline.Meta["receiver_type"]) +} + +// TestCSharpExtractor_HangfireSpawns_InsideConfigLambda: registrations +// standardly live inside a configuration lambda +// (AddHangfireServer(opts => { … })) — the deferred-client detection +// must descend where the task/async walk does not. +func TestCSharpExtractor_HangfireSpawns_InsideConfigLambda(t *testing.T) { + src := []byte(`public class Startup { + public void Configure(IServiceCollection services, string cron) { + services.AddHangfireServer(opts => + { + RecurringJob.AddOrUpdate("sync", (x) => x.SynchronizeAsync(), cron); + Task.Run(() => Helper()); + }); + } + + public void Helper() { } +} +`) + result, err := NewCSharpExtractor().Extract("Startup.cs", src) + require.NoError(t, err) + + e := findSpawn(result.Edges, "unresolved::*.SynchronizeAsync") + require.NotNil(t, e, "registration inside a config lambda must still spawn") + assert.Equal(t, "SyncService", e.Meta["receiver_type"]) + assert.Equal(t, "Startup.cs::Startup.Configure", e.From) + + // Task/async attribution semantics are unchanged: a Task.Run inside + // a lambda still emits nothing. + assert.Nil(t, findSpawn(result.Edges, "unresolved::Task.Run"), + "lambda-nested Task.Run attribution must not change") +} + +// TestCSharpExtractor_HangfireSpawns_TypeParamReceiver: a generic +// wrapper's Enqueue names no indexed type — emitting a hint would +// steer the resolver onto an arbitrary same-named method. Emit nothing. +func TestCSharpExtractor_HangfireSpawns_TypeParamReceiver(t *testing.T) { + src := []byte(`public class JobScheduler { + public void EnqueueJob() where T : IJob { + BackgroundJob.Enqueue(x => x.Run()); + } + public void EnqueueEntity() where TEntity : IJob { + BackgroundJob.Enqueue(x => x.Run()); + } +} +`) + result, err := NewCSharpExtractor().Extract("Wrapper.cs", src) + require.NoError(t, err) + + for _, e := range result.Edges { + if e.Kind == graph.EdgeSpawns { + assert.NotEqual(t, "background", e.Meta["mode"], + "type-param receiver must not emit a background spawn: %s", e.To) + } + } +} + +// TestCSharpExtractor_HangfireSpawns_SameMethodNameAcrossReceivers: job +// classes conventionally share method names (Run/ExecuteAsync) and +// registrations cluster in one method — every receiver must keep its +// own edge (the dedup key includes the receiver). +func TestCSharpExtractor_HangfireSpawns_SameMethodNameAcrossReceivers(t *testing.T) { + src := []byte(`public class Setup { + public void Configure() { + BackgroundJob.Enqueue(m => m.ExecuteAsync()); + BackgroundJob.Enqueue(c => c.ExecuteAsync()); + } +} +`) + result, err := NewCSharpExtractor().Extract("Setup.cs", src) + require.NoError(t, err) + + var recvs []string + for _, e := range result.Edges { + if e.Kind == graph.EdgeSpawns && e.To == "unresolved::*.ExecuteAsync" { + r, _ := e.Meta["receiver_type"].(string) + recvs = append(recvs, r) + } + } + assert.ElementsMatch(t, []string{"Mailer", "Cleaner"}, recvs, + "each receiver keeps its own spawn edge") +} + +// TestCSharpExtractor_HangfireSpawns_TVShowIsAClass: the type-param +// check is an AST fact, not a naming guess — a real class that happens +// to spell T+uppercase keeps its receiver-typed edge. +func TestCSharpExtractor_HangfireSpawns_TVShowIsAClass(t *testing.T) { + src := []byte(`public class Setup { + public void Configure() { + BackgroundJob.Enqueue(x => x.Refresh()); + } +} +`) + result, err := NewCSharpExtractor().Extract("Setup.cs", src) + require.NoError(t, err) + + e := findSpawn(result.Edges, "unresolved::*.Refresh") + require.NotNil(t, e, "TVShow is a class, not a type parameter") + assert.Equal(t, "TVShow", e.Meta["receiver_type"]) +} + +// TestCSharpExtractor_HangfireSpawns_NoWrongLambda: an invocationless +// job lambda must not let a later lambda (the cron callback) substitute, +// and a captured-receiver inline call carries no type evidence — both +// emit nothing. +func TestCSharpExtractor_HangfireSpawns_NoWrongLambda(t *testing.T) { + src := []byte(`public class Setup { + public void Configure(IJob job, string id) { + RecurringJob.AddOrUpdate(id, x => x.Prop, () => Cron.Daily()); + BackgroundJob.Enqueue(() => job.Run()); + } +} +`) + result, err := NewCSharpExtractor().Extract("Setup.cs", src) + require.NoError(t, err) + + for _, e := range result.Edges { + if e.Kind == graph.EdgeSpawns { + assert.NotEqual(t, "background", e.Meta["mode"], + "neither the cron callback nor a captured-receiver call may spawn: %s", e.To) + } + } +} + +// TestCSharpExtractor_HangfireSpawns_Negatives: same method names on +// other receivers, and non-registration Hangfire calls, emit nothing. +func TestCSharpExtractor_HangfireSpawns_Negatives(t *testing.T) { + src := []byte(`public class NotJobs { + public void Run(Cache cache) { + cache.AddOrUpdate("k", x => x.Refresh()); + RecurringJob.RemoveIfExists("sync users"); + } +} +`) + result, err := NewCSharpExtractor().Extract("NotJobs.cs", src) + require.NoError(t, err) + + for _, e := range result.Edges { + if e.Kind == graph.EdgeSpawns { + assert.NotEqual(t, "background", e.Meta["mode"], + "non-Hangfire receiver must not emit a background spawn: %s", e.To) + } + } +} diff --git a/internal/resolver/csharp_hangfire_spawn_test.go b/internal/resolver/csharp_hangfire_spawn_test.go new file mode 100644 index 000000000..97981c5bb --- /dev/null +++ b/internal/resolver/csharp_hangfire_spawn_test.go @@ -0,0 +1,35 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zzet/gortex/internal/graph" +) + +// TestResolveHangfireSpawn_ReceiverTyped locks the rail the C# Hangfire +// extraction rides: an EdgeSpawns targeting `unresolved::*.M` with a +// receiver_type hint must land on that type's method, not on a +// same-named method elsewhere. +func TestResolveHangfireSpawn_ReceiverTyped(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "Setup.cs::JobSetup.Configure", Kind: graph.KindMethod, Name: "Configure", + FilePath: "Setup.cs", Language: "csharp"}) + g.AddNode(&graph.Node{ID: "Sync.cs::SyncService.SynchronizeAsync", Kind: graph.KindMethod, Name: "SynchronizeAsync", + FilePath: "Sync.cs", Language: "csharp", Meta: map[string]any{"receiver": "SyncService"}}) + g.AddNode(&graph.Node{ID: "Other.cs::OtherService.SynchronizeAsync", Kind: graph.KindMethod, Name: "SynchronizeAsync", + FilePath: "Other.cs", Language: "csharp", Meta: map[string]any{"receiver": "OtherService"}}) + + spawn := &graph.Edge{ + From: "Setup.cs::JobSetup.Configure", To: "unresolved::*.SynchronizeAsync", + Kind: graph.EdgeSpawns, FilePath: "Setup.cs", Line: 5, + Meta: map[string]any{"mode": "background", "receiver_type": "SyncService"}, + } + g.AddEdge(spawn) + + New(g).ResolveAll() + + assert.Equal(t, "Sync.cs::SyncService.SynchronizeAsync", spawn.To, + "receiver-typed spawn must bind to the generic argument's type, not a same-named method") +}