Skip to content

Commit 1d957a4

Browse files
committed
fix(extractors): resolve inline object-literal dispatch tables on WASM
WASM's extractSubscriptCallInfo had no equivalent of native's RES-2 extract_dispatch_table_call, so ({a:fnA,b:fnB})[key]() classified as a generic flagged computed-key sink while native resolved it to fnA/fnB via the points-to wildcard solver — a genuine engine-parity gap. Add extractDispatchTableCall, mirroring the native extractor: when a subscript call's object is an object literal (optionally unwrapped from a parenthesized expression) and the index is a bare identifier, seed arrayElemBindings under a synthetic <dt_line_col> name and emit a <dt_line_col>[*] call with dynamicKind 'dispatch-table' so the existing pts wildcard resolution path (already used for array-literal dispatch) picks it up identically on both engines. Threaded through both extraction paths (dispatchQueryMatch for the WASM worker, handleCallExpr for direct/test callers) per the two-path extractor architecture. Adds 'dispatch-table' to the DynamicKind union and restores the pts-javascript dispatch-table fixture, points-to unit tests, and a query-vs-walk + cross-engine parity case that were dropped from a prior merge without being ported to WASM. Impact: 9 functions changed, 20 affected
1 parent 453a768 commit 1d957a4

8 files changed

Lines changed: 274 additions & 8 deletions

File tree

src/extractors/javascript.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ function dispatchQueryMatch(
331331
classes: ClassRelation[],
332332
exps: Export[],
333333
callbackParamShapes: CallbackParamShapes,
334+
arrayElemBindings: ArrayElemBinding[],
334335
): void {
335336
if (c.fn_node) {
336337
handleFnCapture(c, definitions);
@@ -365,7 +366,7 @@ function dispatchQueryMatch(
365366
if (cbDef) definitions.push(cbDef);
366367
calls.push(...extractCallbackReferenceCalls(c.callmem_node, callbackParamShapes));
367368
} else if (c.callsub_node) {
368-
const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node);
369+
const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node, arrayElemBindings);
369370
if (callInfo) calls.push(callInfo);
370371
calls.push(...extractCallbackReferenceCalls(c.callsub_node, callbackParamShapes));
371372
} else if (c.newfn_node) {
@@ -421,7 +422,16 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
421422
// Build capture lookup for this match (1-3 captures each, very fast)
422423
const c: Record<string, TreeSitterNode> = Object.create(null);
423424
for (const cap of match.captures) c[cap.name] = cap.node;
424-
dispatchQueryMatch(c, definitions, calls, imports, classes, exps, callbackParamShapes);
425+
dispatchQueryMatch(
426+
c,
427+
definitions,
428+
calls,
429+
imports,
430+
classes,
431+
exps,
432+
callbackParamShapes,
433+
arrayElemBindings,
434+
);
425435
}
426436

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

3500-
function extractCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | null {
3510+
function extractCallInfo(
3511+
fn: TreeSitterNode,
3512+
callNode: TreeSitterNode,
3513+
arrayElemBindings?: ArrayElemBinding[],
3514+
): Call | null {
35013515
const fnType = fn.type;
35023516
if (fnType === 'identifier') {
35033517
if (fn.text === 'eval') {
@@ -3528,7 +3542,7 @@ function extractCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | n
35283542
return extractMemberExprCallInfo(fn, callNode);
35293543
}
35303544
if (fnType === 'subscript_expression') {
3531-
return extractSubscriptCallInfo(fn, callNode);
3545+
return extractSubscriptCallInfo(fn, callNode, arrayElemBindings);
35323546
}
35333547
return null;
35343548
}
@@ -3684,8 +3698,72 @@ function extractMemberExprCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode)
36843698
return { name: propText, line: callLine, receiver };
36853699
}
36863700

