mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
c051ea0a0e
goRefQualification read a dotted Go call's package qualifier from its line, as the line's only `X.Name` spelling. A second spelling of the name left it with none (`Digest:` beside `digest.Digest(dig)`, `"Log using Infof"`), and another selector before it was taken instead (`klog` for `err.Error` in `klog.Error(err.Error())`). It now takes the reference name's first segment, the only one that can be a package. A call through a parameter or local that takes an import's name (etcd's `jwt, err := newTokenProviderJWT(…)`, testify suites' `suite` receivers) is a call on that variable. A per-file scope reader finds the parameters, receivers, results, `:=`, `var` and `const` names in scope at the call, so such a call is no longer one into the package. The variable's declared type, when its declaration writes one, decides what the call reaches: - An outside package's type, or a value an outside package's function hands out (`clock := clocktesting.NewFakePassiveClock(…)`), has none of the project's methods. - A project package's type is resolved as the receiver's type. This also covers parameters declared through a package (`logger logger.Interface`). - A field read through the local (`metadata.Type.String()`) keeps its own type. A sync drops a file's scope reading with the other per-file memos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
/**
|
|
* A Go name written through an outside package is none of the project's
|
|
* symbols (#2322), so which package a call is written through decides what
|
|
* it may reach. Two misreadings of that qualifier, both seen on real trees:
|
|
*
|
|
* - A dotted call reference (`klog.Infof`) is recorded at its receiver, and
|
|
* the reader looked for the line's only `X.Infof` instead of taking the
|
|
* receiver the reference names. A second spelling of the name on the line
|
|
* left it with no qualifier — `Digest:` beside `digest.Digest(dig)`
|
|
* (harbor), `"Log using Infof"` (kubernetes) — so the call kept a project
|
|
* namesake's method, and another selector before it made `perr.Error` in
|
|
* `klog.Error(perr.Error())` a call through `klog`. Only the first segment
|
|
* can be a package: `s.cache.Get` is no call through a `cache` import.
|
|
* - A parameter or local can take an import's name: etcd's
|
|
* `jwt, err := newTokenProviderJWT(…)` beside `import ".../jwt/v5"`. A
|
|
* call through it is a method call on the variable, which the
|
|
* outside-package rule rejected along with the package's own calls.
|
|
*/
|
|
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',
|
|
// Project methods named like the outside packages' functions.
|
|
'logging/wrapper.go': `package logging
|
|
|
|
// An adapter with klog's method names, like kubernetes' etcd3 logger.
|
|
type klogWrapper struct{}
|
|
|
|
func (klogWrapper) Infof(format string, args ...interface{}) {}
|
|
func (klogWrapper) Errorf(format string, args ...interface{}) {}
|
|
`,
|
|
'registry/suite.go': `package registry
|
|
|
|
type Suite struct{}
|
|
|
|
func (s *Suite) Digest(v string) string { return v }
|
|
`,
|
|
'diff/differ.go': `package diff
|
|
|
|
type Differ struct{}
|
|
|
|
func (d *Differ) Diff(a, b string) string { return a + b }
|
|
`,
|
|
'example/example.go': `package example
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/distribution/distribution/v3"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/opencontainers/go-digest"
|
|
"github.com/patrickmn/go-cache"
|
|
"k8s.io/klog/v2"
|
|
)
|
|
|
|
type parseError struct{}
|
|
|
|
func (e *parseError) Error() string { return "parse" }
|
|
|
|
type lru struct{}
|
|
|
|
func (l *lru) Get(key string) string { return key }
|
|
|
|
type Server struct {
|
|
cache *lru
|
|
}
|
|
|
|
func (s *Server) Lookup(key string) string {
|
|
return s.cache.Get(key)
|
|
}
|
|
|
|
func NewServer() *Server {
|
|
_ = cache.New(0, 0)
|
|
return &Server{cache: &lru{}}
|
|
}
|
|
|
|
func Run(dig string) {
|
|
klog.Infof("Log using Infof, key: %s", "value")
|
|
err := errors.New("fail")
|
|
klog.Errorf("Log using Errorf, err: %v", err)
|
|
desc := &distribution.Descriptor{Digest: digest.Digest(dig)}
|
|
_ = desc
|
|
perr := &parseError{}
|
|
klog.Error(perr.Error())
|
|
}
|
|
|
|
func TestParser(t *testing.T) {
|
|
t.Errorf("Unexpected policy! Diff:\\n%s", cmp.Diff("a", "b"))
|
|
}
|
|
`,
|
|
'auth/jwt.go': `package auth
|
|
|
|
import "github.com/golang-jwt/jwt/v5"
|
|
|
|
type tokenJWT struct{ key string }
|
|
|
|
func newTokenProviderJWT(key string) (*tokenJWT, error) { return &tokenJWT{key: key}, nil }
|
|
|
|
func (t *tokenJWT) assign(user string) (string, error) { return user, nil }
|
|
|
|
func (t *tokenJWT) info(token string) (string, bool) { return token, true }
|
|
|
|
// Named like the package's Parse.
|
|
type jwtOptions struct{}
|
|
|
|
func (o *jwtOptions) Parse(token string) error { return nil }
|
|
|
|
func parse(token string) {
|
|
_, _ = jwt.Parse(token, nil)
|
|
}
|
|
`,
|
|
'auth/jwt_test.go': `package auth
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
func TestJWTInfo(t *testing.T) {
|
|
jwt, err := newTokenProviderJWT("key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _ := jwt.assign("abc")
|
|
_, ok := jwt.info(token)
|
|
_ = ok
|
|
}
|
|
|
|
func TestJWTParse(t *testing.T) {
|
|
jwt, err := jwt.Parse("x", nil)
|
|
_, _ = jwt, err
|
|
}
|
|
|
|
func TestJWTClosure(t *testing.T) {
|
|
check := func(jwt *tokenJWT) {
|
|
jwt.info("t")
|
|
}
|
|
check(nil)
|
|
_, _ = jwt.Parse("y", nil)
|
|
}
|
|
`,
|
|
'compare/compare.go': `package compare
|
|
|
|
import "github.com/google/go-cmp/cmp"
|
|
|
|
type Comparer interface {
|
|
Compare(a, b int) int
|
|
}
|
|
|
|
func comparers() []Comparer { return nil }
|
|
|
|
func Check(cmp Comparer) int {
|
|
return cmp.Compare(1, 2)
|
|
}
|
|
|
|
func Report(a, b string) string {
|
|
for _, cmp := range comparers() {
|
|
_ = cmp.Compare(3, 4)
|
|
}
|
|
return cmp.Diff(a, b)
|
|
}
|
|
|
|
func Report2(a, b string) string {
|
|
cmp := cmp.Diff(a, b)
|
|
return cmp
|
|
}
|
|
`,
|
|
// A variable's declared type says whose methods it has: kubernetes'
|
|
// `clock clock.PassiveClock` parameter, `cache := &atomic.Bool{}`, and
|
|
// harbor's `logger logger.Interface`.
|
|
'clock/clock.go': `package clock
|
|
|
|
import "time"
|
|
|
|
// Named like k8s.io/utils/clock's interface.
|
|
type Clock interface {
|
|
Now() time.Time
|
|
}
|
|
`,
|
|
'cache/store.go': `package cache
|
|
|
|
type Store interface {
|
|
Add(obj interface{}) error
|
|
}
|
|
`,
|
|
'jobs/logger/logger.go': `package logger
|
|
|
|
type Interface interface {
|
|
Error(v ...interface{})
|
|
String() string
|
|
}
|
|
|
|
func Error(v ...interface{}) {}
|
|
`,
|
|
'jobs/transfer/transfer.go': `package transfer
|
|
|
|
type Logger struct{}
|
|
|
|
func (l *Logger) Error(v ...interface{}) {}
|
|
`,
|
|
'jobs/job.go': `package jobs
|
|
|
|
import (
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"example.com/app/cache"
|
|
"example.com/app/jobs/logger"
|
|
"k8s.io/utils/clock"
|
|
clocktesting "k8s.io/utils/clock/testing"
|
|
)
|
|
|
|
func logError(logger logger.Interface, err error) {
|
|
logger.Error(err)
|
|
}
|
|
|
|
func nextCheck(clock clock.PassiveClock) int64 {
|
|
return clock.Now().Unix()
|
|
}
|
|
|
|
func cachedHasSynced(store cache.Store) func() bool {
|
|
cache := &atomic.Bool{}
|
|
cache.Store(false)
|
|
return cache.Load
|
|
}
|
|
|
|
func describe(logger logger.Interface) string {
|
|
return logger.Kind.String()
|
|
}
|
|
|
|
func elapsed() time.Time {
|
|
clock := clocktesting.NewFakePassiveClock(time.Unix(1, 0))
|
|
now := clock.Now()
|
|
return now
|
|
}
|
|
`,
|
|
// harbor's controller/robot.Robot embeds model.Robot, whose method a
|
|
// local named like the import calls.
|
|
'robot/model/model.go': `package model
|
|
|
|
type Robot struct{ Name string }
|
|
|
|
func (r *Robot) ToJSON() (string, error) { return r.Name, nil }
|
|
`,
|
|
'robot/robot.go': `package robot
|
|
|
|
import "example.com/app/robot/model"
|
|
|
|
type Robot struct {
|
|
model.Robot
|
|
Level string
|
|
}
|
|
`,
|
|
'scan/job_test.go': `package scan
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"example.com/app/robot"
|
|
)
|
|
|
|
func TestJob(t *testing.T) {
|
|
robot := &robot.Robot{Level: "system"}
|
|
data, _ := robot.ToJSON()
|
|
_ = data
|
|
}
|
|
`,
|
|
'store/store.go': `package store
|
|
|
|
import "github.com/patrickmn/go-cache"
|
|
|
|
type store struct{}
|
|
|
|
func (cache *store) Refresh() {
|
|
cache.load()
|
|
}
|
|
|
|
func (s *store) load() {}
|
|
|
|
func New() *cache.Cache {
|
|
return cache.New(0, 0)
|
|
}
|
|
`,
|
|
};
|
|
|
|
let root = '';
|
|
let cg: CodeGraph;
|
|
|
|
beforeAll(async () => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-ref-qualifier-'));
|
|
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` (its nth occurrence, from 1). */
|
|
const lineOf = (file: string, snippet: string, nth = 1): number => {
|
|
const lines = files[file]!.split('\n');
|
|
let seen = 0;
|
|
const at = lines.findIndex((l) => l.includes(snippet) && ++seen === nth);
|
|
if (at < 0) throw new Error(`no ${snippet} in ${file}`);
|
|
return at + 1;
|
|
};
|
|
|
|
/** What the calls on the line of `file` holding `snippet` reach, as qualified names. */
|
|
const reachedAt = (file: string, snippet: string, nth = 1): string[] => {
|
|
const line = lineOf(file, snippet, nth);
|
|
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);
|
|
};
|
|
|
|
describe('a dotted Go call is written through its own receiver', () => {
|
|
it('whatever else on the line spells the name', () => {
|
|
// `"Log using Infof"` and the `Digest:` key spell the name again.
|
|
expect(reachedAt('example/example.go', 'klog.Infof(')).not.toContain('klogWrapper::Infof');
|
|
expect(reachedAt('example/example.go', 'klog.Errorf(')).not.toContain('klogWrapper::Errorf');
|
|
expect(reachedAt('example/example.go', 'digest.Digest(dig)')).not.toContain('Suite::Digest');
|
|
expect(reachedAt('example/example.go', 'cmp.Diff("a", "b")')).not.toContain('Differ::Diff');
|
|
});
|
|
|
|
it('not through another selector on its line', () => {
|
|
// `klog.Error(perr.Error())`: perr's own method, not a call through klog.
|
|
expect(reachedAt('example/example.go', 'klog.Error(perr.Error())')).toContain('parseError::Error');
|
|
// `s.cache.Get(key)`: the field's type, not the go-cache package the
|
|
// middle segment is named like.
|
|
expect(reachedAt('example/example.go', 's.cache.Get(key)')).toContain('lru::Get');
|
|
});
|
|
});
|
|
|
|
describe('a Go parameter or local named like an import', () => {
|
|
it('is the variable where it is in scope', () => {
|
|
expect(reachedAt('auth/jwt_test.go', 'jwt.assign(')).toContain('tokenJWT::assign');
|
|
expect(reachedAt('auth/jwt_test.go', 'jwt.info(token)')).toContain('tokenJWT::info');
|
|
// A closure's parameter, a function's parameter, a range variable and a method receiver.
|
|
expect(reachedAt('auth/jwt_test.go', 'jwt.info("t")')).toContain('tokenJWT::info');
|
|
expect(reachedAt('compare/compare.go', 'cmp.Compare(1, 2)')).toContain('Comparer::Compare');
|
|
expect(reachedAt('compare/compare.go', 'cmp.Compare(3, 4)')).toContain('Comparer::Compare');
|
|
expect(reachedAt('store/store.go', 'cache.load()')).toContain('store::load');
|
|
});
|
|
|
|
it('leaves the package what the import names everywhere else', () => {
|
|
// Another function, the declaration's own right-hand side, past a
|
|
// closure or a loop that bound the name.
|
|
expect(reachedAt('auth/jwt.go', 'jwt.Parse(token, nil)')).not.toContain('jwtOptions::Parse');
|
|
expect(reachedAt('auth/jwt_test.go', 'jwt.Parse("x", nil)')).not.toContain('jwtOptions::Parse');
|
|
expect(reachedAt('auth/jwt_test.go', 'jwt.Parse("y", nil)')).not.toContain('jwtOptions::Parse');
|
|
expect(reachedAt('compare/compare.go', 'return cmp.Diff(a, b)')).not.toContain('Differ::Diff');
|
|
expect(reachedAt('compare/compare.go', 'cmp := cmp.Diff(a, b)')).not.toContain('Differ::Diff');
|
|
});
|
|
|
|
it('has the methods of the type it is declared as', () => {
|
|
// An outside package's type holds none of the project's methods, nor
|
|
// does a value one of its functions hands out…
|
|
expect(reachedAt('jobs/job.go', 'clock.Now()')).not.toContain('Clock::Now');
|
|
expect(reachedAt('jobs/job.go', 'cache.Store(false)')).toEqual([]);
|
|
expect(reachedAt('jobs/job.go', 'now := clock.Now()')).toEqual([]);
|
|
// …and a project package's type its own: not the function the import
|
|
// names, nor another package's `Logger::Error`. A method an embedded
|
|
// type brings in counts.
|
|
expect(reachedAt('jobs/job.go', 'logger.Error(err)')).toEqual(['Interface::Error']);
|
|
expect(reachedAt('scan/job_test.go', 'robot.ToJSON()')).toEqual(['Robot::ToJSON']);
|
|
// The type is the variable's, not a field's read through it.
|
|
expect(reachedAt('jobs/job.go', 'logger.Kind.String()')).not.toContain('Interface::String');
|
|
});
|
|
|
|
it('is read from the file as a sync leaves it', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-ref-qualifier-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('auth/jwt.go', files['auth/jwt.go']!);
|
|
// The same lines either way: a stale reading would still see the local.
|
|
const use = (decl: string) => `package auth
|
|
|
|
import "github.com/golang-jwt/jwt/v5"
|
|
|
|
func use() {
|
|
${decl}
|
|
jwt.assign("user")
|
|
}
|
|
`;
|
|
write('auth/use.go', use('jwt, _ := newTokenProviderJWT("key")'));
|
|
const synced = await CodeGraph.init(dir, { index: true });
|
|
try {
|
|
const assigns = (): string[] => {
|
|
const ids = synced.getNodesInFile('auth/use.go').map((n) => n.id);
|
|
return synced.getOutgoingEdgesFrom(ids).filter((e) => e.line === 7).map((e) => synced.getNode(e.target)!.qualifiedName);
|
|
};
|
|
expect(assigns()).toEqual(['tokenJWT::assign']);
|
|
write('auth/use.go', use('_, _ = jwt.Parse("key", nil)'));
|
|
await synced.sync();
|
|
expect(assigns()).toEqual([]);
|
|
} finally {
|
|
synced.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|