Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
- In Go, more method calls now reach the right method: through a variable named like a standard-library package (`ring`, `token`, `parser`), a parameter whose type comes from another package (`s *store.Store`), and the result of a function or a conversion (`r := newRing()`, `s := store.NewStore()`). A call on a value whose type comes from outside your project, like `ctx.Done()` on a `context.Context` or `req.WithContext(…)` on an `*http.Request`, or on an alias of such a type, no longer links to an unrelated project method that happens to share the name. Re-index Go projects after upgrading. (#1954)

## [1.6.2] - 2026-10-03

Expand Down
285 changes: 285 additions & 0 deletions __tests__/go-receiver-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/**
* Go method calls through a receiver whose type is written somewhere other
* than a plain parameter of the caller's own package:
*
* - a variable named like a standard-library package the file does not
* import (`ring *ringLog`, `token := get()`), which used to be skipped as
* a call into that package;
* - a package-qualified parameter (`s *store.Store`);
* - the result of a function or a conversion (`r := newRing()`, `s :=
* store.NewStore()`, `list := web.Users(names)`).
*
* A receiver typed outside the project (`conn net.Conn`, `ctx
* context.Context`, `c, _ := net.Dial(…)`, an alias `type Ctx =
* context.Context`) gets no edge rather than a project method that happens
* to share the name. A project function's result
* declared as an outside type by value (`http.RoundTripper`) is left as it
* was: that is usually an interface a project type implements.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';

async function indexProject(files: Record<string, string>): Promise<{ dir: string; cg: CodeGraph }> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-go-recv-'));
fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\ngo 1.22\n');
for (const [file, source] of Object.entries(files)) {
fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), source);
}
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
return { dir, cg };
}

/** `file::Qualified::name` of every method `caller` (a function name) calls. */
async function methodCallees(cg: CodeGraph, caller: string): Promise<string[]> {
const node = (await cg.searchNodes(caller, { limit: 20 })).find(
(r) => r.node.name === caller && r.node.kind === 'function',
);
expect(node, caller).toBeDefined();
return (await cg.getCallees(node!.node.id))
.filter((c) => c.node.kind === 'method')
.map((c) => `${c.node.filePath}::${c.node.qualifiedName}`)
.sort();
}

describe('Go receiver typing', () => {
let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
({ dir, cg } = await indexProject({
'main.go': `package main

import (
"bytes"
"net"
"net/http"
"os"

"example.com/app/store"
)

type ringLog struct{ buf []byte }

func (r *ringLog) Write(b []byte) {}
func (r *ringLog) Reset() {}
func (r *ringLog) Reader() *reader { return &reader{} }

type reader struct{}

func (r *reader) Reset() {}

type Engine struct{}

func (e *Engine) Run() *Engine { return e }
func (e *Engine) Stop() {}

func newRing() *ringLog { return &ringLog{} }
func NewEngine(n int) (*Engine, error) { return &Engine{}, nil }
func open() (*os.File, error) { return nil, nil }
func wrap(e *Engine) *ringLog { return nil }

type limiter struct{}

func (l *limiter) RoundTrip(r *http.Request) (*http.Response, error) { return nil, nil }

func transport() http.RoundTripper { return &limiter{} }

func lowerParam(ring *ringLog, b []byte) { ring.Write(b) }
func lowerValueParam(ring ringLog) { ring.Reset() }
func groupedParam(a, ring *ringLog) { ring.Reset() }
func lowerCtor() { r := newRing(); r.Reset() }
func exportedCtor() { e, _ := NewEngine(1); e.Stop() }
func qualifiedParam(s *store.Store) { s.Put("a") }
func qualifiedCtor() { s := store.NewStore(); s.Put("b") }

func multiLineCtor() {
e, err := NewEngine(
1,
)
_ = err
e.Stop()
}

func ifCtor() {
if r := newRing(); r != nil {
r.Write(nil)
}
}

// \`r\` is what Reader returns, which is not read: not a ringLog.
func chainedCtor() { r := newRing().Reader(); r.Reset() }

// On the binding's own line \`e\` is still the parameter.
func selfArg(e *Engine) {
if e := wrap(e.Run()); e != nil {
e.Write(nil)
}
}

func stdlibParam(buf *bytes.Buffer) { buf.Truncate(0) }
func stdlibCtor() { c, _ := net.Dial("tcp", ""); c.RemoteAddr() }
func projectFuncReturningStdlib() { f, _ := open(); f.Truncate(0) }
func interfaceResult() { rt := transport(); rt.RoundTrip(nil) }
func netParam(conn net.Conn) { conn.LocalAddr() }
func untyped(get func() net.Conn) { token := get(); token.LocalAddr() }

func main() {}
`,
'store/store.go': `package store

type Store struct{}

func NewStore() *Store { return &Store{} }

func (s *Store) Put(k string) {}
`,
// Same-named methods elsewhere, so a guess by name has somewhere wrong to go.
'decoy/decoy.go': `package decoy

type Decoy struct{}

func (d *Decoy) Write(b []byte) {}
func (d *Decoy) Reset() {}
func (d *Decoy) Run() {}
func (d *Decoy) Stop() {}
func (d *Decoy) Put(k string) {}
func (d *Decoy) Truncate(n int) {}
func (d *Decoy) RemoteAddr() {}
func (d *Decoy) LocalAddr() {}

type Store struct{}

func (s *Store) Put(k string) {}
`,
}));
});

afterAll(() => {
cg?.close();
fs.rmSync(dir, { recursive: true, force: true });
});

it.each([
['lowerParam', ['main.go::ringLog::Write']],
['lowerValueParam', ['main.go::ringLog::Reset']],
['groupedParam', ['main.go::ringLog::Reset']],
])('%s: a receiver named like a standard-library package resolves on its type', async (caller, expected) => {
expect(await methodCallees(cg, caller)).toEqual(expected);
});

it.each([
['lowerCtor', ['main.go::ringLog::Reset']],
['exportedCtor', ['main.go::Engine::Stop']],
['multiLineCtor', ['main.go::Engine::Stop']],
['ifCtor', ['main.go::ringLog::Write']],
['qualifiedCtor', ['store/store.go::Store::Put']],
])('%s: a receiver bound to a call resolves on what the callee returns', async (caller, expected) => {
expect(await methodCallees(cg, caller)).toEqual(expected);
});

it('a package-qualified parameter resolves in that package', async () => {
expect(await methodCallees(cg, 'qualifiedParam')).toEqual(['store/store.go::Store::Put']);
});

it('a binding is not typed by the head of a call chain, nor on its own line', async () => {
expect(await methodCallees(cg, 'chainedCtor')).not.toContain('main.go::ringLog::Reset');
expect(await methodCallees(cg, 'selfArg')).toEqual(['main.go::Engine::Run']);
});

it.each(['stdlibParam', 'stdlibCtor', 'projectFuncReturningStdlib', 'netParam', 'untyped'])(
'%s: a receiver typed outside the project, or untyped, gets no edge',
async (caller) => {
expect(await methodCallees(cg, caller)).toEqual([]);
},
);

it('an outside interface a project function returns still reaches its implementation', async () => {
expect(await methodCallees(cg, 'interfaceResult')).toEqual(['main.go::limiter::RoundTrip']);
});
});

