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
|
### 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)
|
- 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)
|
- `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)
|
- 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)', () => {
|
describe('Imported singleton instance-method calls (#1292)', () => {
|
||||||
// `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used
|
// `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used
|
||||||
// to emit its calls edge to the CONSTANT (resolvedBy:'import'), while the
|
// 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
|
// scope keywords: such calls previously emitted a bare method
|
||||||
// name, which either failed to resolve or resolved ambiguously.
|
// name, which either failed to resolve or resolved ambiguously.
|
||||||
calleeName = `${getNodeText(receiver, this.source)}.${methodName}`;
|
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 {
|
} else {
|
||||||
calleeName = methodName;
|
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/Kotlin: receiver may be a field whose name doesn't match the type by
|
||||||
// Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
|
// Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
|
||||||
// the field in the enclosing class to get its declared type, then resolve
|
// the field in the enclosing class to get its declared type, then resolve
|
||||||
@@ -1717,6 +1732,92 @@ export function matchMethodCall(
|
|||||||
return null;
|
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.
|
* Split a camelCase or PascalCase string into words.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user