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 @@ -160,6 +160,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a call is now matched to the package or variable it is actually made on. Before, a call through a package, like `klog.Infof("Log using Infof")` or `digest.Digest(dig)` inside `&distribution.Descriptor{Digest: …}`, could link to a project method of the same name when the name appeared again on the line, and in `klog.Error(err.Error())` the `err.Error()` call was taken for a call into `klog`. A parameter, receiver or local variable named like an import, like etcd's `jwt, err := newTokenProviderJWT(…)` or the `suite` receiver in a testify test suite, now counts as that variable, so calls on it link to the methods of the type it is declared as, and to nothing when that type comes from outside your project. Re-index Go projects after upgrading.
- In Go, a call through a parameter or local variable named like a standard-library package, such as `context`, `log`, `trace`, `printer` or `token`, now links to the method it calls. Before, every call written `context.…` or `printer.…` was taken for a call into the standard library and linked to nothing: gin's `context.AbortWithError(…)` inside `func(context *Context)`, harbor's `context.IsAuthenticated()` after `context := NewSecurityContext(…)` and `log.Errorf(…)` after `log := log.G(ctx)`, etcd's `printer.DBHashKV(…)` and `trace.Step(…)`. The variable's declaration decides the method: the type it is declared as, or the package of the project function its value comes from. A variable holding a value from outside your project, like `scanner := bufio.NewScanner(r)` or `url, err := url.Parse(raw)`, still links to nothing, and one whose type can't be told links only to a method no other type in the project has, never to one picked because it matches the variable's name. Re-index Go projects after upgrading.
- In Go, a call of a method name that nearly every type has, like `err.Error()`, `id.String()`, `ctx.Err()` or `mu.Lock()`, now links to that method of the type its variable is declared as, and to nothing when that type is built into Go, like `error` or `string`, or comes from outside your project, like `context.Context`, `sync.Mutex` or `testing.TB`. Before, when the variable's type wasn't worked out, such a call was matched by a word its name shares with a type's, so kubernetes' `err.Error()` calls linked to an unrelated `notRegisteredErr` type and etcd's `leaderID.String()` to `MoveLeaderRequest`. A call on a variable whose type still can't be told now links only to a type the variable is named after, like `id` for an `ID`. Re-index Go projects after upgrading.
- In Go, a call on a parameter or variable declared with a type from another package, like `client rest.Interface`, `app storage.Appender` or `pod *v1.Pod`, now links to that type's method, including one it gets from a type it embeds or one an alias names, and to nothing when the type comes from outside your project, like `w http.ResponseWriter`, `c clock.PassiveClock` or an alias of such a type. Before, the type was read only for a few method names that nearly every type has, so other calls were matched by name: `client.Patch(…)` linked to a `RESTClient` rather than the interface it is declared as, `c.Now()` to an unrelated `Clock`, and a `sync.Pool` or `http.Client` variable's calls to a project type of that name. Names declared together, like `a, b rest.Interface`, `var ( … )` blocks, named results and function literal parameters are read the same way, always from the declaration in scope rather than another variable of that name further up, like a test table's field. Re-index Go projects after upgrading. Thanks @danusha2345, whose #1954 found the same gap.
- In Go, a method call on a variable or constant declared at the top level of a package, like `lg.Panic(…)` after `var lg *zap.Logger` or `RoleEndpoint.String()` after `RoleEndpoint Role = "endpoints"`, now links to that method of the type it is declared with, from any file of the package, and to nothing when that type, or the value it is set to, comes from outside your project. Before, nothing read such a declaration, so the call was matched by its method's name alone and could link to an unrelated type's method, like a project logger's `Panic` or another package's `Role`. Re-index Go projects after upgrading.
- React Router routes written as JSX now read `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `<Suspense fallback={<Loader />}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading.
Expand Down
331 changes: 331 additions & 0 deletions __tests__/go-declared-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
/**
* A Go receiver's declared type decides which method a call on it reaches.
* The line patterns that read it take a parameter's type only when it is a
* lowercase name of the caller's own package (`s *server`) or starts with a
* capital (`lg Logger`), so a parameter declared through a package — `c
* clock.PassiveClock`, `pod *v1.Pod`, `client kubernetes.Interface` — had
* no type, and a call on it went to whatever project method a guess by name
* found: kubernetes' `c.Now()` reached apimachinery's unrelated `Clock::Now`,
* `client.Patch()` a `RESTClient` instead of the `rest.Interface` declared.
* Only a call of a standard method name (`String`, `Error`, `Header`) was
* read through the scope reader. Now every call is: a parameter, a result,
* a name sharing its type with the next (`a, b rest.Interface`), a `var (…)`
* spec, a function literal's parameter. The patterns scan back without
* block scope, so the scope reader's declaration wins where both find one.
*
* A type from a package outside the project has none of the project's
* methods, and neither has an alias of one. gin declares its own
* `ResponseWriter`, which `w http.ResponseWriter` is not.
*/
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';

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

