fix(ts): an awaited call with type arguments links to its callee (#2595)

tree-sitter-typescript parses `await f<T>(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<T>()` 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 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-20 17:11:12 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 63476df6a6
commit 8afb970a23
5 changed files with 154 additions and 8 deletions
+1
View File
@@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, 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)
+80
View File
@@ -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<T>(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<User>("/me"); }',
'export async function assigned() { const u = await fetchApi<User>("/me"); return u; }',
'export async function viaNamespace() { return await api.fetchApi<User>("/me"); }',
'export async function viaThis() { return await this.load<User>("/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<SavedChats>(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<string>("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<User>("/me")).json(); }',
'export async function chainedThen() { return await api.get<User>("/me").then(r => r); }',
'export async function commented() { return await /* typed */ fetchApi<User>("/me"); }',
'export async function lineCommented() { return await // typed\n fetchApi<User>("/me"); }',
'export async function doubled() { return await await fetchApi<User>("/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', () => {
+18
View File
@@ -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<User>("/me"); }',
'export async function b() { return await api.fetchApi<User>("/me"); }',
'export async function c() { return (await load())(); }',
'export async function d() { return (await fetchApi<User>("/me")).json(); }',
'export async function e() { return await api.get<User>("/me").then(r => r); }',
'export async function f() { return await /* typed */ fetchApi<User>("/me"); }',
'export async function g() { return await await fetchApi<User>("/me"); }',
'export async function h() { return await // typed\n fetchApi<User>("/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);
+27 -5
View File
@@ -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<T>(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<T>(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<String> {
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))
}
+28 -3
View File
@@ -530,6 +530,21 @@ const TS_JS_TRANSPARENT_RECEIVER_TYPES = new Set([
'satisfies_expression', 'type_assertion', 'await_expression',
]);
/**
* tree-sitter-typescript parses `await f<T>(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<T>(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,
});
}
}