3701+
/**
3702+
* RES-2: inline object-literal dispatch table — `({a:fnA,b:fnB})[key]()`.
3703+
*
3704+
* Mirrors `extract_dispatch_table_call` in
3705+
* `crates/codegraph-core/src/extractors/javascript.rs`. When the subscript's
3706+
* object is an object literal (optionally unwrapped from a parenthesized
3707+
* expression) and the index is a bare identifier, records each property's
3708+
* identifier value as an `ArrayElemBinding` under a synthetic `<dt_line_col>`
3709+
* name and returns a `<dt_line_col>[*]` call — the existing points-to
3710+
* wildcard resolution path (already used for `const arr = [f1, f2]; arr[i]()`
3711+
* patterns) then resolves it to each concrete target identically on both
3712+
* engines (#1897).
3713+
*
3714+
* Returns `null` when the object isn't an object literal, or none of its
3715+
* property values are resolvable bare identifiers.
3716+
*/
3717+
function extractDispatchTableCall(
3718+
obj: TreeSitterNode | null,
3719+
index: TreeSitterNode,
3720+
callNode: TreeSitterNode,
3721+
arrayElemBindings: ArrayElemBinding[],
3722+
): Call | null {
3723+
if (!obj) return null;
3724+
// Unwrap parenthesized_expression: ({a:fn})[key]()
3725+
const objNode =
3726+
obj.type === 'parenthesized_expression'
3727+
? (obj.childForFieldName('expression') ?? obj.child(1) ?? obj)
3728+
: obj;
3729+
if (objNode.type !== 'object') return null;
3730+
3731+
const line = nodeStartLine(callNode);
3732+
const col = callNode.startPosition.column;
3733+
const tableName = `<dt_${line}_${col}>`;
3734+
let idx = 0;
3735+
for (let i = 0; i < objNode.childCount; i++) {
3736+
const child = objNode.child(i);
3737+
if (!child) continue;
3738+
if (child.type === 'shorthand_property_identifier') {
3739+
if (!BUILTIN_GLOBALS.has(child.text)) {
3740+
arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: child.text });
3741+
idx++;
3742+
}
3743+
} else if (child.type === 'pair') {
3744+
const val = child.childForFieldName('value');
3745+
if (val?.type === 'identifier' && !BUILTIN_GLOBALS.has(val.text)) {
3746+
arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: val.text });
3747+
idx++;
3748+
}
3749+
}
3750+
}
3751+
if (idx === 0) return null;
3752+
return {
3753+
name: `${tableName}[*]`,
3754+
line,
3755+
dynamic: true,
3756+
dynamicKind: 'dispatch-table',
3757+
keyExpr: index.text,
3758+
};
3759+
}
3760+
36873761
/** Extract call info from a subscript_expression function node (obj[key]()). */
3688-
function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | null {
3762+
function extractSubscriptCallInfo(
3763+
fn: TreeSitterNode,
3764+
callNode: TreeSitterNode,
3765+
arrayElemBindings?: ArrayElemBinding[],
3766+
): Call | null {
36893767
const obj = fn.childForFieldName('object');
36903768
const index = fn.childForFieldName('index');
36913769
if (!index) return null;
@@ -3705,8 +3783,12 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode):
37053783
}
37063784
}
37073785

3708-
// obj[variable]() — key is a variable; may be resolvable via pts (RES-1), else flagged
3786+
// obj[variable]() — key is a variable; may be resolvable via pts (RES-1/RES-2), else flagged
37093787
if (indexType === 'identifier') {
3788+
if (arrayElemBindings) {
3789+
const dispatchCall = extractDispatchTableCall(obj, index, callNode, arrayElemBindings);
3790+
if (dispatchCall) return dispatchCall;
3791+
}
37103792
const receiver = extractReceiverName(obj);
37113793
return {
37123794
name: '<dynamic:computed-key>',

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,8 @@ export type DynamicKind =
491491
| 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved
492492
| 'eval' // eval() / new Function() — undecidable; always flagged
493493
| 'unresolved-dynamic' // any other detected dynamic pattern; flagged
494-
| '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
494+
| '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
495+
| '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
495496

496497
/** A function/method call detected by an extractor. */
497498
export interface Call {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// pts-dispatch-table: inline object-literal subscript dispatch {a:fnA,b:fnB}[k]()
2+
function dtFn1() {}
3+
function dtFn2() {}
4+
5+
function runDispatch(key) {
6+
({ a: dtFn1, b: dtFn2 })[key]();
7+
}

tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@
9393
"kind": "calls",
9494
"mode": "pts-spread",
9595
"notes": "y() inside consumer2 — spread consumer2(...[sprFn3, sprFn4]) binds sprFn4 to y"
96+
},
97+
{
98+
"source": { "name": "runDispatch", "file": "dispatch-table.js" },
99+
"target": { "name": "dtFn1", "file": "dispatch-table.js" },
100+
"kind": "calls",
101+
"mode": "pts-dispatch-table",
102+
"notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value"
103+
},
104+
{
105+
"source": { "name": "runDispatch", "file": "dispatch-table.js" },
106+
"target": { "name": "dtFn2", "file": "dispatch-table.js" },
107+
"kind": "calls",
108+
"mode": "pts-dispatch-table",
109+
"notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value"
96110
}
97111
]
98112
}

