mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 06:53:52 +08:00
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:
co-authored by
Claude Opus 5.5
parent
e24827011e
commit
6e24711590
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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, ' '));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user