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 @@ -154,6 +154,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
- In Go, more method calls now reach the right method: through a parameter whose type comes from another package (`s *store.Store`), and through 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
281 changes: 281 additions & 0 deletions __tests__/go-receiver-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* Go method calls through a receiver whose type is written somewhere other
* than a plain parameter of the caller's own 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 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'])(
'%s: a receiver typed outside the project 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([]);
},
);
});
Loading