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 @@ -149,6 +149,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 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.
- A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading.
Expand Down
250 changes: 250 additions & 0 deletions __tests__/go-package-value-receivers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* Go calls through a variable or constant a package declares at the top
* level — `lg.Panic(…)` after `var lg *zap.Logger`, `RoleEndpoint.String()`
* after `RoleEndpoint Role = "endpoints"` — read by that declaration, in the
* file or another of the package, as a local's is: the method of the project
* type it is declared as, or nothing for a type or value from outside the
* project. Before, nothing read such a declaration, and the call was guessed
* by its method's name: etcd's `lg.Panic(…)` reached a project logger's
* `Panic`, prometheus's `RoleEndpoint.String()` another package's `Role`.
*/
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';

let root = '';
let cg: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-pkg-values-'));
const files: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
'logx/logger.go': `package logx

type Logger struct{}

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

func (l *Logger) Panic(msg string) {}

func (l *Logger) Info(msg string) {}
`,
'tools/dump.go': `package tools

import "go.uber.org/zap"

var lg *zap.Logger

func dump() {
lg.Panic("boom")
}
`,
'tools/other.go': `package tools

func other() {
lg.Info("other file")
}
`,
'tools/shadow.go': `package tools

import "example.com/app/logx"

func shadow() {
lg := logx.New()
lg.Panic("local")
}
`,
'discovery/kubernetes/kubernetes.go': `package kubernetes

type Role string

func (r Role) String() string { return string(r) }

const (
RoleNode Role = "node"
RolePod Role = "pod"
)
`,
'discovery/kubernetes/metrics.go': `package kubernetes

func nodeRole() string {
return RoleNode.String()
}
`,
'discovery/kubernetes/kind.go': `package kubernetes

type Kind int

const (
KindA Kind = iota
// KindB repeats KindA's type.
KindB
KindC
)

func (k Kind) Label() string { return "" }

func kindLabel() string {
return KindB.Label()
}
`,
'discovery/kubernetes/roles.go': `package kubernetes

var podRole = Role("pod")

func podRoleName() string {
return podRole.String()
}
`,
'discovery/aws/aws.go': `package aws

type Role string

func (r Role) String() string { return string(r) }
`,
'tags/tag.go': `package tags

type Tag struct{}

func (t Tag) Label() string { return "" }
`,
'registry/registry.go': `package registry

type Registry struct{}

func (r *Registry) Register(name string) {}

var defaultRegistry = &Registry{}

func register() {
defaultRegistry.Register("x")
}
`,
'plugins/manager.go': `package plugins

type Manager struct{}

func (m *Manager) Register(name string) {}
`,
'buffers/buffers.go': `package buffers

import (
"bytes"
"io"
)

var buf = bytes.NewBuffer(nil)

func flush(w io.Writer) {
buf.WriteTo(w)
}
`,
'sinks/sink.go': `package sinks

import "io"

type Sink struct{}

func (s *Sink) WriteTo(w io.Writer) (int64, error) { return 0, nil }
`,
'parsers/parser.go': `package parsers

type Parser struct{}

func NewParser() *Parser { return &Parser{} }

func (p *Parser) ParseExpr(s string) {}
`,
'codecs/codec.go': `package codecs

type Encoder interface {
Encode() string
}

type Serializer interface {
Encoder
}

type Codec Serializer

type jsonCodec struct{}

func (jsonCodec) Encode() string { return "" }

var JSONCodec Codec = jsonCodec{}

func encode() string {
return JSONCodec.Encode()
}
`,
'parsers/use.go': `package parsers

var testParser = NewParser()

func parse() {
testParser.ParseExpr("x")
}
`,
};
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?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

/** `file::qualifiedName resolvedBy@confidence` of every method the function `name` in `file` calls. */
function calls(file: string, name: string): string[] {
const fn = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'method');
expect(fn, name).toBeDefined();
return cg
.getOutgoingEdges(fn!.id)
.filter((e) => e.kind === 'calls')
.map((e) => ({ e, n: cg.getNode(e.target)! }))
.filter(({ n }) => n.kind === 'method')
.map(({ e, n }) => `${n.filePath.replace(/\\/g, '/')}::${n.qualifiedName} ${String(e.metadata?.resolvedBy)}@${String(e.metadata?.confidence)}`)
.sort();
}

describe('Go calls through a package-level variable or constant', () => {
it.each([
// `var lg *zap.Logger`: not the project's only `Panic`.
['tools/dump.go', 'dump', 'a variable of a type from outside the project'],
// The same `lg`, declared in another file of the package.
['tools/other.go', 'other', 'a variable another file declares'],
// `var buf = bytes.NewBuffer(nil)`: not the project's only `WriteTo`.
['buffers/buffers.go', 'flush', 'a value a package from outside the project hands out'],
])('links nothing from %s %s: %s', (file, fn) => {
expect(calls(file, fn)).toEqual([]);
});

it.each([
// `RoleNode Role = "node"` in kubernetes.go, not aws's `Role`.
['discovery/kubernetes/metrics.go', 'nodeRole', 'discovery/kubernetes/kubernetes.go::Role::String instance-method@0.9'],
// `KindB`, alone in its group, has `Kind` from `KindA Kind = iota`.
['discovery/kubernetes/kind.go', 'kindLabel', 'discovery/kubernetes/kind.go::Kind::Label instance-method@0.9'],
// `var defaultRegistry = &Registry{}`, not a plugin `Manager`.
['registry/registry.go', 'register', 'registry/registry.go::Registry::Register instance-method@0.9'],
// `var podRole = Role("pod")`.
['discovery/kubernetes/roles.go', 'podRoleName', 'discovery/kubernetes/kubernetes.go::Role::String instance-method@0.9'],
])('links %s %s through the declared type', (file, fn, target) => {
expect(calls(file, fn)).toEqual([target]);
});

it('leaves a local of the name, a value a project function returns and a type whose methods it can\'t see as they were', () => {
// `lg := logx.New()` shadows the package's `lg`.
expect(calls('tools/shadow.go', 'shadow').filter((c) => c.startsWith('logx/logger.go::Logger::Panic '))).toHaveLength(1);
// `var testParser = NewParser()` says no type.
expect(calls('parsers/use.go', 'parse').filter((c) => c.startsWith('parsers/parser.go::Parser::ParseExpr '))).toHaveLength(1);
// `var JSONCodec Codec = jsonCodec{}`: `Codec` is defined over an
// interface, whose methods its node doesn't carry.
expect(calls('codecs/codec.go', 'encode')).not.toEqual([]);
});
});
Loading