From d428bcd986d2bc1cf7669508b3ac3c04260b3a84 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 4 Sep 2026 12:13:45 +0300 Subject: [PATCH] fix(extraction): never fabricate an edge from a call-result receiver (#1683, #1681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member call whose receiver is itself a call — `d.setdefault(k, []).append(v)`, `make().run()` — lost the receiver at extraction and degraded to the bare method name, which exact-matched (or fuzzy-matched) any top-level project symbol of that name: a call edge from an unrelated function, in Python and JavaScript alike. Keep the inner callee, encoded as `().` like the Java/C++ chains, in the wasm walker and both kernels; an inner callee with no static name emits nothing. The resolver owns the chain shape for TS/JS/Python: it is routed past the import strategy (which bound `useStore.getState().reset` to the imported store constant) and never reaches the fuzzy split. The one fallback kept is the store-accessor idiom — Zustand's `get()` / `useStore.getState()` followed by a method name with exactly one callable in the project — which the object-literal store coverage relies on. Everything else resolves to nothing: what an inner call returns is not knowable from its name. --- CHANGELOG.md | 2 + .../call-receiver-no-fabrication.test.ts | 81 +++++++++++++++++++ __tests__/fixtures/kernel-parity/torture.js | 9 +++ __tests__/fixtures/kernel-parity/torture.py | 9 +++ __tests__/object-literal-methods.test.ts | 5 +- codegraph-kernel/src/python.rs | 23 ++++++ codegraph-kernel/src/tsjs/extractors.rs | 25 +++++- src/extraction/tree-sitter.ts | 26 ++++++ src/resolution/index.ts | 13 +++ src/resolution/name-matcher.ts | 37 +++++++++ 10 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 __tests__/call-receiver-no-fabrication.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..fab8740b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ 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) + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/call-receiver-no-fabrication.test.ts b/__tests__/call-receiver-no-fabrication.test.ts new file mode 100644 index 000000000..28412dedd --- /dev/null +++ b/__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 + * `().`, 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 `().` 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']); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 50ddd0246..f1e57a6ba 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__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(); +} diff --git a/__tests__/fixtures/kernel-parity/torture.py b/__tests__/fixtures/kernel-parity/torture.py index 900fc74f8..813e55416 100644 --- a/__tests__/fixtures/kernel-parity/torture.py +++ b/__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() diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts index 1722ad2d0..39f8de1c6 100644 --- a/__tests__/object-literal-methods.test.ts +++ b/__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). diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index b2397facd..da46beb8c 100644 --- a/codegraph-kernel/src/python.rs +++ b/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): + // `().`, 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 { + 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 diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index f240c5e79..35ec6df85 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/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 `().`, 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 { + 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; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7ef90c273..1694e9698 100644 --- a/src/extraction/tree-sitter.ts +++ b/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 + // `().` 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 && diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7988c1bcf..f2a0b9fd4 100644 --- a/src/resolution/index.ts +++ b/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); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..26de2c386 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2245,6 +2245,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. */ @@ -2644,6 +2668,19 @@ export function matchReference( if (result) return result; } + // A call-receiver chain the extractor encoded as `().` 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;