Skip to content

Commit 81ef6fa

Browse files
authored
feat(resolver): resolve this-dispatch inside Object.defineProperty accessor functions (JS) (#1346)
* feat(resolver): resolve this-dispatch inside Object.defineProperty accessor functions (JS) When a function is registered as a getter or setter via Object.defineProperty(obj, "bar", { get: getter }), `this` inside that function refers to the target object. This commit tracks that binding and resolves this.method() calls against the registered object's type. Closes #1335 * fix(resolver): remove no-op filters and add null guard in defineProperty path * fix(resolver): spread ReadonlyArray to mutable Array in defineProperty fallback (#1346) Assigning lookup.byNameAndFile() result (ReadonlyArray) directly to the mutable targets variable caused TS4104 type errors after the no-op .filter() was removed. Spread into new arrays with [...qualified] and [...sameFile]. * docs(bench): update JS expected-edge count comment to 34 (#1346)
1 parent bd85a0c commit 81ef6fa

9 files changed

Lines changed: 262 additions & 0 deletions

File tree

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,94 @@ function buildFnRefBindingsPtsPostPass(
709709
}
710710
}
711711

712+
/**
713+
* Object.defineProperty accessor post-pass for the native call-edge path.
714+
*
715+
* When a function is registered as a getter/setter via
716+
* `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()`
717+
* inside `getter` need to resolve against `obj` (because `this === obj` when
718+
* the accessor is invoked). The native Rust engine has no knowledge of
719+
* `definePropertyReceivers`, so this JS post-pass adds the missing edges.
720+
*/
721+
function buildDefinePropertyPostPass(
722+
ctx: PipelineContext,
723+
getNodeIdStmt: NodeIdStmt,
724+
allEdgeRows: EdgeRowTuple[],
725+
sharedLookup?: CallNodeLookup,
726+
): void {
727+
const filesWithReceivers = [...ctx.fileSymbols].filter(
728+
([, symbols]) => symbols.definePropertyReceivers && symbols.definePropertyReceivers.size > 0,
729+
);
730+
if (filesWithReceivers.length === 0) return;
731+
732+
const seenByPair = new Set<string>();
733+
for (const [srcId, tgtId] of allEdgeRows) {
734+
seenByPair.add(`${srcId}|${tgtId}`);
735+
}
736+
737+
const { barrelOnlyFiles, rootDir } = ctx;
738+
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
739+
740+
for (const [relPath, symbols] of filesWithReceivers) {
741+
if (barrelOnlyFiles.has(relPath)) continue;
742+
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
743+
if (!fileNodeRow) continue;
744+
745+
const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
746+
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
747+
const definePropertyReceivers = symbols.definePropertyReceivers!;
748+
749+
for (const call of symbols.calls) {
750+
if (call.receiver !== 'this') continue;
751+
752+
const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
753+
if (!caller.callerName) continue;
754+
755+
const receiverVarName = definePropertyReceivers.get(caller.callerName);
756+
if (!receiverVarName) continue;
757+
758+
// Only add edges the native engine missed (no direct target already).
759+
const { targets: directTargets } = resolveCallTargets(
760+
lookup,
761+
call,
762+
relPath,
763+
importedNames,
764+
typeMap as Map<string, unknown>,
765+
caller.callerName,
766+
);
767+
if (directTargets.length > 0) continue;
768+
769+
// Resolve via receiver type
770+
let targets: ReadonlyArray<{ id: number; file: string }> = [];
771+
const typeEntry = typeMap.get(receiverVarName);
772+
const typeName = typeEntry
773+
? typeof typeEntry === 'string'
774+
? typeEntry
775+
: (typeEntry as { type?: string }).type
776+
: null;
777+
if (typeName) {
778+
const qualifiedName = `${typeName}.${call.name}`;
779+
targets = lookup.byNameAndFile(qualifiedName, relPath);
780+
}
781+
// Same-file fallback for plain object-literal methods
782+
if (targets.length === 0) {
783+
targets = lookup.byNameAndFile(call.name, relPath);
784+
}
785+
786+
for (const t of targets) {
787+
const edgeKey = `${caller.id}|${t.id}`;
788+
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
789+
const conf = computeConfidence(relPath, t.file, null);
790+
if (conf > 0) {
791+
seenByPair.add(edgeKey);
792+
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'ts-native']);
793+
}
794+
}
795+
}
796+
}
797+
}
798+
}
799+
712800
/**
713801
* Phase 8.5: CHA + RTA post-pass for the native call-edge path.
714802
*
@@ -1020,6 +1108,50 @@ function buildFileCallEdges(
10201108
}
10211109
}
10221110

1111+
// Object.defineProperty accessor fallback: when a function is registered as
1112+
// a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`,
1113+
// calls to `this.X()` inside `getter` resolve against `obj` (this === obj
1114+
// when the accessor is invoked). If the same-class fallback above found
1115+
// nothing, try treating `obj` as the receiver and look up `obj.X` in the
1116+
// typeMap, or fall back to a same-file lookup of any definition named X
1117+
// that belongs to the object literal or its type.
1118+
if (
1119+
targets.length === 0 &&
1120+
call.receiver === 'this' &&
1121+
caller.callerName != null &&
1122+
symbols.definePropertyReceivers
1123+
) {
1124+
const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName);
1125+
if (receiverVarName) {
1126+
// Try typeMap lookup for receiver.methodName
1127+
const typeEntry = typeMap.get(receiverVarName);
1128+
const typeName = typeEntry
1129+
? typeof typeEntry === 'string'
1130+
? typeEntry
1131+
: (typeEntry as { type?: string }).type
1132+
: null;
1133+
if (typeName) {
1134+
const qualifiedName = `${typeName}.${call.name}`;
1135+
const qualified = lookup.byNameAndFile(qualifiedName, relPath);
1136+
if (qualified.length > 0) {
1137+
targets = [...qualified];
1138+
}
1139+
}
1140+
// If still no targets, search for any definition named `call.name` in
1141+
// the same file — handles plain object literals where the method isn't
1142+
// qualified (e.g. `const obj = { baz() {} }` defines `baz` directly).
1143+
// Note: this is intentionally broad — it matches any same-file definition
1144+
// with the called name, not just members of the receiver object. This is
1145+
// the same behaviour used by the native post-pass path (buildDefinePropertyPostPass).
1146+
if (targets.length === 0) {
1147+
const sameFile = lookup.byNameAndFile(call.name, relPath);
1148+
if (sameFile.length > 0) {
1149+
targets = [...sameFile];
1150+
}
1151+
}
1152+
}
1153+
}
1154+
10231155
for (const t of targets) {
10241156
const edgeKey = `${caller.id}|${t.id}`;
10251157
if (t.id !== caller.id) {
@@ -1482,6 +1614,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
14821614
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
14831615
// not resolved to their original function on the native path.
14841616
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1617+
// Object.defineProperty accessor post-pass: resolve this-dispatch inside
1618+
// getter/setter functions registered via Object.defineProperty.
1619+
buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
14851620
// Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
14861621
// The native Rust engine has no knowledge of the CHA context, so this/self
14871622
// calls and interface dispatch are not expanded to concrete implementations.

src/domain/wasm-worker-entry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,9 @@ function serializeExtractorOutput(
806806
astNodes,
807807
...(symbols.fnRefBindings?.length ? { fnRefBindings: symbols.fnRefBindings } : {}),
808808
...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}),
809+
...(symbols.definePropertyReceivers?.size
810+
? { definePropertyReceivers: Array.from(symbols.definePropertyReceivers.entries()) }
811+
: {}),
809812
...(symbols.returnTypeMap?.size
810813
? { returnTypeMap: Array.from(symbols.returnTypeMap.entries()) }
811814
: {}),

src/domain/wasm-worker-pool.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp
108108
if (ser.astNodes !== undefined) out.astNodes = ser.astNodes as unknown as ASTNodeRow[];
109109
if (ser.fnRefBindings?.length) out.fnRefBindings = ser.fnRefBindings;
110110
if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions;
111+
if (ser.definePropertyReceivers?.length) {
112+
const m = new Map<string, string>();
113+
for (const [k, v] of ser.definePropertyReceivers) m.set(k, v);
114+
out.definePropertyReceivers = m;
115+
}
111116
if (ser.returnTypeMap?.length) {
112117
const returnTypeMap = new Map<string, TypeMapEntry>();
113118
for (const [k, v] of ser.returnTypeMap) returnTypeMap.set(k, v);

src/domain/wasm-worker-protocol.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface SerializedExtractorOutput {
6666
}>;
6767
fnRefBindings?: import('../types.js').FnRefBinding[];
6868
newExpressions?: readonly string[];
69+
/** Serialized definePropertyReceivers map (funcName → receiverVarName) as tuple array. */
70+
definePropertyReceivers?: Array<[string, string]>;
6971
returnTypeMap?: Array<[string, TypeMapEntry]>;
7072
callAssignments?: CallAssignment[];
7173
paramBindings?: ParamBinding[];