type Request struct{}

// Interface is what a client of the API server offers.
type Interface interface {
Patch(pt string) *Request
Get() *Request
}

// RESTClient is one implementation of it: a call through Interface is not a call on it.
type RESTClient struct{}

func (c *RESTClient) Patch(pt string) *Request { return &Request{} }
func (c *RESTClient) Get() *Request { return &Request{} }
`,
'store/store.go': `package store

type Store struct{}

func (s *Store) Put(k string) {}
func (s *Store) Get(k string) string { return k }

// Cached gets Put and Get from the Store it embeds.
type Cached struct {
*Store
}
`,
'api/api.go': `package api

import "example.com/app/store"

// Alias is store.Store under another name.
type Alias = store.Store
`,
'config/config.go': `package config

import "example.com/app/store"

type Config struct {
Store *store.Store
}
`,
// Methods named like the ones the calls below make, for a guess by name to find.
'decoy/decoy.go': `package decoy

import "net/http"

type Decoy struct{}

func New() *Decoy { return &Decoy{} }

func (d *Decoy) Put(k string) {}
func (d *Decoy) Get(k string) string { return k }

// Like gin's own ResponseWriter.
type ResponseWriter interface {
Header() http.Header
Write(b []byte) (int, error)
}

type responseWriter struct{}

func (w *responseWriter) Header() http.Header { return nil }
func (w *responseWriter) Write(b []byte) (int, error) { return len(b), nil }

// Like harbor's jobservice worker.
type Worker struct{}

func (w *Worker) WithContext(ctx interface{}) *Worker { return w }

// Like apimachinery's clock.
type Clock struct{}

func (c *Clock) Now() int64 { return 0 }

// A struct named like the handlers package's alias of context.Context.
type Ctx struct{}

func (c *Ctx) Done() <-chan struct{} { return nil }

// An HTTP client of the project's own.
type Client struct{}

func (c *Client) Do(req *http.Request) error { return nil }

// A request of the project's own, whose URL has a Query.
type Request struct {
URL *Location
}

type Location struct{}

func (l *Location) Query() string { return "" }
`,
'handlers/handlers.go': `package handlers

import (
"context"
"net/http"

"example.com/app/api"
"example.com/app/config"
"example.com/app/decoy"
"example.com/app/rest"
"example.com/app/store"
"k8s.io/utils/clock"
)

// Ctx is context.Context under another name.
type Ctx = context.Context

// Local is the type of a test table's field below.
type Local struct{}

func (l *Local) Put(k string) {}

func update(client rest.Interface) {
client.Patch("json")
}

func pair(a, b rest.Interface) {
a.Get()
b.Patch("merge")
}

func multiLine(
ctx context.Context,
s *store.Store,
) {
s.Put("multi")
}

func open() (s *store.Store) {
s.Put("result")
return s
}

func block() {
var (
s *store.Store
err error
)
s.Put("block")
_ = err
}

func closure() {
put := func(s *store.Store) {
s.Put("closure")
}
_ = put
}

func table(s *store.Store) {
cases := []struct {
s *Local
}{}
_ = cases
s.Put("table")
}

func flushAll(c *store.Cached) {
c.Put("embedded")
}

func viaAlias(a api.Alias) {
a.Get("alias")
}

func value(s *store.Store) {
put := s.Put
_ = put
}

func chain(cfg *config.Config) {
cfg.Store.Put("chain")
}

func shadow(s *store.Store) {
if s != nil {
s := decoy.New()
s.Put("shadow")
}
}

func serve(w http.ResponseWriter, writer http.ResponseWriter, req *http.Request) {
w.Header()
writer.Header()
req.WithContext(context.Background())
req.URL.Query()
}

func tick(c clock.PassiveClock) {
c.Now()
}

func wait(ctx context.Context, c Ctx) {
ctx.Done()
c.Done()
}

func send() {
var rw http.ResponseWriter
rw.Write(nil)
cl := &http.Client{}
cl.Do(nil)
}

