diff --git a/CHANGELOG.md b/CHANGELOG.md index 44dcf95..955349b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Go method calls through a struct field (`target.conn.Exec(...)`) no longer bind to unrelated same-named local methods when the field's type is external — `conn *sql.DB` calls were being attributed to a local interface that happened to declare `Exec`, fabricating internal dependencies. Chained field calls now resolve by inferring the field's declared type from the struct definition: in-project types (including unexported ones like chi's `tree *node`) gain correct, validated call edges that never existed before, and external types (standard library, third-party modules) are left unlinked instead of guessed. (#1276) - TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292) - `codegraph node -f ` now prints the symbol's source body. Pinning an ambiguous name to a specific file (the whole point of `-f` when many files define the same function) returned only the location and caller trail with no code. (#1284) - Deleting a whole directory is now picked up by watch mode: the files inside it are removed from the index on the next auto-sync instead of lingering as stale records until an unrelated edit happened to trigger one. Operating systems often report a directory deletion as a single event on the directory itself (with no per-file events for its contents), which the watcher previously discarded. (#1285) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index da5a4d1..3965ea9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2541,6 +2541,151 @@ func main() { }); }); + describe('Go field-chain receiver calls (#1276)', () => { + // `target.conn.Exec(...)` where `conn *sql.DB` used to emit a BARE `Exec` + // ref, which exact-matched the only local `Exec` — an unrelated + // interface's method — fabricating an internal dependency. Chained Go + // receivers now resolve exclusively via validated field-hop inference: + // external field types produce NO edge; in-project ones produce the + // correct edge (new recall). + it('external receiver types produce no edge; in-project field chains resolve correctly', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1276-')); + try { + fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example.com/app\n\ngo 1.22\n'); + fs.mkdirSync(path.join(tmpDir, 'flow')); + fs.writeFileSync( + path.join(tmpDir, 'flow', 'flow.go'), + `package flow + +import "database/sql" + +type InternalStore interface { + Exec(string, ...any) (sql.Result, error) + QueryRow(string, ...any) *sql.Row +} + +type Target struct{ conn *sql.DB } + +func (target *Target) Write() error { + _, err := target.conn.Exec("insert") + return err +} + +func (target *Target) Read() *sql.Row { + return target.conn.QueryRow("select") +} + +type Store struct{} + +func (s *Store) Put(key string) {} + +type Repo struct{ db *Store } + +func (r *Repo) Save() { + r.db.Put("k") +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // The unrelated local interface's methods have NO callers — the + // external sql.DB calls must not bind to them. + const execDecl = (await cg.searchNodes('Exec', { limit: 10 })).find( + (r) => r.node.kind === 'method' + ); + if (execDecl) { + const execCallers = await cg.getCallers(execDecl.node.id); + expect(execCallers.map((c) => c.node.name)).not.toContain('Write'); + } + const qrDecl = (await cg.searchNodes('QueryRow', { limit: 10 })).find( + (r) => r.node.kind === 'method' + ); + if (qrDecl) { + const qrCallers = await cg.getCallers(qrDecl.node.id); + expect(qrCallers.map((c) => c.node.name)).not.toContain('Read'); + } + + // The in-project field chain resolves (validated), gaining an edge the + // bare-name era never produced. + const put = (await cg.searchNodes('Put', { limit: 10 })).find( + (r) => r.node.kind === 'method' && r.node.qualifiedName?.includes('Store') + ); + expect(put).toBeDefined(); + const putCallers = await cg.getCallers(put!.node.id); + expect(putCallers.map((c) => c.node.name)).toContain('Save'); + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('unexported field types resolve; stdlib-qualified types never bind a same-named local decoy', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1276b-')); + try { + fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example.com/b\n\ngo 1.22\n'); + fs.writeFileSync( + path.join(tmpDir, 'm.go'), + `package m + +import "net/http" + +type node struct{} + +func (n *node) InsertRoute(path string) {} + +// A local type sharing the stdlib interface's name — the decoy the +// package-qualifier gate exists for. +type Handler func() + +func (h Handler) ServeHTTP() {} + +type Mux struct { + // the tree router lives below this comment (the comment must not + // donate a field type) + handler http.Handler + tree *node +} + +func (mx *Mux) handle(path string) { + mx.tree.InsertRoute(path) +} + +func (mx *Mux) dispatch() { + mx.handler.ServeHTTP(nil, nil) +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Unexported in-package field type: chain resolves (chi's mx.tree shape), + // and the doc comment above the field donates nothing. + const insert = (await cg.searchNodes('InsertRoute', { limit: 5 })).find( + (r) => r.node.kind === 'method' + ); + expect(insert).toBeDefined(); + const insertCallers = await cg.getCallers(insert!.node.id); + expect(insertCallers.map((c) => c.node.name)).toContain('handle'); + + // `handler http.Handler` is stdlib — the call must NOT bind to the + // local decoy `Handler.ServeHTTP`. + const serve = (await cg.searchNodes('ServeHTTP', { limit: 5 })).find( + (r) => r.node.kind === 'method' + ); + if (serve) { + const serveCallers = await cg.getCallers(serve.node.id); + expect(serveCallers.map((c) => c.node.name)).not.toContain('dispatch'); + } + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + }); + describe('Imported singleton instance-method calls (#1292)', () => { // `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used // to emit its calls edge to the CONSTANT (resolvedBy:'import'), while the diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a94d3ed..9ee791c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4431,6 +4431,21 @@ export class TreeSitterExtractor { // scope keywords: such calls previously emitted a bare method // name, which either failed to resolve or resolved ambiguously. calleeName = `${getNodeText(receiver, this.source)}.${methodName}`; + } else if ( + this.language === 'go' && + receiver && + receiver.type === 'selector_expression' && + /^[A-Za-z_]\w*\.[A-Za-z_]\w*$/.test(getNodeText(receiver, this.source).replace(/\s+/g, '')) + ) { + // Go 2-hop field chain `target.conn.Exec(...)`: keep the + // receiver chain so resolution can infer `conn`'s declared type + // from the Target struct. Previously this emitted the bare + // method name, and when the field's type is EXTERNAL (sql.DB) + // the bare name exact-matched an unrelated same-named local + // method — a fabricated internal dependency (#1276). Chained + // Go receivers resolve strictly via validated field-hop + // inference (see matchGoFieldChainCall) or stay unresolved. + calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; } else { calleeName = methodName; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b4d1e31..fca29e2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1554,6 +1554,21 @@ export function matchMethodCall( } } + // Go 2-hop field chain `base.field.Method` (#1276): the base's type comes + // from the enclosing scope (typed parameter / method receiver / local var), + // the field's declared type from that struct's own declaration lines, and + // the method is VALIDATED on the field's type by resolveMethodOnType. This + // branch is EXCLUSIVE for chained Go receivers: when the hop can't be + // inferred or the field's type is external (`conn *sql.DB` — no project + // node), the ref stays unresolved rather than falling through to the + // bare-name strategies below, which is exactly how `target.conn.Exec(...)` + // fabricated a dependency on an unrelated local interface's same-named + // method. Chained Go receivers were never emitted before #1276, so there + // is no prior recall to preserve on the fallback path. + if (ref.language === 'go' && dotMatch && objectOrClass!.includes('.')) { + return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context); + } + // Java/Kotlin: receiver may be a field whose name doesn't match the type by // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up // the field in the enclosing class to get its declared type, then resolve @@ -1717,6 +1732,92 @@ export function matchMethodCall( return null; } +/** Go builtin/primitive field types that can never carry a project method. */ +const GO_BUILTIN_FIELD_TYPES = new Set([ + 'string', 'bool', 'byte', 'rune', 'error', 'any', + 'int', 'int8', 'int16', 'int32', 'int64', + 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr', + 'float32', 'float64', 'complex64', 'complex128', + 'chan', 'map', 'func', 'struct', 'interface', +]); + +/** + * Resolve a Go 2-hop field-chain call `base.field.Method(...)` (#1276): + * `target.conn.Exec("insert")` where `func (target *Target) Write()` and + * `type Target struct { conn *sql.DB }`. Two inference hops, both read from + * source the same way #1108 does: + * 1. `base`'s type from the enclosing scope (method receiver, typed + * parameter, or local declaration) via inferLocalReceiverType; + * 2. `field`'s declared type from the struct's own declaration lines. + * The method is then resolved AND VALIDATED on the field's type. A field + * whose type has no project node (`sql.DB`, any external dependency) yields + * null — the caller treats this branch as exclusive for chained Go + * receivers, so the ref stays unresolved instead of name-guessing. + */ +function matchGoFieldChainCall( + receiverChain: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext +): ResolvedRef | null { + const segs = receiverChain.split('.'); + if (segs.length !== 2 || !segs[0] || !segs[1]) return null; + const [base, field] = segs; + + const baseType = inferLocalReceiverType(base!, ref, context); + if (!baseType) return null; + + const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`); + + const structs = preferCallSiteFile(context.getNodesByName(baseType), ref.filePath).filter( + (n) => (n.kind === 'struct' || n.kind === 'class') && n.language === 'go' + ); + for (const s of structs) { + const source = context.readFile(s.filePath); + if (!source) continue; + // Only the struct's own declaration lines — a same-named identifier + // elsewhere in the file can't donate a type. Matched LINE BY LINE with + // comments stripped: chi's `Mux` has a doc comment reading "the tree + // router" right above `tree *node`, and a whole-block match captured + // `router` from the prose instead of `node` from the field. + const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine); + for (const rawLine of declLines) { + const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''); + const m = line.match(fieldTypeRe); + if (!m || !m[1]) continue; + const rawType = m[1]; + // A package-qualified field type (`http.Handler`, `sql.DB`) is only + // followed when the package is IN-MODULE: stripping the qualifier and + // matching the bare name would conflate a stdlib/third-party type with + // any same-named project type — on chi, `handler http.Handler` bound + // to an example app's unrelated local `Handler`. That is the exact + // fabrication this matcher exists to prevent (#1276). + if (rawType.includes('.')) { + const pkg = rawType.split('.')[0]!; + const mod = context.getGoModule?.(); + const imp = context + .getImportMappings(s.filePath, 'go') + .find((i) => i.localName === pkg); + const inModule = + !!mod && + !!imp && + (imp.source === mod.modulePath || imp.source.startsWith(mod.modulePath + '/')); + if (!inModule) continue; + } + // Unexported (lowercase) types are idiomatic Go and stay eligible — + // chi's `mx.tree.FindRoute()` chains through `tree *node`. A + // mis-capture is harmless: resolveMethodOnType only returns a + // validated `::` match. + const fieldType = rawType.split('.').pop(); + if (!fieldType || !/^[A-Za-z_]/.test(fieldType) || GO_BUILTIN_FIELD_TYPES.has(fieldType)) continue; + const resolved = resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method'); + if (resolved) return resolved; + } + } + return null; +} + /** * Split a camelCase or PascalCase string into words. */