diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index d76ae2109..8f7b4ad85 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -331,6 +331,7 @@ function dispatchQueryMatch( classes: ClassRelation[], exps: Export[], callbackParamShapes: CallbackParamShapes, + arrayElemBindings: ArrayElemBinding[], ): void { if (c.fn_node) { handleFnCapture(c, definitions); @@ -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) { @@ -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 = 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) @@ -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); @@ -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') { @@ -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; } @@ -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 `` + * name and returns a `[*]` 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 = ``; + 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; @@ -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: '', diff --git a/src/types.ts b/src/types.ts index 30e4452f8..533a577ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 `[*]` 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 { diff --git a/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js b/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js new file mode 100644 index 000000000..5082ab41d --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js @@ -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](); +} diff --git a/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json index a198fe0f3..36fcc3045 100644 --- a/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json @@ -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" } ] } diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index 1b825fd01..7b6f9bf2d 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -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 `[*]` 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](); +} `, }, { diff --git a/tests/engines/query-walk-parity.test.ts b/tests/engines/query-walk-parity.test.ts index 9a45822fc..4c891e142 100644 --- a/tests/engines/query-walk-parity.test.ts +++ b/tests/engines/query-walk-parity.test.ts @@ -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 `[*]` 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 diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 790bf0c50..a17a92f4f 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -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 [*] 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('[*]'), + ); + 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('[*]'), + ); + 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('[*]'), + ); + 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: '', 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(` diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index e32b2df71..5775a133d 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -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: '', index: 0, elemName: 'dtFn1' }, + { arrayName: '', index: 1, elemName: 'dtFn2' }, + ]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', pts)).toContain('dtFn1'); + expect(resolveViaPointsTo('[*]', pts)).toContain('dtFn2'); + }); + + it('resolves only identifier values, not string keys', () => { + const defNames = new Set(['fn1']); + const arrayElemBindings = [{ arrayName: '', index: 0, elemName: 'fn1' }]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', 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: '', index: 0, elemName: 'fnA' }, + { arrayName: '', index: 1, elemName: 'fnB' }, + { arrayName: '', index: 0, elemName: 'fnC' }, + { arrayName: '', index: 1, elemName: 'fnD' }, + ]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnA'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnB'); + expect(resolveViaPointsTo('[*]', pts)).not.toContain('fnC'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnC'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnD'); + expect(resolveViaPointsTo('[*]', pts)).not.toContain('fnA'); + }); +});