From 84b7df948753fc7d90f732221a6331270d90c544 Mon Sep 17 00:00:00 2001 From: Kunal Roy Choudhury Date: Mon, 9 Nov 2026 21:29:08 +0530 Subject: [PATCH] fix(go): type a call's receiver from the enclosing function's parameters A Go function gets its collaborators as parameters, but only `:=`, `var` and struct fields were bound to a type. `func handle(s *Server) { s.Start() }` had no receiver type, so the call was dropped and `callers Server.Start` missed every caller that is handed the server instead of building it. Bind each parameter of a declared function or method (grouped names and named results included) to its type. A name declared again with a type we cannot read (a func literal parameter, a `:=` from an untyped call, a range or type-switch variable) now hides the outer binding instead of inheriting it, so `s.Start()` under `s := other()` no longer resolves to the parameter's type. --- src/graph/bindings.ts | 42 ++++++++++++++++-- test/graph-go-params.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 test/graph-go-params.test.ts diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index f637ff449..538699bc0 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -19,14 +19,22 @@ export class FileBindings { private map = new Map(); set(scopePath: string, name: string, type: string): void { - this.map.set(`${scopePath}|${name}`, type); + const key = `${scopePath}|${name}`; + if (this.map.get(key) === "") return; + this.map.set(key, type); + } + + /** `name` is declared in this scope with a type we cannot read, so it hides any outer + * binding of the same name and nothing binds it again. */ + shadow(scopePath: string, name: string): void { + this.map.set(`${scopePath}|${name}`, ""); } /** Innermost-first: for scope ["a","b"] name "x", tries `a.b|x`, `a|x`, `|x`. */ lookup(scope: string[], name: string): string | null { for (let i = scope.length; i >= 0; i--) { const hit = this.map.get(`${scope.slice(0, i).join(".")}|${name}`); - if (hit) return hit; + if (hit !== undefined) return hit || null; } return null; } @@ -712,6 +720,29 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo const name = node.childForFieldName("name"); const type = goTypeName(node.childForFieldName("type")); if (name?.type === "identifier" && type) bindings.set(scopePath, name.text, type); + else for (const id of node.namedChildren.filter((c) => c.type === "identifier")) bindings.shadow(scopePath, id.text); + return; + } + if (node.type === "parameter_declaration") { + // A declared function's or method's parameter, which is how Go code receives its + // collaborators (`func handle(s *Server)`). A func literal's parameters share the + // enclosing definition's scope here, so they hide the outer name instead of binding. + const owner = node.parent?.parent; + const declared = owner?.type === "function_declaration" || owner?.type === "method_declaration"; + if (!declared && owner?.type !== "func_literal") return; + const type = declared ? goTypeName(node.childForFieldName("type")) : null; + for (const name of node.namedChildren.filter((c) => c.type === "identifier")) { + if (type) bindings.set(scopePath, name.text, type); + else bindings.shadow(scopePath, name.text); + } + return; + } + if (node.type === "range_clause" || node.type === "receive_statement") { + for (const id of node.childForFieldName("left")?.namedChildren ?? []) if (id.type === "identifier") bindings.shadow(scopePath, id.text); + return; + } + if (node.type === "type_switch_statement") { + for (const id of node.childForFieldName("alias")?.namedChildren ?? []) if (id.type === "identifier") bindings.shadow(scopePath, id.text); return; } if (node.type === "field_declaration") { @@ -729,7 +760,11 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo for (let i = 0; i < names.length; i++) { const nameNode = names[i]; let expr = exprs[i]; - if (!nameNode || nameNode.type !== "identifier" || !expr) continue; + if (!nameNode || nameNode.type !== "identifier") continue; + if (!expr) { + bindings.shadow(scopePath, nameNode.text); + continue; + } if (expr.type === "unary_expression") { expr = expr.namedChildren.find((c) => c.type === "composite_literal") ?? expr; } @@ -743,6 +778,7 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo if (fn?.type === "identifier" && /^New[A-Z]/.test(fn.text)) typeName = fn.text.slice(3); } if (typeName) bindings.set(scopePath, nameNode.text, typeName); + else bindings.shadow(scopePath, nameNode.text); } } diff --git a/test/graph-go-params.test.ts b/test/graph-go-params.test.ts new file mode 100644 index 000000000..aa8686031 --- /dev/null +++ b/test/graph-go-params.test.ts @@ -0,0 +1,85 @@ +/** + * Go call receivers typed from the enclosing function's parameters in the Tier-1 code graph. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; + +test("Go extraction: a call through a typed parameter resolves to the parameter type's method", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-go-param-")); + try { + writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n"); + writeFileSync( + join(dir, "server.go"), + "package main\n\ntype Server struct{}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n\n" + + "type Other struct{}\n\nfunc (o *Other) Start() {}\n", + ); + writeFileSync( + join(dir, "use.go"), + "package main\n\n" + + "func pointer(s *Server) { s.Start() }\n" + + "func value(n int, s Server) { s.Stop() }\n" + + "func grouped(a, b *Server) { a.Start(); b.Stop() }\n" + + "func (o *Other) method(s *Server) { s.Start() }\n" + + "func named() (s *Server, err error) { s.Start(); return }\n" + + "func closure() { f := func(s *Server) { s.Start() }; f(nil) }\n" + + "func untyped(s []Server) { s.Start() }\n", + ); + + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + const calls = (source: string) => + graph.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target).sort(); + assert.deepEqual(calls("use.go#pointer"), ["server.go#Server.Start"]); + assert.deepEqual(calls("use.go#value"), ["server.go#Server.Stop"]); + assert.deepEqual(calls("use.go#grouped"), ["server.go#Server.Start", "server.go#Server.Stop"]); + assert.deepEqual(calls("use.go#Other.method"), ["server.go#Server.Start"]); + assert.deepEqual(calls("use.go#named"), ["server.go#Server.Start"]); + assert.deepEqual(calls("use.go#untyped"), []); + // a func literal shares its enclosing definition's scope, so its parameters stay unbound + assert.deepEqual(calls("use.go#closure"), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Go extraction: a func literal parameter, an untyped local or a range variable hides the typed parameter of that name", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-go-param-shadow-")); + try { + writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n"); + writeFileSync( + join(dir, "server.go"), + "package main\n\ntype Server struct{}\n\nfunc (s *Server) Start() {}\n\n" + + "type Other struct{}\n\nfunc (o *Other) Start() {}\n\nfunc make2() *Other { return nil }\n", + ); + writeFileSync( + join(dir, "use.go"), + "package main\n\n" + + "func literal(s *Server) { run(func(s *Other) { s.Start() }) }\n" + + "func local(s *Server) { if true { s := make2(); s.Start() } }\n" + + "func ranged(s *Server, xs []*Other) { for _, s := range xs { s.Start() } }\n" + + "func multi(s *Server) { if true { s, err := two(); _ = err; s.Start() } }\n" + + "func plain(s *Server) { s.Start() }\n" + + "func run(f func(*Other)) {}\n" + + "func two() (*Other, error) { return nil, nil }\n", + ); + + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + const calls = (source: string) => + graph.edges.filter((e) => e.relation === "calls" && e.source === source && e.target.includes("Start")).map((e) => e.target); + assert.deepEqual(calls("use.go#literal"), []); + assert.deepEqual(calls("use.go#local"), []); + assert.deepEqual(calls("use.go#ranged"), []); + assert.deepEqual(calls("use.go#multi"), []); + assert.deepEqual(calls("use.go#plain"), ["server.go#Server.Start"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});