mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +08:00
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:
co-authored by
Claude Opus 5.5
parent
63476df6a6
commit
8afb970a23
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user