From 8afb970a23886eced6261d977a029240407702f6 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Tue, 20 Oct 2026 13:11:12 -0400 Subject: [PATCH] fix(ts): an awaited call with type arguments links to its callee (#2595) tree-sitter-typescript parses `await f(x)` as a call whose callee is `await f`, so the reference was named `await f` and matched nothing. Both engines now peel the await, skipping a comment after it, and the reference sits where the callee starts, as for `await f(x)`: on the call node, which starts at await, the checks that read the source at a call's position missed the callee, and a package-imported `await get()` linked to a project class's static get. A/B: excalidraw +2 (compressData, decompressData), vben +6, zod and bulletproof-react unchanged. Fixes issue 2586. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 80 +++++++++++++++++++++++++ __tests__/kernel-tsjs-parity.test.ts | 18 ++++++ codegraph-kernel/src/tsjs/extractors.rs | 32 ++++++++-- src/extraction/tree-sitter.ts | 31 +++++++++- 5 files changed, 154 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c67197ba..f1b8b242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) - In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 1ceb2271..498b5abc 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -764,6 +764,86 @@ function main() { const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); expect(calls.some((c) => c.referenceName === 'processData')).toBe(true); }); + + // tree-sitter-typescript parses `await f(x)` as a call whose callee is + // `await f`, so the ref was named `await f` and matched nothing (#2586). + it.each([['user.ts'], ['user.tsx']])('an awaited call with type arguments names its callee: %s (#2586)', (file) => { + const code = [ + 'export async function awaited() { return await fetchApi("/me"); }', + 'export async function assigned() { const u = await fetchApi("/me"); return u; }', + 'export async function viaNamespace() { return await api.fetchApi("/me"); }', + 'export async function viaThis() { return await this.load("/me"); }', + '', + ].join('\n'); + const result = extractFromSource(file, code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const byFn = (name: string) => { + const fn = result.nodes.find((n) => n.name === name)!; + return calls.filter((c) => c.fromNodeId === fn.id).map((c) => c.referenceName); + }; + expect(byFn('awaited')).toEqual(['fetchApi']); + expect(byFn('assigned')).toEqual(['fetchApi']); + expect(byFn('viaNamespace')).toEqual(['api.fetchApi']); + expect(byFn('viaThis')).toEqual(['load']); + expect(calls.some((c) => c.referenceName.startsWith('await'))).toBe(false); + // The reference sits where the callee starts, as for `await f(x)` — not on + // the `await`, which the call node starts at. + const lines = code.split('\n'); + const columnOf = (name: string) => { + const fn = result.nodes.find((n) => n.name === name)!; + return calls.find((c) => c.fromNodeId === fn.id)!.column; + }; + expect(columnOf('awaited')).toBe(lines[0]!.indexOf('fetchApi')); + expect(columnOf('viaNamespace')).toBe(lines[2]!.indexOf('api.fetchApi')); + }); + + it('an awaited generic call to a name a package exports links to nothing in the project (#2586)', async () => { + const dir = createTempDir(); + try { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'app', dependencies: { 'idb-keyval': '6.2.1' } })); + fs.mkdirSync(path.join(dir, 'src')); + // excalidraw's `await get(key, store)` from idb-keyval, beside + // a project class whose static `get` the name alone would match. + fs.writeFileSync(path.join(dir, 'src', 'storage.ts'), + 'import { get } from "idb-keyval";\nexport async function load() { return await get("k"); }\n'); + fs.writeFileSync(path.join(dir, 'src', 'cache.ts'), 'export class Cache {\n static get = (s: string) => s;\n}\n'); + const cg = await CodeGraph.init(dir, { index: true }); + try { + const load = cg.getNodesByKind('function').find((n) => n.name === 'load')!; + expect(cg.getOutgoingEdges(load.id).filter((e) => e.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + } finally { + cleanupTempDir(dir); + } + }, 60_000); + + it.each([['user.ts'], ['user.tsx'], ['user.ets']])('an awaited generic call peels every await and skips comments: %s (#2586)', (file) => { + const code = [ + 'export async function chainedParen() { return (await fetchApi("/me")).json(); }', + 'export async function chainedThen() { return await api.get("/me").then(r => r); }', + 'export async function commented() { return await /* typed */ fetchApi("/me"); }', + 'export async function lineCommented() { return await // typed\n fetchApi("/me"); }', + 'export async function doubled() { return await await fetchApi("/me"); }', + '', + ].join('\n'); + const result = extractFromSource(file, code); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const byFn = (name: string) => { + const fn = result.nodes.find((n) => n.name === name)!; + return calls.filter((c) => c.fromNodeId === fn.id).map((c) => c.referenceName).sort(); + }; + expect(byFn('commented')).toEqual(['fetchApi']); + expect(byFn('lineCommented')).toEqual(['fetchApi']); + expect(byFn('doubled')).toEqual(['fetchApi']); + expect(calls.some((c) => /^await|^\/[*/]/.test(c.referenceName))).toBe(false); + // ArkTS keeps its own chain encoding; TS/TSX name the inner callee. + if (file !== 'user.ets') { + expect(byFn('chainedParen')).toEqual(['fetchApi', 'fetchApi().json']); + expect(byFn('chainedThen')).toEqual(['api.get', 'api.get().then']); + } + }); }); describe('Arrow Function Export Extraction', () => { diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 38e46728..f408f822 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -126,6 +126,24 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { } }); + it.each([['ts', 'typescript'], ['tsx', 'tsx']] as const)('an awaited call with type arguments names its callee: %s (#2586)', (ext, language) => { + const source = [ + 'export async function a() { return await fetchApi("/me"); }', + 'export async function b() { return await api.fetchApi("/me"); }', + 'export async function c() { return (await load())(); }', + 'export async function d() { return (await fetchApi("/me")).json(); }', + 'export async function e() { return await api.get("/me").then(r => r); }', + 'export async function f() { return await /* typed */ fetchApi("/me"); }', + 'export async function g() { return await await fetchApi("/me"); }', + 'export async function h() { return await // typed\n fetchApi("/me").then(r => r); }', + '', + ].join('\n'); + const result = assertParity(`awaited.${ext}`, source, language); + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toEqual(expect.arrayContaining(['fetchApi', 'api.fetchApi', 'fetchApi().json', 'api.get().then'])); + expect(calls.some((n) => /^await|^\/[*/]/.test(n))).toBe(false); + }); + it.each(['bundle.js', 'vendor-min.js', 'vendor.min.js'])('a minified bundle keeps its literals unowned: %s (#2300)', (file) => { // Mostly long lines dense with code punctuation, as a minifier writes them. const line = `var a={b:function(){return c(1,2)},d:function(e){return e}};window.L={f:function(){return a.b()}};`.repeat(40); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 1c84f8c3..d77dd8d9 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1398,9 +1398,15 @@ impl<'t> Walker<'t> { if self.stack.is_empty() { return; } - let func = node - .child_by_field_name("function") - .or_else(|| node.named_child(0)); + let raw = node.child_by_field_name("function").or_else(|| node.named_child(0)); + let func = raw.map(|f| self.peel_await_callee(f)); + // Where the reference sits: the call, or the callee an `await` was + // peeled off (`await f(x)` starts at `await`; the call to `f` + // starts where `f` does, as in `await f(x)`). Mirrors refStart. + let at = match (raw, func) { + (Some(r), Some(f)) if r.id() != f.id() => f, + _ => node, + }; let mut callee_name = String::new(); if let Some(func) = func { @@ -1476,7 +1482,7 @@ impl<'t> Walker<'t> { } if !callee_name.is_empty() { - self.push_call_ref(&callee_name.clone(), node); + self.push_call_ref(&callee_name.clone(), at); } } @@ -1498,10 +1504,26 @@ impl<'t> Walker<'t> { Some(self.text(property).to_string()) } + /// `await f(x)` parses with `await f` as the callee; the call is to + /// `f` (#2586). Peels every `await`, skipping a comment written after one. + /// TS-family only. Mirrors peelTsAwaitCallee. + fn peel_await_callee(&self, node: Node<'t>) -> Node<'t> { + if !self.variant.is_ts() { + return node; + } + let mut cur = node; + while cur.kind() == "await_expression" { + let mut walker = cur.walk(); + let Some(inner) = cur.named_children(&mut walker).find(|c| c.kind() != "comment") else { break }; + cur = inner; + } + cur + } + /// 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 inner = self.peel_await_callee(call.child_by_field_name("function")?); Self::plain_member_name(self.text(inner)) } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 66e1e8c4..091aa9ee 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -530,6 +530,21 @@ const TS_JS_TRANSPARENT_RECEIVER_TYPES = new Set([ 'satisfies_expression', 'type_assertion', 'await_expression', ]); +/** + * tree-sitter-typescript parses `await f(x)` as a call whose callee is + * `await f` (#2586); the call is to `f`. Peels every `await`, skipping a + * comment written after one. Mirrored in the kernel (tsjs/extractors.rs). + */ +function peelTsAwaitCallee(node: SyntaxNode): SyntaxNode { + let cur = node; + while (cur.type === 'await_expression') { + const inner = cur.namedChildren.find((c: SyntaxNode) => c.type !== 'comment'); + if (!inner) break; + cur = inner; + } + return cur; +} + /** * Strip {@link TS_JS_TRANSPARENT_RECEIVER_TYPES} wrappers off a receiver. * tree-sitter-typescript parses `a && b!.c()` as `(a && b)!.c()`; the `!` @@ -5244,6 +5259,10 @@ export class TreeSitterExtractor { // Get the function/method being called let calleeName = ''; + // Where the call's reference sits: the call itself, or the callee an + // `await` was peeled off (`await f(x)` starts at `await`; the call to + // `f` starts where `f` does, as in `await f(x)`). + let refStart = node.startPosition; // Java/Kotlin method_invocation has 'object' + 'name' fields instead of 'function' // PHP member_call_expression has 'object' + 'name', scoped_call_expression has 'scope' + 'name' @@ -5424,6 +5443,11 @@ export class TreeSitterExtractor { } } else { let func = getChildByField(node, 'function') || node.namedChild(0); + if (func && (this.language === 'typescript' || this.language === 'tsx' || this.language === 'arkts')) { + const peeled = peelTsAwaitCallee(func); + if (peeled !== func) refStart = peeled.startPosition; + func = peeled; + } if (func && this.language === 'swift') { // `-(a + b)`: an operator token applied to a parenthesized value @@ -5710,7 +5734,8 @@ export class TreeSitterExtractor { // 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'); + let innerFn = getChildByField(receiver, 'function'); + if (innerFn && (this.language === 'typescript' || this.language === 'tsx')) innerFn = peelTsAwaitCallee(innerFn); 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}`; @@ -5836,8 +5861,8 @@ export class TreeSitterExtractor { fromNodeId: callerId, referenceName: calleeName, referenceKind: 'calls', - line: node.startPosition.row + 1, - column: node.startPosition.column, + line: refStart.row + 1, + column: refStart.column, }); } }