fix(go): field-chain calls resolve via validated type inference, never bare-name guessing (#1316)

target.conn.Exec("insert") with `conn *sql.DB` emitted a BARE `Exec`
ref (the receiver chain was dropped for non-identifier receivers), and
exact-match then bound it to the only local `Exec` — an unrelated
interface's method — fabricating an internal dependency (#1276).

Extraction now keeps Go 2-hop selector chains (`base.field.Method`),
and a dedicated matcher resolves them EXCLUSIVELY via two inference
hops: base's type from the enclosing scope (#1108 machinery), field's
declared type from the struct's own declaration lines (comment-
stripped, per-line — chi's "the tree router" doc comment otherwise
donates a phantom type). resolveMethodOnType validates the target.
Package-qualified field types are followed only when the package is
in-module — `handler http.Handler` must not bind a same-named local
decoy. Failure at any hop leaves the ref unresolved: chained Go
receivers never fall through to the bare-name strategies (they were
never emitted before, so no prior recall depends on that path).

chi before/after: node count stable (1,181); 8 correct field-chain
edges gained (mx.tree.FindRoute/InsertRoute/routes, validated,
including the unexported `node` type); the removed edges are the
prior bare-name guesses on external receivers.

Fixes #1276

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 15:49:24 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 2ec877b08c
commit 41c2029798
4 changed files with 262 additions and 0 deletions
+1
View File
@@ -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 <symbol> -f <file>` 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)
+145
View File
@@ -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
+15
View File
@@ -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;
}
+101
View File
@@ -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 `<type>::<method>` 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.
*/