فهرست منبع

fix(extraction): never fabricate an edge from a call-result receiver (#1748)

A member call whose receiver is itself a call — `d.setdefault(k, []).append(v)`,
`make().run()` — used to drop the receiver at extraction time, degrade to the
bare method name, and exact-match any top-level project symbol of that name
(Python and JavaScript/TypeScript). Keep the inner callee encoded as
`<inner>().<method>` in the TS extractor and native kernel; the name-matcher
refuses to guess for that shape (store-accessor exception only). Based on
#1692, rebased onto main after #1746. Fixes #1683.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Colby Mchenry 12 ساعت پیش
والد
کامیت
bb1d3093eb

+ 1 - 0
CHANGELOG.md

@@ -207,6 +207,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### Symbols, tests and the viewer
 
+- A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681)
 - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731)
 - **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719)
 

+ 81 - 0
__tests__/call-receiver-no-fabrication.test.ts

@@ -0,0 +1,81 @@
+/**
+ * A member call whose receiver is itself a call never fabricates an edge
+ * (#1683, #1681). `d.setdefault(k, []).append(v)` used to lose its receiver at
+ * extraction time, degrade to the bare `append`, and exact-match any top-level
+ * project function of that name — a call edge from an unrelated function,
+ * reproduced in Python and JavaScript alike. The receiver is now kept as
+ * `<inner>().<method>`, which nothing name-matches; the inner call resolves
+ * on its own as before.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+let dir: string;
+let cg: CodeGraph;
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1683-'));
+  fs.mkdirSync(path.join(dir, 'py'));
+  fs.mkdirSync(path.join(dir, 'js'));
+  fs.writeFileSync(path.join(dir, 'py', '__init__.py'), '');
+  fs.writeFileSync(
+    path.join(dir, 'py', 'collect.py'),
+    'def append(item):\n    return item\n\ndef get(key):\n    return key\n\ndef make():\n    return {}\n\n' +
+      'def bucket(d, k, v):\n    d.setdefault(k, []).append(v)\n    return d.items().get(k)\n\n' +
+      'def fresh():\n    return make().get("x")\n'
+  );
+  fs.writeFileSync(
+    path.join(dir, 'js', 'collect.js'),
+    'function append(item) { return item; }\nfunction run() { return 1; }\nfunction make() { return {}; }\n' +
+      'function bucket(d, k, v) { d.setdefault(k, []).append(v); make().run(); (0, make)().run(); }\n' +
+      'module.exports = { append, run, make, bucket };\n'
+  );
+  cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg.destroy();
+  fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const fn = (name: string, file: string) => cg.getNodesByName(name).find((n) => n.kind === 'function' && n.filePath.endsWith(file))!;
+const calleesOf = (name: string, file: string) =>
+  cg.getCallees(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name).sort();
+// Callers through `calls` edges only — a `module.exports = { run }` value reference is not a call.
+const callersOf = (name: string, file: string) =>
+  cg.getCallers(fn(name, file).id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.name);
+
+describe('call-expression receivers (#1683)', () => {
+  it('Python: no edge from a call-result receiver to a same-named top-level function', () => {
+    expect(calleesOf('bucket', 'collect.py')).toEqual([]);
+    expect(callersOf('append', 'collect.py')).toEqual([]);
+    expect(callersOf('get', 'collect.py')).toEqual([]);
+    // The inner call still resolves on its own; `.get` on its unknown product does not.
+    expect(calleesOf('fresh', 'collect.py')).toEqual(['make']);
+  });
+
+  it('JavaScript: the same shape, and the inner call keeps its edge', () => {
+    expect(callersOf('append', 'collect.js')).toEqual([]);
+    // `make().run()` — what `make` returns is unknown, so `run` is not guessed.
+    expect(callersOf('run', 'collect.js')).toEqual([]);
+    expect(calleesOf('bucket', 'collect.js')).toEqual(['make']);
+  });
+
+  it('encodes the receiver as `<inner>().<method>` and drops a receiver with no static callee', () => {
+    const r = extractFromSource('src/x.js', 'function f(d) { d.setdefault("k", []).append(1); make().run(); (0, make)().run(); arr[0]().go(); }');
+    const names = r.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort();
+    // `(0, make)` and `arr[0]` are the inner calls' own refs, unchanged; their chains are dropped.
+    expect(names).toEqual(['(0, make)', 'arr[0]', 'd.setdefault', 'd.setdefault().append', 'make', 'make().run']);
+    const py = extractFromSource('x.py', 'def f(d):\n    d.setdefault("k", []).append(1)\n    d.items().get(2)\n');
+    expect(py.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort())
+      .toEqual(['d.items', 'd.items().get', 'd.setdefault', 'd.setdefault().append']);
+  });
+});

+ 9 - 0
__tests__/fixtures/kernel-parity/torture.js

@@ -73,3 +73,12 @@ export default {
     },
   },
 };
+
+// --- call-expression receivers (#1683) ----------------------------------------
+function bucketChains(d, k, v) {
+  d.setdefault(k, []).append(v);
+  make().run();
+  (0, make)().run();
+  arr[0]().go();
+  obj.make().run().again();
+}

+ 9 - 0
__tests__/fixtures/kernel-parity/torture.py

@@ -47,3 +47,12 @@ def shadowed():
 
 handlers = {"recv": target_cb}
 callbacks = [target_cb, view]
+
+
+# --- call receivers (#1683) ---------------------------------------------------
+def bucket_chains(d, k, v):
+    d.setdefault(k, []).append(v)
+    d.items().get(k)
+    make().run()
+    (lambda: make)()().run()
+    obj.make().run().again()

+ 4 - 1
__tests__/object-literal-methods.test.ts

@@ -55,7 +55,10 @@ describe('object-literal method extraction', () => {
     // so an in-store calls edge will resolve once the pipeline runs.
     const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
     const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
-    expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset');
+    // `get().reset()` keeps its call receiver (#1683): the ref is the chain
+    // `get().reset`, which the resolver binds to the store's own `reset`.
+    expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset');
+    expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset');
 
     // The action's body wasn't mis-attributed to the file scope (the reason we
     // skip the generic body-visit for the store-factory call).

+ 23 - 0
codegraph-kernel/src/python.rs

@@ -608,6 +608,13 @@ impl<'t> Walker<'t> {
                         } else {
                             callee_name = method_name.to_string();
                         }
+                    } else if let Some(r) = receiver.filter(|r| r.kind() == "call") {
+                        // Call receiver — `d.setdefault(k, []).append(v)` (#1683):
+                        // `<inner>().<method>`, or nothing when the inner callee
+                        // is not a plain name / attribute chain. Mirrors
+                        // TreeSitterExtractor.extractCall.
+                        let Some(inner) = self.plain_inner_callee(r) else { return };
+                        callee_name = format!("{inner}().{method_name}");
                     } else {
                         callee_name = method_name.to_string();
                     }
@@ -626,6 +633,22 @@ impl<'t> Walker<'t> {
         }
     }
 
+    /// The callee of a call receiver when it is a plain identifier or attribute
+    /// chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+    fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
+        let inner = call.child_by_field_name("function")?;
+        let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+        if text.is_empty() {
+            return None;
+        }
+        let ok = text.split('.').all(|seg| {
+            let mut chars = seg.chars();
+            matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
+                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
+        });
+        if ok { Some(text) } else { None }
+    }
+
     /// extractDecoratorsFor — python decorators are PRECEDING SIBLINGS inside
     /// decorated_definition. Only bare-identifier decorators yield a target
     /// (python's `call` kind isn't `call_expression`, and `attribute` isn't in

+ 23 - 2
codegraph-kernel/src/tsjs/extractors.rs

@@ -1103,9 +1103,14 @@ impl<'t> Walker<'t> {
                         } else {
                             callee_name = method_name.to_string();
                         }
+                    } else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
+                        // Call receiver — `make().run()` (#1683): keep the inner
+                        // callee as `<inner>().<method>`, or emit nothing when it
+                        // is not a plain name / member chain. Mirrors
+                        // TreeSitterExtractor.extractCall.
+                        let Some(inner) = self.plain_inner_callee(r) else { return };
+                        callee_name = format!("{inner}().{method_name}");
                     } else {
-                        // (the call-receiver re-encode branches are other
-                        // languages'; TS/JS keeps the bare method name)
                         callee_name = method_name.to_string();
                     }
                 }
@@ -1128,6 +1133,22 @@ impl<'t> Walker<'t> {
 
     // --- extractInstantiation -----------------------------------------------------------
 
+    /// The callee of a call-expression receiver when it is a plain identifier
+    /// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
+    fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
+        let inner = call.child_by_field_name("function")?;
+        let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+        if text.is_empty() {
+            return None;
+        }
+        let ok = text.split('.').all(|seg| {
+            let mut chars = seg.chars();
+            matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
+                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
+        });
+        if ok { Some(text) } else { None }
+    }
+
     pub(super) fn extract_instantiation(&mut self, node: Node<'t>) {
         if self.stack.is_empty() {
             return;

+ 26 - 0
src/extraction/tree-sitter.ts

@@ -4573,6 +4573,32 @@ 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 === 'typescript' ||
+                this.language === 'javascript' ||
+                this.language === 'tsx' ||
+                this.language === 'jsx' ||
+                this.language === 'python') &&
+              receiver &&
+              (receiver.type === 'call_expression' || receiver.type === 'call')
+            ) {
+              // Receiver that is itself a call — `d.setdefault(k, []).append(v)`,
+              // `make().run()`, `res.json().data` (#1683). The bare method name
+              // this used to emit exact-matched any top-level project symbol of
+              // that name and fabricated a call edge from an unrelated function
+              // (`append`, `get`, `run`…). Keep the inner callee, encoded as
+              // `<inner>().<method>` like the Java/Kotlin/C++ chains: the
+              // marker never appears in an ordinary ref, so nothing name-matches
+              // it, and a chain resolver can later infer the receiver's type
+              // from what the inner call returns. An inner callee that is not a
+              // plain name or member chain (`(await x)()`, `arr[0]()`) has no
+              // static receiver at all — emit nothing: a silent miss, never a
+              // wrong edge. The inner call is visited on its own either way.
+              // Mirrored in the kernel (tsjs/extractors.rs, python.rs).
+              const innerFn = getChildByField(receiver, 'function');
+              const innerCallee = innerFn ? getNodeText(innerFn, this.source).replace(/\s+/g, '') : '';
+              if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(innerCallee)) return;
+              calleeName = `${innerCallee}().${methodName}`;
             } else if (
               this.language === 'go' &&
               receiver &&

+ 13 - 0
src/resolution/index.ts

@@ -974,6 +974,19 @@ export class ReferenceResolver {
     if (fwEarly) return fwEarly;
 
     // Strategy 2: Try import-based resolution
+    // A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683)
+    // names the ROOT's import, not the method's: letting resolveViaImport see
+    // it binds the call to the imported store constant and the method is
+    // never looked up. The name-matcher owns the chain shape for these
+    // languages — the Java/Kotlin/C++ chains keep their existing path.
+    if (
+      ref.referenceKind === 'calls' &&
+      CHAIN_SHAPE.test(ref.referenceName) &&
+      (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
+    ) {
+      return this.gateLanguage(matchReference(ref, this.context), ref);
+    }
+
     const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
     const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
     if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);

+ 37 - 0
src/resolution/name-matcher.ts

@@ -2507,6 +2507,30 @@ function matchRustSelfFieldCall(
   return null;
 }
 
+/**
+ * The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE
+ * ACCESSOR. Zustand's `get()` inside the store factory and
+ * `useStore.getState()` outside it hand back the store whose actions are
+ * indexed as functions (#1573), so a unique callable of the method's name in
+ * the same language family is what `get().reset()` reaches. Nothing else
+ * qualifies: a chain rooted in a project value still says nothing about what
+ * the inner call RETURNS — `db.prepare(sql).all()` would bind to any project
+ * function named `all` — so it resolves to nothing, exactly like a chain
+ * rooted in a parameter (`d.setdefault(k, []).append(v)`).
+ */
+function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+  const m = ref.referenceName.match(/^([\w$.]+)\(\)\.(\w+)$/);
+  if (!m || !m[1] || !m[2]) return null;
+  const inner = m[1];
+  const method = m[2];
+  if (!(inner === 'get' || inner === 'getState' || inner.endsWith('.getState'))) return null;
+  const callables = context
+    .getNodesByName(method)
+    .filter((n) => (n.kind === 'function' || n.kind === 'method') && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId);
+  if (callables.length !== 1) return null;
+  return { original: ref, targetNodeId: callables[0]!.id, confidence: 0.6, resolvedBy: 'exact-match' };
+}
+
 /**
  * Split a camelCase or PascalCase string into words.
  */
@@ -2917,6 +2941,19 @@ export function matchReference(
     if (result) return result;
   }
 
+  // A call-receiver chain the extractor encoded as `<inner>().<method>` for a
+  // language with no chain resolver above (TS/JS, Python — #1683) is a
+  // receiver whose type is unknown. Nothing below may guess for it: the
+  // method-call pattern rejects the parens, exact name never matches, but the
+  // fuzzy strategy splits on `.` and would hand `make().run` to any `run` —
+  // the fabricated edge the encoding exists to prevent.
+  if (
+    ref.referenceName.includes('().') &&
+    (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
+  ) {
+    return nmTimed('storeAccessorChain', ref, () => matchStoreAccessorChain(ref, context));
+  }
+
   // 2. Method call pattern
   result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
   if (result) return result;