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:
co-authored by
Claude Fable 5
parent
2ec877b08c
commit
41c2029798
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user