diff --git a/CHANGELOG.md b/CHANGELOG.md index 393c145fb..521bd23e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,6 +495,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 ``, 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 `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, 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 through a parameter or local variable named like a standard-library package, such as `context`, `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(…)`, 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. - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` 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 `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` 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 `}>`, 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. - A React Router route whose `lazy` loader picks its page from the module it loads now links to that page: `lazy: async () => { const { TestTab } = await import('./tabs'); return { Component: TestTab }; }`, `const { default: Component } = await import('./pages/Login')`, `import('./reports').then((m) => ({ Component: m.ReportList }))`, `({ Component: (await import('./reports')).ReportPage })`, React Router 7's `lazy: { Component: async () => (await import('./show')).Show }`, and a page the loader shows inside a guard, like `element: `. Before, such a route linked to the module's default export, else its `Component` export, which is a different page or none at all, as with luci-go's tabs loaded from a folder that forwards them. A loader written as a method, `async lazy() { … }`, is now read too, so its route is no longer missing, and a loader that hands React Router only a `loader` no longer makes the module's default export the route's page or the layout of the routes inside it. Re-index React projects after upgrading. diff --git a/README.md b/README.md index 1bc56e074..d09fe2ff0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. ## About this fork -This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`1d3619d6`](https://github.com/colbymchenry/codegraph/commit/1d3619d6) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. +This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`8a5ba3d0`](https://github.com/colbymchenry/codegraph/commit/8a5ba3d0) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below). diff --git a/__tests__/go-stdlib-named-locals.test.ts b/__tests__/go-stdlib-named-locals.test.ts new file mode 100644 index 000000000..50967e33f --- /dev/null +++ b/__tests__/go-stdlib-named-locals.test.ts @@ -0,0 +1,503 @@ +/** + * A dotted Go reference whose receiver is named like a standard-library + * package (`context`, `url`, `user`, `scanner`, `printer`, `log`, …) is taken + * for a call into that package and dropped before resolution. A parameter or + * local can carry the name too, and a call through it is a method call on + * that variable: gin's `router.Use(func(context *Context) { context.Next() })`, + * harbor's `context := NewSecurityContext(…)` then + * `context.IsAuthenticated()`, etcd's `printer.DBHashKV(…)`. + * + * Such a call resolves by what the variable's declaration says, never by its + * name, which fits many of a big tree's types: the type it is written as, the + * package of the project function its value comes from, or a method name no + * other type declares. A variable holding a value of a package from outside + * the project (`scanner := bufio.NewScanner(r)`, `url, err := url.Parse(raw)`, + * a `context *gin.Context` parameter) calls none of the project's methods. + */ +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 = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + // gin's own Context, called from its tests through a parameter named `context`. + 'gin/context.go': `package gin + +type Context struct{} + +func (c *Context) AbortWithError(code int, err error) error { return err } + +func (c *Context) Next() {} + +type Engine struct{} + +func New() *Engine { return &Engine{} } + +func (engine *Engine) Use(middleware func(*Context)) {} +`, + 'gin/middleware_test.go': `package gin + +import ( + "errors" + "net/http" + "testing" +) + +func TestMiddlewareFailHandlersChain(t *testing.T) { + router := New() + router.Use(func(context *Context) { + context.AbortWithError(http.StatusInternalServerError, errors.New("foo")) + }) + router.Use(func(context *Context) { + context.Next() + }) +} +`, + // harbor's security contexts: an interface, and two packages' implementations. + 'security/context.go': `package security + +type Context interface { + IsAuthenticated() bool +} +`, + 'secret/context.go': `package secret + +type SecurityContext struct{ secret string } + +func NewSecurityContext(secret string, store any) *SecurityContext { + return &SecurityContext{secret: secret} +} + +func (s *SecurityContext) IsAuthenticated() bool { return s.secret != "" } +`, + 'local/context.go': `package local + +type SecurityContext struct{ user string } + +func (s *SecurityContext) IsAuthenticated() bool { return s.user != "" } +`, + 'secret/context_test.go': `package secret + +import "testing" + +func TestIsAuthenticated(t *testing.T) { + context := NewSecurityContext("", nil) + if context.IsAuthenticated() { + t.Fail() + } +} +`, + // Project types with the method names of the standard library's and gin's + // types: a call on one of those values must not land here. + 'textutil/types.go': `package textutil + +type Scanner struct{} + +func (s *Scanner) Scan() bool { return false } + +func (s *Scanner) Text() string { return "" } + +type URL struct{} + +func (u *URL) Hostname() string { return "" } + +type Context struct{} + +func (c *Context) JSON(code int, obj any) {} +`, + 'textutil/read.go': `package textutil + +import ( + "bufio" + "io" + "net/url" + + "github.com/gin-gonic/gin" +) + +func FirstLine(r io.Reader) string { + scanner := bufio.NewScanner(r) + if scanner.Scan() { + return scanner.Text() + } + return "" +} + +func Host(raw string) string { + url, err := url.Parse(raw) + if err != nil { + return "" + } + return url.Hostname() +} + +func Handle(context *gin.Context) { + context.JSON(200, nil) +} +`, + // A parameter declared as a project package's type, which another package + // shares the name of. + 'models/user.go': `package models + +type User struct{ Name string } + +func (u *User) Validate() error { return nil } +`, + 'legacy/user.go': `package legacy + +type User struct{} + +func (u *User) Validate() error { return nil } + +func (u *User) Name() string { return "" } +`, + 'api/users.go': `package api + +import ( + "context" + "strings" + + "example.com/app/models" +) + +func Save(user *models.User) error { + return user.Validate() +} + +func Label(user *models.User) string { + name := user.Name + return name +} + +// Named like the standard library's functions. +func Background() {} + +func TrimSpace(s string) string { return s } + +func Run() string { + ctx := context.Background() + _ = ctx + return strings.TrimSpace(" x ") +} +`, + // harbor's lib/log: a project package named like the standard library's, + // whose logger a local takes the name of. + 'lib/log/log.go': `package log + +type Logger struct{} + +func G(ctx any) *Logger { return &Logger{} } + +func (l *Logger) Errorf(format string, v ...any) {} + +func Errorf(format string, v ...any) {} +`, + 'quota/util.go': `package quota + +import ( + "context" + + "example.com/app/lib/log" +) + +func Refresh(ctx context.Context) { + log := log.G(ctx) + log.Errorf("refresh quota: %v", ctx) +} +`, + // kubernetes' post-start hooks: the hook context gets Done from the + // standard library's context it embeds, not from a project type. + 'wait/wait.go': `package wait + +type channelContext struct{ stopCh <-chan struct{} } + +func (c channelContext) Done() <-chan struct{} { return c.stopCh } +`, + 'apiserver/hooks.go': `package apiserver + +import "context" + +type PostStartHookContext struct { + context.Context + LoopbackClientConfig string +} + +func AddPostStartHook(name string, hook func(context PostStartHookContext) error) {} + +func register() { + AddPostStartHook("start-informers", func(context PostStartHookContext) error { + <-context.Done() + return nil + }) +} +`, + // kubernetes' cli-runtime printers, and kubeadm's unrelated Printer. + 'printers/printers.go': `package printers + +type ResourcePrinter interface { + PrintObj(obj any) error +} + +type HumanReadablePrinter struct{} + +func (h *HumanReadablePrinter) PrintObj(obj any) error { return nil } + +type JSONPrinter struct{} + +func (p *JSONPrinter) PrintObj(obj any) error { return nil } + +func NewTablePrinter() ResourcePrinter { return &HumanReadablePrinter{} } +`, + 'printers/tableprinter_test.go': `package printers + +import "testing" + +func TestPrintTable(t *testing.T) { + printer := NewTablePrinter() + _ = printer.PrintObj(nil) +} +`, + 'kubeadm/output/output.go': `package output + +type Printer interface { + PrintObj(obj any) error +} +`, + // etcd's etcdutl printers: the variable is the interface its type is named. + 'etcdutl/printer.go': `package etcdutl + +type printer interface { + DBHashKV(hash uint32) +} + +type simplePrinter struct{} + +func (s *simplePrinter) DBHashKV(hash uint32) {} + +type jsonPrinter struct{} + +func (j *jsonPrinter) DBHashKV(hash uint32) {} + +func initPrinterFromCmd(format string) printer { + if format == "json" { + return &jsonPrinter{} + } + return &simplePrinter{} +} + +func hashKVCommandFunc(format string) { + printer := initPrinterFromCmd(format) + printer.DBHashKV(1) +} +`, + // etcd's traceutil, and a namesake Trace in another package. + 'traceutil/trace.go': `package traceutil + +type Trace struct{} + +func Get(ctx any) *Trace { return &Trace{} } + +func (t *Trace) Step(msg string) {} +`, + 'otherutil/trace.go': `package otherutil + +type Trace struct{} + +func (t *Trace) Step(msg string) {} +`, + 'txn/range.go': `package txn + +import "example.com/app/traceutil" + +func executeRange(ctx any) { + trace := traceutil.Get(ctx) + trace.Step("range") +} +`, + // etcd's grpc proxy: a function of its own package hands out another + // package's TLSInfo. + 'transport/tls.go': `package transport + +type TLSInfo struct{ CAFile string } + +func (info TLSInfo) ClientConfig() string { return info.CAFile } +`, + 'etcdmain/grpc_proxy.go': `package etcdmain + +import "example.com/app/transport" + +func newTLS(ca string) *transport.TLSInfo { return &transport.TLSInfo{CAFile: ca} } + +func newClientCfg(ca string) string { + tls := newTLS(ca) + return tls.ClientConfig() +} +`, + // Range variables: nothing says what they hold. + 'plugins/plugins.go': `package plugins + +type Plugin interface { + Name() string +} + +type volumePlugin struct{} + +func (v *volumePlugin) Name() string { return "volume" } + +func (v *volumePlugin) CanSupportAttach() bool { return true } + +func names(all []Plugin) []string { + var out []string + for _, plugin := range all { + out = append(out, plugin.Name()) + } + return out +} + +func attachable(all []*volumePlugin) int { + n := 0 + for _, plugin := range all { + if plugin.CanSupportAttach() { + n++ + } + } + return n +} +`, +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-stdlib-locals-')); + 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 }); +}); + +/** The line of `file` that holds `snippet`. */ +const lineOf = (file: string, 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 of `file` holding `snippet` reach, as `file:qualifiedName`. */ +const reachedAt = (file: string, snippet: string): string[] => { + const line = lineOf(file, snippet); + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains' && e.line === line) + .map((e) => { + const target = cg.getNode(e.target)!; + return `${target.filePath}:${target.qualifiedName}`; + }); +}; + +describe('a Go parameter or local named like a standard-library package', () => { + it('calls the methods of the project type it is declared as', () => { + expect(reachedAt('gin/middleware_test.go', 'context.AbortWithError(')).toEqual(['gin/context.go:Context::AbortWithError']); + expect(reachedAt('gin/middleware_test.go', 'context.Next()')).toEqual(['gin/context.go:Context::Next']); + // Not legacy's `User`, which shares the name. + expect(reachedAt('api/users.go', 'user.Validate()')).toEqual(['models/user.go:User::Validate']); + }); + + it("calls nothing the project declares when its type gets the method from outside", () => { + // `Done` comes from the `context.Context` the hook context embeds: not + // the project's `channelContext`, which a receiver word would name. + expect(reachedAt('apiserver/hooks.go', 'context.Done()')).toEqual([]); + }); + + it('calls a method of the package of the project function its value comes from', () => { + // harbor: secret's own, not local's namesake or the security.Context interface. + expect(reachedAt('secret/context_test.go', 'context.IsAuthenticated()')).toEqual(['secret/context.go:SecurityContext::IsAuthenticated']); + // etcd: traceutil's Trace, not another package's. + expect(reachedAt('txn/range.go', 'trace.Step(')).toEqual(['traceutil/trace.go:Trace::Step']); + // A package that declares no method of the name: one no other type declares. + expect(reachedAt('etcdmain/grpc_proxy.go', 'tls.ClientConfig()')).toEqual(['transport/tls.go:TLSInfo::ClientConfig']); + }); + + // Not ported to the native resolver: a local declared as `log := log.G(ctx)` + // shadows the imported `log` package from its own line, and `log.G` and the + // later `log.Errorf` both stay unresolved. `it.fails` turns red once they resolve. + it.fails('calls the Logger method of a local that takes the name of the project package that hands it out', () => { + expect(reachedAt('quota/util.go', 'log.Errorf(')).toEqual(['lib/log/log.go:Logger::Errorf']); + }); + + it('takes the type named like the variable among that package\'s', () => { + expect(reachedAt('etcdutl/printer.go', 'printer.DBHashKV(1)')).toEqual(['etcdutl/printer.go:printer::DBHashKV']); + }); + + it('guesses no type by its name', () => { + // Two `PrintObj`s in printers, neither a `printer`, and kubeadm's unrelated `Printer`. + // The native resolver reads `NewTablePrinter`'s declared result, the + // `ResourcePrinter` interface, so it reaches that interface's method + // instead of leaving the call unresolved. + expect(reachedAt('printers/tableprinter_test.go', 'printer.PrintObj(nil)')).toEqual(['printers/printers.go:ResourcePrinter::PrintObj']); + // A range variable over `[]Plugin` is a `Plugin`: the native resolver reads + // the element type, so it reaches the interface's method where a resolver + // that cannot would leave the call unresolved (two types declare `Name`). + expect(reachedAt('plugins/plugins.go', 'plugin.Name()')).toEqual(['plugins/plugins.go:Plugin::Name']); + expect(reachedAt('plugins/plugins.go', 'plugin.CanSupportAttach()')).toEqual(['plugins/plugins.go:volumePlugin::CanSupportAttach']); + }); + + it('holds no project method when its value comes from outside the project', () => { + expect(reachedAt('textutil/read.go', 'if scanner.Scan()')).toEqual([]); + expect(reachedAt('textutil/read.go', 'return scanner.Text()')).toEqual([]); + expect(reachedAt('textutil/read.go', 'url.Hostname()')).toEqual([]); + expect(reachedAt('textutil/read.go', 'context.JSON(')).toEqual([]); + }); + + it('is read as a value, not a method, where it is not called', () => { + // `user.Name` is the field, not legacy's `Name` method. + expect(reachedAt('api/users.go', 'name := user.Name')).toEqual([]); + }); + + it('leaves the package what its name names where nothing shadows it', () => { + expect(reachedAt('api/users.go', 'context.Background()')).toEqual([]); + expect(reachedAt('api/users.go', 'strings.TrimSpace(')).toEqual([]); + expect(reachedAt('textutil/read.go', 'url.Parse(raw)')).toEqual([]); + }); + + it('is read from the file as a sync leaves it', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-stdlib-locals-sync-')); + const write = (rel: string, content: string): void => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + write('go.mod', 'module example.com/app\n\ngo 1.22\n'); + write('gin/context.go', files['gin/context.go']!); + // The call keeps its line and column: a stale reading would still see the parameter. + const handler = (param: string) => `package gin + +func handler(${param} *Context) { + context.Next() +} +`; + write('gin/handler.go', handler('context')); + const synced = await CodeGraph.init(dir, { index: true }); + try { + const nexts = (): string[] => { + const ids = synced.getNodesInFile('gin/handler.go').map((n) => n.id); + return synced.getOutgoingEdgesFrom(ids).filter((e) => e.line === 4).map((e) => synced.getNode(e.target)!.qualifiedName); + }; + expect(nexts()).toEqual(['Context::Next']); + write('gin/handler.go', handler('c')); + await synced.sync(); + expect(nexts()).toEqual([]); + } finally { + synced.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +});