Skip to content
Open
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
96 changes: 89 additions & 7 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ function dispatchQueryMatch(
classes: ClassRelation[],
exps: Export[],
callbackParamShapes: CallbackParamShapes,
arrayElemBindings: ArrayElemBinding[],
): void {
if (c.fn_node) {
handleFnCapture(c, definitions);
Expand Down Expand Up @@ -365,7 +366,7 @@ function dispatchQueryMatch(
if (cbDef) definitions.push(cbDef);
calls.push(...extractCallbackReferenceCalls(c.callmem_node, callbackParamShapes));
} else if (c.callsub_node) {
const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node);
const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node, arrayElemBindings);
if (callInfo) calls.push(callInfo);
calls.push(...extractCallbackReferenceCalls(c.callsub_node, callbackParamShapes));
} else if (c.newfn_node) {
Expand Down Expand Up @@ -421,7 +422,16 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
// Build capture lookup for this match (1-3 captures each, very fast)
const c: Record<string, TreeSitterNode> = Object.create(null);
for (const cap of match.captures) c[cap.name] = cap.node;
dispatchQueryMatch(c, definitions, calls, imports, classes, exps, callbackParamShapes);
dispatchQueryMatch(
c,
definitions,
calls,
imports,
classes,
exps,
callbackParamShapes,
arrayElemBindings,
);
}

// Extract top-level constants via targeted walk (query patterns don't cover these)
Expand Down Expand Up @@ -1630,7 +1640,7 @@ function handleCallExpr(
ctx.calls.push({ name: 'this', line: nodeStartLine(node) });
return; // no further processing needed for this()-style calls
}
const callInfo = extractCallInfo(fn, node);
const callInfo = extractCallInfo(fn, node, ctx.arrayElemBindings);
if (callInfo) ctx.calls.push(callInfo);
if (fn.type === 'member_expression') {
const cbDef = extractCallbackDefinition(node, fn);
Expand Down Expand Up @@ -3497,7 +3507,11 @@ function extractReceiverName(objNode: TreeSitterNode | null): string | undefined
return objNode.text;
}

function extractCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | null {
function extractCallInfo(
fn: TreeSitterNode,
callNode: TreeSitterNode,
arrayElemBindings?: ArrayElemBinding[],
): Call | null {
const fnType = fn.type;
if (fnType === 'identifier') {
if (fn.text === 'eval') {
Expand Down Expand Up @@ -3528,7 +3542,7 @@ function extractCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | n
return extractMemberExprCallInfo(fn, callNode);
}
if (fnType === 'subscript_expression') {
return extractSubscriptCallInfo(fn, callNode);
return extractSubscriptCallInfo(fn, callNode, arrayElemBindings);
}
return null;
}
Expand Down Expand Up @@ -3684,8 +3698,72 @@ function extractMemberExprCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode)
return { name: propText, line: callLine, receiver };
}

/**
* RES-2: inline object-literal dispatch table — `({a:fnA,b:fnB})[key]()`.
*
* Mirrors `extract_dispatch_table_call` in
* `crates/codegraph-core/src/extractors/javascript.rs`. When the subscript's
* object is an object literal (optionally unwrapped from a parenthesized
* expression) and the index is a bare identifier, records each property's
* identifier value as an `ArrayElemBinding` under a synthetic `<dt_line_col>`
* name and returns a `<dt_line_col>[*]` call — the existing points-to
* wildcard resolution path (already used for `const arr = [f1, f2]; arr[i]()`
* patterns) then resolves it to each concrete target identically on both
* engines (#1897).
*
* Returns `null` when the object isn't an object literal, or none of its
* property values are resolvable bare identifiers.
*/
function extractDispatchTableCall(
obj: TreeSitterNode | null,
index: TreeSitterNode,
callNode: TreeSitterNode,
arrayElemBindings: ArrayElemBinding[],
): Call | null {
if (!obj) return null;
// Unwrap parenthesized_expression: ({a:fn})[key]()
const objNode =
obj.type === 'parenthesized_expression'
? (obj.childForFieldName('expression') ?? obj.child(1) ?? obj)
: obj;
if (objNode.type !== 'object') return null;

const line = nodeStartLine(callNode);
const col = callNode.startPosition.column;
const tableName = `<dt_${line}_${col}>`;
let idx = 0;
for (let i = 0; i < objNode.childCount; i++) {
const child = objNode.child(i);
if (!child) continue;
if (child.type === 'shorthand_property_identifier') {
if (!BUILTIN_GLOBALS.has(child.text)) {
arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: child.text });
idx++;
}
} else if (child.type === 'pair') {
const val = child.childForFieldName('value');
if (val?.type === 'identifier' && !BUILTIN_GLOBALS.has(val.text)) {
arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: val.text });
idx++;
}
}
}
if (idx === 0) return null;
return {
name: `${tableName}[*]`,
line,
dynamic: true,
dynamicKind: 'dispatch-table',
keyExpr: index.text,
};
}

/** Extract call info from a subscript_expression function node (obj[key]()). */
function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | null {
function extractSubscriptCallInfo(
fn: TreeSitterNode,
callNode: TreeSitterNode,
arrayElemBindings?: ArrayElemBinding[],
): Call | null {
const obj = fn.childForFieldName('object');
const index = fn.childForFieldName('index');
if (!index) return null;
Expand All @@ -3705,8 +3783,12 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode):
}
}

// obj[variable]() — key is a variable; may be resolvable via pts (RES-1), else flagged
// obj[variable]() — key is a variable; may be resolvable via pts (RES-1/RES-2), else flagged
if (indexType === 'identifier') {
if (arrayElemBindings) {
const dispatchCall = extractDispatchTableCall(obj, index, callNode, arrayElemBindings);
if (dispatchCall) return dispatchCall;
}
const receiver = extractReceiverName(obj);
return {
name: '<dynamic:computed-key>',
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ export type DynamicKind =
| 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved
| 'eval' // eval() / new Function() — undecidable; always flagged
| 'unresolved-dynamic' // any other detected dynamic pattern; flagged
| 'value-ref'; // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771), assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776), or the right operand of an `instanceof` check (e.g. `err instanceof CodegraphError`, #1784) — resolved only against function/method/class-kind targets (class only relevant for the instanceof site); unresolved (e.g. plain data references) are dropped silently, NOT flagged
| 'value-ref' // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771), assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776), or the right operand of an `instanceof` check (e.g. `err instanceof CodegraphError`, #1784) — resolved only against function/method/class-kind targets (class only relevant for the instanceof site); unresolved (e.g. plain data references) are dropped silently, NOT flagged
| 'dispatch-table'; // inline object-literal subscript dispatch, e.g. `({a:fnA,b:fnB})[key]()` (#1897) — resolved via the points-to wildcard solver against synthetic `<dt_line_col>[*]` array-elem bindings seeded from each property's identifier value; never flagged (excluded from FLAG_ONLY_DYNAMIC_KINDS) so an unresolved table produces no sink edge, matching the named-array `[fn1,fn2][*]` dispatch pattern

/** A function/method call detected by an extractor. */
export interface Call {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// pts-dispatch-table: inline object-literal subscript dispatch {a:fnA,b:fnB}[k]()
function dtFn1() {}
function dtFn2() {}

function runDispatch(key) {
({ a: dtFn1, b: dtFn2 })[key]();
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@
"kind": "calls",
"mode": "pts-spread",
"notes": "y() inside consumer2 — spread consumer2(...[sprFn3, sprFn4]) binds sprFn4 to y"
},
{
"source": { "name": "runDispatch", "file": "dispatch-table.js" },
"target": { "name": "dtFn1", "file": "dispatch-table.js" },
"kind": "calls",
"mode": "pts-dispatch-table",
"notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value"
},
{
"source": { "name": "runDispatch", "file": "dispatch-table.js" },
"target": { "name": "dtFn2", "file": "dispatch-table.js" },
"kind": "calls",
"mode": "pts-dispatch-table",
"notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value"
}
]
}
17 changes: 17 additions & 0 deletions tests/engines/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,23 @@ router.get('/users/:id', authHandler);
Array.from(arr, mapCallback);
Array.from(arr, mapCallback, thisArg);
Uint8Array.from(arr, mapCallback);
`,
},
{
// Regression guard for #1897: WASM previously had no mirror of native's
// RES-2 inline object-literal dispatch-table detection
// (extract_dispatch_table_call), so `({a:fnA,b:fnB})[key]()` classified
// as a generic flagged computed-key sink on WASM while native resolved
// it via the pts wildcard. Both engines must now emit the exact same
// synthetic `<dt_line_col>[*]` call name (line/col computed identically).
name: 'JavaScript — inline object-literal dispatch table must agree between engines',
file: 'dispatch-table.js',
code: `
function dtFn1() {}
function dtFn2() {}
function runDispatch(key) {
return ({ a: dtFn1, b: dtFn2 })[key]();
}
`,
},
{
Expand Down
17 changes: 17 additions & 0 deletions tests/engines/query-walk-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,23 @@ function runDemo(users: string[]): void {
processEach(users, logUser);
findMergeCandidates(users);
}
`,
},
{
// Regression guard for #1897: inline object-literal dispatch-table
// subscript calls (`({a:fnA,b:fnB})[key]()`) must produce the same
// synthetic `<dt_line_col>[*]` call on both extraction paths — the query
// path threads `arrayElemBindings` through `dispatchQueryMatch`'s
// `callsub_node` branch, the walk path through `handleCallExpr`'s
// `ctx.arrayElemBindings`.
name: 'inline object-literal dispatch table (#1897)',
file: 'test.js',
code: `
function fnA() {}
function fnB() {}
function run(key) {
return ({ a: fnA, b: fnB })[key]();
}
`,
},
// TSX
Expand Down
91 changes: 91 additions & 0 deletions tests/parsers/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,97 @@ function runDemo(users: string[]): void {
});
});

