diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b80a152..be64cd47e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. Re-index Go projects after upgrading. - In Go, a method an interface declares, like etcd's `KV.Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)`, now links to the types its parameters and results name, so impact on `PutResponse` or `OpOption` reaches the interface method and every caller that goes through the interface. Before, an interface's methods linked to no types at all. Re-index Go projects after upgrading. +- In Go, a package imported under an explicit name, like `v3 "go.etcd.io/etcd/client/v3"`, now owns that name even when another import's path ends in it, like `"github.com/cheggaaa/pb/v3"` (package `pb`) in etcd's benchmark tool. Before, whichever of the two came first took every `v3.` name, so `v3.Client` and `v3.Op` could link to nothing. Re-index Go projects after upgrading. - In Go, a struct or defined type now counts as implementing an interface only when its methods take as many parameters and return as many results as the interface's. Before, matching method names was enough, so etcd's watch cache, whose `Get` takes a context, a key and options, was listed as a `peerGetter`, whose `Get` takes a peer ID, and a call through an interface could be shown reaching methods it can never reach. Look-alikes could also crowd out the real implementations, so `codegraph_explore` stopped at grpc-go's xDS `TransportBuilder` instead of reaching the transport it builds. A server still counts for the gRPC client interface of the service it serves, like etcd's `kvServer` for `KVClient`, so a client's call keeps leading to the handler that serves it. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. diff --git a/__tests__/go-import-alias-precedence.test.ts b/__tests__/go-import-alias-precedence.test.ts new file mode 100644 index 000000000..c6a700ba5 --- /dev/null +++ b/__tests__/go-import-alias-precedence.test.ts @@ -0,0 +1,118 @@ +/** + * Go binds a name to one import per file. etcd's benchmark tool imports + * `"github.com/cheggaaa/pb/v3"` (package `pb`) beside + * `v3 "go.etcd.io/etcd/client/v3"`, so `v3.Op` is clientv3's `Op`. The index + * also listed the unaliased import under its last path element, `v3`, and the + * import met first — the progress-bar library, outside the project — took + * every `v3.` qualifier, so `v3.Op`, `v3.Client` and `v3.OpGet(…)` linked to + * nothing. + * + * An unaliased import is no longer listed under a name another import is + * explicitly given; it keeps the name goimports assumes for it (`pb`). + */ +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'; + +const MODULE: Record = { + 'go.mod': 'module go.etcd.io/etcd\n\ngo 1.24\n', + 'client/v3/op.go': [ + 'package clientv3', + '', + 'type Op struct{}', + '', + 'type Client struct{}', + '', + 'func (c *Client) Do(op Op) error { return nil }', + '', + 'func OpGet(key string) Op { return Op{} }', + '', + ].join('\n'), + // The unaliased import first, as etcd writes it. + 'tools/benchmark/cmd/txn_mixed.go': [ + 'package cmd', + '', + 'import (', + '\t"github.com/cheggaaa/pb/v3"', + '', + '\tv3 "go.etcd.io/etcd/client/v3"', + ')', + '', + 'type request struct {', + '\top v3.Op', + '}', + '', + 'func run(c *v3.Client, bar *pb.ProgressBar) error {', + '\treturn c.Do(v3.OpGet("k"))', + '}', + '', + ].join('\n'), + // The aliased import first. + 'tools/benchmark/cmd/put.go': [ + 'package cmd', + '', + 'import (', + '\tv3 "go.etcd.io/etcd/client/v3"', + '\t"github.com/cheggaaa/pb/v3"', + ')', + '', + 'func put(bar *pb.ProgressBar) v3.Op {', + '\treturn v3.OpGet("k")', + '}', + '', + ].join('\n'), +}; + +describe.each([ + ['default', 'LF'], + ['wasm', 'LF'], + ['default', 'CRLF'], + ['wasm', 'CRLF'], +])('a Go import named explicitly owns its name (%s, %s)', (backend, eol) => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeAll(async () => { + kernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-alias-')); + for (const [rel, content] of Object.entries(MODULE)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), eol === 'CRLF' ? content.replace(/\n/g, '\r\n') : content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + const linksFrom = (file: string, name: string) => { + const node = cg!.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import')!; + expect(node, `${name} in ${file}`).toBeTruthy(); + return cg!.getOutgoingEdgesFrom([node.id]) + .filter((e) => e.kind !== 'contains') + .map((e) => `${e.kind} ${cg!.getNode(e.target)!.filePath}:${cg!.getNode(e.target)!.qualifiedName}`) + .sort(); + }; + + it('`v3.` is the aliased import, whichever import comes first', () => { + expect(linksFrom('tools/benchmark/cmd/txn_mixed.go', 'request')).toEqual(['references client/v3/op.go:Op']); + expect(linksFrom('tools/benchmark/cmd/txn_mixed.go', 'run')).toEqual([ + 'calls client/v3/op.go:Client::Do', + 'calls client/v3/op.go:OpGet', + 'references client/v3/op.go:Client', + ]); + expect(linksFrom('tools/benchmark/cmd/put.go', 'put')).toEqual([ + 'calls client/v3/op.go:OpGet', + 'references client/v3/op.go:Op', + ]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index c87b61f7a..0bced5dd5 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1382,10 +1382,17 @@ function extractGoImports(content: string): ImportMapping[] { const mapping = (localName: string, source: string): ImportMapping => ({ localName, exportedName: '*', source, isDefault: false, isNamespace: true }); + // Go binds a name to one import of a file, so a name another import is + // given explicitly is not an unaliased import's: etcd's benchmark tool has + // `"github.com/cheggaaa/pb/v3"` (package pb) beside + // `v3 "go.etcd.io/etcd/client/v3"`, and listed first, pb took every `v3.`. + const explicit = new Set(specs.flatMap(({ name }) => (name !== undefined && name !== '.' && name !== '_' ? [name] : []))); // A dot import (`. "pkg"`) binds no name of its own; it stays listed under // its last path element. - const mappings = specs.map(({ name, source }) => - mapping(name === undefined || name === '.' ? source.split('/').pop()! : name, source)); + const mappings = specs + .map(({ name, source }) => ({ explicitly: name !== undefined && name !== '.', localName: name === undefined || name === '.' ? source.split('/').pop()! : name, source })) + .filter(({ explicitly, localName }) => explicitly || !explicit.has(localName)) + .map(({ localName, source }) => mapping(localName, source)); const bound = new Set(mappings.map((m) => m.localName)); const assumed = new Map(); for (const { name, source } of specs) {