describe('Go receiver typing through conversions and aliases', () => {
let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
({ dir, cg } = await indexProject({
'web/context.go': `package web

type Context struct{}

func (c *Context) MakeAuditRecord() {}

type Users []string

func (u Users) Usernames() []string { return nil }
`,
'api4/handlers.go': `package api4

import (
"context"

"example.com/app/web"
)

type Context = web.Context

type (
Handler = *web.Context
Ctx = context.Context
)

type Defined web.Context

func projectAlias(h Handler) { h.MakeAuditRecord() }
func conversion(names []string) { list := web.Users(names); list.Usernames() }
func definedType(d *Defined) { d.MakeAuditRecord() }
func stdlibReceiver(ctx context.Context) { ctx.Done() }
func aliasedStdlib(ctx Ctx) { ctx.Done() }
`,
// Same-named types and methods elsewhere, so neither a lookup by type
// name nor a guess by method name has a single answer to fall back on.
'decoy/decoy.go': `package decoy

type Context struct{}

func (c *Context) MakeAuditRecord() {}

// Named like the alias of an outside type in api4.
type Ctx struct{}

func (c *Ctx) Done() {}

type Decoy struct{}

func (d *Decoy) Done() {}
func (d *Decoy) Usernames() {}
`,
}));
});

afterAll(() => {
cg?.close();
fs.rmSync(dir, { recursive: true, force: true });
});

it('a receiver bound to a conversion resolves on the converted type', async () => {
expect(await methodCallees(cg, 'conversion')).toEqual(['web/context.go::Users::Usernames']);
});

it('an alias of a project type still resolves on that type', async () => {
expect(await methodCallees(cg, 'projectAlias')).toEqual(['web/context.go::Context::MakeAuditRecord']);
});

it.each(['definedType', 'stdlibReceiver', 'aliasedStdlib'])(
'%s: a defined type, or a receiver typed outside the project, gets no edge',
async (caller) => {
expect(await methodCallees(cg, caller)).toEqual([]);
},
);
});
23 changes: 7 additions & 16 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, GO_STDLIB_PACKAGES, matchGoAssertedCall } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
Expand Down Expand Up @@ -133,20 +133,6 @@ const PYTHON_BUILT_IN_METHODS = new Set([
'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek',
]);

const GO_STDLIB_PACKAGES = new Set([
'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync',
'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context',
'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect',
'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding',
'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall',
'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap',
'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url',
'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer',
'token', 'types', 'cgo', 'plugin', 'race', 'ioutil',
// Kubernetes-common stdlib aliases
'utilruntime', 'utilwait', 'utilnet',
]);

const GO_BUILT_INS = new Set([
'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close',
'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag',
Expand Down Expand Up @@ -2805,7 +2791,12 @@ export class ReferenceResolver {
const dotIdx = name.indexOf('.');
if (dotIdx > 0) {
const pkg = name.substring(0, dotIdx);
if (GO_STDLIB_PACKAGES.has(pkg)) {
// Only when the file imports it: an unimported `ring` / `token` /
// `url` is a local variable (`func flush(ring *ringLog) { ring.Write(b) }`).
if (
GO_STDLIB_PACKAGES.has(pkg) &&
this.context.getImportMappings(ref.filePath, 'go').some((i) => i.localName === pkg)
) {
return true;
}
}
Expand Down
Loading