fix(go): a call through a func-typed struct field reaches the functions put in it (#2745)

A Go struct can hold a function in a field and call it through the field.
Prometheus' service discovery runs every provider that way: refresh.Discovery
calls d.refreshf(ctx), NewDiscovery copies refreshf from Options.RefreshF,
and each provider passes its own method (RefreshF: d.refresh). Nothing
answered d.refreshf, so a flow from Discovery.Run stopped at the field; #2430
rightly dropped the interface-impl edges that had stood in for it.

goFuncFieldEdges, a Go-only synthesis pass, links a call x.F(...) through a
func field of a struct to each function or method value put in that field:
by a composite literal (keyed or positional, found through its instantiates
edge), by an assignment x.F = v, or by one level of field-to-field copy
(refreshf: opts.RefreshF). A value is what the resolver already linked it
to, else x's method m for x.m, an interface's method for an interface value.
Receivers are typed through the resolver's own Go scope reader
(goLocalDeclarationAt over goLocalDecl, #2706), or by a local's composite
literal, new(T), type assertion or resolved call result. Channels are keyed
by package, struct and field, so a generic field name never crosses between
unrelated structs; one package's test wiring links no other package's test;
an edge cites a function's production wiring; generated files are not read.

Edges are calls, provenance heuristic, synthesizedBy go-func-field, with
field, via (package-qualified when the copied field is another package's)
and registeredAt; explore, the node trail and ContextBuilder read them as
"func field `Discovery.refreshf` (set from `Options.RefreshF`)". The pass
sits after the Go framework passes, with a pool cost hint, and every Go file
now counts as a synthesis input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 13:04:48 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e24827011e
commit 6e24711590
7 changed files with 1298 additions and 4 deletions
+1
View File
@@ -161,6 +161,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a type defined over a slice, map, function or basic type now counts as implementing the interfaces its methods satisfy, just as a struct does: prometheus's `type staticDiscoverer []*targetgroup.Group` is a `Discoverer`, gin's `type formSource map[string][]string` a `setter`, etcd's `type Int64Comparable int64` a `Comparable`, and an adapter like `type HandlerFunc func(…)` a `Handler`. Before, only structs were considered, so these types were missing from an interface's implementations in `codegraph_explore`, impact and the type hierarchy, and a call through the interface, like `Discoverer.Run` or `setter.TrySet`, stopped there instead of reaching their methods. Re-index Go projects after upgrading.
- In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading.
- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading.
- In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading.
- In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading.
- In Go, a struct now links to the types its fields are declared with, like etcd's `PutResponse` holding a `*ResponseHeader` or gin's `Context` holding a `HandlersChain`, so impact on `ResponseHeader` or `HandlersChain` reaches every struct that holds one and the code that uses those structs, and `codegraph_node` lists a struct's field types. Before, a struct linked only to the types it embeds. A field written through another package, like `*pb.ResponseHeader`, links to that package's type, one from outside your project, like `context.Context`, links to nothing, the types an embedded generic is filled in with, like the `Pod` in client-go's `listers.ResourceIndexer[*v1.Pod]`, link too, and a struct that holds itself, like a list node's `next *node`, doesn't link to itself. `codegraph_explore` still answers a question about a flow with the code that runs it, not with the structs that merely hold one of its types. Re-index Go projects after upgrading.
- In Go, an unexported name used in another file of its package, like prometheus's `memSeries` in `tsdb/head_append.go`, now links to that package's own declaration when a package nested inside it, like `tsdb/agent`, declares the same name. Before, such a parameter, result, struct field, composite literal or call linked to nothing. A call or a function value that the surrounding function binds itself, like a test's `init := func(…)` followed by `init(t)`, or a local `volumeSpec` variable passed on, no longer links to the package's declaration of that name. A name bound in one `case` of a `switch` or `select`, or a parameter named inside a function-typed result like `func() func(run string)`, is not such a binding anywhere else, so a call there still links. Thanks @inth3shadows. (#2580) Re-index Go projects after upgrading.
+434
View File
@@ -0,0 +1,434 @@
/**
* A Go struct can hold a function in a field and call it through the field.
* Prometheus' service discovery runs every provider that way:
*
* type Discovery struct { refreshf func(ctx context.Context) ([]*targetgroup.Group, error) }
* func NewDiscovery(opts Options) *Discovery { return &Discovery{refreshf: opts.RefreshF} }
* func (d *Discovery) refresh(ctx context.Context) (…) { … d.refreshf(ctx) … }
*
* and each provider wires its own method in, `refresh.NewDiscovery(refresh.Options{…,
* RefreshF: d.refresh})`. Static extraction sees a call to `d.refreshf`, which no method
* answers, so a flow from `Discovery.Run` stopped at `refresh` and never reached a
* provider. The go-func-field pass links the call to every function the field is given,
* with the wiring site as `registeredAt`.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import type { Edge, Node } from '../src/types';
const FILES: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
'discovery/refresh/refresh.go': `package refresh
import "context"
type Group struct{ Source string }
// Options are the options for a refreshing discovery.
type Options struct {
Mech string
Interval int
RefreshF func(ctx context.Context) ([]*Group, error)
}
// Discovery runs a refresh function on an interval.
type Discovery struct {
interval int
refreshf func(ctx context.Context) ([]*Group, error)
}
func NewDiscovery(opts Options) *Discovery {
return &Discovery{
interval: opts.Interval,
refreshf: opts.RefreshF,
}
}
func (d *Discovery) Run(ctx context.Context) {
d.refresh(ctx)
}
func (d *Discovery) refresh(ctx context.Context) ([]*Group, error) {
tgs, err := d.refreshf(ctx)
return tgs, err
}
`,
// A provider puts its own method in, through Options.
'discovery/aws/ec2.go': `package aws
import (
"context"
"example.com/app/discovery/refresh"
)
type EC2Discovery struct {
*refresh.Discovery
region string
}
func NewEC2Discovery(region string) *EC2Discovery {
d := &EC2Discovery{region: region}
d.Discovery = refresh.NewDiscovery(refresh.Options{
Mech: "ec2",
RefreshF: d.refresh,
})
return d
}
func (d *EC2Discovery) refresh(ctx context.Context) ([]*refresh.Group, error) {
return []*refresh.Group{{Source: d.region}}, nil
}
`,
// A provider puts in an interface value's method: what a call returned.
'discovery/hetzner/hetzner.go': `package hetzner
import (
"context"
"example.com/app/discovery/refresh"
)
type refresher interface {
refresh(ctx context.Context) ([]*refresh.Group, error)
}
type robotDiscovery struct{ endpoint string }
func (d *robotDiscovery) refresh(ctx context.Context) ([]*refresh.Group, error) {
return nil, nil
}
type Discovery struct {
*refresh.Discovery
}
func newRefresher(endpoint string) (refresher, error) {
return &robotDiscovery{endpoint: endpoint}, nil
}
func NewDiscovery(endpoint string) (*Discovery, error) {
r, err := newRefresher(endpoint)
if err != nil {
return nil, err
}
return &Discovery{
Discovery: refresh.NewDiscovery(refresh.Options{
Mech: "hetzner",
RefreshF: r.refresh,
}),
}, nil
}
`,
// The same field name in two unrelated structs.
'jobs/jobs.go': `package jobs
type Job struct {
name string
fn func() error
}
func cleanup() error { return nil }
func NewCleanupJob() *Job {
return &Job{name: "cleanup", fn: cleanup}
}
// What a parameter holds, or a function literal, is no function the project declares.
func NewJob(name string, fn func() error) *Job {
return &Job{name: name, fn: fn}
}
func NewInlineJob() *Job {
return &Job{name: "inline", fn: func() error { return nil }}
}
func (j *Job) Run() error {
return j.fn()
}
`,
'hooks/hooks.go': `package hooks
type Hook struct {
fn func() error
}
func notify() error { return nil }
func NewHook() *Hook {
h := &Hook{}
h.fn = notify
return h
}
func (h *Hook) Fire() error {
return h.fn()
}
`,
// A positional literal, as etcd's printers are built.
'printer/printer.go': `package printer
type printerRPC struct {
prefix string
p func(v any)
}
func printJSON(v any) {}
func NewJSONPrinter() *printerRPC {
return &printerRPC{"json", printJSON}
}
func (p *printerRPC) Put(v any) { p.p(v) }
`,
// A field its own package fills in production and in a test, and another package's test.
'client/client.go': `package client
type Client struct {
Hook func()
}
func defaultHook() {}
func (c *Client) Do() { c.Hook() }
`,
'client/wire.go': `package client
func New() *Client {
return &Client{Hook: defaultHook}
}
`,
'client/a_test.go': `package client
import "testing"
func newTestClient() *Client {
return &Client{Hook: defaultHook}
}
func TestDo(t *testing.T) {
c := newTestClient()
c.Hook()
}
`,
'other/other_test.go': `package other
import (
"testing"
"example.com/app/client"
)
func otherHook() {}
func TestOther(t *testing.T) {
c := client.New()
c.Hook = otherHook
c.Do()
}
`,
// A field of a named func type, given a method value through a conversion.
'server/server.go': `package server
type HandlerFunc func(path string) error
type Server struct {
handler HandlerFunc
}
func (s *Server) serveStatic(path string) error { return nil }
func New() *Server {
s := &Server{}
s.handler = HandlerFunc(s.serveStatic)
return s
}
func (s *Server) Handle(path string) error {
return s.handler(path)
}
`,
};
let root = '';
let cg: CodeGraph;
let prevOffloadDisable: string | undefined;
beforeAll(async () => {
// Assertions read RAW codegraph_explore output; managed offload would replace it.
prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE;
process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-func-field-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
}, 120_000);
afterAll(() => {
cg?.destroy();
if (root) fs.rmSync(root, { recursive: true, force: true });
if (prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
else process.env.CODEGRAPH_OFFLOAD_DISABLE = prevOffloadDisable;
});
const meta = (e: Edge): Record<string, unknown> => (e.metadata ?? {}) as Record<string, unknown>;
/** The line of `fixture` holding `text`, as `file:line`. */
function siteOf(file: string, text: string): string {
const index = FILES[file]!.split('\n').findIndex((l) => l.includes(text));
expect(index, `${text} in ${file}`).toBeGreaterThanOrEqual(0);
return `${file}:${index + 1}`;
}
/** The one function or method `qualifiedName` in `file`. */
function fn(qualifiedName: string, file: string): Node {
const name = qualifiedName.split('::').pop()!;
const found = cg
.getNodesByName(name)
.filter((n) => n.filePath === file && n.qualifiedName === qualifiedName && (n.kind === 'function' || n.kind === 'method'));
expect(found, `${qualifiedName} in ${file}`).toHaveLength(1);
return found[0]!;
}
/** The go-func-field edges out of a function, as `target @registeredAt [field via]`. */
function funcFieldCalls(from: Node): string[] {
return cg
.getOutgoingEdgesFrom([from.id], ['calls'])
.filter((e) => meta(e).synthesizedBy === 'go-func-field')
.map((e) => {
const t = cg.getNode(e.target)!;
const m = meta(e);
return `${t.qualifiedName} (${t.filePath}) @${String(m.registeredAt)} [${String(m.field)}${m.via ? ` via ${String(m.via)}` : ''}]`;
})
.sort();
}
describe('a call through a Go func field reaches the functions put in it', () => {
it("links prometheus' refresh to each provider's method, through the Options field it is copied from", () => {
const refresh = fn('Discovery::refresh', 'discovery/refresh/refresh.go');
expect(funcFieldCalls(refresh)).toEqual([
`EC2Discovery::refresh (discovery/aws/ec2.go) @${siteOf('discovery/aws/ec2.go', 'RefreshF: d.refresh')} [Discovery.refreshf via Options.RefreshF]`,
// `r refresher`, from newRefresher's result: the interface method, which its implementations answer.
`refresher::refresh (discovery/hetzner/hetzner.go) @${siteOf('discovery/hetzner/hetzner.go', 'RefreshF: r.refresh')} [Discovery.refreshf via Options.RefreshF]`,
]);
// The edge sits on the call through the field.
const lines = cg
.getOutgoingEdgesFrom([refresh.id], ['calls'])
.filter((e) => meta(e).synthesizedBy === 'go-func-field')
.map((e) => `${e.line}:${e.provenance}`);
const call = siteOf('discovery/refresh/refresh.go', 'd.refreshf(ctx)').split(':').pop();
expect(new Set(lines)).toEqual(new Set([`${call}:heuristic`]));
});
it('keeps a field name two unrelated structs share apart', () => {
expect(funcFieldCalls(fn('Job::Run', 'jobs/jobs.go'))).toEqual([
`cleanup (jobs/jobs.go) @${siteOf('jobs/jobs.go', 'fn: cleanup')} [Job.fn]`,
]);
expect(funcFieldCalls(fn('Hook::Fire', 'hooks/hooks.go'))).toEqual([
`notify (hooks/hooks.go) @${siteOf('hooks/hooks.go', 'h.fn = notify')} [Hook.fn]`,
]);
});
it('reads a positional literal and a conversion to a func type', () => {
expect(funcFieldCalls(fn('printerRPC::Put', 'printer/printer.go'))).toEqual([
`printJSON (printer/printer.go) @${siteOf('printer/printer.go', '"json", printJSON')} [printerRPC.p]`,
]);
expect(funcFieldCalls(fn('Server::Handle', 'server/server.go'))).toEqual([
`Server::serveStatic (server/server.go) @${siteOf('server/server.go', 'HandlerFunc(s.serveStatic)')} [Server.handler]`,
]);
});
it("cites a function's production wiring, and keeps one package's test wiring out of another's tests", () => {
const wired = `defaultHook (client/client.go) @${siteOf('client/wire.go', 'Hook: defaultHook')} [Client.Hook]`;
expect(funcFieldCalls(fn('Client::Do', 'client/client.go'))).toEqual([
wired,
// What another package's test puts in the field reaches the production call it makes.
`otherHook (other/other_test.go) @${siteOf('other/other_test.go', 'c.Hook = otherHook')} [Client.Hook]`,
]);
// client's own test binary never holds other's test.
expect(funcFieldCalls(fn('TestDo', 'client/a_test.go'))).toEqual([wired]);
});
it('puts no edge on a function that only wires a field', () => {
for (const [name, file] of [
['NewDiscovery', 'discovery/refresh/refresh.go'],
['NewEC2Discovery', 'discovery/aws/ec2.go'],
['NewCleanupJob', 'jobs/jobs.go'],
['NewHook', 'hooks/hooks.go'],
] as const) {
expect(funcFieldCalls(fn(name, file)), name).toEqual([]);
}
});
it('codegraph_explore follows the flow from Run to the provider, with the wiring site', async () => {
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'Discovery.Run EC2Discovery.refresh' });
const text = res.content?.[0]?.text ?? '';
const flow = text.slice(0, text.indexOf('**Exploration'));
expect(flow).toContain('**Flow');
expect(flow).toContain(
`↓ dynamic: func field \`Discovery.refreshf\` (set from \`Options.RefreshF\`) @${siteOf('discovery/aws/ec2.go', 'RefreshF: d.refresh')}`
);
expect(flow).toMatch(/refresh \(discovery\/aws\/ec2\.go:\d+\)/);
});
});
describe('a sync that wires a function into a field', () => {
let dir = '';
let graph: CodeGraph | undefined;
afterEach(() => {
graph?.destroy();
graph = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = '';
});
it('links the provider it adds, and unlinks it when the wiring goes', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-func-field-sync-'));
const write = (rel: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
};
// A file that only wires: no method, no struct of its own.
const gce = (options: string) => `package gce
import (
"context"
"example.com/app/discovery/refresh"
)
func refreshInstances(ctx context.Context) ([]*refresh.Group, error) { return nil, nil }
func NewDiscovery() *refresh.Discovery {
return refresh.NewDiscovery(refresh.Options{${options}})
}
`;
write('go.mod', FILES['go.mod']!);
write('discovery/refresh/refresh.go', FILES['discovery/refresh/refresh.go']!);
write('discovery/gce/gce.go', gce('Mech: "gce"'));
graph = await CodeGraph.init(dir, { index: true });
const g = graph;
const linked = (): string[] => {
const [refresh] = g.getNodesByName('refresh').filter((n) => n.qualifiedName === 'Discovery::refresh');
return g
.getOutgoingEdgesFrom([refresh!.id], ['calls'])
.filter((e) => meta(e).synthesizedBy === 'go-func-field')
.map((e) => `${g.getNode(e.target)!.name} @${String(meta(e).registeredAt)}`);
};
expect(linked()).toEqual([]);
write('discovery/gce/gce.go', gce('Mech: "gce", RefreshF: refreshInstances'));
await g.sync({ paths: ['discovery/gce/gce.go'] });
expect(linked()).toEqual(['refreshInstances @discovery/gce/gce.go:12']);
write('discovery/gce/gce.go', gce('Mech: "gce"'));
await g.sync({ paths: ['discovery/gce/gce.go'] });
expect(linked()).toEqual([]);
}, 60_000);
});
+7
View File
@@ -102,6 +102,13 @@ describe('describeSynthesizedHop', () => {
[{ synthesizedBy: 'redux-thunk', via: 'fetchUser' }, 'redux thunk', ''],
[{ synthesizedBy: 'gin-middleware-chain', via: 'Auth' }, 'gin middleware chain', ''],
[{ synthesizedBy: 'event-bus', channel: 'event', event: 'user.created' }, 'bus event `user.created`', ''],
// A Go func field, and one whose function was copied in from another field.
[{ synthesizedBy: 'go-func-field', field: 'Lexer.state' }, 'func field `Lexer.state`', ''],
[
{ synthesizedBy: 'go-func-field', field: 'Discovery.refreshf', via: 'Options.RefreshF' },
'func field `Discovery.refreshf` (set from `Options.RefreshF`)',
'',
],
// The detail only the call paths print.
[{ synthesizedBy: 'queue-job', event: 'send-email', queue: 'emails' }, 'queue job `send-email`', ' on `emails`'],
[{ synthesizedBy: 'event-bus', channel: 'socket', event: 'chat', tier: 'client→server' }, 'socket message `chat`', ' → server'],
+4
View File
@@ -75,6 +75,10 @@ export function describeSynthesizedHop(edge: Edge | null | undefined): Synthesiz
return hop(`fn-pointer ${m.via ? String(m.via) : ''}`);
case 'goframe-route':
return hop(`GoFrame route ${m.route ? String(m.route) : ''}`);
case 'go-func-field':
// Go: a call through a struct field holding a function, `d.refreshf(ctx)`;
// `via` is the field it was copied from, which the wiring site writes.
return hop(`func field ${quoted(m.field, 'a func field')}${m.via ? ` (set from \`${String(m.via)}\`)` : ''}`);
default:
// redux-thunk, cpp-override, gin-middleware-chain, flutter-build, …
return hop(by.replace(/-/g, ' '));
+8 -2
View File
@@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
import { goframeRouteEdges } from './goframe-synthesizer';
import { goFuncFieldEdges } from './go-func-field-synthesizer';
import { expoRouterReturnEdges } from './expo-router-synthesizer';
import { nextLinkEdges } from './next-router-synthesizer';
import { reactRouterLinkEdges } from './react-router-synthesizer';
@@ -4910,7 +4911,9 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean
if (/\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc|def|inc|tbl)$/i.test(filePath) &&
/\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true;
if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true;
if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true;
// Every Go file: besides the declarations the interface passes read, a call
// through a func field, or a function put in one, can sit in any of them.
if (/\.go$/.test(filePath)) return true;
if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true;
if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true;
// A wrapper renders the component it wraps (jsxEdges), in a file with no markup of its own too.
@@ -5009,6 +5012,9 @@ export const SYNTH_PASSES: SynthPassDef[] = [
run: (q, c, y, sub) => cFnPointerDispatchEdges(q, c, y, sub),
},
{ name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) },
// `d.refreshf(ctx)` onto the functions put in that func field (`RefreshF: d.refresh`).
// After the Go framework passes: a pair one of them bridges keeps its more specific edge.
{ name: 'goFuncFieldEdges', gate: (has) => has('go'), run: (q, c, y) => goFuncFieldEdges(q, c, y, MAX_CALLBACKS_PER_CHANNEL) },
// `router.push(await helper())` — the helper's return literals are the screens.
{ name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
// `<Link href="/x">` / an internal `<a href>` — markup, not a call; the component navigates.
@@ -5030,7 +5036,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [
* order after these. A wrong hint costs wall time, never edges.
*/
const SYNTH_PASS_COST_HINT: Readonly<Record<string, number>> = {
cFnPtrEdges: 100, registryEdges: 60, tierEdges: 55, jsxEdges: 25, rnEventEdgesList: 24,
cFnPtrEdges: 100, registryEdges: 60, tierEdges: 55, goFuncFieldEdges: 30, jsxEdges: 25, rnEventEdgesList: 24,
ifaceEdges: 18, flutterEdges: 16, cppEdges: 15, emitterEdges: 12, fieldEdges: 11,
mybatisEdges: 10, vuexEdges: 8, closureCollEdges: 6, piniaEdges: 5, renderEdges: 4,
};
+817
View File
@@ -0,0 +1,817 @@
/**
* Go func-field dispatch synthesis.
*
* A Go struct can hold a function in a field and call it through the field:
*
* type Discovery struct { refreshf func(ctx context.Context) ([]*Group, error) }
* func (d *Discovery) refresh(ctx context.Context) (…) { … d.refreshf(ctx) … }
*
* Static extraction sees a call to `d.refreshf`, which no method answers, so a
* flow stops at the field. Prometheus' service discovery is the canonical
* shape: `refresh.Discovery.refresh` calls `d.refreshf`, which `NewDiscovery`
* copies from `Options.RefreshF`, which each provider fills with its own method
* value (`refresh.Options{…, RefreshF: d.refresh}` in discovery/aws/ec2.go).
*
* This links the function making the call to each function or method value
* the field is given, keyed by (struct, field):
* • a composite literal — keyed `S{F: v}` or positional `S{a, v}` — found
* through the literal's resolved `instantiates` edge, so the struct is the
* one the resolver chose, package and all;
* • an assignment `x.F = v`;
* • one level of field-to-field copy: `S{F: o.G}` / `x.F = o.G` gives S.F
* what T.G is given directly (`refreshf: opts.RefreshF`).
* A value is what the resolver already linked it to — the function-value
* `references` edge at that spot — else, for `x.m`, the method `m` of x's
* type: an interface's method when x is an interface value (`r.refresh` with
* `r refresher`), whose interface-impl edges reach the implementations. A
* function literal has no node and links nothing; a conversion to a func type
* (`HandlerFunc(h.serve)`) is the value it converts.
*
* A call or an assignment counts only where its receiver's type is known: the
* receiver, parameter or local in scope there, as the resolver's Go scope
* reader finds it, writes the type, or the local's value gives it — a
* composite literal, `new(T)`, a type assertion or a resolved call's result.
* One field hop is allowed (`s.opts.F(…)`), and the field is that struct's own
* or one embedding promotes from a struct one level down. A generic field name
* (`fn`, `handler`, `f`) shared by unrelated structs never crosses between them.
* Generated files (`// Code generated … DO NOT EDIT.`, `*.pb.go`) are not read.
*
* Edges are `calls`, `provenance: 'heuristic'`, `synthesizedBy: 'go-func-field'`,
* with `field` (`Discovery.refreshf`), `via` for a value copied from another
* field (`Options.RefreshF`, or `rest.Config.WrapTransport` from another
* package), and `registeredAt` = where the function was put
* in a field (`discovery/aws/ec2.go:221`) — its production wiring before its
* test wiring when it is put in at several. Each channel keeps at most the
* caller's per-channel cap of values, in the same order, and a function one
* package's test puts in a field links no call in another package's test:
* each package's tests build a binary of their own.
*/
import type { Edge, Node } from '../types';
import type { QueryBuilder } from '../db/queries';
import type { ResolutionContext } from './types';
import type { MaybeYield } from './cooperative-yield';
import { enclosingFn } from './synth-utils';
import { blankGoCode, goLocalDeclarationAt, GO_TYPE_KINDS } from './name-matcher';
/** One field of a Go struct, in declaration order. */
interface GoField {
name: string;
/** The type as written, comments and tag dropped: `func(ctx context.Context) error`, `*refresh.Discovery`. */
type: string;
/** An embedded field (`*refresh.Discovery`): named for its type, and its fields are promoted. */
embedded: boolean;
}
/** A struct type, by package directory and name (build-tag twins share one). */
interface GoStructType {
key: string;
name: string;
dir: string;
/** Its package's name, as the `package` clause writes it. */
pkg: string;
/** The first declaration; its file qualifies the field types. */
node: Node;
fields: GoField[];
funcFields: Set<string>;
}
/** A function put in a field, and where. */
interface FieldValue {
target: Node;
/** `file:line` of the wiring. */
site: string;
test: boolean;
/** The field it was copied from, for a value that came through another field. */
via?: string;
}
/** Source with comments and literal contents blanked, plus its line starts. */
interface MaskedFile {
text: string;
lineStarts: number[];
}
/** A type as written somewhere, with the file whose package and imports qualify it. */
interface TypeText {
type: string;
file: string;
}
/** What a type expression names: a project struct, a project interface, or nothing known. */
type ResolvedType = { kind: 'struct'; struct: GoStructType } | { kind: 'interface'; node: Node } | null;
/** What a value put in a field is. */
type ValueOutcome = { kind: 'fn'; target: Node } | { kind: 'copy'; holder: GoStructType; field: string } | null;
const IDENT = String.raw`[A-Za-z_]\w*`;
/** `x.F(` / `x.y.F(` — a call through a selector, its receiver at most two identifiers long. */
const SELECTOR_CALL_RE = new RegExp(String.raw`(?<![\w.])(${IDENT}(?:\s*\.\s*${IDENT})?)\s*\.\s*(${IDENT})\s*\(`, 'g');
/** `x.F = v` / `x.y.F = v` — never `==`. */
const SELECTOR_ASSIGN_RE = new RegExp(String.raw`(?<![\w.])(${IDENT}(?:\s*\.\s*${IDENT})?)\s*\.\s*(${IDENT})\s*=(?!=)`, 'g');
/** Cheap prefilter on the raw text: `.F(` or `.F =` for some name. */
const SELECTOR_USE_RE = /\.\s*([A-Za-z_]\w*)\s*(?:\(|=(?!=))/g;
/** A type written as a name: `T`, `*T`, `pkg.T`, `T[int]`. */
const NAMED_TYPE_RE = new RegExp(String.raw`^[*&\s]*(${IDENT})(?:\s*\.\s*(${IDENT}))?\s*(?:\[[\s\S]*\])?\s*$`);
/** A plain value: `h`, `d.refresh`, `pkg.Handle`. */
const PLAIN_VALUE_RE = new RegExp(String.raw`^${IDENT}(?:\s*\.\s*${IDENT})?$`);
/** The head of a `type` spec, from its name to its type: `HandlerFunc func(`, `Alias = pkg.T`. */
const TYPE_SPEC_RE = new RegExp(String.raw`^${IDENT}\s*(?:\[[^\]]*\])?\s*=?\s*([^\n;]+)`);
/** The names before the one a declaration is read at: `a, ` in `a, b := …`. */
const NAMES_BEFORE_RE = new RegExp(String.raw`(?:${IDENT}\s*,\s*)*$`);
/** A declaration's names from the one it is read at, to its `:=` or `=`: `b, c :=` in `a, b, c := …`. */
const NAMES_FROM_RE = new RegExp(String.raw`^${IDENT}((?:\s*,\s*${IDENT})*)\s*(?::=|=(?!=))`);
/** Words a result list's `x T` item can start with that are a type, not a name. */
const TYPE_KEYWORDS = new Set(['chan', 'func', 'map', 'struct', 'interface']);
/** Predeclared types: never a project type, never worth a lookup. */
const PREDECLARED_TYPES = new Set([
'any', 'bool', 'byte', 'comparable', 'complex64', 'complex128', 'error', 'float32', 'float64',
'int', 'int8', 'int16', 'int32', 'int64', 'rune', 'string',
'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
]);
function lineStartsOf(text: string): number[] {
const starts = [0];
for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
return starts;
}
/** 1-based line of `offset`. */
function lineAt(file: MaskedFile, offset: number): number {
let lo = 0;
let hi = file.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (file.lineStarts[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
/** Offset of the bracket matching the one at `open`, in masked text; -1 when unbalanced. */
function matchingBracket(text: string, open: number): number {
let depth = 0;
for (let i = open; i < text.length; i++) {
const c = text[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (--depth === 0) return i;
}
}
return -1;
}
/**
* The top-level items between the bracket at `open` and its match at `close`,
* in masked text, each with where it starts: `{a: x, b: y}` → `a: x`, `b: y`.
*/
function listItems(text: string, open: number, close: number): Array<{ text: string; start: number }> {
const items: Array<{ text: string; start: number }> = [];
let depth = 0;
let start = open + 1;
const push = (end: number): void => {
const raw = text.slice(start, end);
const t = raw.trim();
if (t) items.push({ text: t, start: start + raw.length - raw.trimStart().length });
};
for (let i = open + 1; i < close; i++) {
const c = text[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === ',' && depth === 0) {
push(i);
start = i + 1;
}
}
push(close);
return items;
}
/** Where the statement from `from` ends: a line break or `;` outside brackets, or a bracket closing around it. */
function statementEnd(text: string, from: number): number {
let depth = 0;
for (let i = from; i < text.length; i++) {
const c = text[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) return i;
depth--;
} else if ((c === '\n' || c === ';') && depth === 0) {
return i;
}
}
return text.length;
}
/**
* Each item's type in a Go result list: `(int, error)` and `(n int, err error)`
* give `int`, `error`; a grouped `(a, b int)` gives `int` twice. A list of bare
* types (`(chan int, error)`) names nothing: a named list's last item always
* has a type.
*/
function goListTypes(items: readonly string[]): string[] {
const split = (it: string): RegExpExecArray | null => {
const m = /^([A-Za-z_]\w*)\s+(\S[\s\S]*)$/.exec(it.trim());
return m && !TYPE_KEYWORDS.has(m[1]!) ? m : null;
};
if (items.length === 0 || !split(items[items.length - 1]!)) return items.map((it) => it.trim());
const types: string[] = [];
let type = '';
for (let i = items.length - 1; i >= 0; i--) {
const m = split(items[i]!);
if (m) type = m[2]!.trim();
types.unshift(type);
}
return types;
}
/** The result types of a signature `(params) results` as extraction stores it; null for another shape. */
function goResultTypes(signature: string | undefined): string[] | null {
const sig = signature ? blankGoCode(signature).trim() : '';
if (!sig.startsWith('(')) return null;
const close = matchingBracket(sig, 0);
if (close < 0) return null;
const rest = sig.slice(close + 1).trim();
if (!rest.startsWith('(')) return rest ? [rest] : [];
const end = matchingBracket(rest, 0);
return end < 0 ? null : goListTypes(listItems(rest, 0, end).map((i) => i.text));
}
function pkgDir(filePath: string): string {
const f = filePath.replace(/\\/g, '/');
const slash = f.lastIndexOf('/');
return slash < 0 ? '.' : f.slice(0, slash);
}
/**
* Production wiring before test wiring, then by site: the order a capped
* channel keeps its values in, and which site a value put in a field at
* several cites.
*/
function wiringOrder(a: FieldValue, b: FieldValue): number {
return Number(a.test) - Number(b.test) || a.site.localeCompare(b.site, 'en', { numeric: true }) || a.target.id.localeCompare(b.target.id);
}
/** Store `value` under `key` in a memo that drops its oldest entry past `max`, so a whole-repo walk stays bounded. */
function remember<K, V>(map: Map<K, V>, max: number, key: K, value: V): V {
if (map.size >= max) map.delete(map.keys().next().value as K);
map.set(key, value);
return value;
}
/**
* Edges from each Go function that calls through a func-typed struct field to
* the functions the field is given. See the file comment.
*/
export async function goFuncFieldEdges(
queries: QueryBuilder,
ctx: ResolutionContext,
onYield: MaybeYield,
maxPerChannel: number,
): Promise<Edge[]> {
// ---- Source -----------------------------------------------------------------
const maskedMemo = new Map<string, MaskedFile | null>();
const maskedFile = (file: string, raw?: string | null): MaskedFile | null => {
const hit = maskedMemo.get(file);
if (hit !== undefined) return hit;
const content = raw === undefined ? ctx.readFile(file) : raw;
let m: MaskedFile | null = null;
if (content !== null) {
const text = blankGoCode(content);
m = { text, lineStarts: lineStartsOf(text) };
}
return remember(maskedMemo, 128, file, m);
};
const offsetOf = (f: MaskedFile, line: number, column: number): number =>
(f.lineStarts[line - 1] ?? f.text.length) + column;
// ---- Types, by package ------------------------------------------------------
const byNameMemo = new Map<string, Node[]>();
const nodesNamed = (name: string): Node[] =>
byNameMemo.get(name) ?? remember(byNameMemo, 8192, name, ctx.getNodesByName(name).filter((n) => n.language === 'go'));
const importDirMemo = new Map<string, string | null>();
const importDir = (qualifier: string, file: string): string | null => {
const key = `${file}\0${qualifier}`;
const hit = importDirMemo.get(key);
if (hit !== undefined) return hit;
const imp = ctx.getImportMappings(file, 'go').find((i) => i.localName === qualifier);
// Room for every qualifier the type scan below reads, so none is looked up twice.
return remember(importDirMemo, 65536, key, imp ? ctx.getGoPackageDir?.(imp.source, file) ?? null : null);
};
const typeNodes = (name: string, dir: string): Node[] =>
PREDECLARED_TYPES.has(name) ? [] : nodesNamed(name).filter((n) => GO_TYPE_KINDS.has(n.kind) && pkgDir(n.filePath) === dir);
/** type_alias node id → the type it is declared as, as written after its name (filled by the type scan below). */
const specs = new Map<string, string | null>();
/** The declared type of a `type` spec node: `func(*Context)` for `type HandlerFunc func(*Context)`. */
const specType = (n: Node): string | null => specs.get(n.id) ?? null;
const funcTypeMemo = new Map<string, boolean>();
/** Whether a `type` spec declares a function type: `type HandlerFunc func(*Context)`. */
const isFuncTypeNode = (n: Node): boolean => {
let yes = funcTypeMemo.get(n.id);
if (yes === undefined) {
yes = n.kind === 'type_alias' && /^func\s*\(/.test(specType(n) ?? '');
funcTypeMemo.set(n.id, yes);
}
return yes;
};
/** The project types a name written in `file` can mean: `T` in its own package, `pkg.T` in the imported one. */
const namedTypes = (text: string, file: string): { name: string; dir: string; nodes: Node[] } | null => {
const m = NAMED_TYPE_RE.exec(text.trim());
if (!m || (!m[2] && PREDECLARED_TYPES.has(m[1]!))) return null;
const dir = m[2] ? importDir(m[1]!, file) : pkgDir(file);
if (!dir) return null;
const name = m[2] ?? m[1]!;
return { name, dir, nodes: typeNodes(name, dir) };
};
/** Whether a field's declared type can be called: `func(…)…`, or a project func type (not a pointer to one). */
const isFuncFieldType = (type: string, file: string): boolean => {
if (/^func\s*\(/.test(type)) return true;
if (type.startsWith('*')) return false;
const named = namedTypes(type, file);
return !!named && named.nodes.length > 0 && named.nodes.every(isFuncTypeNode);
};
/** A struct's fields, read from its declaration in `f`. */
const readFields = (f: MaskedFile, n: Node): GoField[] | null => {
const from = offsetOf(f, n.startLine, n.startColumn ?? 0);
const to = f.lineStarts[n.endLine ?? n.startLine] ?? f.text.length;
const kw = /\bstruct\s*\{/.exec(f.text.slice(from, to));
if (!kw) return null;
const open = from + kw.index + kw[0].length - 1;
const close = matchingBracket(f.text, open);
if (close < 0) return null;
const fields: GoField[] = [];
let depth = 0;
let start = open + 1;
const take = (end: number): void => {
let decl = f.text.slice(start, end).trim();
// A tag is a string literal, blanked down to its quotes.
const quote = decl[decl.length - 1];
if (quote === '`' || quote === '"') decl = decl.slice(0, Math.max(0, decl.lastIndexOf(quote, decl.length - 2))).trim();
if (!decl) return;
const embedded = NAMED_TYPE_RE.exec(decl);
if (embedded) {
fields.push({ name: embedded[2] ?? embedded[1]!, type: decl, embedded: true });
return;
}
const named = /^([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s+(\S[\s\S]*)$/.exec(decl);
if (!named) return;
const type = named[2]!.replace(/\s+/g, ' ').trim();
for (const name of named[1]!.split(',')) fields.push({ name: name.trim(), type, embedded: false });
};
for (let i = open + 1; i < close; i++) {
const c = f.text[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if ((c === '\n' || c === ';') && depth === 0) {
take(i);
start = i + 1;
}
}
take(close);
return fields;
};
// Generated code — protobuf messages, deep copies, clientsets, mocks (`// Code
// generated … DO NOT EDIT.`) — is left out: it is no flow a reader follows.
const goFiles = ctx.getAllFiles().filter((file) => file.endsWith('.go'));
const isGenerated = queries.generatedPredicateFor(goFiles);
// Each file declaring a struct or another type is read once: its structs'
// fields, its specs' types, and the names it uses as `.F(` / `.F =`, so the
// site scan below reads it again only when one of those is a func field's.
const declsByFile = new Map<string, Node[]>();
let scanned = 0;
for (const kind of ['struct', 'type_alias'] as const) {
for (const n of queries.iterateNodesByKindIn(kind, ['go'])) {
if ((++scanned & 255) === 0) await onYield();
if (isGenerated(n.filePath)) continue;
const list = declsByFile.get(n.filePath);
if (list) list.push(n);
else declsByFile.set(n.filePath, [n]);
}
}
const structs = new Map<string, GoStructType>(); // `${dir}\0${name}`
const selectorNames = new Map<string, string>(); // file → ` F G … `, the names it uses as `.F(` / `.F =`
let filesRead = 0;
for (const [file, decls] of declsByFile) {
if ((++filesRead & 31) === 0) await onYield();
const f = maskedFile(file);
if (!f) continue;
const dir = pkgDir(file);
const pkg = /^\s*package\s+([A-Za-z_]\w*)/m.exec(f.text)?.[1] ?? dir.slice(dir.lastIndexOf('/') + 1);
// The packages the types written here come through, looked up while the
// file's imports are at hand rather than after it has left every cache.
const qualifiers = (type: string | null | undefined): void => {
const q = type ? NAMED_TYPE_RE.exec(type) : null;
if (q?.[2]) importDir(q[1]!, file);
};
for (const n of decls) {
if (n.kind === 'type_alias') {
const at = offsetOf(f, n.startLine, n.startColumn ?? 0);
const spec = TYPE_SPEC_RE.exec(f.text.slice(at, at + 400))?.[1]?.trim() ?? null;
specs.set(n.id, spec);
qualifiers(spec);
continue;
}
const key = `${dir}\0${n.name}`;
if (structs.has(key)) continue; // a build-tag twin: the first declaration speaks for both
const fields = readFields(f, n);
if (!fields) continue;
structs.set(key, { key, name: n.name, dir, pkg, node: n, fields, funcFields: new Set() });
for (const fld of fields) qualifiers(fld.type);
}
const used = new Set<string>();
SELECTOR_USE_RE.lastIndex = 0;
for (let m = SELECTOR_USE_RE.exec(f.text); m; m = SELECTOR_USE_RE.exec(f.text)) used.add(m[1]!);
// Joined into one new string, which keeps no slice of the file's text alive.
selectorNames.set(file, ` ${[...used].join(' ')} `);
}
const funcFieldNames = new Set<string>();
for (const s of structs.values()) {
for (const fld of s.fields) {
if (fld.embedded || !isFuncFieldType(fld.type, s.node.filePath)) continue;
s.funcFields.add(fld.name);
funcFieldNames.add(fld.name);
}
}
if (funcFieldNames.size === 0) return [];
const resolveMemo = new Map<string, ResolvedType>();
/** What a type written in `file` names: a project struct or interface; a defined type or alias is followed. */
const resolveType = (text: string, file: string, depth = 0): ResolvedType => {
const key = `${file}\0${text}`;
const hit = resolveMemo.get(key);
if (hit !== undefined) return hit;
let out: ResolvedType = null;
const named = depth <= 3 ? namedTypes(text, file) : null;
if (named) {
const s = structs.get(`${named.dir}\0${named.name}`);
const iface = named.nodes.find((t) => t.kind === 'interface');
if (s) out = { kind: 'struct', struct: s };
else if (iface) out = { kind: 'interface', node: iface };
else if (named.nodes.length > 0) {
// `type A = B` and `type A B` have B's fields.
const under = specType(named.nodes[0]!);
if (under) out = resolveType(under, named.nodes[0]!.filePath, depth + 1);
}
}
return remember(resolveMemo, 8192, key, out);
};
/** Whether `s` declares a method named `name` (selector lookup takes it over a promoted field). */
const hasMethod = (s: GoStructType, name: string): boolean =>
nodesNamed(name).some((n) => n.kind === 'method' && n.qualifiedName === `${s.name}::${name}` && pkgDir(n.filePath) === s.dir);
/** The struct whose func field `field` a value of `s` reaches: its own, or the one embedding promotes it from. */
const fieldOwner = (s: GoStructType, field: string): GoStructType | null => {
if (s.funcFields.has(field)) return s;
if (s.fields.some((f) => f.name === field) || hasMethod(s, field)) return null;
const holders: GoStructType[] = [];
for (const emb of s.fields) {
if (!emb.embedded) continue;
const t = resolveType(emb.type, s.node.filePath);
if (t?.kind === 'struct' && t.struct.fields.some((f) => f.name === field)) holders.push(t.struct);
}
// Two at one depth is ambiguous to Go; one must hold a func field there.
return holders.length === 1 && holders[0]!.funcFields.has(field) ? holders[0]! : null;
};
// ---- Typing a name inside a function -------------------------------------
const staticCallsMemo = new Map<string, Edge[]>();
const staticCalls = (fn: Node): Edge[] =>
staticCallsMemo.get(fn.id) ??
remember(staticCallsMemo, 256, fn.id, queries.getOutgoingEdges(fn.id, ['calls']).filter((e) => e.provenance !== 'heuristic'));
/**
* What the right side of a declaration gives its `index`th name (of
* `names`): a composite literal's type, `new(T)`, a type assertion, or a
* resolved call's result. Undefined when it can't be told.
*/
const rhsType = (f: MaskedFile, fn: Node, rhsStart: number, index: number, names: number): TypeText | undefined => {
const rhs = f.text.slice(rhsStart, statementEnd(f.text, rhsStart));
if (/^\s*range\b/.test(rhs)) return undefined;
const lead = rhs.length - rhs.trimStart().length;
const values = listItems(`(${rhs})`, 0, rhs.length + 1);
let value: string;
let result = index;
if (names > 1 && values.length === names) {
value = values[index]!.text;
result = 0;
} else if (values.length === 1) {
value = values[0]!.text;
} else {
return undefined;
}
const typeAt = (re: string): TypeText | undefined => {
const m = new RegExp(re).exec(value);
return m && result === 0 ? { type: m[1]!, file: fn.filePath } : undefined;
};
const literal = typeAt(String.raw`^&?\s*(${IDENT}(?:\s*\.\s*${IDENT})?)\s*(?:\[[^\]]*\])?\s*\{`);
if (literal) return literal;
const created = typeAt(String.raw`^new\s*\(\s*(${IDENT}(?:\s*\.\s*${IDENT})?)\s*(?:\[[^\]]*\])?\s*\)$`);
if (created) return created;
const asserted = typeAt(String.raw`\.\s*\(\s*(?!type\s*\))(\*?\s*${IDENT}(?:\s*\.\s*${IDENT})?)\s*\)$`);
if (asserted) return asserted;
// `f(…)`, `pkg.F(…)`, `x.M(…)` — the whole value one call the resolver linked.
const call = new RegExp(String.raw`^(${IDENT}(?:\s*\.\s*${IDENT})*)\s*\(`).exec(value);
if (!call || matchingBracket(value, call[0].length - 1) !== value.length - 1) return undefined;
const name = call[1]!.replace(/\s+/g, '');
const line = lineAt(f, rhsStart + lead);
const callees = [...new Set(staticCalls(fn).filter((e) => e.line === line && e.metadata?.refName === name).map((e) => e.target))];
if (callees.length !== 1) return undefined;
const callee = queries.getNodeById(callees[0]!);
if (!callee || (callee.kind !== 'function' && callee.kind !== 'method')) return undefined;
const type = goResultTypes(callee.signature)?.[result];
return type ? { type, file: callee.filePath } : undefined;
};
/**
* What `name` is where offset `at` uses it in `fn`: the receiver, parameter
* or local the resolver's scope reader finds in scope there, with its type —
* the type its declaration writes, else what its value gives it — or null
* when that can't be told. Undefined when `fn` declares no such name: it is
* package-level, or an import.
*/
const lookupName = (fn: Node, f: MaskedFile, name: string, at: number): TypeText | null | undefined => {
const line = lineAt(f, at);
const decl = goLocalDeclarationAt(name, { filePath: fn.filePath, line, column: at - f.lineStarts[line - 1]! }, ctx);
if (!decl) return undefined;
if (decl.type !== undefined) return decl.type.startsWith('...') ? null : { type: decl.type, file: fn.filePath };
// `r, err := newRefresher(…)`, `var x = f()`, `v, ok := y.(*T)`: the name's place in its declaration.
const p = offsetOf(f, decl.line, decl.column);
const from = NAMES_FROM_RE.exec(f.text.slice(p, p + 400));
if (!from) return null;
const before = NAMES_BEFORE_RE.exec(f.text.slice(f.lineStarts[decl.line - 1]!, p))![0];
const index = before ? before.split(',').length - 1 : 0;
const names = index + (from[1] ? from[1].split(',').length : 1);
return rhsType(f, fn, p + from[0].length, index, names) ?? null;
};
/** The type of `name` where offset `at` uses it in `fn`; null when it can't be told. */
const typeOfName = (fn: Node, f: MaskedFile, name: string, at: number): TypeText | null =>
lookupName(fn, f, name, at) ?? null;
/** The struct or interface `x` / `x.y` has where offset `at` uses it in `fn`. */
const typeOfExpr = (fn: Node, f: MaskedFile, expr: string, at: number): ResolvedType => {
const segs = expr.split('.');
const base = typeOfName(fn, f, segs[0]!, at);
let t = base ? resolveType(base.type, base.file) : null;
for (const seg of segs.slice(1)) {
if (t?.kind !== 'struct') return null;
let holder: GoStructType = t.struct;
let fld = holder.fields.find((x) => x.name === seg);
if (!fld) {
// Promoted from a struct embedded one level down.
const owners = holder.fields
.filter((x) => x.embedded)
.map((x) => resolveType(x.type, holder.node.filePath))
.filter((r): r is { kind: 'struct'; struct: GoStructType } => r?.kind === 'struct' && r.struct.fields.some((x) => x.name === seg));
if (owners.length !== 1) return null;
holder = owners[0]!.struct;
fld = holder.fields.find((x) => x.name === seg);
}
t = fld ? resolveType(fld.type, holder.node.filePath) : null;
}
return t;
};
// ---- Values put in fields ----------------------------------------------------
const fnRefsMemo = new Map<string, Edge[]>();
const fnRefs = (owner: Node): Edge[] =>
fnRefsMemo.get(owner.id) ??
remember(fnRefsMemo, 256, owner.id, queries.getOutgoingEdges(owner.id, ['references']).filter((e) => e.metadata?.fnRef === true));
/** The one method named `member` a type declares (an interface's, or a struct's own). */
const methodOf = (typeNode: Node, member: string): Node | null => {
const hits = queries.getOutgoingEdges(typeNode.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method' && n.name === member);
return hits.length === 1 ? hits[0]! : null;
};
/** What a value put in a field is: a function or method, another func field's values, or nothing known. */
const valueOf = (owner: Node, fn: Node | null, f: MaskedFile, value: string, at: number, line: number): ValueOutcome => {
let v = value.trim();
// A conversion to a func type is the value it converts: `HandlerFunc(h.serve)`.
const conv = new RegExp(String.raw`^(${IDENT}(?:\s*\.\s*${IDENT})?)\s*\(`).exec(v);
if (conv && matchingBracket(v, conv[0].length - 1) === v.length - 1) {
const named = namedTypes(conv[1]!, owner.filePath);
if (!named || named.nodes.length === 0 || !named.nodes.every(isFuncTypeNode)) return null;
v = v.slice(conv[0].length, -1).trim();
}
if (!PLAIN_VALUE_RE.test(v)) return null;
const name = v.replace(/\s+/g, '');
const dot = name.indexOf('.');
const base = dot < 0 ? name : name.slice(0, dot);
const member = dot < 0 ? '' : name.slice(dot + 1);
// What `base` names in the enclosing function: a local, a parameter or the receiver — else something package-level.
const local = fn ? lookupName(fn, f, base, at) : undefined;
// The resolver's own reading of this function value.
const refs = [...new Set(fnRefs(owner).filter((e) => e.line === line && e.metadata?.refName === name).map((e) => e.target))];
const ref = refs.length === 1 ? queries.getNodeById(refs[0]!) : null;
const refFn = ref && (ref.kind === 'function' || ref.kind === 'method') ? ref : null;
if (dot < 0) {
// A bare name is a package-level function: Go names no method bare, and a local holds some other function.
if (local !== undefined) return null;
if (refFn) return refFn.kind === 'function' ? { kind: 'fn', target: refFn } : null;
if (refs.length > 0) return null;
// Extraction keeps a bare function value only when its file declares or imports it.
const fns = nodesNamed(name).filter((n) => n.kind === 'function' && pkgDir(n.filePath) === pkgDir(owner.filePath));
return fns.length === 1 ? { kind: 'fn', target: fns[0]! } : null;
}
const t = local ? resolveType(local.type, local.file) : null;
if (t?.kind === 'interface') {
const m = methodOf(t.node, member);
if (m) return { kind: 'fn', target: m };
} else if (t?.kind === 'struct') {
// `opts.RefreshF`: another func field, whose functions this one gets.
const holder = fieldOwner(t.struct, member);
if (holder) return { kind: 'copy', holder, field: member };
const m = refFn ? null : methodOf(t.struct.node, member);
if (m) return { kind: 'fn', target: m };
}
if (refFn) return { kind: 'fn', target: refFn };
if (local !== undefined || refs.length > 0) return null;
// `pkg.Func`.
const dir = importDir(base, owner.filePath);
if (!dir) return null;
const fns = nodesNamed(member).filter((n) => n.kind === 'function' && pkgDir(n.filePath) === dir);
return fns.length === 1 ? { kind: 'fn', target: fns[0]! } : null;
};
const direct = new Map<string, FieldValue[]>(); // channel → functions put in it
const copies = new Map<string, Array<{ from: string; label: string }>>(); // channel → channels copied into it
/** Note what a value put in `into`'s field `field` is. */
const record = (into: GoStructType, field: string, outcome: ValueOutcome, file: string, line: number): void => {
if (!outcome) return;
const channel = `${into.key}\0${field}`;
if (outcome.kind === 'copy') {
const from = `${outcome.holder.key}\0${outcome.field}`;
if (from === channel) return;
// `refreshf: opts.RefreshF` reads `Options.RefreshF`; another package's is `rest.Config.WrapTransport`.
const { holder } = outcome;
const label = `${holder.dir === into.dir ? '' : `${holder.pkg}.`}${holder.name}.${outcome.field}`;
const list = copies.get(channel) ?? [];
if (!list.some((c) => c.from === from)) list.push({ from, label });
copies.set(channel, list);
return;
}
const list = direct.get(channel) ?? [];
const value: FieldValue = { target: outcome.target, site: `${file}:${line}`, test: file.endsWith('_test.go') };
const had = list.findIndex((v) => v.target.id === value.target.id);
if (had < 0) list.push(value);
else if (wiringOrder(value, list[had]!) < 0) list[had] = value;
direct.set(channel, list);
};
// ---- Composite literals of the structs with func fields, by the file they're in ----
const literalsByFile = new Map<string, Array<{ s: GoStructType; owner: Node; line: number; column: number }>>();
for (const s of structs.values()) {
if (s.funcFields.size === 0) continue;
for (const decl of typeNodes(s.name, s.dir)) {
if (decl.kind !== 'struct') continue;
for (const e of queries.getIncomingEdges(decl.id, ['instantiates'])) {
const owner = e.line ? queries.getNodeById(e.source) : null;
if (!owner || owner.language !== 'go') continue;
const list = literalsByFile.get(owner.filePath) ?? [];
list.push({ s, owner, line: e.line!, column: e.column ?? 0 });
literalsByFile.set(owner.filePath, list);
}
}
}
await onYield();
/** The functions a struct literal puts in func fields: `S{F: v}`, or positional `S{a, v}`. */
const readLiteral = (f: MaskedFile, lit: { s: GoStructType; owner: Node; line: number; column: number }): void => {
const { s, owner } = lit;
// The literal's `{` is on its type's line: Go puts no line break between them.
const lineStart = f.lineStarts[lit.line - 1] ?? 0;
const lineText = f.text.slice(lineStart, f.lineStarts[lit.line] ?? f.text.length);
const typeRe = new RegExp(String.raw`(?<![\w.])(?:(${IDENT})\s*\.\s*)?${s.name}\s*(?:\[[^\]{}]*\])?\s*\{`, 'g');
let open = -1;
let nearest = Infinity;
for (let m = typeRe.exec(lineText); m; m = typeRe.exec(lineText)) {
// The type as written must name this struct's package: `refresh.Options` through its import, a bare name its own.
if ((m[1] ? importDir(m[1], owner.filePath) : pkgDir(owner.filePath)) !== s.dir) continue;
const distance = Math.abs(m.index - lit.column);
if (distance < nearest) {
nearest = distance;
open = lineStart + m.index + m[0].length - 1;
}
}
if (open < 0) return;
const close = matchingBracket(f.text, open);
if (close < 0) return;
const fn = owner.kind === 'function' || owner.kind === 'method' ? owner : null;
const items = listItems(f.text, open, close);
const keys = items.map((it) => /^([A-Za-z_]\w*)\s*:(?!=)\s*/.exec(it.text));
const fieldsAt: Array<{ field: string; value: string; at: number }> = [];
if (keys.every((k) => k)) {
items.forEach((it, i) => fieldsAt.push({ field: keys[i]![1]!, value: it.text.slice(keys[i]![0].length), at: it.start + keys[i]![0].length }));
} else if (keys.every((k) => !k) && items.length === s.fields.length) {
items.forEach((it, i) => fieldsAt.push({ field: s.fields[i]!.name, value: it.text, at: it.start }));
}
for (const { field, value, at } of fieldsAt) {
if (!s.funcFields.has(field)) continue;
const line = lineAt(f, at);
record(s, field, valueOf(owner, fn, f, value, at, line), owner.filePath, line);
}
};
// ---- Each Go file once: its literals, its calls through a field, its assignments to one ----
interface Dispatch { fn: Node; line: number; channel: string; field: string }
const dispatches: Dispatch[] = [];
let filesScanned = 0;
for (const file of goFiles) {
if ((++filesScanned & 31) === 0) await onYield();
if (isGenerated(file)) continue;
const literals = literalsByFile.get(file);
const used = selectorNames.get(file);
let raw: string | null | undefined;
let mentions = false;
if (used !== undefined) {
mentions = used.split(' ').some((name) => funcFieldNames.has(name));
} else {
raw = ctx.readFile(file);
if (!raw) continue;
SELECTOR_USE_RE.lastIndex = 0;
for (let m = SELECTOR_USE_RE.exec(raw); m && !mentions; m = SELECTOR_USE_RE.exec(raw)) mentions = funcFieldNames.has(m[1]!);
}
if (!mentions && !literals) continue;
const f = maskedFile(file, raw);
if (!f) continue;
for (const lit of literals ?? []) readLiteral(f, lit);
if (!mentions) continue;
const nodes = ctx.getNodesInFile(file);
SELECTOR_CALL_RE.lastIndex = 0;
for (let m = SELECTOR_CALL_RE.exec(f.text); m; m = SELECTOR_CALL_RE.exec(f.text)) {
const field = m[2]!;
if (!funcFieldNames.has(field)) continue;
const line = lineAt(f, m.index);
const fn = enclosingFn(nodes, line);
if (!fn || fn.language !== 'go') continue;
const t = typeOfExpr(fn, f, m[1]!.replace(/\s+/g, ''), m.index);
const holder = t?.kind === 'struct' ? fieldOwner(t.struct, field) : null;
if (holder) dispatches.push({ fn, line, channel: `${holder.key}\0${field}`, field: `${holder.name}.${field}` });
}
SELECTOR_ASSIGN_RE.lastIndex = 0;
for (let m = SELECTOR_ASSIGN_RE.exec(f.text); m; m = SELECTOR_ASSIGN_RE.exec(f.text)) {
const field = m[2]!;
if (!funcFieldNames.has(field)) continue;
const fn = enclosingFn(nodes, lineAt(f, m.index));
if (!fn || fn.language !== 'go') continue;
const t = typeOfExpr(fn, f, m[1]!.replace(/\s+/g, ''), m.index);
const holder = t?.kind === 'struct' ? fieldOwner(t.struct, field) : null;
if (!holder) continue;
const valueStart = m.index + m[0].length;
const value = f.text.slice(valueStart, statementEnd(f.text, valueStart));
const at = valueStart + value.length - value.trimStart().length;
const line = lineAt(f, at);
record(holder, field, valueOf(fn, fn, f, value, at, line), file, line);
}
}
// ---- Edges -------------------------------------------------------------------
const valuesMemo = new Map<string, FieldValue[]>();
const valuesOf = (channel: string): FieldValue[] => {
let out = valuesMemo.get(channel);
if (out) return out;
const byTarget = new Map<string, FieldValue>();
const add = (v: FieldValue): void => {
const had = byTarget.get(v.target.id);
if (!had || wiringOrder(v, had) < 0) byTarget.set(v.target.id, v);
};
for (const v of direct.get(channel) ?? []) add(v);
for (const c of copies.get(channel) ?? []) for (const v of direct.get(c.from) ?? []) add({ ...v, via: c.label });
out = [...byTarget.values()].sort(wiringOrder).slice(0, maxPerChannel);
valuesMemo.set(channel, out);
return out;
};
const edges: Edge[] = [];
const seen = new Set<string>();
for (const d of dispatches) {
const testCall = d.fn.filePath.endsWith('_test.go');
for (const v of valuesOf(d.channel)) {
if (v.target.id === d.fn.id) continue;
// Each package's tests build a binary of their own: what one package's
// test puts in a field, another package's test never calls.
if (testCall && v.test && pkgDir(v.site.slice(0, v.site.lastIndexOf(':'))) !== pkgDir(d.fn.filePath)) continue;
const key = `${d.fn.id}>${v.target.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: d.fn.id,
target: v.target.id,
kind: 'calls',
line: d.line,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'go-func-field',
field: d.field,
...(v.via ? { via: v.via } : {}),
registeredAt: v.site,
},
});
}
}
return edges;
}
+27 -2
View File
@@ -2703,7 +2703,7 @@ const GO_PREDECLARED_CALLS: ReadonlySet<string> = new Set([
* statement's blocks (`else` included), a `select` case's in its clause, a
* parameter in its function's body.
*/
function goLocalDecl(name: string, ref: UnresolvedRef, context: ResolutionContext): GoDecl | undefined {
function goLocalDecl(name: string, ref: Pick<UnresolvedRef, 'filePath' | 'line' | 'column'>, context: ResolutionContext): GoDecl | undefined {
const index = goScopeIndex(ref.filePath, context);
if (!index?.decls.has(name)) return undefined;
const at = (index.lineStarts[ref.line - 1] ?? index.code.length) + Math.max(0, ref.column);
@@ -2714,6 +2714,31 @@ function goLocalDecl(name: string, ref: UnresolvedRef, context: ResolutionContex
return found?.[2];
}
/**
* The declaration {@link goLocalDecl} reads for `name` at `at` (1-based line,
* 0-based column): where the declared name is, and the type it is declared
* with when the declaration writes one — `d *Discovery`, `var o refresh.Options`,
* `x := &T{…}`. Undefined when the function around `at` declares no such name:
* it is package-level, or an import.
*/
export function goLocalDeclarationAt(
name: string,
at: Pick<UnresolvedRef, 'filePath' | 'line' | 'column'>,
context: ResolutionContext
): { line: number; column: number; type?: string } | undefined {
const decl = goLocalDecl(name, at, context);
if (!decl) return undefined;
const { lineStarts } = goScopeIndex(at.filePath, context)!;
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid]! <= decl.at) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: decl.at - lineStarts[lo]!, type: decl.type };
}
/**
* Whether a Go reference's name is a parameter or local of the function
* around it, which shadows every declaration of its package: client-go's
@@ -3031,7 +3056,7 @@ function goScopesOf(index: GoScopeIndex, name: string): Array<[number, number, G
* blanked: offsets and newlines stay, so a regex match still maps to its
* line and column.
*/
function blankGoCode(src: string): string {
export function blankGoCode(src: string): string {
const parts: string[] = [];
let last = 0;
const blank = (from: number, to: number): void => {