diff --git a/CHANGELOG.md b/CHANGELOG.md index 106f2518a..863e2441d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -323,6 +323,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, the methods a type gets from an embedded standard-library type, like `io.Closer`, `http.ResponseWriter`, `testing.TB`, `*os.File` or `sync.Mutex`, or from the built-in `error`, now count when working out which types implement an interface, each with its parameters and results. Before, they were left out: prometheus's `TBRun`, which is all of `testing.TB` plus `Run`, was listed as implemented by a type that only has a `Run`, an interface made only of standard-library ones, like `slog.Handler` plus `io.Closer`, had no implementations at all, and a struct embedding `sync.Mutex` didn't implement an interface of your own that asks for `Lock` and `Unlock`. An interface that embeds a type from another module, whose methods can't be seen, is now implemented only by a type that embeds the same one or by the only type with all its other methods, not by every type that shares a method name with it. Re-index Go projects after upgrading. - In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. - 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 now counts as implementing an interface only when its methods take and return the interface's types, not just as many of them: caddy's TLS matchers, whose `Match` takes a `*tls.ClientHelloInfo`, no longer implement its HTTP `RequestMatcher`, whose `Match` takes an `*http.Request`. Before, each false match also sent a call through the interface to a method it never reaches, and could push a real implementer off the list. 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 JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761) diff --git a/__tests__/go-implements-types.test.ts b/__tests__/go-implements-types.test.ts new file mode 100644 index 000000000..0684b30f7 --- /dev/null +++ b/__tests__/go-implements-types.test.ts @@ -0,0 +1,277 @@ +/** + * Go wants an implementer's methods identical to the interface's, and the + * parameter and result counts alone let a type with the right number of the + * wrong types through: + * + * type RequestMatcher interface { Match(*http.Request) bool } // caddyhttp + * func (m MatchServerName) Match(hello *tls.ClientHelloInfo) bool // caddytls + * + * so caddy's TLS matchers "implemented" its HTTP one, and each false entry also + * took a place under the per-interface cap. The pass now compares the types the + * text can settle: each by its name, with the package left off, since the two + * declarations may sit in different packages and write `Context` and + * `caddy.Context`. A true alias, a type parameter and a type that holds a list + * of its own say nothing, and a gRPC server still crosses to its client + * interface at the server's counts. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + // caddy's two matcher interfaces, and a matcher of each. + 'httpx/matcher.go': `package httpx + +import "net/http" + +type RequestMatcher interface { + Match(*http.Request) bool +} + +type MatchHost []string + +func (m MatchHost) Match(r *http.Request) bool { return false } + +type Encode struct{} + +func (e *Encode) Match(rw *responseWriter) bool { return false } + +type responseWriter struct{} +`, + 'tlsx/matcher.go': `package tlsx + +import "crypto/tls" + +type ConnectionMatcher interface { + Match(*tls.ClientHelloInfo) bool +} + +type MatchServerName []string + +func (m MatchServerName) Match(hello *tls.ClientHelloInfo) bool { return false } +`, + // A type its own package writes bare and another through its package. + 'core/modules.go': `package core + +type Context struct{} + +type Provisioner interface { + Provision(Context) error +} + +type Own struct{} + +func (o *Own) Provision(ctx Context) error { return nil } +`, + 'plugin/plugin.go': `package plugin + +import ( + "example.com/app/core" + mod "example.com/app/core" +) + +type Plugin struct{} + +func (p *Plugin) Provision(ctx core.Context) error { return nil } + +type Renamed struct{} + +func (r *Renamed) Provision(ctx mod.Context) error { return nil } +`, + // A grouped list on one side, none on the other; the results are named on one. + 'lists/lists.go': `package lists + +type Copier interface { + Copy(string, string) (int, error) +} + +type Grouped struct{} + +func (g *Grouped) Copy(from, to string) (n int, err error) { return 0, nil } + +type Bytes struct{} + +func (b *Bytes) Copy(from []byte, to string) (int, error) { return 0, nil } +`, + // interface{} and any, uint8 and byte are one type. + 'spelled/spelled.go': `package spelled + +type Sink interface { + Put(v any, b []byte) +} + +type Old struct{} + +func (o *Old) Put(v interface{}, b []uint8) {} +`, + // A true alias stands for another name; a defined type does not. + 'aliased/aliased.go': `package aliased + +type Handle = uint64 + +type Store interface { + Get(id uint64) string +} + +type ByAlias struct{} + +func (a *ByAlias) Get(id Handle) string { return "" } + +type Other uint64 + +type ByDefined struct{} + +func (d *ByDefined) Get(id Other) string { return "" } +`, + // A generic declaration's parameters stand for any type, its other types don't. + 'generic/generic.go': `package generic + +type Keyed interface { + Key() string + Value() int +} + +type Pair[K comparable, V any] struct{} + +func (p *Pair[K, V]) Key() K { var k K; return k } +func (p *Pair[K, V]) Value() V { var v V; return v } + +type Tagged[T any] struct{} + +func (t *Tagged[T]) Key() T { var k T; return k } +func (t *Tagged[T]) Value() int64 { return 0 } + +type Getter[T any] interface { + Get() T +} + +type Text struct{} + +func (t *Text) Get() string { return "" } +`, + // What a generic base passes on is read with its parameters. + 'promoted/promoted.go': `package promoted + +type Base[T any] struct{} + +func (b *Base[T]) Load() T { var v T; return v } + +type Loader interface { + Load() string +} + +type Holder struct { + Base[string] +} +`, + // A type that holds a list of its own isn't compared. + 'funcs/funcs.go': `package funcs + +import "context" + +type Runner interface { + Run(fn func(context.Context) error) +} + +type Job struct{} + +func (j *Job) Run(fn func(ctx context.Context) error) {} +`, + // A gRPC server and its generated client interface, at the server's counts. + 'pb/rpc_grpc.pb.go': `package pb + +import ( + "context" + + "google.golang.org/grpc" +) + +type RangeRequest struct{} +type RangeResponse struct{} + +type KVClient interface { + Range(ctx context.Context, in *RangeRequest, opts ...grpc.CallOption) (*RangeResponse, error) +} +`, + 'server/kv.go': `package server + +import ( + "context" + + pb "example.com/app/pb" +) + +type kvServer struct{} + +func (s *kvServer) Range(ctx context.Context, r *pb.RangeRequest) (*pb.RangeResponse, error) { + return nil, nil +} +`, +}; + +describe('go-implements compares what the signatures say of the types', () => { + let dir: string; + let cg: CodeGraph; + const implementers = (iface: string): string[] => { + const node = cg.getNodesByKind('interface').find((n: Node) => n.name === iface); + expect(node, iface).toBeDefined(); + return cg.getIncomingEdges(node!.id).filter((e) => e.kind === 'implements') + .map((e) => cg.getNode(e.source)!.name).sort(); + }; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-impl-types-')); + for (const [rel, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + cg = await CodeGraph.init(dir, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('keeps a matcher of another parameter type out', () => { + expect(implementers('RequestMatcher')).toEqual(['MatchHost']); + expect(implementers('ConnectionMatcher')).toEqual(['MatchServerName']); + }); + + it('reads a type by its name, however its package is written', () => { + expect(implementers('Provisioner')).toEqual(['Own', 'Plugin', 'Renamed']); + }); + + it('reads a grouped list, a named result and an unnamed list alike', () => { + expect(implementers('Copier')).toEqual(['Grouped']); + }); + + it('takes `interface{}` for `any` and `uint8` for `byte`', () => { + expect(implementers('Sink')).toEqual(['Old']); + }); + + it('lets a true alias stand for its type, and no defined type', () => { + expect(implementers('Store')).toEqual(['ByAlias']); + }); + + it('reads a declaration\'s type parameters as any type, and the rest as written', () => { + expect(implementers('Keyed')).toEqual(['Pair']); + expect(implementers('Getter')).toEqual(['Text']); + }); + + it('reads what a generic base promotes with its parameters', () => { + expect(implementers('Loader')).toEqual(['Base', 'Holder']); + }); + + it('leaves a type with a list of its own uncompared', () => { + expect(implementers('Runner')).toEqual(['Job']); + }); + + it('still crosses from a gRPC client interface to the server of its service', () => { + expect(implementers('KVClient')).toEqual(['kvServer']); + }); +}); diff --git a/__tests__/go-promoted-dispatch.test.ts b/__tests__/go-promoted-dispatch.test.ts index 76b5d6243..64b6728fb 100644 --- a/__tests__/go-promoted-dispatch.test.ts +++ b/__tests__/go-promoted-dispatch.test.ts @@ -276,14 +276,14 @@ describe('a call through a Go interface reaches the method embedding promotes', it('links the interface method to the embedded type that declares it', () => { const seriesSet = one('SeriesSet', 'storage/interface.go'); const chunkSet = one('ChunkSeriesSet', 'storage/interface.go'); - // blockSeriesSet's embedding, the first of the two. - const embedding = at('tsdb/querier.go', '\tblockBaseSeriesSet'); - // Both structs satisfy both interfaces by name, and both get Next from - // the same base: one edge, through the first of them. - for (const iface of [seriesSet, chunkSet]) { - expect(dispatch(cg, methodOf(cg, iface, 'Next'))).toEqual([`blockBaseSeriesSet::Next <- blockSeriesSet @${embedding}`]); - expect(dispatch(cg, methodOf(cg, iface, 'Err'))).toEqual([`blockBaseSeriesSet::Err <- blockSeriesSet @${embedding}`]); - expect(dispatch(cg, methodOf(cg, iface, 'At'))).toEqual(['blockChunkSeriesSet::At', 'blockSeriesSet::At']); + const lines = FILES['tsdb/querier.go']!.split('\n'); + const embedded = lines.flatMap((text, i) => (text === '\tblockBaseSeriesSet' ? [`tsdb/querier.go:${i + 1}`] : [])); + // Each struct's `At` returns its own interface's type, and each gets Next + // from the same base, through its own embedding. + for (const [iface, type, embedding] of [[seriesSet, 'blockSeriesSet', embedded[0]], [chunkSet, 'blockChunkSeriesSet', embedded[1]]] as const) { + expect(dispatch(cg, methodOf(cg, iface, 'Next'))).toEqual([`blockBaseSeriesSet::Next <- ${type} @${embedding}`]); + expect(dispatch(cg, methodOf(cg, iface, 'Err'))).toEqual([`blockBaseSeriesSet::Err <- ${type} @${embedding}`]); + expect(dispatch(cg, methodOf(cg, iface, 'At'))).toEqual([`${type}::At`]); } }); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 08f0ac3f3..b69341030 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -983,6 +983,63 @@ export function goSignatureArity(signature: string | undefined): string | null { return parts ? `${parts.params.length}/${parts.results.length}` : null; } +/** Words that start a Go type, in a parameter that could otherwise read as a name and its type. */ +const GO_TYPE_WORDS: ReadonlySet = new Set(['func', 'chan', 'map', 'struct', 'interface']); + +/** + * The types of a Go parameter or result list's items: `ctx context.Context` + * is a `context.Context`, the `a` of `a, b int` an `int`, and in a list with + * no names each item is a type. + */ +function goItemTypes(items: readonly string[]): string[] { + const named = items.map((item) => { + const m = /^([A-Za-z_]\w*)\s+(\S[\s\S]*)$/.exec(item); + return m && !GO_TYPE_WORDS.has(m[1]!) ? m[2]! : null; + }); + if (named.every((type) => type === null)) return [...items]; + const types: string[] = []; + let type = ''; + for (let i = items.length - 1; i >= 0; i--) types[i] = type = named[i] ?? type; + return types; +} + +/** + * A Go type as two declarations can compare it: with no spaces or package + * qualifiers, as the two may sit in different packages and write `Context` and + * `caddy.Context`. Null for a type that holds a list of its own (a `func`, a + * `struct`), whose text differs by more than that. + */ +function goComparableType(written: string): string | null { + const flat = written.replace(/\s+/g, '').replace(/interface\{\}/g, 'any'); + if (/\bfunc\(|\bstruct\{|\binterface\{/.test(flat)) return null; + return flat.replace(/[A-Za-z_]\w*\./g, '').replace(/\buint8\b/g, 'byte').replace(/\bint32\b/g, 'rune'); +} + +/** A Go method's parameter and result types, each as goComparableType gives it, one list. Null when the signature doesn't read. */ +function goMethodTypes(signature: string | undefined): Array | null { + const parts = goSignatureParts(signature); + return parts ? [...goItemTypes(parts.params), ...goItemTypes(parts.results)].map(goComparableType) : null; +} + +/** The source a Go type declaration starts with: its line, and the next two for a list that wraps. */ +function goDeclarationText(n: Node, ctx: ResolutionContext): string { + const lines = ctx.getFileLines?.(n.filePath) ?? ctx.readFile(n.filePath)?.split(/\r?\n/) ?? []; + return lines.slice(n.startLine - 1, n.startLine + 2).join('\n'); +} + +/** The type parameters a Go type declares: `K` and `V` of `type Cache[K comparable, V any] struct`. */ +function goTypeParameterNames(n: Node, ctx: ResolutionContext): string[] { + const text = goDeclarationText(n, ctx); + const open = /^\s*(?:type\s+)?[A-Za-z_]\w*\s*\[/.exec(text); + const list = open ? goListItems(text, open[0].length - 1) : null; + return list ? list.items.flatMap((item) => /^[A-Za-z_]\w*/.exec(item)?.[0] ?? []) : []; +} + +/** Whether a Go `type_alias` node is a true alias, `type A = B`, rather than a defined type. */ +function isGoTrueAlias(n: Node, ctx: ResolutionContext): boolean { + return /^\s*(?:type\s+)?[A-Za-z_]\w*\s*(?:\[[^\]]*\]\s*)?=/.test(goDeclarationText(n, ctx)); +} + /** * The arity a gRPC server implements an RPC with, read off the signature the * generated client has for it. A unary call (`Range(ctx, in *RangeRequest, @@ -1058,11 +1115,19 @@ function goGrpcServerArity(signature: string | undefined): string | null { * (see `wantedArities`), the bridge a client's call crosses to reach its * handler. * - * Types are not compared, and a promoted name counts even where Go finds it - * ambiguous or hidden by a field — over-approximation accepted, in line with - * the other dispatch synthesizers; capped per interface. Empty interfaces - * (`any`) are skipped so they don't match every type, and a struct that - * embeds the interface keeps the edge its embedding already is. + * At those counts its types must be the interface's too, each by its name with + * the package left off, since the two may sit in different packages (see + * `typesFit`): caddy's TLS `MatchServerName`, with `Match(*tls.ClientHelloInfo) + * bool`, is no HTTP `RequestMatcher`, whose `Match` takes an `*http.Request`. + * A true alias, a declaration's type parameter and a type that holds a list of + * its own rule nothing out, nor does a method a standard-library embedding + * brings. + * + * A promoted name counts even where Go finds it ambiguous or hidden by a field + * — over-approximation accepted, in line with the other dispatch synthesizers; + * capped per interface. Empty interfaces (`any`) are skipped so they don't + * match every type, and a struct that embeds the interface keeps the edge its + * embedding already is. */ async function goImplementsEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { let scanned255 = 0; @@ -1287,6 +1352,68 @@ async function goImplementsEdges(queries: QueryBuilder, ctx: ResolutionContext, return true; }; + // Each method in a type's method set → the types of its declarations there; + // null when one of them doesn't read or a standard-library embedding brings + // it, whose types the table doesn't hold. Built only for the pairs whose + // counts already fit. + const typesMemo = new Map> | null>>(); + const methodTypes = (id: string): Map> | null> => { + let types = typesMemo.get(id); + if (types) return types; + types = new Map(); + for (const t of embedClosure(id)) { + ownMethods(t); + for (const [name, signatures] of ownSignatures.get(t) ?? []) { + let known = types.get(name); + if (known === null) continue; + if (!known) types.set(name, (known = [])); + for (const signature of signatures) { + const read = goMethodTypes(signature); + if (!read) { + types.set(name, null); + break; + } + known.push(read); + } + } + for (const methods of outside.get(t)?.stdlib ?? []) for (const name of methods.keys()) types.set(name, null); + } + typesMemo.set(id, types); + return types; + }; + // The names that stand for some other type: the project's true aliases, and + // the type parameters of a type or of what it embeds. + let aliases: Set | undefined; + const parameterMemo = new Map(); + const typeParameters = (id: string): string[] => { + let names = parameterMemo.get(id); + if (!names) { + const type = queries.getNodeById(id); + parameterMemo.set(id, (names = type ? goTypeParameterNames(type, ctx) : [])); + } + return names; + }; + // Go wants each method's types to be the interface's. A declaration at + // another count is the gRPC server `wantedArities` lets through: the + // parameters are its service's, not the client's. + const typesFit = (s: Node, iface: Node): boolean => { + aliases ??= new Set(goImplementers.filter((n) => n.kind === 'type_alias' && isGoTrueAlias(n, ctx)).map((n) => n.name)); + const known = aliases; + const parameters = new Set([...embedClosure(s.id), ...embedClosure(iface.id)].flatMap(typeParameters)); + const open = (type: string | null): boolean => type === null || + type.split(/\W+/).some((word) => known.has(word) || parameters.has(word)); + const sameType = (a: string | null, b: string | null): boolean => a === b || open(a) || open(b); + const have = methodTypes(s.id); + for (const [name, wantedTypes] of methodTypes(iface.id)) { + const got = have.get(name); + if (!wantedTypes || !got) continue; + const fits = wantedTypes.some((want) => got.some((type) => + type.length !== want.length || type.every((t, i) => sameType(t, want[i]!)))); + if (!fits) return false; + } + return true; + }; + // A type without a method, or an embedded type nothing describes, satisfies // no interface this pass looks at. const candidates: Node[] = []; @@ -1334,7 +1461,7 @@ async function goImplementsEdges(queries: QueryBuilder, ctx: ResolutionContext, const have = candidateMethods.get(s.id)!; if (!covers(have.methods, want.methods)) continue; if (!wanted) wanted = wantedArities(iface); - if (!aritiesFit(s.id, wanted)) continue; + if (!aritiesFit(s.id, wanted) || !typesFit(s, iface)) continue; covering++; if (embeds(s.id).includes(iface.id)) continue; // declared by embedding it if (!coversOpaque(have, want)) {