Skip to content
Merged
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 @@ -75,6 +75,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading.
- In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading.
- In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. 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 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 `<Route {...rest}>`, 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 `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, 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.
- In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374)
Expand Down
379 changes: 379 additions & 0 deletions __tests__/go-promoted-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,379 @@
/**
* A Go struct can satisfy an interface with methods it gets by embedding:
*
* type blockBaseSeriesSet struct{ … } // prometheus's tsdb/querier.go
* func (b *blockBaseSeriesSet) Next() bool // Next, Err, Warnings — no At
*
* type blockSeriesSet struct{ blockBaseSeriesSet }
* func (b *blockSeriesSet) At() storage.Series
*
* go-implements links blockSeriesSet to storage.SeriesSet, and a call through
* `SeriesSet.Next` then runs blockBaseSeriesSet's Next. The interface-dispatch
* bridge linked an interface's methods only to the methods the implementing
* struct declares, so that call reached nothing: blockBaseSeriesSet has no At
* and implements nothing itself. The bridge now follows the embedding to the
* method Go's selector picks — the shallowest one, none when two tie — and
* stops at an embedded interface, whose method is a dynamic call again.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import type { Edge, Node } from '../src/types';

const BIG_METHODS = 45;
const big = (i: number) => `M${String(i).padStart(2, '0')}`;

const FILES: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
'storage/interface.go': `package storage

type Series interface{ Labels() string }

type ChunkSeries interface{ Labels() string }

type SeriesSet interface {
Next() bool
At() Series
Err() error
Warnings() []string
}

type ChunkSeriesSet interface {
Next() bool
At() ChunkSeries
Err() error
Warnings() []string
}
`,
'tsdb/querier.go': `package tsdb

import "example.com/app/storage"

// Next, Err and Warnings for the two series sets below. No At: it is
// neither series set itself.
type blockBaseSeriesSet struct {
err error
}

func (b *blockBaseSeriesSet) Next() bool { return false }
func (b *blockBaseSeriesSet) Err() error { return b.err }
func (b *blockBaseSeriesSet) Warnings() []string { return nil }

type blockSeriesSet struct {
blockBaseSeriesSet
}

func (b *blockSeriesSet) At() storage.Series { return nil }

type blockChunkSeriesSet struct {
blockBaseSeriesSet
}

func (b *blockChunkSeriesSet) At() storage.ChunkSeries { return nil }

func newBlockSeriesSet() storage.SeriesSet { return &blockSeriesSet{} }
`,
'promql/engine.go': `package promql

import "example.com/app/storage"

func expandSeriesSet(it storage.SeriesSet) int {
n := 0
for it.Next() {
n++
}
return n
}
`,
'selector/selector.go': `package selector

type Doer interface {
Do()
Name() string
}

type Runner interface{ Do() }

// Do from two levels down.
type inner struct{}

func (inner) Do() {}

type middle struct{ inner }

type deep struct {
middle
}

func (deep) Name() string { return "" }

// near's Do at depth 1 hides far's at depth 2.
type near struct{}

func (near) Do() {}

type far struct{}

func (far) Do() {}

type farWrap struct{ far }

type shadowed struct {
farWrap
near
}

func (shadowed) Name() string { return "" }

// Two Dos at depth 1: Go rejects t.Do(), no method runs.
type left struct{}

func (left) Do() {}

type right struct{}

func (right) Do() {}

type tie struct {
left
right
}

func (tie) Name() string { return "" }

// One type's Do at depth 2 along two paths: as ambiguous.
type core struct{}

func (core) Do() {}

type viaA struct{ core }

type viaB struct{ core }

type diamond struct {
viaA
viaB
}

func (diamond) Name() string { return "" }

// Do from the interface it embeds: whatever that holds runs.
type delegate struct{ Runner }

func (delegate) Name() string { return "" }

// The embedded interface's Do at depth 1 hides buried's at depth 2.
type buried struct{}

func (buried) Do() {}

type cover struct{ buried }

type hidden struct {
cover
Runner
}

func (hidden) Name() string { return "" }

// Do from the defined type it embeds.
type Chain []func()

func (c Chain) Do() {}

type chained struct{ Chain }

func (chained) Name() string { return "" }

// full is a Doer itself: its own edge, whoever embeds it.
type fullWrap struct{ full }

type full struct{}