src/extractors/javascript.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
359359
const newExpressions: string[] = [];
360360
extractNewExpressionsWalk(tree.rootNode, newExpressions);
361361

362+
// Object.defineProperty accessor receiver bindings
363+
const definePropertyReceivers: Map<string, string> = new Map();
364+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
365+
362366
return {
363367
definitions,
364368
calls,
@@ -371,6 +375,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
371375
fnRefBindings,
372376
paramBindings,
373377
newExpressions,
378+
...(definePropertyReceivers.size > 0 ? { definePropertyReceivers } : {}),
374379
};
375380
}
376381

@@ -629,6 +634,10 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
629634
const newExpressions: string[] = [];
630635
extractNewExpressionsWalk(tree.rootNode, newExpressions);
631636
ctx.newExpressions = newExpressions;
637+
// Object.defineProperty accessor receiver bindings
638+
const definePropertyReceivers: Map<string, string> = new Map();
639+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
640+
if (definePropertyReceivers.size > 0) ctx.definePropertyReceivers = definePropertyReceivers;
632641
return ctx;
633642
}
634643

@@ -1415,6 +1424,79 @@ function extractNewExpressionsWalk(rootNode: TreeSitterNode, newExpressions: str
14151424
walk(rootNode, 0);
14161425
}
14171426

1427+
/**
1428+
* Walk the AST to find `Object.defineProperty(obj, "bar", { get: getter })` patterns
1429+
* and record which functions are used as getter/setter accessors for which objects.
1430+
*
1431+
* Result is stored in the provided map as `funcName → receiverVarName`.
1432+
*/
1433+
function extractDefinePropertyReceiversWalk(
1434+
rootNode: TreeSitterNode,
1435+
out: Map<string, string>,
1436+
): void {
1437+
function walk(node: TreeSitterNode, depth: number): void {
1438+
if (depth >= MAX_WALK_DEPTH) return;
1439+
if (node.type === 'call_expression') {
1440+
const fn = node.childForFieldName('function');
1441+
// Match `Object.defineProperty`
1442+
if (fn?.type === 'member_expression') {
1443+
const obj = fn.childForFieldName('object');
1444+
const prop = fn.childForFieldName('property');
1445+
if (
1446+
obj?.type === 'identifier' &&
1447+
obj.text === 'Object' &&
1448+
prop?.text === 'defineProperty'
1449+
) {
1450+
const argsNode = node.childForFieldName('arguments') ?? findChild(node, 'arguments');
1451+
if (argsNode) {
1452+
// Collect non-punctuation children: arg0 (target obj), arg1 (prop name string), arg2 (descriptor)
1453+
const argChildren: TreeSitterNode[] = [];
1454+
for (let i = 0; i < argsNode.childCount; i++) {
1455+
const c = argsNode.child(i);
1456+
if (!c) continue;
1457+
if (c.type === ',' || c.type === '(' || c.type === ')') continue;
1458+
argChildren.push(c);
1459+
}
1460+
if (argChildren.length >= 3) {
1461+
const targetObj = argChildren[0];
1462+
const descriptor = argChildren[2];
1463+
if (targetObj?.type === 'identifier' && descriptor?.type === 'object') {
1464+
const targetName = targetObj.text;
1465+
// Walk the descriptor object's pair children looking for get/set
1466+
for (let i = 0; i < descriptor.childCount; i++) {
1467+
const pair = descriptor.child(i);
1468+
if (pair?.type !== 'pair') continue;
1469+
const key = pair.childForFieldName('key');
1470+
const val = pair.childForFieldName('value');
1471+
if (
1472+
key &&
1473+
(key.text === 'get' || key.text === 'set') &&
1474+
val?.type === 'identifier' &&
1475+
!BUILTIN_GLOBALS.has(val.text)
1476+
) {
1477+
// Known limitation: if the same function is registered as an
1478+
// accessor on multiple objects, last-write-wins — only the
1479+
// last target object is retained. This is an unusual pattern
1480+
// (sharing one function across multiple defineProperty calls)
1481+
// and covering it would require Map<string, string[]> which
1482+
// changes the consumer API. Tracked as a known edge case.
1483+
out.set(val.text, targetName);
1484+
}
1485+
}
1486+
}
1487+
}
1488+
}
1489+
}
1490+
}
1491+
}
1492+
for (let i = 0; i < node.childCount; i++) {
1493+
const child = node.child(i);
1494+
if (child) walk(child, depth + 1);
1495+
}
1496+
}
1497+
walk(rootNode, 0);
1498+
}
1499+
14181500
/**
14191501
* Extract variable-to-type assignments into a per-file type map.
14201502
*

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,16 @@ export interface ExtractorOutput {
601601
* project-wide instantiated-types set for Rapid Type Analysis filtering.
602602
*/
603603
newExpressions?: readonly string[];
604+
/**
605+
* Object.defineProperty receiver bindings: maps function name → target object name.
606+
* Records `Object.defineProperty(obj, "bar", { get: getter })` so the edge builder
607+
* can resolve `this.X()` calls inside `getter` as `obj.X()` (this === obj when the
608+
* accessor is invoked through the property).
609+
*
610+
* Example: `Object.defineProperty(obj, "bar", { get: getter })` emits
611+
* `definePropertyReceivers.set("getter", "obj")`.
612+
*/
613+
definePropertyReceivers?: Map<string, string>;
604614
/** WASM tree retained for downstream analysis (complexity, CFG, dataflow). */
605615
_tree?: TreeSitterTree;
606616
/** Language identifier. */

