fix(js,ts): a callee that names nothing records no reference, and a wrapped callee is the plain call (#2742)

A TS/JS call whose callee was no name and no member access (an IIFE, a
curried `connect(mapState)(View)`, `handlers[type]()`, `(0, fn)()`, a
tagged template on a call) stored the callee's whole source text as the
reference's name. Nothing can answer such a name, so each one parked as a
failed reference, an IIFE's entire body among them. extractCall now reads
the callee past the wrappers that keep it the same callee (parentheses,
`!`, `as` / `satisfies`, `await`, type arguments) and records nothing when
that is not a name or a dotted chain; the calls inside are visited on
their own. Mirrored in the kernel (tsjs/extractors.rs callee_name_of).

The reference of a wrapped callee sits at its name, where a plain call's
does, and the resolver's bare-call check reads `fn!(`, `(fn)(`,
`(fn as F)(` and `(fn satisfies F)(` as it reads `fn(`: SigNoz's
`(callback as FrameRequestCallback)(0)` and `(callback)(0)` name the
arrow's own parameter, and from the `(` they bound a generated interface's
`callback` property.

tree-sitter-typescript parses a prefix operator over a generic call as it
parses `await f<T>()` (#2586): `!settings.get<boolean>('X')` and
`void apiRequest<T>()` are calls whose callee is `!settings.get` /
`void apiRequest`. The callee peel that handled `await` now peels a prefix
operator too (peelTsPrefixedCallee; the kernel's peel_await_callee), so the
call is to `settings.get`, and `void apiRequest<T>().then()` encodes as
`apiRequest().then`, not `voidapiRequest().then`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 11:01:53 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent aa01692620
commit ae0fc631e0
8 changed files with 211 additions and 19 deletions
+1
View File
@@ -268,6 +268,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading.
- In TypeScript, a type written with a qualifier, like `React.ReactNode`, `Y.Doc`, `TChat.ChatMessage` or `EQueryType.CLICKHOUSE`, now links to the member of what the qualifier names: a type from a package, like React's, no longer links to a class or component of your own that shares its name, and a member of an enum or a namespace your project declares links to that member instead of to another enum's or a same-named component. Re-index TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading.
- In JavaScript and TypeScript, a call whose callee is not a name, like an immediately invoked function `(() => { … })()`, a curried call such as `connect(mapState)(View)`, `handlers[type]()` or `(0, fn)()`, no longer stores the callee's whole source text as the name of a reference that nothing in the project can answer. A single immediately invoked function could leave its entire body in the index that way. The calls inside such a callee, and the inner call of a curried one, are still recorded. A call through parentheses, a cast or a non-null assertion, like `(fn)()`, `(fn as F)()` or `fn!()`, now links to `fn` exactly as `fn()` does, so a parameter named `fn` no longer links to a same-named property elsewhere in the project. In TypeScript, a call with type arguments behind an operator, like `!settings.get<boolean>('X')` or `void apiRequest<Status>()`, now links to what it calls, as `await load<T>()` already did.
## [1.6.2] - 2026-10-03
+79 -2
View File
@@ -72,10 +72,87 @@ describe('call-expression receivers (#1683)', () => {
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']);
// `(0, make)()` and `arr[0]()` call a value with no name; their chains are dropped too.
expect(names).toEqual(['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']);
});
// A callee that names nothing used to give the reference its whole source
// text: an IIFE's body, `connect(mapState)`, `handlers[type]`. No symbol has
// such a name, so nothing resolved and each one parked as a failed reference.
it('records no name for a callee that names nothing, and looks through wrappers that keep one', () => {
const js = extractFromSource('src/x.js', [
'class A extends B { constructor() { super(1); } }',
'function f(handlers, type, fn, ns) {',
' (() => { inner(); })();',
' (function () { inner2(); })();',
' connect(mapState)(View);',
' create()(setup);',
" breakpoint('tablet')`padding: 0;`;",
' handlers[type](1);',
' (0, fn)();',
' (fn)();',
' (ns.fn)();',
'}',
].join('\n'));
expect(js.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort())
.toEqual(['breakpoint', 'connect', 'create', 'fn', 'inner', 'inner2', 'ns.fn', 'super']);
// A tag with an object type argument error-recovers into a call on `css<{…}>`.
const ts = extractFromSource('src/x.ts', 'function g(fn: () => void) { fn!(); (fn as F)(); css<{ open: boolean }>`color: red;`; }');
expect(ts.unresolvedReferences.filter((u) => u.referenceKind === 'calls').map((u) => u.referenceName).sort())
.toEqual(['css', 'fn', 'fn']);
});
// tree-sitter-typescript parses a prefix operator over a generic call as the
// call's callee, as it does `await f<T>()` (#2586): `!settings.get<boolean>(…)`
// was a call named `!settings.get`.
it('looks through a prefix operator over a generic call', () => {
const ts = extractFromSource('src/x.ts', [
'function h() {',
" if (!settings.get<boolean>('X')) return;",
' void apiRequest<Status>(1);',
' return typeof load<T>() === "x" || -count<number>() > 0 || !!useFlag<boolean>();',
'}',
].join('\n'));
const calls = ts.unresolvedReferences.filter((u) => u.referenceKind === 'calls');
expect(calls.map((u) => u.referenceName).sort()).toEqual(['apiRequest', 'count', 'load', 'settings.get', 'useFlag']);
// Each sits where its callee's name starts, past the operator.
expect(calls.find((u) => u.referenceName === 'settings.get')).toMatchObject({ line: 2, column: 7 });
expect(calls.find((u) => u.referenceName === 'apiRequest')).toMatchObject({ line: 3, column: 7 });
});
// The reference sits where the name does, and is a bare call as a plain
// call's is: SigNoz's `(callback as FrameRequestCallback)(0)` names the
// arrow's own parameter, and `(callback)(0)` starting at the `(` bound a
// generated interface's `callback` property.
it('treats a wrapped callee as the plain call, so a parameter it names shadows a namesake', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cast-callee-'));
try {
fs.writeFileSync(path.join(root, 'schemas.ts'), 'export interface DTO {\n callback?: string[] | null;\n}\n');
fs.writeFileSync(path.join(root, 'raf.test.ts'), [
"import type { DTO } from './schemas';",
"describe('raf', () => {",
' beforeEach(() => {',
" jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {",
' callback(0);',
' (callback as FrameRequestCallback)(1);',
' callback!(2);',
' (callback)(3);',
' return 0;',
' });',
' });',
'});',
'export const dto: DTO = {};',
].join('\n'));
const graph = CodeGraph.initSync(root);
await graph.indexAll();
const property = graph.getNodesByName('callback').find((n) => n.kind === 'property')!;
expect(graph.getCallers(property.id)).toEqual([]);
graph.destroy();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
@@ -112,3 +112,14 @@ const Row = styled.div.attrs((props) => ({ role: roleOf(props) }))`
${Panel} { margin: 0; }
`;
const MemoPanel = React.memo(Panel, legacyHelper);
// --- callees that name nothing, and wrappers that keep one -----------------------
function calleeShapes(handlers, type, fn, ns) {
(() => { legacyHelper(1, 2); })();
(function () { arrow(3); })();
connect(mapState)(Widget);
breakpoint('tablet')`padding: 0;`;
handlers[type](1);
(0, fn)();
(fn)();
(ns.fn)();
}
@@ -153,6 +153,21 @@ export const MemoCompared = memo(ImportedComponent, helperFn);
export const MemoForward = memo(forwardRef(RenderInput));
export const RefForwarded = React.forwardRef(RenderInput);
// Callees that name nothing, and the wrappers that keep one.
export async function calleeShapes(handlers: Record<string, () => void>, fn: () => void, load: () => Promise<() => void>) {
(() => { helperFn(); })();
create<ButtonProps>()(helperFn);
handlers['open']();
(0, fn)();
fn!();
(fn as () => void)();
(await load())();
await helperFn<ButtonProps>();
void helperFn<ButtonProps>();
if (!NS.helper.deep<boolean>(1)) return;
return typeof create<ButtonProps>() === 'function' && !!create<RowProps>();
}
export function App() {
const nodes: GraphNode[] = [];
return (
+2
View File
@@ -22,6 +22,8 @@ re!(styled_callee, r"^styled\b");
re!(pascal_case, r"^[A-Z]");
// extractCall parenthesized-conversion normalization
re!(paren_conversion, r"^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$");
// tsJsCalleeName's dotted chain (`a.b.c`); ASCII classes, as JS's `\w` is
re!(dotted_chain, r"^[A-Za-z_$][0-9A-Za-z_$]*(?:\.[A-Za-z_$][0-9A-Za-z_$]*)+$");
// flushFnRefCandidates SIMPLE_NAME
re!(simple_name, r"^[A-Za-z_$][A-Za-z0-9_$]*$");
// flushFnRefCandidates QUALIFIED_IMPORT
+47 -8
View File
@@ -1529,7 +1529,7 @@ impl<'t> Walker<'t> {
// 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) {
let mut at = match (raw, func) {
(Some(r), Some(f)) if r.id() != f.id() => f,
_ => node,
};
@@ -1596,7 +1596,15 @@ impl<'t> Walker<'t> {
}
}
} else {
callee_name = self.text(func).to_string();
// A callee that names nothing (a call, a function, a
// subscript) is no reference. Mirrors tsJsCalleeName.
match self.callee_name_of(func) {
Some((name, name_at)) => {
callee_name = name;
at = name_at;
}
None => return,
}
}
}
@@ -1612,6 +1620,27 @@ impl<'t> Walker<'t> {
}
}
/// The name a non-member callee writes, past `(fn)`, `fn!`, `(fn as F)`,
/// `await` and a tag's type arguments (`css<Props>`), or None for one that
/// names nothing: a call, a function, a subscript, `(0, fn)`. A dotted
/// chain keeps its text, `(a.b)()` → `a.b`. The node is where the name
/// starts, which is where the reference sits. Mirrors tsJsCalleeName.
fn callee_name_of(&self, func: Node<'t>) -> Option<(String, Node<'t>)> {
let mut cur = peel_receiver(func);
while cur.kind() == "instantiation_expression" {
let inner = cur.child_by_field_name("function").or_else(|| cur.named_child(0))?;
cur = peel_receiver(inner);
}
match cur.kind() {
"identifier" | "super" | "import" => Some((self.text(cur).to_string(), cur)),
"member_expression" => {
let chain: String = self.text(cur).chars().filter(|c| !c.is_whitespace()).collect();
util::dotted_chain().is_match(&chain).then_some((chain, cur))
}
_ => None,
}
}
// --- extractInstantiation -----------------------------------------------------------
/// `this.<field>` as a member_expression receiver → Some(field) (#1496, #1987).
@@ -1630,17 +1659,27 @@ 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.
/// `await f<T>(x)` parses with `await f` as the callee (#2586), and a
/// prefix operator over a generic call the same way
/// (`!settings.get<boolean>('X')`, `void apiRequest<T>()`); the call is to
/// `f`. Peels every `await` and prefix operator, skipping a comment written
/// after an `await`. TS-family only. Mirrors peelTsPrefixedCallee.
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 };
loop {
let inner = match cur.kind() {
"unary_expression" => cur.child_by_field_name("argument"),
"await_expression" => {
let mut walker = cur.walk();
let found = cur.named_children(&mut walker).find(|c| c.kind() != "comment");
found
}
_ => break,
};
let Some(inner) = inner else { break };
cur = inner;
}
cur
+46 -7
View File
@@ -559,13 +559,17 @@ const TS_JS_TRANSPARENT_RECEIVER_TYPES = new Set([
/**
* 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).
* `await f` (#2586), and a prefix operator over a generic call the same way:
* `!settings.get<boolean>('X')`, `void apiRequest<T>()`, `typeof load<T>()`.
* The call is to `f`. Peels every `await` and prefix operator, skipping a
* comment written after an `await`. Mirrored in the kernel (tsjs/extractors.rs).
*/
function peelTsAwaitCallee(node: SyntaxNode): SyntaxNode {
function peelTsPrefixedCallee(node: SyntaxNode): SyntaxNode {
let cur = node;
while (cur.type === 'await_expression') {
const inner = cur.namedChildren.find((c: SyntaxNode) => c.type !== 'comment');
while (cur.type === 'await_expression' || cur.type === 'unary_expression') {
const inner = cur.type === 'unary_expression'
? getChildByField(cur, 'argument')
: cur.namedChildren.find((c: SyntaxNode) => c.type !== 'comment');
if (!inner) break;
cur = inner;
}
@@ -593,6 +597,32 @@ function peelTsJsReceiver(node: SyntaxNode): SyntaxNode {
return cur;
}
/**
* The name a TS/JS call's callee writes when it is no member access, past the
* wrappers that keep it the same callee — `(fn)()`, `fn!()`, `(fn as F)()`,
* `await f<T>()`, the type arguments of a tagged template's tag
* (`css<Props>\`…\``) — or null when it names nothing: a call (`connect(map)
* (View)`, `create<T>()(…)`, `breakpoint('tablet')\`…\``), a function (an
* IIFE), a subscript (`handlers[type]()`), `(0, fn)()`. Its text used to be
* the reference's name, an IIFE's whole body among them, which no symbol is.
* A dotted chain in parentheses keeps its text, `(a.b)()` → `a.b`, as the
* parenthesized-callee normalization in extractCall reads it. `at` is where the
* name starts, which is where the reference sits, as a plain `fn()` call's does:
* scope checks read the name there (a parameter `fn` shadows a namesake).
*/
function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: SyntaxNode } | null {
let cur = peelTsJsReceiver(func);
while (cur.type === 'instantiation_expression') {
const inner = getChildByField(cur, 'function') ?? cur.namedChild(0);
if (!inner) return null;
cur = peelTsJsReceiver(inner);
}
if (cur.type === 'identifier' || cur.type === 'super' || cur.type === 'import') return { name: getNodeText(cur, source), at: cur };
if (cur.type !== 'member_expression') return null;
const chain = getNodeText(cur, source).replace(/\s+/g, '');
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null;
}
/**
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
* `super` (the resolver reads the owner off the enclosing class), a member
@@ -5603,7 +5633,7 @@ 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);
const peeled = peelTsPrefixedCallee(func);
if (peeled !== func) refStart = peeled.startPosition;
func = peeled;
}
@@ -5909,7 +5939,7 @@ export class TreeSitterExtractor {
// wrong edge. The inner call is visited on its own either way.
// Mirrored in the kernel (tsjs/extractors.rs, python.rs).
let innerFn = getChildByField(receiver, 'function');
if (innerFn && (this.language === 'typescript' || this.language === 'tsx')) innerFn = peelTsAwaitCallee(innerFn);
if (innerFn && (this.language === 'typescript' || this.language === 'tsx')) innerFn = peelTsPrefixedCallee(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}`;
@@ -5992,6 +6022,15 @@ export class TreeSitterExtractor {
} else {
calleeName = getNodeText(func, this.source);
}
} else if (TS_JS_CHAIN_LANGUAGES.has(this.language)) {
// A callee that names nothing — a call, a function, a subscript —
// is no reference: a silent miss, never a name no symbol has. A
// curried call's inner call is visited on its own. Mirrored in the
// kernel's extract_call (tsjs/extractors.rs callee_name_of).
const named = tsJsCalleeName(func, this.source);
if (named === null) return;
calleeName = named.name;
refStart = named.at.startPosition;
} else {
calleeName = getNodeText(func, this.source);
}
+10 -2
View File
@@ -3628,10 +3628,12 @@ function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boo
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1]
?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
if (line === undefined) return false;
// `^<name>\s*[(<]` at the call's column, without compiling a pattern per call.
// `^<name>\s*[(<]` at the call's column (or a JS/TS wrapper's tail after the
// name), without compiling a pattern per call.
if (!line.startsWith(ref.referenceName, ref.column)) return false;
CALL_OPENER.lastIndex = ref.column + ref.referenceName.length;
if (!CALL_OPENER.test(line)) return false;
JS_PEELED_CALLEE_TAIL.lastIndex = CALL_OPENER.lastIndex;
if (!CALL_OPENER.test(line) && !(JS_FAMILY.has(ref.language) && JS_PEELED_CALLEE_TAIL.test(line))) return false;
// Nothing but whitespace, an operator or an opener may precede a bare call:
// read the text before the column backwards, past trailing whitespace,
// instead of end-anchoring a pattern that scans the prefix from its start.
@@ -3649,6 +3651,12 @@ function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boo
/** `\s*[(<]` from a given index (sticky) — an optional call's `?.(` too. */
const CALL_OPENER = /\s*(?:\?\.\s*)?[(<]/y;
/**
* What follows a JS/TS callee's name inside a wrapper the extractor sees
* through — `fn!()`, `(fn)()`, `(fn as F)()`, `(fn satisfies F)()` — whose
* reference sits at the name, as a plain call's does (sticky).
*/
const JS_PEELED_CALLEE_TAIL = /\s*[!)]|\s+(?:as|satisfies)(?![\w$])/y;
const WHITESPACE = /\s/;
const WORD_CHAR = /\w/;
/** A character that ends a receiver: `.`, a word character, `$`, `]` or `)`. */