Преглед изворни кода

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>
Colby Mchenry пре 1 месец
родитељ
комит
41c2029798
4 измењених фајлова са 262 додато и 0 уклоњено
  1. 1 0
      CHANGELOG.md
  2. 145 0
      __tests__/resolution.test.ts
  3. 15 0
      src/extraction/tree-sitter.ts
  4. 101 0
      src/resolution/name-matcher.ts

+ 1 - 0
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 <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 - 0
__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

+ 15 - 0
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;
             }

+ 101 - 0
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 `<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.
  */