From 2aa46f36be5491ef8af0f1093896acda52766f84 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 06:33:09 -0500 Subject: [PATCH 1/6] fix(go): a dotted call is written through its own receiver, and a local named like an import is that variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 it reaches: an outside package's type has none of the project's methods, a project package's type is resolved as the receiver's type. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-ref-qualifier.test.ts | 363 ++++++++++++++++++++++ src/resolution/import-resolver.ts | 3 + src/resolution/name-matcher.ts | 471 +++++++++++++++++++++++++++-- 3 files changed, 820 insertions(+), 17 deletions(-) create mode 100644 __tests__/go-ref-qualifier.test.ts diff --git a/__tests__/go-ref-qualifier.test.ts b/__tests__/go-ref-qualifier.test.ts new file mode 100644 index 000000000..1b9d7ddf7 --- /dev/null +++ b/__tests__/go-ref-qualifier.test.ts @@ -0,0 +1,363 @@ +/** + * 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 = { + '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{}) +} + +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" + + "example.com/app/cache" + "example.com/app/jobs/logger" + "k8s.io/utils/clock" +) + +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 +} +`, + // 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… + expect(reachedAt('jobs/job.go', 'clock.Now()')).not.toContain('Clock::Now'); + expect(reachedAt('jobs/job.go', 'cache.Store(false)')).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']); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 8847ef653..b18f4fc98 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -2565,6 +2565,9 @@ function resolveGoCrossPackageReference( const receiver = ref.referenceName.substring(0, dotIdx); const memberName = ref.referenceName.substring(dotIdx + 1); if (!memberName) return null; + // A parameter or local named like the import holds the call there: + // `store := newStore()`, then `store.Get(k)` is no call into package store. + if (!goRefQualifier(ref, context)) return null; for (const imp of imports) { if (imp.localName !== receiver) continue; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7d148d80d..44661c525 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1890,6 +1890,15 @@ interface GoQualification { written?: string; /** The file's import that name is. */ imported?: ImportMapping; + /** + * For a call through a parameter or local that takes an import's name, the + * type it is declared as, when its declaration writes one (`*tokenJWT`, + * `clock.PassiveClock`), and the import that type is written through: + * `clock` in a parameter `clock clock.PassiveClock`, `atomic` in + * `cache := &atomic.Bool{}`. + */ + declaredType?: string; + typePackage?: ImportMapping; } const GO_QUALIFIERS = new WeakMap>(); @@ -1903,24 +1912,55 @@ function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQ const key = `${ref.filePath}\0${ref.line}\0${ref.column}\0${ref.referenceName}`; const hit = memo.get(key); if (hit !== undefined) return hit; - const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; - const at = Math.max(0, ref.column); - // The qualifier right before the name at the reference's column, or the - // line's only spelling of the name. A variadic `...chunks.Meta` is written - // through `chunks` too: the ellipsis is no receiver. - const written = line.startsWith(name, at) ? /(?:^|[^\w.]|\.{3})([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] - : !new RegExp(`(? m.localName === written) : undefined; - const found = { written, imported }; + let written: string | undefined; + const dot = ref.referenceName.indexOf('.'); + if (dot >= 0) { + // A dotted reference spells its receiver, and only the first segment can + // be a package: `klog` in `klog.Infof`, `s` in `s.cache.Get`. Reading + // the line for it found nothing where the name is spelled again — + // harbor's `Digest:` key beside `digest.Digest(dig)` — and another + // selector's where one comes first: `klog` for `err.Error` in + // `klog.Error(err.Error())`. + const head = ref.referenceName.slice(0, dot); + if (/^[A-Za-z_]\w*$/.test(head)) written = head; + } else { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; + const at = Math.max(0, ref.column); + // The qualifier right before the name at the reference's column, or the + // line's only spelling of the name. A variadic `...chunks.Meta` is written + // through `chunks` too: the ellipsis is no receiver. + written = line.startsWith(name, at) ? /(?:^|[^\w.]|\.{3})([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] + : !new RegExp(`(? m.localName === written) : undefined; + let declaredType: string | undefined; + let typePackage: ImportMapping | undefined; + // A call through a parameter or local that takes the import's name is a + // call on that variable: etcd's `jwt, err := newTokenProviderJWT(…)`, then + // `jwt.assign(…)` beside `import "github.com/golang-jwt/jwt/v5"`; harbor's + // testify suites' `func (suite *DaoTestSuite) …` beside testify's `suite`. + // A type's qualifier is always a package, and a value read through one + // (`quota.Name`) is no call. + const local = imported && dot >= 0 && ref.referenceKind === 'calls' ? goLocalDecl(written!, ref, context) : undefined; + if (local) { + declaredType = local.type; + const qualifier = /^[*&\s]*([A-Za-z_]\w*)\.[A-Za-z_]\w*$/.exec(declaredType ?? '')?.[1]; + typePackage = qualifier ? imports.find((m) => m.localName === qualifier) : undefined; + written = undefined; + imported = undefined; + } + const found = { written, imported, declaredType, typePackage }; memo.set(key, found); return found; } /** * The import a Go reference is written through — `context` in - * `context.Context`, `store` in `store.Manager` — read from its line, since - * the index keeps only the name. Undefined for a name written bare, or - * through anything that isn't one of the file's imports. + * `context.Context`, `store` in `store.Manager` — taken from a dotted + * reference's receiver, else read from its line, since the index keeps only + * the name. Undefined for a name written bare, or through anything that isn't + * one of the file's imports there. */ export function goRefQualifier(ref: UnresolvedRef, context: ResolutionContext): ImportMapping | undefined { return goRefQualification(ref, context).imported; @@ -1941,13 +1981,15 @@ export function isGoUnknownQualified(ref: UnresolvedRef, context: ResolutionCont /** * Whether a Go reference is written through an imported package from outside * the project's modules — `context.Context`, `fmt.Errorf`, a third-party - * `gin.H`. + * `gin.H` — or is a call on a variable declared as such a package's type: + * kubernetes' `clock.Now()` on a parameter `clock clock.PassiveClock`. */ function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { - const imported = goRefQualifier(ref, context); - if (!imported) return false; - return !(imported.source.startsWith('.') || imported.source.includes('/internal/') || - context.getGoPackageDir?.(imported.source, ref.filePath) != null); + const { imported, typePackage } = goRefQualification(ref, context); + const pkg = imported ?? typePackage; + if (!pkg) return false; + return !(pkg.source.startsWith('.') || pkg.source.includes('/internal/') || + context.getGoPackageDir?.(pkg.source, ref.filePath) != null); } /** @@ -1967,6 +2009,391 @@ function isInGoQualifierPackage(candidate: Node, ref: UnresolvedRef, context: Re return pkgDir == null || goPackageDir(candidate.filePath) === pkgDir; } +/** + * A Go file's blocks and the names its functions declare: the receivers, + * parameters and results of every `func` with a body, and each name a `:=`, + * `var` or `const` declares. + */ +interface GoScopeIndex { + /** The source, comments and string contents blanked, offsets kept. */ + code: string; + lineStarts: number[]; + /** Offsets of every `{` and `}`, with the innermost block open after each. */ + events: number[]; + open: number[]; + close: Map; + decls: Map; + /** Where each declared name is bound, read on demand: [start, end, declaration]. */ + scopes: Map>; +} + +interface GoDecl { + /** Offset of the declared name. */ + at: number; + /** + * A statement's local, an `if` / `for` / `switch` header's, a `select` + * case's, or a parameter of the function whose body opens at `body`. + */ + kind: 'local' | 'header' | 'case' | 'param'; + body?: number; + /** The declared type as written, when the declaration writes one. */ + type?: string; +} + +const GO_SCOPE_INDEXES = new WeakMap>(); +const GO_TYPE_KEYWORDS: ReadonlySet = new Set(['chan', 'func', 'interface', 'map', 'struct']); + +/** + * The parameter or local of the Go function around `ref` that `name` is + * there, if one is. Go gives an identifier the innermost block that declares + * it, from the end of its declaration on: a header's names live in its + * statement's blocks (`else` included), a `select` case's in its clause, a + * parameter in its function's body. + */ +function goLocalDecl(name: string, ref: UnresolvedRef, context: ResolutionContext): GoDecl | undefined { + const index = goScopeIndex(ref.filePath, context); + if (!index?.decls.has(name)) return undefined; + const at = (index.lineStarts[ref.line - 1] ?? index.code.length) + Math.max(0, ref.column); + let found: [number, number, GoDecl] | undefined; + for (const scope of goScopesOf(index, name)) { + if (at > scope[0] && at < scope[1] && (!found || scope[0] > found[0])) found = scope; + } + return found?.[2]; +} + +function goScopeIndex(filePath: string, context: ResolutionContext): GoScopeIndex | null { + let memo = GO_SCOPE_INDEXES.get(context); + if (!memo) GO_SCOPE_INDEXES.set(context, (memo = new Map())); + const hit = memo.get(filePath); + if (hit !== undefined) return hit; + const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/); + const index = lines ? readGoScopes(blankGoCode(lines.join('\n'))) : null; + // Refs arrive file by file; a few files' worth is all a pass revisits. + if (memo.size >= 64) memo.delete(memo.keys().next().value!); + memo.set(filePath, index); + return index; +} + +function isGoIdentChar(c: number): boolean { + return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95; +} + +function readGoScopes(code: string): GoScopeIndex { + const lineStarts = [0]; + const events: number[] = []; + const open: number[] = []; + const close = new Map(); + const stack: number[] = []; + for (let i = 0; i < code.length; i++) { + const ch = code.charCodeAt(i); + if (ch === 10) { lineStarts.push(i + 1); continue; } + if (ch === 123) stack.push(i); + else if (ch === 125) { + const at = stack.pop(); + if (at !== undefined) close.set(at, i); + } else continue; + events.push(i); + open.push(stack.length > 0 ? stack[stack.length - 1]! : -1); + } + const decls = new Map(); + const add = (name: string, decl: GoDecl): void => { + if (name === '_') return; + const list = decls.get(name); + if (list) list.push(decl); + else decls.set(name, [decl]); + }; + // `a, b := …`: the identifiers before each `:=`. + for (let i = code.indexOf(':='); i >= 0; i = code.indexOf(':=', i + 2)) { + const names: Array<[string, number]> = []; + let p = i; + for (;;) { + while (p > 0 && (code[p - 1] === ' ' || code[p - 1] === '\t')) p--; + let s = p; + while (s > 0 && isGoIdentChar(code.charCodeAt(s - 1))) s--; + if (s === p || /\d/.test(code[s]!) || code[s - 1] === '.') break; + names.push([code.slice(s, p), s]); + p = s; + while (p > 0 && (code[p - 1] === ' ' || code[p - 1] === '\t')) p--; + if (code[p - 1] !== ',') break; + p--; + } + if (names.length === 0) continue; + const first = names[names.length - 1]![1]; + const lead = code.slice(code.lastIndexOf('\n', first - 1) + 1, first); + const kind = /^\s*(?:\}\s*else\s+)?(?:if|for|switch)\b/.test(lead) ? 'header' : /^\s*case\b/.test(lead) ? 'case' : 'local'; + // `x := &pkg.T{…}` writes the type it declares. + const type = names.length === 1 ? /^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{/.exec(code.slice(i + 2, i + 160))?.[1] : undefined; + for (const [name, at] of names) add(name, { at, kind, type }); + } + // `var x T`, `var a, b = …`, `const (…)`: names before a type, an `=` or + // the line's end — in a group, on lines outside the brackets of a value + // spanning several (`http.MethodHead,` in a `[]string{…}` is no name). + const spec = /[ \t]*([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)(?=[ \t]*(?:\n|$|=(?!=))|[ \t]+(?:[*[A-Za-z_(]|<-))/y; + const addSpec = (at: number): void => { + spec.lastIndex = at; + const s = spec.exec(code); + if (!s) return; + const type = /^[ \t]+([^\s=;][^=;\n]*?)[ \t]*(?:[=;\n]|$)/.exec(code.slice(at + s[0].length, at + s[0].length + 160))?.[1]; + let p = at + s[0].length - s[1]!.length; + for (const part of s[1]!.split(',')) { + const name = part.trim(); + add(name, { at: p + part.indexOf(name), kind: 'local', type }); + p += part.length + 1; + } + }; + for (const m of code.matchAll(/(?; body: number } | null { + const groups: Array<[number, number]> = []; + let p = at; + const skip = (): void => { while (code[p] === ' ' || code[p] === '\t') p++; }; + const group = (): boolean => { + if (code[p] !== '(') return false; + const end = goClosing(code, p); + groups.push([p + 1, end]); + p = end + 1; + skip(); + return true; + }; + // `func Name[T any](…)`, `func (r T) Name(…)`, or a literal / type `func(…)`. + const name = (): boolean => { + const m = /^[A-Za-z_]\w*/.exec(code.slice(p, p + 128)); + if (!m || GO_TYPE_KEYWORDS.has(m[0])) return false; + let q = p + m[0].length; + while (code[q] === ' ' || code[q] === '\t') q++; + if (code[q] === '[') q = goClosing(code, q) + 1; + while (code[q] === ' ' || code[q] === '\t') q++; + if (code[q] !== '(') return false; + p = q; + return true; + }; + skip(); + if (code[p] !== '(') { + if (!name() || !group()) return null; + } else { + group(); + if (name() && !group()) return null; + } + if (code[p] === '(') group(); + // A result type up to the body; a function type ends at a `,`, `)`, `}`, + // `=`, `;` or the line's end instead. + let depth = 0; + for (; p < code.length; p++) { + const c = code[p]; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') { if (--depth < 0) return null; } + else if (c === '{') { + if (depth > 0) continue; + // `struct{…}` / `interface{…}` is the result type's own brace. + if (/(?:struct|interface)\s*$/.test(code.slice(Math.max(0, p - 12), p))) { p = goClosing(code, p); continue; } + return { groups, body: p }; + } else if (depth === 0 && (c === '\n' || c === ',' || c === ';' || c === '=' || c === '}')) return null; + } + return null; +} + +/** + * The names a Go parameter list between `from` and `to` declares, with the + * type each is declared as: `(a, b int, c string)` declares a and b as `int` + * and c as `string`; `(int, pkg.T)` and `(*T, ...U)` declare none. + */ +function goParamNames(code: string, from: number, to: number): Array<[string, number, string]> { + const parts: Array<[number, number]> = []; + let depth = 0; + let start = from; + for (let i = from; i < to; i++) { + const c = code[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ',' && depth === 0) { parts.push([start, i]); start = i + 1; } + } + parts.push([start, to]); + const names: Array<[string, number, string]> = []; + let grouped: Array<[string, number]> = []; + for (const [s, e] of parts) { + const text = code.slice(s, e); + const m = /^\s*([A-Za-z_]\w*)/.exec(text); + if (!m || GO_TYPE_KEYWORDS.has(m[1]!)) continue; + const rest = text.slice(m[0].length); + // `pkg.T` is a type; a variadic `args ...T` is a name. + if (/^\s*\.(?!\.\.)/.test(rest)) continue; + const at = s + m[0].length - m[1]!.length; + if (/^\s*$/.test(rest)) { grouped.push([m[1]!, at]); continue; } + // `a, b int`: the names before a typed one share its type. + const type = rest.trim(); + for (const [name, nameAt] of grouped) names.push([name, nameAt, type]); + grouped = []; + names.push([m[1]!, at, type]); + } + // A list with no typed name names no parameters: its lone identifiers are types. + return names; +} + +/** The innermost `{…}` holding an offset, as [open, close] — null outside every block. */ +function goBlockAt(index: GoScopeIndex, offset: number): [number, number] | null { + let lo = 0; + let hi = index.events.length - 1; + let at = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (index.events[mid]! < offset) { at = mid; lo = mid + 1; } else hi = mid - 1; + } + const open = at < 0 ? -1 : index.open[at]!; + return open < 0 ? null : [open, index.close.get(open) ?? index.code.length]; +} + +/** Where the statement declaring at `at` ends: its line's end, or a `;`, outside brackets. */ +function goStatementEnd(code: string, at: number): number { + let depth = 0; + for (let i = at; i < code.length; i++) { + const c = code[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') { if (--depth < 0) return i; } + else if ((c === '\n' || c === ';') && depth === 0) return i; + } + return code.length; +} + +/** + * The blocks of the `if` / `for` / `switch` statement whose header declares + * at `at`: gofmt ends a header's line with its block's `{` (a composite + * literal's in the header has more on its line), and an `else` chain keeps + * the header's names. + */ +function goHeaderBlocks(index: GoScopeIndex, at: number): Array<[number, number]> { + const code = index.code; + const blocks: Array<[number, number]> = []; + let depth = 0; + for (let i = at; i < code.length; i++) { + const c = code[i]; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === '}') break; + else if (c === '{') { + const end = index.close.get(i) ?? code.length; + if (depth !== 0 || !/^[ \t]*(?:\n|$)/.test(code.slice(i + 1, i + 80))) { i = end; continue; } + blocks.push([i, end]); + const chained = /^[ \t]*else\b[ \t]*/.exec(code.slice(end + 1, end + 40)); + if (!chained) break; + i = end + chained[0].length; + depth = 0; + } + } + return blocks; +} + +function goScopesOf(index: GoScopeIndex, name: string): Array<[number, number, GoDecl]> { + const hit = index.scopes.get(name); + if (hit) return hit; + const scopes: Array<[number, number, GoDecl]> = []; + for (const d of index.decls.get(name) ?? []) { + if (d.kind === 'param') { + scopes.push([d.body!, index.close.get(d.body!) ?? index.code.length, d]); + continue; + } + // Outside every block it is a package-level name, which no import shares. + const block = goBlockAt(index, d.at); + if (!block) continue; + if (d.kind === 'header') { + for (const [start, end] of goHeaderBlocks(index, d.at)) scopes.push([start, end, d]); + continue; + } + const from = goStatementEnd(index.code, d.at); + let to = block[1]; + if (d.kind === 'case') { + const clause = /\n[ \t]*(?:case\b|default[ \t]*:)/g; + clause.lastIndex = from; + for (let m = clause.exec(index.code); m && m.index < to; m = clause.exec(index.code)) { + if (goBlockAt(index, m.index + 1)?.[0] === block[0]) { to = m.index; break; } + } + } + if (from < to) scopes.push([from, to, d]); + } + index.scopes.set(name, scopes); + return scopes; +} + +/** + * Go source with its comments and the contents of its strings and runes + * blanked: offsets and newlines stay, so a regex match still maps to its + * line and column. + */ +function blankGoCode(src: string): string { + let out = ''; + let last = 0; + const blank = (from: number, to: number): void => { + out += src.slice(last, from) + src.slice(from, to).replace(/[^\n]/g, ' '); + last = to; + }; + for (let i = 0; i < src.length; i++) { + const c = src.charCodeAt(i); + if (c === 47 && src.charCodeAt(i + 1) === 47) { // `//` + const end = src.indexOf('\n', i); + blank(i, end < 0 ? src.length : end); + i = last - 1; + } else if (c === 47 && src.charCodeAt(i + 1) === 42) { // `/*` + const end = src.indexOf('*/', i + 2); + blank(i, end < 0 ? src.length : end + 2); + i = last - 1; + } else if (c === 96) { // a raw string + const end = src.indexOf('`', i + 1); + blank(i + 1, end < 0 ? src.length : end); + i = last; + } else if (c === 34 || c === 39) { // "…" or '…' + let j = i + 1; + while (j < src.length) { + const d = src.charCodeAt(j); + if (d === 92) { j += 2; continue; } + if (d === c || d === 10) break; + j++; + } + blank(i + 1, Math.min(j, src.length)); + i = last; + } + } + return out + src.slice(last); +} + const PHP_CLASS_KINDS: ReadonlySet = new Set(['class', 'interface', 'trait', 'enum']); /** * Whether a bare PHP class name at `ref` can mean `candidate`. An unqualified @@ -10100,6 +10527,16 @@ export function matchMethodCall( // A pytest test's parameter is what its fixture returns: flaskbb's // `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`. if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context); + // A Go parameter named like an import is declared through a package as + // often as not (`logger logger.Interface`), which the patterns above + // don't read; the scope reader kept its type. + if (!inferredType && ref.language === 'go' && dotMatch) { + const declared = goRefQualification(ref, context).declaredType; + if (declared) { + inferredType = normalizeInferredTypeName(declared); + decl.raw = declared; + } + } if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch) { inferredType = nmTimedT('mc-member', ref, () => inferMemberReceiverType(objectOrClass!, ref, context)); // A field of a built-in type (`string _name`, `int count`) has no project method. From 2848446902e6f98c49b81c18325b2f437c600b2a Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:11:09 -0500 Subject: [PATCH 2/6] fix(go): drop a file's parameter and local reading when a sync changes it The Go scope reader keeps each file's declarations per resolution context. clearNameMatcherMemos now drops them with the other per-file memos, so a sync that removes a local named like an import stops treating the call as one on that local. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-ref-qualifier.test.ts | 35 ++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 1 + 2 files changed, 36 insertions(+) diff --git a/__tests__/go-ref-qualifier.test.ts b/__tests__/go-ref-qualifier.test.ts index 1b9d7ddf7..da46c43fc 100644 --- a/__tests__/go-ref-qualifier.test.ts +++ b/__tests__/go-ref-qualifier.test.ts @@ -360,4 +360,39 @@ describe('a Go parameter or local named like an import', () => { expect(reachedAt('jobs/job.go', 'logger.Error(err)')).toEqual(['Interface::Error']); expect(reachedAt('scan/job_test.go', 'robot.ToJSON()')).toEqual(['Robot::ToJSON']); }); + + 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); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d05efe264..26e991050 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9610,6 +9610,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { PY_PLUGGED_MODULES.delete(context); SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); + GO_SCOPE_INDEXES.delete(context); GO_EMBEDS.delete(context); GO_ALIAS_TARGETS.delete(context); JAVA_FILE_SCOPES.delete(context); From 1517be4792d6ae4f46fce0776f9c1a05176951ae Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:28:12 -0500 Subject: [PATCH 3/6] fix(go): a field read through a local named like an import keeps its own type `metadata.Type.String()` on a parameter `metadata prompb.MetricMetadata` calls String on the field, so the parameter's declared type only stands for a call made on the parameter itself. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-ref-qualifier.test.ts | 7 +++++++ src/resolution/name-matcher.ts | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/__tests__/go-ref-qualifier.test.ts b/__tests__/go-ref-qualifier.test.ts index da46c43fc..0feb04475 100644 --- a/__tests__/go-ref-qualifier.test.ts +++ b/__tests__/go-ref-qualifier.test.ts @@ -193,6 +193,7 @@ type Store interface { type Interface interface { Error(v ...interface{}) + String() string } func Error(v ...interface{}) {} @@ -226,6 +227,10 @@ func cachedHasSynced(store cache.Store) func() bool { cache.Store(false) return cache.Load } + +func describe(logger logger.Interface) string { + return logger.Kind.String() +} `, // harbor's controller/robot.Robot embeds model.Robot, whose method a // local named like the import calls. @@ -359,6 +364,8 @@ describe('a Go parameter or local named like an import', () => { // 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 () => { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 26e991050..f460f2ec0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10735,8 +10735,9 @@ export function matchMethodCall( if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context); // A Go parameter named like an import is declared through a package as // often as not (`logger logger.Interface`), which the patterns above - // don't read; the scope reader kept its type. - if (!inferredType && ref.language === 'go' && dotMatch) { + // don't read; the scope reader kept its type. Not a field's: in + // `metadata.Type.String()` that is the type of `metadata`. + if (!inferredType && ref.language === 'go' && dotMatch && !objectOrClass!.includes('.')) { const declared = goRefQualification(ref, context).declaredType; if (declared) { inferredType = normalizeInferredTypeName(declared); From 2af6ccb2c31283b819bc1fddb85079c44eb3888e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:39:16 -0500 Subject: [PATCH 4/6] docs(changelog): Go calls through their own receiver, and locals named like an import Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08226636c..6fbc3d012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 ``, 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 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) +- 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. - 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. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. From bd82b8fe88b8aa0b30c73c69f8fa97d940e353e1 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 08:12:08 -0500 Subject: [PATCH 5/6] fix(go): a local named like an import, set from an outside package's function, has no project method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clock := clocktesting.NewFakePassiveClock(…)` holds a value of a type that package declares, so `clock.Now()` is no call to a project `Clock`'s Now. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-ref-qualifier.test.ts | 12 +++++++++++- src/resolution/name-matcher.ts | 21 +++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/__tests__/go-ref-qualifier.test.ts b/__tests__/go-ref-qualifier.test.ts index 0feb04475..cad483034 100644 --- a/__tests__/go-ref-qualifier.test.ts +++ b/__tests__/go-ref-qualifier.test.ts @@ -208,10 +208,12 @@ func (l *Logger) Error(v ...interface{}) {} 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) { @@ -231,6 +233,12 @@ func cachedHasSynced(store cache.Store) func() bool { 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. @@ -356,9 +364,11 @@ describe('a Go parameter or local named like an import', () => { }); it('has the methods of the type it is declared as', () => { - // An outside package's type holds none of the project's methods… + // 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. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f460f2ec0..3ef853265 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1925,9 +1925,10 @@ interface GoQualification { /** * For a call through a parameter or local that takes an import's name, the * type it is declared as, when its declaration writes one (`*tokenJWT`, - * `clock.PassiveClock`), and the import that type is written through: + * `clock.PassiveClock`), and the import that type is written through — * `clock` in a parameter `clock clock.PassiveClock`, `atomic` in - * `cache := &atomic.Bool{}`. + * `cache := &atomic.Bool{}` — or its value comes from: `reflect` in + * `value := reflect.New(t)`. */ declaredType?: string; typePackage?: ImportMapping; @@ -1977,7 +1978,10 @@ function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQ const local = imported && dot >= 0 && ref.referenceKind === 'calls' ? goLocalDecl(written!, ref, context) : undefined; if (local) { declaredType = local.type; - const qualifier = /^[*&\s]*([A-Za-z_]\w*)\.[A-Za-z_]\w*$/.exec(declaredType ?? '')?.[1]; + // A value a package outside the project hands out is of a type it + // declares: kubernetes' `clock := clocktesting.NewFakePassiveClock(…)` + // has no project method `Now`. + const qualifier = /^[*&\s]*([A-Za-z_]\w*)\.[A-Za-z_]\w*$/.exec(declaredType ?? '')?.[1] ?? local.from; typePackage = qualifier ? imports.find((m) => m.localName === qualifier) : undefined; written = undefined; imported = undefined; @@ -2112,6 +2116,8 @@ interface GoDecl { body?: number; /** The declared type as written, when the declaration writes one. */ type?: string; + /** The package a `:=` takes its value from: `clocktesting` in `clock := clocktesting.NewFakeClock(…)`. */ + from?: string; } const GO_SCOPE_INDEXES = new WeakMap>(); @@ -2195,9 +2201,12 @@ function readGoScopes(code: string): GoScopeIndex { const first = names[names.length - 1]![1]; const lead = code.slice(code.lastIndexOf('\n', first - 1) + 1, first); const kind = /^\s*(?:\}\s*else\s+)?(?:if|for|switch)\b/.test(lead) ? 'header' : /^\s*case\b/.test(lead) ? 'case' : 'local'; - // `x := &pkg.T{…}` writes the type it declares. - const type = names.length === 1 ? /^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{/.exec(code.slice(i + 2, i + 160))?.[1] : undefined; - for (const [name, at] of names) add(name, { at, kind, type }); + // `x := &pkg.T{…}` writes the type it declares; `x := pkg.New(…)` the + // package its value comes from. + const rhs = code.slice(i + 2, i + 160); + const type = names.length === 1 ? /^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{/.exec(rhs)?.[1] : undefined; + const from = /^[ \t]*&?[ \t]*([A-Za-z_]\w*)\.[A-Za-z_]\w*[ \t]*[({]/.exec(rhs)?.[1]; + for (const [name, at] of names) add(name, { at, kind, type, from }); } // `var x T`, `var a, b = …`, `const (…)`: names before a type, an `=` or // the line's end — in a group, on lines outside the brackets of a value From 46c7feb238102edc951edeacf9b314102d5a7315 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 08:33:52 -0500 Subject: [PATCH 6/6] perf(go): blank a file's comments and strings in one join, and find its braces by regex The Go scope reader built its blanked text by appending each piece and replacing every character of each comment and string. It now collects the pieces and joins them once, and skips from one comment or quote to the next. Same text on every Go file of gin, prometheus, etcd, harbor and kubernetes; about twice as fast. Co-Authored-By: Claude Opus 5.5 --- src/resolution/name-matcher.ts | 50 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3ef853265..6e19f368b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2160,18 +2160,19 @@ function isGoIdentChar(c: number): boolean { function readGoScopes(code: string): GoScopeIndex { const lineStarts = [0]; + for (let i = code.indexOf('\n'); i >= 0; i = code.indexOf('\n', i + 1)) lineStarts.push(i + 1); const events: number[] = []; const open: number[] = []; const close = new Map(); const stack: number[] = []; - for (let i = 0; i < code.length; i++) { - const ch = code.charCodeAt(i); - if (ch === 10) { lineStarts.push(i + 1); continue; } - if (ch === 123) stack.push(i); - else if (ch === 125) { + const braces = /[{}]/g; + for (let m = braces.exec(code); m; m = braces.exec(code)) { + const i = m.index; + if (code.charCodeAt(i) === 123) stack.push(i); + else { const at = stack.pop(); if (at !== undefined) close.set(at, i); - } else continue; + } events.push(i); open.push(stack.length > 0 ? stack[stack.length - 1]! : -1); } @@ -2442,27 +2443,33 @@ function goScopesOf(index: GoScopeIndex, name: string): Array<[number, number, G * line and column. */ function blankGoCode(src: string): string { - let out = ''; + const parts: string[] = []; let last = 0; const blank = (from: number, to: number): void => { - out += src.slice(last, from) + src.slice(from, to).replace(/[^\n]/g, ' '); + parts.push(src.slice(last, from)); + const newline = src.indexOf('\n', from); + parts.push(newline < 0 || newline >= to ? ' '.repeat(to - from) : src.slice(from, to).replace(/[^\n]/g, ' ')); last = to; }; - for (let i = 0; i < src.length; i++) { + const special = /[/"'`]/g; + for (let m = special.exec(src); m; m = special.exec(src)) { + const i = m.index; const c = src.charCodeAt(i); - if (c === 47 && src.charCodeAt(i + 1) === 47) { // `//` - const end = src.indexOf('\n', i); - blank(i, end < 0 ? src.length : end); - i = last - 1; - } else if (c === 47 && src.charCodeAt(i + 1) === 42) { // `/*` - const end = src.indexOf('*/', i + 2); - blank(i, end < 0 ? src.length : end + 2); - i = last - 1; + if (c === 47) { + const d = src.charCodeAt(i + 1); + if (d === 47) { // `//` + const end = src.indexOf('\n', i); + blank(i, end < 0 ? src.length : end); + } else if (d === 42) { // `/*` + const end = src.indexOf('*/', i + 2); + blank(i, end < 0 ? src.length : end + 2); + } else continue; + special.lastIndex = last; } else if (c === 96) { // a raw string const end = src.indexOf('`', i + 1); blank(i + 1, end < 0 ? src.length : end); - i = last; - } else if (c === 34 || c === 39) { // "…" or '…' + special.lastIndex = last + 1; + } else { // "…" or '…' let j = i + 1; while (j < src.length) { const d = src.charCodeAt(j); @@ -2471,10 +2478,11 @@ function blankGoCode(src: string): string { j++; } blank(i + 1, Math.min(j, src.length)); - i = last; + special.lastIndex = last + 1; } } - return out + src.slice(last); + parts.push(src.slice(last)); + return parts.join(''); } const PHP_CLASS_KINDS: ReadonlySet = new Set(['class', 'interface', 'trait', 'enum']);