diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b80a152..ceb1558de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a call on a field or variable whose class the file imports now goes through that class and the classes and interfaces it extends, each found the way its own file names it, before any other class of the same name. Before, when the class didn't declare the method itself, the call went to whichever same-named class had it: in vscode, `this._storage.dispose()` reached a test fixture's `Disposable`, and calls on a library's services in bitwarden reached the browser app's copies. A call like `this.logService.trace()`, whose `ILogService` gets `trace` from the `ILogger` it extends, linked nowhere and now links to `ILogger`'s method. A field's call whose class and everything it extends are known but lack the method, as when a package's base class provides it, now links to nothing rather than to an unrelated class. Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript, a call on a value of an interface that extends one also declared as a constant, like vscode's `ITerminalLogService extends ILogService` where `ILogService` is its dependency-injection decorator too, now links to the method the extended interface declares, such as `ILogger`'s `trace`. Before, the constant was taken for the extended type, so the call linked nowhere. Re-index TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading. +- In Go, a call into one of your own packages that shares its name with a standard-library package, like a project's `lib/errors`, `pkg/log` or `model/user`, now links: `errors.New(…)` or `log.Infof(…)` in a file that imports your package reaches your function, so its callers and impact are no longer nearly empty. The same goes for a struct built from such a package, like `types.Policy{…}`. A file that imports the standard library's package of that name is unaffected. Re-index Go projects after upgrading. (#2368) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/go-stdlib-named-package.test.ts b/__tests__/go-stdlib-named-package.test.ts new file mode 100644 index 000000000..ec2f7ea63 --- /dev/null +++ b/__tests__/go-stdlib-named-package.test.ts @@ -0,0 +1,172 @@ +/** + * A project package may carry a standard-library name — harbor's + * `src/lib/errors` and `src/lib/log`, a `pkg/types`, a `model/user`. What a + * file imports under that name decides whose `errors.New(…)` it writes: the + * project package's when the import path is inside one of the project's + * modules, the standard library's otherwise. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-stdname-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'lib/errors/errors.go': `package errors + +type Error struct { + Code string +} + +func New(msg string) *Error { + return &Error{Code: msg} +} +`, + 'lib/log/log.go': `package log + +func Printf(format string, args ...any) {} +`, + 'svc/project.go': `package svc + +import ( + "example.com/app/lib/errors" + "example.com/app/lib/log" +) + +func Create(name string) *errors.Error { + log.Printf("create %s", name) + return errors.New(name) +} + +func Literal() errors.Error { + return errors.Error{Code: "x"} +} +`, + 'svc/renamed.go': `package svc + +import ( + liberrors "example.com/app/lib/errors" +) + +func Renamed() *liberrors.Error { + return liberrors.New("x") +} +`, + 'svc/both.go': `package svc + +import ( + stderrors "errors" + + errors "example.com/app/lib/errors" +) + +func Ours() *errors.Error { + return errors.New("x") +} + +func Theirs() error { + return stderrors.New("y") +} +`, + 'svc/std.go': `package svc + +import ( + "errors" + "log" +) + +func Std() error { + log.Printf("std") + return errors.New("std") +} +`, + 'lib/log/v2/log.go': `package log + +func Infof(format string, args ...any) {} +`, + // Unaliased imports whose paths end in a major version are known as `log` + // and `errors`; only the first is a package of this module. + 'svc/versioned.go': `package svc + +import ( + "example.com/app/lib/log/v2" + "github.com/acme/errors/v2" +) + +func Versioned() error { + log.Infof("versioned") + return errors.New("outside") +} +`, + 'svc/variable.go': `package svc + +type bag struct{} + +func (b *bag) Len() int { return 0 } + +func Variable(errors *bag) int { + errors.New("x") + return errors.Len() +} +`, + }; + 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 }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `kind file:name` of every edge leaving the function `fn` of `file`. */ +const edgesFrom = (file: string, fn: string) => { + const ids = cg.getNodesInFile(file).filter((n) => n.name === fn).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => { + const target = cg.getNode(e.target)!; + return `${e.kind} ${target.filePath}:${target.qualifiedName}`; + }); +}; + +describe('Go project package named like a standard-library package', () => { + it('resolves a call written through its import', () => { + const edges = edgesFrom('svc/project.go', 'Create'); + expect(edges).toContain('calls lib/errors/errors.go:New'); + expect(edges).toContain('calls lib/log/log.go:Printf'); + }); + + it('resolves a composite literal of its type', () => { + expect(edgesFrom('svc/project.go', 'Literal')).toContain('instantiates lib/errors/errors.go:Error'); + }); + + it('resolves through an import alias', () => { + expect(edgesFrom('svc/renamed.go', 'Renamed')).toContain('calls lib/errors/errors.go:New'); + }); + + it('resolves when the alias is the standard-library name itself', () => { + expect(edgesFrom('svc/both.go', 'Ours')).toContain('calls lib/errors/errors.go:New'); + expect(edgesFrom('svc/both.go', 'Theirs')).toEqual([]); + }); + + it('resolves through an unaliased import whose path ends in a major version', () => { + expect(edgesFrom('svc/versioned.go', 'Versioned')).toEqual(['calls lib/log/v2/log.go:Infof']); + }); + + it('leaves a file importing the standard library package alone', () => { + expect(edgesFrom('svc/std.go', 'Std')).toEqual([]); + }); + + it('leaves a variable of that name alone when the file imports no such package', () => { + const edges = edgesFrom('svc/variable.go', 'Variable'); + expect(edges).not.toContain('calls lib/errors/errors.go:New'); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index d6b4f2348..e57a4a124 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2779,6 +2779,16 @@ export class ReferenceResolver { return false; } + /** + * True when the Go file imports `qualifier` — by its last path segment or + * an alias — from a package of one of the project's own modules. + */ + private isGoProjectPackage(ref: UnresolvedRef, qualifier: string): boolean { + return this.context + .getImportMappings(ref.filePath, ref.language) + .some((imp) => imp.localName === qualifier && this.context.getGoPackageDir?.(imp.source, ref.filePath) != null); + } + /** * Check if reference is to a built-in or external symbol */ @@ -2873,7 +2883,9 @@ export class ReferenceResolver { // Go standard library packages — refs like "fmt.Println", "http.ListenAndServe", etc. if (ref.language === 'go') { const dotIdx = name.indexOf('.'); - if (dotIdx > 0) { + // What the file imports decides before the name does: a project package + // may be called `errors`, `log` or `types` too. + if (dotIdx > 0 && !this.isGoProjectPackage(ref, name.substring(0, dotIdx))) { const pkg = name.substring(0, dotIdx); // Not a local bound from a type assertion that is named like one: // grpc-go's `parser.ParseConfig(…)` after `parser, ok :=