tests/benchmarks/resolution/fixtures/javascript/define-property.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,18 @@ function create() {
3030
obj.f1();
3131
obj.f2();
3232
}
33+
34+
// Object.defineProperty accessor this-dispatch:
35+
// When getter is registered as a get accessor for accessorTarget, `this` inside getter
36+
// refers to accessorTarget. So this.baz() → accessorTarget.baz → baz.
37+
function baz() {
38+
return 42;
39+
}
40+
41+
const accessorTarget = { baz };
42+
43+
function getter() {
44+
this.baz();
45+
}
46+
47+
Object.defineProperty(accessorTarget, 'bar', { get: getter });

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@
164164
"mode": "pts-create-prototype",
165165
"notes": "obj.f2() — resolved via Object.create({ f1, f2 })"
166166
},
167+
{
168+
"source": { "name": "getter", "file": "define-property.js" },
169+
"target": { "name": "baz", "file": "define-property.js" },
170+
"kind": "calls",
171+
"mode": "define-property",
172+
"notes": "this.baz() inside getter — this === accessorTarget (registered via Object.defineProperty)"
173+
},
167174
{
168175
"source": { "name": "runBind", "file": "bind-call-apply.js" },
169176
"target": { "name": "greet", "file": "bind-call-apply.js" },

tests/benchmarks/resolution/resolution-benchmark.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const TECHNIQUE_MAP: Record<string, string> = {
9393
'points-to': 'points-to',
9494
'pts-define-property': 'points-to',
9595
'pts-create-prototype': 'points-to',
96+
'define-property': 'ts-native',
9697
};
9798

9899
// ── Configuration ────────────────────────────────────────────────────────
@@ -118,6 +119,8 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
118119
// (5 new edges in define-property.js) + Phase 8.5 adds class-inheritance and prototype edges
119120
// (inheritance.js, prototypes.js, prototypes2.js), lifting total expected to 30. Phase 8.3f
120121
// adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33.
122+
// Phase 8.3g adds Object.defineProperty accessor this-dispatch (1 new edge in define-property.js),
123+
// total expected now 34.
121124
javascript: { precision: 1.0, recall: 0.9 },
122125
// TS 0.72: Phase 8.3e adds this.method() same-class resolution (Shape.describe → Shape.area),
123126
// lifting recall from 69.4% to 72.2%. Remaining gap (interface-dispatch, CHA) is tracked

0 commit comments

Comments
 (0)