Skip to content

Commit 23ae37a

Browse files
committed
fix: merge origin/main into feat/resolver-destructuring-rest-params-1336
2 parents ad0a7e7 + 81ef6fa commit 23ae37a

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
@@ -712,6 +712,94 @@ function buildFnRefBindingsPtsPostPass(
712712
}
713713
}
714714

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

1139+
// Object.defineProperty accessor fallback: when a function is registered as
1140+
// a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`,
1141+
// calls to `this.X()` inside `getter` resolve against `obj` (this === obj
1142+
// when the accessor is invoked). If the same-class fallback above found
1143+
// nothing, try treating `obj` as the receiver and look up `obj.X` in the
1144+
// typeMap, or fall back to a same-file lookup of any definition named X
1145+
// that belongs to the object literal or its type.
1146+
if (
1147+
targets.length === 0 &&
1148+
call.receiver === 'this' &&
1149+
caller.callerName != null &&
1150+
symbols.definePropertyReceivers
1151+
) {
1152+
const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName);
1153+
if (receiverVarName) {
1154+
// Try typeMap lookup for receiver.methodName
1155+
const typeEntry = typeMap.get(receiverVarName);
1156+
const typeName = typeEntry
1157+
? typeof typeEntry === 'string'
1158+
? typeEntry
1159+
: (typeEntry as { type?: string }).type
1160+
: null;
1161+
if (typeName) {
1162+
const qualifiedName = `${typeName}.${call.name}`;
1163+
const qualified = lookup.byNameAndFile(qualifiedName, relPath);
1164+
if (qualified.length > 0) {
1165+
targets = [...qualified];
1166+
}
1167+
}
1168+
// If still no targets, search for any definition named `call.name` in
1169+
// the same file — handles plain object literals where the method isn't
1170+
// qualified (e.g. `const obj = { baz() {} }` defines `baz` directly).
1171+
// Note: this is intentionally broad — it matches any same-file definition
1172+
// with the called name, not just members of the receiver object. This is
1173+
// the same behaviour used by the native post-pass path (buildDefinePropertyPostPass).
1174+
if (targets.length === 0) {
1175+
const sameFile = lookup.byNameAndFile(call.name, relPath);
1176+
if (sameFile.length > 0) {
1177+
targets = [...sameFile];
1178+
}
1179+
}
1180+
}
1181+
}
1182+
10511183
for (const t of targets) {
10521184
const edgeKey = `${caller.id}|${t.id}`;
10531185
if (t.id !== caller.id) {
@@ -1510,6 +1642,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
15101642
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
15111643
// not resolved to their original function on the native path.
15121644
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1645+
// Object.defineProperty accessor post-pass: resolve this-dispatch inside
1646+
// getter/setter functions registered via Object.defineProperty.
1647+
buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
15131648
// Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
15141649
// The native Rust engine has no knowledge of the CHA context, so this/self
15151650
// 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
@@ -371,6 +371,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
371371
const newExpressions: string[] = [];
372372
extractNewExpressionsWalk(tree.rootNode, newExpressions);
373373

374+
// Object.defineProperty accessor receiver bindings
375+
const definePropertyReceivers: Map<string, string> = new Map();
376+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
377+
374378
return {
375379
definitions,
376380
calls,
@@ -384,6 +388,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
384388
paramBindings,
385389
objectRestParamBindings: objectRestParamBindings.length ? objectRestParamBindings : undefined,
386390
newExpressions,
391+
...(definePropertyReceivers.size > 0 ? { definePropertyReceivers } : {}),
387392
};
388393
}
389394

@@ -646,6 +651,10 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
646651
const newExpressions: string[] = [];
647652
extractNewExpressionsWalk(tree.rootNode, newExpressions);
648653
ctx.newExpressions = newExpressions;
654+
// Object.defineProperty accessor receiver bindings
655+
const definePropertyReceivers: Map<string, string> = new Map();
656+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
657+
if (definePropertyReceivers.size > 0) ctx.definePropertyReceivers = definePropertyReceivers;
649658
return ctx;
650659
}
651660

@@ -1432,6 +1441,79 @@ function extractNewExpressionsWalk(rootNode: TreeSitterNode, newExpressions: str
14321441
walk(rootNode, 0);
14331442
}
14341443

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

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,16 @@ export interface ExtractorOutput {
622622
* project-wide instantiated-types set for Rapid Type Analysis filtering.
623623
*/
624624
newExpressions?: readonly string[];
625+
/**
626+
* Object.defineProperty receiver bindings: maps function name → target object name.
627+
* Records `Object.defineProperty(obj, "bar", { get: getter })` so the edge builder
628+
* can resolve `this.X()` calls inside `getter` as `obj.X()` (this === obj when the
629+
* accessor is invoked through the property).
630+
*
631+
* Example: `Object.defineProperty(obj, "bar", { get: getter })` emits
632+
* `definePropertyReceivers.set("getter", "obj")`.
633+
*/
634+
definePropertyReceivers?: Map<string, string>;
625635
/** WASM tree retained for downstream analysis (complexity, CFG, dataflow). */
626636
_tree?: TreeSitterTree;
627637
/** 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)