tests/engines/parity.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,23 @@ router.get('/users/:id', authHandler);
227227
Array.from(arr, mapCallback);
228228
Array.from(arr, mapCallback, thisArg);
229229
Uint8Array.from(arr, mapCallback);
230+
`,
231+
},
232+
{
233+
// Regression guard for #1897: WASM previously had no mirror of native's
234+
// RES-2 inline object-literal dispatch-table detection
235+
// (extract_dispatch_table_call), so `({a:fnA,b:fnB})[key]()` classified
236+
// as a generic flagged computed-key sink on WASM while native resolved
237+
// it via the pts wildcard. Both engines must now emit the exact same
238+
// synthetic `<dt_line_col>[*]` call name (line/col computed identically).
239+
name: 'JavaScript — inline object-literal dispatch table must agree between engines',
240+
file: 'dispatch-table.js',
241+
code: `
242+
function dtFn1() {}
243+
function dtFn2() {}
244+
function runDispatch(key) {
245+
return ({ a: dtFn1, b: dtFn2 })[key]();
246+
}
230247
`,
231248
},
232249
{

tests/engines/query-walk-parity.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,23 @@ function runDemo(users: string[]): void {
274274
processEach(users, logUser);
275275
findMergeCandidates(users);
276276
}
277+
`,
278+
},
279+
{
280+
// Regression guard for #1897: inline object-literal dispatch-table
281+
// subscript calls (`({a:fnA,b:fnB})[key]()`) must produce the same
282+
// synthetic `<dt_line_col>[*]` call on both extraction paths — the query
283+
// path threads `arrayElemBindings` through `dispatchQueryMatch`'s
284+
// `callsub_node` branch, the walk path through `handleCallExpr`'s
285+
// `ctx.arrayElemBindings`.
286+
name: 'inline object-literal dispatch table (#1897)',
287+
file: 'test.js',
288+
code: `
289+
function fnA() {}
290+
function fnB() {}
291+
function run(key) {
292+
return ({ a: fnA, b: fnB })[key]();
293+
}
277294
`,
278295
},
279296
// TSX

tests/parsers/javascript.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,97 @@ function runDemo(users: string[]): void {
15551555
});
15561556
});
15571557

1558+
describe('inline object-literal dispatch table extraction (RES-2, #1897)', () => {
1559+
// Mirrors the Rust `dispatch_table_emits_dt_call_and_array_elem_bindings`
1560+
// / `dispatch_table_parenthesized_object_also_works` unit tests in
1561+
// crates/codegraph-core/src/extractors/javascript.rs.
1562+
it('emits a <dt_line_col>[*] call and array-elem bindings for each identifier value', () => {
1563+
const symbols = parseJS(`
1564+
function dtFn1() {}
1565+
function dtFn2() {}
1566+
function runDispatch(key) { ({ a: dtFn1, b: dtFn2 })[key](); }
1567+
`);
1568+
const dtCall = symbols.calls.find(
1569+
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
1570+
);
1571+
expect(dtCall).toBeDefined();
1572+
expect(dtCall?.dynamic).toBe(true);
1573+
expect(dtCall?.dynamicKind).toBe('dispatch-table');
1574+
expect(dtCall?.keyExpr).toBe('key');
1575+
1576+
const tableName = dtCall!.name.slice(0, -3); // strip trailing "[*]"
1577+
expect(symbols.arrayElemBindings).toContainEqual({
1578+
arrayName: tableName,
1579+
index: 0,
1580+
elemName: 'dtFn1',
1581+
});
1582+
expect(symbols.arrayElemBindings).toContainEqual({
1583+
arrayName: tableName,
1584+
index: 1,
1585+
elemName: 'dtFn2',
1586+
});
1587+
});
1588+
1589+
it('also detects the pattern without wrapping parens in a non-ambiguous expression position', () => {
1590+
// `{...}` needs parens only where it would otherwise be parsed as a
1591+
// block (statement position). In a `return` expression it's already
1592+
// unambiguous, so the object node is not wrapped in a
1593+
// parenthesized_expression — exercising the non-unwrap branch of
1594+
// extractDispatchTableCall, the mirror image of the first test above.
1595+
const symbols = parseJS(`
1596+
function fnA() {}
1597+
function fnB() {}
1598+
function run(k) { return { a: fnA, b: fnB }[k](); }
1599+
`);
1600+
const dtCall = symbols.calls.find(
1601+
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
1602+
);
1603+
expect(dtCall).toBeDefined();
1604+
});
1605+
1606+
it('resolves the shorthand-property form (`{ fnA, fnB }[k]()`)', () => {
1607+
const symbols = parseJS(`
1608+
function fnA() {}
1609+
function fnB() {}
1610+
function run(k) { ({ fnA, fnB })[k](); }
1611+
`);
1612+
const dtCall = symbols.calls.find(
1613+
(c) => c.name.startsWith('<dt_') && c.name.endsWith('>[*]'),
1614+
);
1615+
expect(dtCall).toBeDefined();
1616+
const tableName = dtCall!.name.slice(0, -3);
1617+
expect(symbols.arrayElemBindings).toContainEqual({
1618+
arrayName: tableName,
1619+
index: 0,
1620+
elemName: 'fnA',
1621+
});
1622+
expect(symbols.arrayElemBindings).toContainEqual({
1623+
arrayName: tableName,
1624+
index: 1,
1625+
elemName: 'fnB',
1626+
});
1627+
});
1628+
1629+
it('falls back to the generic computed-key classification for a non-literal object', () => {
1630+
const symbols = parseJS(`function run(handlers, key) { return handlers[key](); }`);
1631+
expect(symbols.calls).toContainEqual(
1632+
expect.objectContaining({ name: '<dynamic:computed-key>', dynamicKind: 'computed-key' }),
1633+
);
1634+
expect(symbols.calls.find((c) => c.dynamicKind === 'dispatch-table')).toBeUndefined();
1635+
});
1636+
1637+
it('does not treat a string-literal key on an object literal as a dispatch table', () => {
1638+
const symbols = parseJS(`
1639+
function fnA() {}
1640+
function run() { return ({ a: fnA })['a'](); }
1641+
`);
1642+
expect(symbols.calls.find((c) => c.dynamicKind === 'dispatch-table')).toBeUndefined();
1643+
expect(symbols.calls).toContainEqual(
1644+
expect.objectContaining({ name: 'a', dynamicKind: 'computed-literal' }),
1645+
);
1646+
});
1647+
});
1648+
15581649
describe('instanceof value-ref extraction (#1784)', () => {
15591650
it('extracts a value-ref call for `instanceof ClassName`', () => {
15601651
const symbols = parseJS(`

tests/unit/points-to.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,3 +477,40 @@ describe('buildPointsToMap — maxIterations cap (issue #1753)', () => {
477477
expect(resolveViaPointsTo('a0', pts)).toEqual(['handler']);
478478
});
479479
});
480+
481+
describe('buildPointsToMap — dispatch-table pts constraints (RES-2, #1897)', () => {
482+
it('seeds wildcard for synthetic dispatch-table array elem bindings', () => {
483+
const defNames = new Set(['dtFn1', 'dtFn2']);
484+
const arrayElemBindings = [
485+
{ arrayName: '<dt_5_2>', index: 0, elemName: 'dtFn1' },
486+
{ arrayName: '<dt_5_2>', index: 1, elemName: 'dtFn2' },
487+
];
488+
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
489+
expect(resolveViaPointsTo('<dt_5_2>[*]', pts)).toContain('dtFn1');
490+
expect(resolveViaPointsTo('<dt_5_2>[*]', pts)).toContain('dtFn2');
491+
});
492+
493+
it('resolves only identifier values, not string keys', () => {
494+
const defNames = new Set(['fn1']);
495+
const arrayElemBindings = [{ arrayName: '<dt_1_0>', index: 0, elemName: 'fn1' }];
496+
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
497+
expect(resolveViaPointsTo('<dt_1_0>[*]', pts)).toEqual(['fn1']);
498+
});
499+
500+
it('two dispatch tables in the same file use distinct synthetic names', () => {
501+
const defNames = new Set(['fnA', 'fnB', 'fnC', 'fnD']);
502+
const arrayElemBindings = [
503+
{ arrayName: '<dt_3_0>', index: 0, elemName: 'fnA' },
504+
{ arrayName: '<dt_3_0>', index: 1, elemName: 'fnB' },
505+
{ arrayName: '<dt_7_0>', index: 0, elemName: 'fnC' },
506+
{ arrayName: '<dt_7_0>', index: 1, elemName: 'fnD' },
507+
];
508+
const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings);
509+
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).toContain('fnA');
510+
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).toContain('fnB');
511+
expect(resolveViaPointsTo('<dt_3_0>[*]', pts)).not.toContain('fnC');
512+
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).toContain('fnC');
513+
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).toContain('fnD');
514+
expect(resolveViaPointsTo('<dt_7_0>[*]', pts)).not.toContain('fnA');
515+
});
516+
});

0 commit comments

Comments
 (0)