func now(realClock clock.PassiveClock) {
tick := realClock.Now
_ = tick
}
`,
};

let root = '';
let cg: CodeGraph;

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

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

const FILE = 'handlers/handlers.go';

/** The line of the handlers file holding `snippet`. */
const lineOf = (snippet: string): number => {
const at = files[FILE]!.split('\n').findIndex((l) => l.includes(snippet));
if (at < 0) throw new Error(`no ${snippet} in ${FILE}`);
return at + 1;
};

/** What the references on the line holding `snippet` reach: `owner::method (resolvedBy confidence)`. */
const reachedAt = (snippet: string): string[] => {
const line = lineOf(snippet);
const ids = cg.getNodesInFile(FILE).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind !== 'contains' && e.line === line)
.map((e) => `${cg.getNode(e.target)!.qualifiedName} (${e.metadata?.resolvedBy} ${e.metadata?.confidence})`);
};

describe('a Go receiver declared through a package', () => {
it('reaches the method of the type it is declared as', () => {
// Not RESTClient's `Patch`, which a word of the receiver's name picked.
expect(reachedAt('client.Patch("json")')).toEqual(['Interface::Patch (instance-method 0.9)']);
// Each name of a group, a line of a multi-line list, a named result, a
// `var (…)` spec, a function literal's parameter.
expect(reachedAt('a.Get()')).toEqual(['Interface::Get (instance-method 0.9)']);
expect(reachedAt('b.Patch("merge")')).toEqual(['Interface::Patch (instance-method 0.9)']);
expect(reachedAt('s.Put("multi")')).toEqual(['Store::Put (instance-method 0.9)']);
expect(reachedAt('s.Put("result")')).toEqual(['Store::Put (instance-method 0.9)']);
expect(reachedAt('s.Put("block")')).toEqual(['Store::Put (instance-method 0.9)']);
expect(reachedAt('s.Put("closure")')).toEqual(['Store::Put (instance-method 0.9)']);
});

it('is the declaration in scope, not a test table field the patterns read above it', () => {
expect(reachedAt('s.Put("table")')).toEqual(['Store::Put (instance-method 0.9)']);
});

it('follows embedding and aliases in the declaring package', () => {
expect(reachedAt('c.Put("embedded")')).toEqual(['Store::Put (instance-method 0.9)']);
expect(reachedAt('a.Get("alias")')).toEqual(['Store::Get (instance-method 0.9)']);
});

it('types a method value and a field chain through it too', () => {
expect(reachedAt('put := s.Put')).toEqual(['Store::Put (function-ref 0.9)']);
expect(reachedAt('cfg.Store.Put("chain")')).toEqual(['Store::Put (instance-method 0.85)']);
});

it('is not what a local of the same name declared without a type holds', () => {
// `s := decoy.New()` shadows the parameter: its type is not *store.Store.
expect(reachedAt('s.Put("shadow")').filter((r) => r.startsWith('Store::'))).toEqual([]);
});
});

describe('a Go receiver of a type from outside the project', () => {
it('reaches none of the project\'s methods', () => {
// net/http's ResponseWriter is not the project's own, whose name it shares.
expect(reachedAt('w.Header()')).toEqual([]);
expect(reachedAt('writer.Header()')).toEqual([]);
expect(reachedAt('req.WithContext(')).toEqual([]);
expect(reachedAt('req.URL.Query()')).toEqual([]);
expect(reachedAt('c.Now()')).toEqual([]);
expect(reachedAt('ctx.Done()')).toEqual([]);
});

it('nor does an alias of one, or a local declared as one', () => {
expect(reachedAt('c.Done()')).toEqual([]);
expect(reachedAt('rw.Write(nil)')).toEqual([]);
expect(reachedAt('cl.Do(nil)')).toEqual([]);
// A method value of one, which a word of its name picked on main.
expect(reachedAt('tick := realClock.Now')).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions __tests__/go-std-method-receivers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,8 @@ describe('Go standard method names on a receiver nothing types', () => {

it.each([
// `var ( query promql.Query )`, read past the table's `query string`;
// `Exec` is no standard name and is guessed as before.
['runQueries', ['promql/engine.go::Query::Exec instance-method@0.8', 'promql/engine.go::Query::String instance-method@0.9']],
// `Exec`, no standard name, is typed by that declaration too.
['runQueries', ['promql/engine.go::Query::Exec instance-method@0.9', 'promql/engine.go::Query::String instance-method@0.9']],
// `version, _ := ParseVersion(…)` says no type, and the table's
// `version string` is another declaration: nothing is ruled out.
['versionText', ['server/version.go::Version::Major instance-method@0.8', 'server/version.go::Version::String instance-method@0.8']],
Expand Down
Loading