From ae0fc631e048a0c674664cfc43738116c8feac4e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 21 Oct 2026 11:01:53 +0000 Subject: [PATCH] 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()` (#2586): `!settings.get('X')` and `void apiRequest()` 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().then()` encodes as `apiRequest().then`, not `voidapiRequest().then`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../call-receiver-no-fabrication.test.ts | 81 ++++++++++++++++++- __tests__/fixtures/kernel-parity/torture.js | 11 +++ __tests__/fixtures/kernel-parity/torture.tsx | 15 ++++ codegraph-kernel/src/textutil.rs | 2 + codegraph-kernel/src/tsjs/extractors.rs | 55 +++++++++++-- src/extraction/tree-sitter.ts | 53 ++++++++++-- src/resolution/name-matcher.ts | 12 ++- 8 files changed, 211 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560dacf1b..85ac3831d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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('X')` or `void apiRequest()`, now links to what it calls, as `await load()` already did. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/call-receiver-no-fabrication.test.ts b/__tests__/call-receiver-no-fabrication.test.ts index 28412dedd..7b5338f85 100644 --- a/__tests__/call-receiver-no-fabrication.test.ts +++ b/__tests__/call-receiver-no-fabrication.test.ts @@ -72,10 +72,87 @@ describe('call-expression receivers (#1683)', () => { 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']); + // `(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()` (#2586): `!settings.get(…)` + // 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('X')) return;", + ' void apiRequest(1);', + ' return typeof load() === "x" || -count() > 0 || !!useFlag();', + '}', + ].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 }); + } + }); }); diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index abe40574f..0f904ff4f 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -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)(); +} diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx index ad5fdfaa0..1ba868fcb 100644 --- a/__tests__/fixtures/kernel-parity/torture.tsx +++ b/__tests__/fixtures/kernel-parity/torture.tsx @@ -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 void>, fn: () => void, load: () => Promise<() => void>) { + (() => { helperFn(); })(); + create()(helperFn); + handlers['open'](); + (0, fn)(); + fn!(); + (fn as () => void)(); + (await load())(); + await helperFn(); + void helperFn(); + if (!NS.helper.deep(1)) return; + return typeof create() === 'function' && !!create(); +} + export function App() { const nodes: GraphNode[] = []; return ( diff --git a/codegraph-kernel/src/textutil.rs b/codegraph-kernel/src/textutil.rs index 69f5aea82..40163ad74 100644 --- a/codegraph-kernel/src/textutil.rs +++ b/codegraph-kernel/src/textutil.rs @@ -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 diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index fb8650b55..ce72780b6 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1529,7 +1529,7 @@ impl<'t> Walker<'t> { // 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) { + 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`), 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.` 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(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(x)` parses with `await f` as the callee (#2586), and a + /// prefix operator over a generic call the same way + /// (`!settings.get('X')`, `void apiRequest()`); 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 diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e0ae68852..efbebf985 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -559,13 +559,17 @@ const TS_JS_TRANSPARENT_RECEIVER_TYPES = new Set([ /** * 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). + * `await f` (#2586), and a prefix operator over a generic call the same way: + * `!settings.get('X')`, `void apiRequest()`, `typeof load()`. + * 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()`, the type arguments of a tagged template's tag + * (`css\`…\``) — or null when it names nothing: a call (`connect(map) + * (View)`, `create()(…)`, `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); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7b1ebf019..f1f558e08 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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; - // `^\s*[(<]` at the call's column, without compiling a pattern per call. + // `^\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 `)`. */