describe('inline object-literal dispatch table extraction (RES-2, #1897)', () => {
// Mirrors the Rust `dispatch_table_emits_dt_call_and_array_elem_bindings`
// / `dispatch_table_parenthesized_object_also_works` unit tests in
// crates/codegraph-core/src/extractors/javascript.rs.
it('emits a <dt_line_col>[*] call and array-elem bindings for each identifier value', () => {
const symbols = parseJS(`
function dtFn1() {}
function dtFn2() {}
function runDispatch(key) { ({ a: dtFn1, b: dtFn2 })[key](); }
`);
const dtCall = symbols.calls.find(
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
);
expect(dtCall).toBeDefined();
expect(dtCall?.dynamic).toBe(true);
expect(dtCall?.dynamicKind).toBe('dispatch-table');
expect(dtCall?.keyExpr).toBe('key');

const tableName = dtCall!.name.slice(0, -3); // strip trailing "[*]"
expect(symbols.arrayElemBindings).toContainEqual({
arrayName: tableName,
index: 0,
elemName: 'dtFn1',
});
expect(symbols.arrayElemBindings).toContainEqual({
arrayName: tableName,
index: 1,
elemName: 'dtFn2',
});
});

it('also detects the pattern without wrapping parens in a non-ambiguous expression position', () => {
// `{...}` needs parens only where it would otherwise be parsed as a
// block (statement position). In a `return` expression it's already
// unambiguous, so the object node is not wrapped in a
// parenthesized_expression — exercising the non-unwrap branch of
// extractDispatchTableCall, the mirror image of the first test above.
const symbols = parseJS(`
function fnA() {}
function fnB() {}
function run(k) { return { a: fnA, b: fnB }[k](); }
`);
const dtCall = symbols.calls.find(
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
);
expect(dtCall).toBeDefined();
});

it('resolves the shorthand-property form (`{ fnA, fnB }[k]()`)', () => {
const symbols = parseJS(`
function fnA() {}
function fnB() {}
function run(k) { ({ fnA, fnB })[k](); }
`);
const dtCall = symbols.calls.find(
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
);
expect(dtCall).toBeDefined();
const tableName = dtCall!.name.slice(0, -3);
expect(symbols.arrayElemBindings).toContainEqual({
arrayName: tableName,
index: 0,
elemName: 'fnA',
});
expect(symbols.arrayElemBindings).toContainEqual({
arrayName: tableName,
index: 1,
elemName: 'fnB',
});
});

it('falls back to the generic computed-key classification for a non-literal object', () => {
const symbols = parseJS(`function run(handlers, key) { return handlers[key](); }`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: '<dynamic:computed-key>', dynamicKind: 'computed-key' }),
);
expect(symbols.calls.find((c) => c.dynamicKind === 'dispatch-table')).toBeUndefined();
});

it('does not treat a string-literal key on an object literal as a dispatch table', () => {
const symbols = parseJS(`
function fnA() {}
function run() { return ({ a: fnA })['a'](); }
`);
expect(symbols.calls.find((c) => c.dynamicKind === 'dispatch-table')).toBeUndefined();
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: 'a', dynamicKind: 'computed-literal' }),
);
});
});

describe('instanceof value-ref extraction (#1784)', () => {
it('extracts a value-ref call for `instanceof ClassName`', () => {
const symbols = parseJS(`
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/points-to.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,40 @@ describe('buildPointsToMap — maxIterations cap (issue #1753)', () => {
expect(resolveViaPointsTo('a0', pts)).toEqual(['handler']);
});
});

describe('buildPointsToMap — dispatch-table pts constraints (RES-2, #1897)', () => {
it('seeds wildcard for synthetic dispatch-table array elem bindings', () => {
const defNames = new Set(['dtFn1', 'dtFn2']);
const arrayElemBindings = [
{ arrayName: '<dt_5_2>', index: 0, elemName: 'dtFn1' },
{ arrayName: '<dt_5_2>', index: 1, elemName: 'dtFn2' },
];
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
expect(resolveViaPointsTo('<dt_5_2>[*]', pts)).toContain('dtFn1');
expect(resolveViaPointsTo('<dt_5_2>[*]', pts)).toContain('dtFn2');
});

it('resolves only identifier values, not string keys', () => {
const defNames = new Set(['fn1']);
const arrayElemBindings = [{ arrayName: '<dt_1_0>', index: 0, elemName: 'fn1' }];
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
expect(resolveViaPointsTo('<dt_1_0>[*]', pts)).toEqual(['fn1']);
});

it('two dispatch tables in the same file use distinct synthetic names', () => {
const defNames = new Set(['fnA', 'fnB', 'fnC', 'fnD']);
const arrayElemBindings = [
{ arrayName: '<dt_3_0>', index: 0, elemName: 'fnA' },
{ arrayName: '<dt_3_0>', index: 1, elemName: 'fnB' },
{ arrayName: '<dt_7_0>', index: 0, elemName: 'fnC' },
{ arrayName: '<dt_7_0>', index: 1, elemName: 'fnD' },
];
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).toContain('fnA');
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).toContain('fnB');
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).not.toContain('fnC');
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).toContain('fnC');
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).toContain('fnD');
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).not.toContain('fnA');
});
});
Loading