From 04a7dff399fd3843eeebc56b5491e57af908c7ad Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 21:54:26 +0200 Subject: [PATCH] Preserve anonymous callback ownership in code graphs --- README.md | 8 +- analyse.go | 3 + anonymous.go | 82 ++++++++++++++ binding_test.go | 2 +- build.go | 6 ++ call.go | 12 ++- callback_test.go | 224 +++++++++++++++++++++++++++++++++++++++ cmd/outline/main_test.go | 29 +++++ resolve.go | 21 +++- ruby_resolve.go | 6 +- 10 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 anonymous.go create mode 100644 callback_test.go diff --git a/README.md b/README.md index c605bd6..cf4b690 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,13 @@ ID, bare name, or qualified name; `Callers`/`Callees` return one-hop call edges; `Affected(seeds, opts)` returns reverse-reachable evidence paths from a set of sinks; `Path(from, to, opts)` returns the shortest forward call chain; `JSON(w)` writes sorted output so repeated builds of unchanged input are -byte-identical. Go and Python include cross-file call resolution. Ruby includes +byte-identical. Anonymous Go functions, Python lambdas, and Ruby blocks have +their own function nodes and retain their body calls. Containment or passing a +callback does not add a call edge to its body. Directly invoked Go/Python +literals have call edges to their anonymous nodes; variable-held callables and +callback execution by another function remain unresolved. + +Go and Python include cross-file call resolution. Ruby includes direct calls within the same class or module and follows literal `require_relative` chains to singleton and `module_function` bodies in loaded files. It retains distinct instance and singleton method names, unresolved diff --git a/analyse.go b/analyse.go index 9d92e63..2e51d2d 100644 --- a/analyse.go +++ b/analyse.go @@ -24,6 +24,8 @@ type decl struct { Method bool ModuleFunction bool Owner string + Anonymous bool + BodyStart uint32 } func (d decl) symID(path string) string { @@ -87,6 +89,7 @@ func extractDecls(src []byte, l *lang, root *ts.Node, matches []ts.QueryMatch) [ for _, m := range matches { raw = append(raw, declsFromMatch(src, l, m)...) } + raw = append(raw, anonymousDecls(src, l, root)...) if exported, merge, ok := explicitExports(l, root, src); ok { for i := range raw { if merge { diff --git a/anonymous.go b/anonymous.go new file mode 100644 index 0000000..151cb9f --- /dev/null +++ b/anonymous.go @@ -0,0 +1,82 @@ +package outline + +import ( + "strconv" + + ts "github.com/odvcencio/gotreesitter" +) + +func anonymousName(kind string, start uint32) string { + return "<" + kind + "@" + strconv.FormatUint(uint64(start), 10) + ">" +} + +func anonymousDecls(src []byte, l *lang, root *ts.Node) []decl { + if l.name != "go" && l.name != "python" && l.name != "ruby" { + return nil + } + var out []decl + walkNamed(root, func(node *ts.Node) { + kind := "" + definition := node + switch l.name { + case "go": + if node.Type(l.language) == "func_literal" { + kind = "func" + } + case "python": + if node.Type(l.language) == "lambda" { + kind = "lambda" + } + case "ruby": + if node.Type(l.language) == "block" || node.Type(l.language) == "do_block" { + kind = "block" + if parent := node.Parent(); parent != nil && parent.Type(l.language) == "lambda" { + definition = parent + kind = "lambda" + } + } + } + if kind == "" { + return + } + bodyStart := node.EndByte() + if body := node.ChildByFieldName("body", l.language); body != nil { + bodyStart = body.StartByte() + } + callStart := bodyStart + if l.name == "ruby" { + if params := definition.ChildByFieldName("parameters", l.language); params != nil { + callStart = params.StartByte() + } + } + out = append(out, decl{ + Name: anonymousName(kind, definition.StartByte()), Kind: KindFunc, + Line: sourceLine(definition), NameAt: definition.StartByte(), + Start: definition.StartByte(), End: definition.EndByte(), SigEnd: bodyStart, + Parent: -1, Params: extractParams(src, l, definition), + Anonymous: true, BodyStart: callStart, + }) + }) + return out +} + +func callEnclosing(decls []decl, pos uint32) int { + best := -1 + for i, d := range decls { + if d.Start > pos { + break + } + // Lambda defaults execute outside their anonymous body. + if d.End > pos && (!d.Anonymous || pos >= d.BodyStart) { + best = i + } + } + return best +} + +func callFunction(node *ts.Node, language *ts.Language) *ts.Node { + for node != nil && node.Type(language) == "parenthesized_expression" && node.NamedChildCount() == 1 { + node = node.NamedChild(0) + } + return node +} diff --git a/binding_test.go b/binding_test.go index 4d7e589..5ef69f1 100644 --- a/binding_test.go +++ b/binding_test.go @@ -12,7 +12,7 @@ func TestBuildGoLocalBindings(t *testing.T) { {"short declaration", "helper := func(){}; helper()", 0, 1}, {"var declaration", "var helper = func(){}; helper()", 0, 1}, {"initializer", "helper := helper; helper()", 0, 1}, - {"initializer call", "helper := func(){ helper() }; helper()", 1, 1}, + {"initializer call", "helper := func(){ helper() }; helper()", 0, 1}, {"before declaration", "helper(); helper := func(){}; helper()", 1, 1}, {"sibling block", "{ helper := func(){}; helper() }; helper()", 1, 1}, {"if initializer", "if helper := func(){}; true { helper() }; helper()", 1, 1}, diff --git a/build.go b/build.go index f0c5107..91ccbc3 100644 --- a/build.go +++ b/build.go @@ -204,6 +204,12 @@ func emitFileNodes(g *Graph, files []fileAnalysis, methodOwners map[string]strin } func qualified(lang string, decls []decl, i int) string { + if d := decls[i]; d.Anonymous { + if d.Parent >= 0 { + return qualified(lang, decls, d.Parent) + "." + d.Name + } + return d.Name + } if lang == "ruby" { return rubyQualified(decls, i) } diff --git a/call.go b/call.go index f6e3350..890a337 100644 --- a/call.go +++ b/call.go @@ -56,7 +56,7 @@ func callsFor(src []byte, l *lang, root *ts.Node, decls []decl) ([]Call, bool) { return nil, false } for i := range calls { - calls[i].In = enclosing(decls, calls[i].Start) + calls[i].In = callEnclosing(decls, calls[i].Start) } return calls, true } @@ -67,7 +67,7 @@ func goCalls(src []byte, language *ts.Language, root *ts.Node) []Call { if node.Type(language) != "call_expression" { return } - fn := node.ChildByFieldName("function", language) + fn := callFunction(node.ChildByFieldName("function", language), language) if fn == nil { return } @@ -76,6 +76,9 @@ func goCalls(src []byte, language *ts.Language, root *ts.Node) []Call { case "identifier": c.Name = fn.Text(src) c.ReceiverKind = ReceiverBare + case "func_literal": + c.Name = anonymousName("func", fn.StartByte()) + c.ReceiverKind = ReceiverExpression case "selector_expression": recv := fn.ChildByFieldName("operand", language) member := fn.ChildByFieldName("field", language) @@ -104,7 +107,7 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { if node.Type(language) != "call" { return } - fn := node.ChildByFieldName("function", language) + fn := callFunction(node.ChildByFieldName("function", language), language) if fn == nil { return } @@ -113,6 +116,9 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { case "identifier": c.Name = fn.Text(src) c.ReceiverKind = ReceiverBare + case "lambda": + c.Name = anonymousName("lambda", fn.StartByte()) + c.ReceiverKind = ReceiverExpression case "attribute": recv := fn.ChildByFieldName("object", language) member := fn.ChildByFieldName("attribute", language) diff --git a/callback_test.go b/callback_test.go new file mode 100644 index 0000000..32afb2e --- /dev/null +++ b/callback_test.go @@ -0,0 +1,224 @@ +package outline + +import ( + "bytes" + "strings" + "testing" +) + +func TestBuildCallbackOwnership(t *testing.T) { + cases := []struct{ language, filename, source string }{ + {"go", "app.go", "package app\nfunc sink(){}\nfunc side(){}\nfunc register(callback func()){}\nfunc entry(){register(func(){sink(); register(func(){sink()})}); side()}\n"}, + {"python", "app.py", "def sink():\n pass\ndef side():\n pass\ndef register(callback):\n pass\ndef entry():\n register(lambda: (sink(), register(lambda: sink())))\n side()\n"}, + {"ruby", "app.rb", "def sink\nend\ndef side\nend\ndef register\nend\ndef entry\n register { sink(); register do\n sink()\n end }\n side()\nend\n"}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + sink := nodeByName(g, KindFunc, "sink") + side := nodeByName(g, KindFunc, "side") + if path := g.Path(entry.ID, sink.ID, TraverseOptions{IncludeInferred: true}); len(path) != 0 { + t.Fatalf("registration produced an execution path: %v", path) + } + if path := g.Path(entry.ID, side.ID, TraverseOptions{IncludeInferred: true}); len(path) != 1 { + t.Fatalf("ordinary call lost: %v", path) + } + owners := assertCallbackOwners(t, g, entry.ID, sink.ID) + assertCallbackContainment(t, g, entry.ID, sink.ID, owners) + assertCallbackRepeat(t, root, g) + }) + } +} + +func assertCallbackOwners(t *testing.T, g *Graph, entry, sink string) map[string]bool { + t.Helper() + owners := make(map[string]bool) + for _, edge := range g.Callers(sink) { + owner := g.Node(edge.From) + if owner == nil || owner.Kind != KindFunc || owner.Exported || owner.ID == entry || owner.Sig == "" { + t.Fatalf("callback call has wrong owner: %v", edge) + } + if !strings.HasPrefix(owner.Name, "<") || strings.Contains(owner.Sig, "sink()") { + t.Fatalf("callback lacks a compact identity/signature: %+v", owner) + } + if edge.Call == nil || edge.Call.Start < uint32(owner.Start) || edge.Call.End > uint32(owner.End) { + t.Fatalf("call span outside callback: %v", edge) + } + owners[owner.ID] = true + } + if len(owners) != 2 { + t.Fatalf("want two separate callback owners, got %v", owners) + } + return owners +} + +func assertCallbackContainment(t *testing.T, g *Graph, entry, sink string, owners map[string]bool) { + t.Helper() + contains := 0 + for _, edge := range g.Edges { + if edge.Rel == RelContains && owners[edge.To] && (edge.From == entry || owners[edge.From]) { + contains++ + } + } + if contains != 2 { + t.Fatalf("callback containment lost: %v", g.Edges) + } + for _, path := range g.Affected([]string{sink}, TraverseOptions{IncludeInferred: true}) { + if len(path) > 0 && path[0].From == entry { + t.Fatalf("affected query attributed callback execution to entry: %v", path) + } + } +} + +func assertCallbackRepeat(t *testing.T, root string, g *Graph) { + t.Helper() + repeated, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + var first, second bytes.Buffer + if err := g.JSON(&first); err != nil { + t.Fatal(err) + } + if err := repeated.JSON(&second); err != nil { + t.Fatal(err) + } + if !bytes.Equal(first.Bytes(), second.Bytes()) { + t.Fatal("callback graph changed on repeated build") + } +} + +func TestBuildDirectAnonymousInvocation(t *testing.T) { + cases := []struct{ filename, source string }{ + {"app.go", "package app\nfunc sink(){}\nfunc entry(){ (func(){sink()})() }\n"}, + {"app.py", "def sink():\n pass\ndef entry():\n (lambda: sink())()\n"}, + } + for _, c := range cases { + t.Run(c.filename, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + sink := nodeByName(g, KindFunc, "sink") + path := g.Path(entry.ID, sink.ID, TraverseOptions{}) + if len(path) != 2 || path[0].To != path[1].From || path[0].To == entry.ID { + t.Fatalf("direct invocation should pass through the anonymous callable: %v", path) + } + }) + } +} + +func TestBuildAnonymousParameterScope(t *testing.T) { + cases := []struct{ language, filename, source string }{ + {"go", "app.go", "package app\nfunc helper(){}\nfunc entry(){ register(func(helper func()){helper()}); helper() }\n"}, + {"python", "app.py", "def helper():\n pass\ndef entry():\n register(lambda helper: helper())\n helper()\n"}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + helper := nodeByName(g, KindFunc, "helper") + resolved := g.Callers(helper.ID) + if len(resolved) != 1 || resolved[0].From != entry.ID { + t.Fatalf("anonymous parameter leaked or failed to shadow: %v", resolved) + } + unresolved := g.Callers(ExtID(c.language, "", "helper")) + if len(unresolved) != 1 || unresolved[0].From == entry.ID { + t.Fatalf("parameter call should belong to the callback: %v", unresolved) + } + }) + } +} + +func TestBuildLambdaDefaultOwnership(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.py": "def default():\n pass\ndef body():\n pass\ndef entry():\n register(lambda helper=default(): body())\n"}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + defaults := g.Callers(nodeByName(g, KindFunc, "default").ID) + body := g.Callers(nodeByName(g, KindFunc, "body").ID) + if len(defaults) != 1 || defaults[0].From != entry.ID || len(body) != 1 || body[0].From == entry.ID { + t.Fatalf("lambda creation and body execution were merged: defaults=%v body=%v", defaults, body) + } +} + +func TestBuildRubyBlockSingletonContext(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.rb": "module Worker\n def self.sink\n end\nend\ndef Worker.entry\n register { self.sink(); sink() }\nend\n"}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := g.Def("Worker.entry") + sink := g.Def("Worker.sink") + if len(entry) != 1 || len(sink) != 1 { + t.Fatalf("missing methods: entry=%v sink=%v", entry, sink) + } + for _, edge := range g.Callers(sink[0].ID) { + if edge.From == entry[0].ID || !strings.HasPrefix(g.Node(edge.From).Name, "(item) { sink() })\nend\n"}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + edges := g.Callers(nodeByName(g, KindFunc, "sink").ID) + if len(edges) != 1 || !strings.HasPrefix(g.Node(edges[0].From).Name, "(item=default()) { body() })"} + for _, source := range cases { + t.Run(source, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.rb": "def default\nend\ndef body\nend\ndef entry\n " + source + "\nend\n"}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + defaults := g.Callers(nodeByName(g, KindFunc, "default").ID) + body := g.Callers(nodeByName(g, KindFunc, "body").ID) + if len(defaults) != 1 || len(body) != 1 || defaults[0].From == entry.ID || defaults[0].From != body[0].From { + t.Fatalf("Ruby callable defaults should be deferred with the body: defaults=%v body=%v", defaults, body) + } + }) + } +} diff --git a/cmd/outline/main_test.go b/cmd/outline/main_test.go index 3bbcce8..5f08fc5 100644 --- a/cmd/outline/main_test.go +++ b/cmd/outline/main_test.go @@ -211,3 +211,32 @@ func assertQuerySelection(t *testing.T, out, wantNodes, wantEdges string) { t.Fatalf("nodes=%v edges=%v, want nodes=%s edges=%s\n%s", nodes, edges, wantNodes, wantEdges, out) } } + +func TestCLIAnonymousOwnership(t *testing.T) { + cases := []struct{ language, filename, source, anonymous string }{ + {"go", "app.go", "package app\nfunc sink(){}\nfunc entry(){register(func(){sink()})}\n", "= 0 && decls[in].Anonymous { + in = decls[in].Parent + } + return in +} + // lexical walks outward from the enclosing declaration, returning the // innermost visible decl matching name. If a parameter of an enclosing // function matches first, it reports shadowed instead. Class bodies do diff --git a/ruby_resolve.go b/ruby_resolve.go index b6fad66..845821d 100644 --- a/ruby_resolve.go +++ b/ruby_resolve.go @@ -200,7 +200,7 @@ func rubyScopeKey(files []*fileAnalysis) string { func rubyIndexMethods(sc rubyScope, f *fileAnalysis) { for i, d := range f.a.Decls { - if d.Kind != KindFunc { + if d.Kind != KindFunc || d.Anonymous { continue } owner, _ := rubyCallContext(f.a.Decls, i) @@ -279,8 +279,8 @@ func (r *resolver) resolveRubyCall(f *fileAnalysis, c Call) (string, string) { if at >= 0 { owner = rubyQualified(f.a.Decls, at) } - if c.In >= 0 { - d := f.a.Decls[c.In] + if in := rubyContextIn(f.a.Decls, c.In); in >= 0 { + d := f.a.Decls[in] if d.Singleton && d.Owner != "" && d.Owner != "self" { owner, _ = rubyReceiverOwner(sc, f.a.Decls, d.Parent, d.Owner) }