diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf87f2b4..eb27eddfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/go-func-field-dispatch.test.ts b/__tests__/go-func-field-dispatch.test.ts new file mode 100644 index 000000000..bac929ddc --- /dev/null +++ b/__tests__/go-func-field-dispatch.test.ts @@ -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 = { + '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 => (e.metadata ?? {}) as Record; + +/** 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); +}); diff --git a/__tests__/synthesized-hop.test.ts b/__tests__/synthesized-hop.test.ts index 22a17ed8b..88524784a 100644 --- a/__tests__/synthesized-hop.test.ts +++ b/__tests__/synthesized-hop.test.ts @@ -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'], diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts index 741749c7a..aa68ee979 100644 --- a/src/graph/synthesized-hop.ts +++ b/src/graph/synthesized-hop.ts @@ -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, ' ')); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 86db4faf1..355cb3294 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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) }, // `` / an internal `` — 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> = { - 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, }; diff --git a/src/resolution/go-func-field-synthesizer.ts b/src/resolution/go-func-field-synthesizer.ts new file mode 100644 index 000000000..12732240f --- /dev/null +++ b/src/resolution/go-func-field-synthesizer.ts @@ -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; +} + +/** 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`(?> 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(map: Map, 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 { + // ---- Source ----------------------------------------------------------------- + const maskedMemo = new Map(); + 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(); + const nodesNamed = (name: string): Node[] => + byNameMemo.get(name) ?? remember(byNameMemo, 8192, name, ctx.getNodesByName(name).filter((n) => n.language === 'go')); + const importDirMemo = new Map(); + 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(); + /** 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(); + /** 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(); + 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(); // `${dir}\0${name}` + const selectorNames = new Map(); // 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(); + 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(); + 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(); + /** 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(); + 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(); + 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(); // channel → functions put in it + const copies = new Map>(); // 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>(); + 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`(? /^([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(); + const valuesOf = (channel: string): FieldValue[] => { + let out = valuesMemo.get(channel); + if (out) return out; + const byTarget = new Map(); + 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(); + 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; +} diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f1f558e08..e76a5b6cc 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2703,7 +2703,7 @@ const GO_PREDECLARED_CALLS: ReadonlySet = 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, 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, + 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 => {