func (full) Do() {}
func (full) Name() string { return "" }
`,
// More of the interface than the cap: one method of its own, the rest from
// a base that lacks the last.
'big/big.go': [
'package big',
'',
'type Big interface {',
...Array.from({ length: BIG_METHODS }, (_, i) => `\t${big(i)}()`),
'}',
'',
'type bigBase struct{}',
'',
...Array.from({ length: BIG_METHODS - 1 }, (_, i) => `func (bigBase) ${big(i)}() {}`),
'',
'type bigOne struct{ bigBase }',
'',
`func (bigOne) ${big(BIG_METHODS - 1)}() {}`,
'',
].join('\n'),
};

let root = '';
let cg: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-promoted-'));
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 });
}, 120_000);

afterAll(() => {
cg?.destroy();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

const meta = (e: Edge): Record<string, unknown> => (e.metadata ?? {}) as Record<string, unknown>;

/** `file:line` of the first fixture line containing `text`. */
function at(file: string, text: string): string {
const line = FILES[file]!.split('\n').findIndex((l) => l.includes(text));
expect(line, `${text} in ${file}`).toBeGreaterThanOrEqual(0);
return `${file}:${line + 1}`;
}

/** The one non-import node with this name in this file. */
function one(name: string, file: string): Node {
const found = cg.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import');
expect(found, `${name} in ${file}`).toHaveLength(1);
return found[0]!;
}

/** An interface's method. */
function methodOf(graph: CodeGraph, iface: Node, name: string): Node {
const m = graph
.getOutgoingEdgesFrom([iface.id], ['contains'])
.map((e) => graph.getNode(e.target))
.find((n) => n?.name === name);
expect(m, `${iface.name}.${name}`).toBeTruthy();
return m!;
}

/** Where a call through the method dispatches, as `Target::m` or `Target::m <- promotedInto @registeredAt`. */
function dispatch(graph: CodeGraph, method: Node): string[] {
return graph
.getOutgoingEdgesFrom([method.id], ['calls'])
.filter((e) => meta(e).synthesizedBy === 'interface-impl')
.map((e) => {
const target = graph.getNode(e.target)?.qualifiedName;
const into = meta(e).promotedInto;
return into === undefined ? `${target}` : `${target} <- ${into} @${meta(e).registeredAt}`;
})
.sort();
}

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']);
}
});

it('picks the provider the way Go\'s selector does', () => {
const doer = one('Doer', 'selector/selector.go');
const file = 'selector/selector.go';
expect(dispatch(cg, methodOf(cg, doer, 'Do'))).toEqual(
[
// The defined type's method.
`Chain::Do <- chained @${at(file, 'type chained struct')}`,
// full's own edge, though fullWrap comes first.
'full::Do',
// Two levels down, through deep's own embedding of middle.
`inner::Do <- deep @${at(file, '\tmiddle')}`,
// near at depth 1, not far at depth 2.
`near::Do <- shadowed @${at(file, '\tnear')}`,
].sort()
);
// Not linked: left and right (tie), core (two paths), the Runner that
// delegate and hidden embed, and buried behind it.
});

it('keeps the provider\'s own edge as it was', () => {
const doer = one('Doer', 'selector/selector.go');
const doEdge = cg
.getOutgoingEdgesFrom([methodOf(cg, doer, 'Do').id], ['calls'])
.find((e) => cg.getNode(e.target)?.qualifiedName === 'full::Do');
expect(doEdge).toMatchObject({
provenance: 'heuristic',
metadata: { synthesizedBy: 'interface-impl', via: 'Do', registeredAt: at('selector/selector.go', 'func (full) Do()') },
});
expect(meta(doEdge!)).not.toHaveProperty('promotedInto');
});

it('caps a struct\'s links into one interface, keeping its own method', () => {
const bigIface = one('Big', 'big/big.go');
const targets = Array.from({ length: BIG_METHODS }, (_, i) => dispatch(cg, methodOf(cg, bigIface, big(i)))).flat();
const embedding = at('big/big.go', 'type bigOne struct');
expect(targets).toHaveLength(40);
expect(targets).toContain(`bigOne::${big(BIG_METHODS - 1)}`);
// The 39 slots left go to methods the base promotes.
const promoted = targets.filter((t) => t.startsWith('bigBase::'));
expect(promoted).toHaveLength(39);
for (const t of promoted) expect(t).toMatch(new RegExp(`^bigBase::M\\d\\d <- bigOne @${embedding}$`));
});

it('shows the hop in codegraph_explore as a promoted method with its embedding', async () => {
const result = await new ToolHandler(cg).execute('codegraph_explore', {
query: 'expandSeriesSet SeriesSet.Next blockBaseSeriesSet.Next',
});
const text = result.content?.[0]?.text ?? '';
expect(text).toContain(
`dynamic: interface → method promoted into blockSeriesSet @${at('tsdb/querier.go', '\tblockBaseSeriesSet')}`
);
});
});

describe('a sync that changes what the struct declares', () => {
let dir = '';
let graph: CodeGraph | undefined;

afterEach(() => {
graph?.destroy();
graph = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = '';
});

it('moves the link between the promoted method and the struct\'s own', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-promoted-sync-'));
const write = (rel: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
};
const wrap = 'package wrap\n\nimport "example.com/app/base"\n\ntype Wrapper struct {\n\tbase.Base\n}\n\nfunc (Wrapper) Name() string { return "" }\n';
write('go.mod', 'module example.com/app\n\ngo 1.22\n');
write('api/api.go', 'package api\n\ntype NamedCloser interface {\n\tClose() error\n\tName() string\n}\n');
write('base/base.go', 'package base\n\ntype Base struct{}\n\nfunc (Base) Close() error { return nil }\n');
write('wrap/wrap.go', wrap);
graph = await CodeGraph.init(dir, { index: true });
const g = graph;
const close = () => dispatch(g, methodOf(g, g.getNodesByName('NamedCloser')[0]!, 'Close'));

// Close comes from base.Base, embedded on line 6.
expect(close()).toEqual(['Base::Close <- Wrapper @wrap/wrap.go:6']);
write('wrap/wrap.go', `${wrap}\nfunc (Wrapper) Close() error { return nil }\n`);
await g.sync({ paths: ['wrap/wrap.go'] });
expect(close()).toEqual(['Wrapper::Close']);
write('wrap/wrap.go', wrap);
await g.sync({ paths: ['wrap/wrap.go'] });
expect(close()).toEqual(['Base::Close <- Wrapper @wrap/wrap.go:6']);
}, 60_000);
});
Loading