Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 79 additions & 2 deletions __tests__/call-receiver-no-fabrication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
11 changes: 11 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)();
}
15 changes: 15 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions codegraph-kernel/src/textutil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 47 additions & 8 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}
}
}

Expand All @@ -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).
Expand All @@ -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
Expand Down
53 changes: 46 additions & 7 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading