Skip to content

Commit f1ebd8b

Browse files
committed
fix: resolve merge conflicts with main
2 parents ffc90ca + 1967fcd commit f1ebd8b

10 files changed

Lines changed: 831 additions & 2 deletions

File tree

docs/roadmap/ROADMAP.md

Lines changed: 567 additions & 2 deletions
Large diffs are not rendered by default.

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
*
@@ -1036,6 +1124,50 @@ function buildFileCallEdges(
10361124
}
10371125
}
10381126

1127+
// Object.defineProperty accessor fallback: when a function is registered as
1128+
// a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`,
1129+
// calls to `this.X()` inside `getter` resolve against `obj` (this === obj
1130+
// when the accessor is invoked). If the same-class fallback above found
1131+
// nothing, try treating `obj` as the receiver and look up `obj.X` in the
1132+
// typeMap, or fall back to a same-file lookup of any definition named X
1133+
// that belongs to the object literal or its type.
1134+
if (
1135+
targets.length === 0 &&
1136+
call.receiver === 'this' &&
1137+
caller.callerName != null &&
1138+
symbols.definePropertyReceivers
1139+
) {
1140+
const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName);
1141+
if (receiverVarName) {
1142+
// Try typeMap lookup for receiver.methodName
1143+
const typeEntry = typeMap.get(receiverVarName);
1144+
const typeName = typeEntry
1145+
? typeof typeEntry === 'string'
1146+
? typeEntry
1147+
: (typeEntry as { type?: string }).type
1148+
: null;
1149+
if (typeName) {
1150+
const qualifiedName = `${typeName}.${call.name}`;
1151+
const qualified = lookup.byNameAndFile(qualifiedName, relPath);
1152+
if (qualified.length > 0) {
1153+
targets = [...qualified];
1154+
}
1155+
}
1156+
// If still no targets, search for any definition named `call.name` in
1157+
// the same file — handles plain object literals where the method isn't
1158+
// qualified (e.g. `const obj = { baz() {} }` defines `baz` directly).
1159+
// Note: this is intentionally broad — it matches any same-file definition
1160+
// with the called name, not just members of the receiver object. This is
1161+
// the same behaviour used by the native post-pass path (buildDefinePropertyPostPass).
1162+
if (targets.length === 0) {
1163+
const sameFile = lookup.byNameAndFile(call.name, relPath);
1164+
if (sameFile.length > 0) {
1165+
targets = [...sameFile];
1166+
}
1167+
}
1168+
}
1169+
}
1170+
10391171
for (const t of targets) {
10401172
const edgeKey = `${caller.id}|${t.id}`;
10411173
if (t.id !== caller.id) {
@@ -1547,6 +1679,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
15471679
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
15481680
// not resolved to their original function on the native path.
15491681
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1682+
// Object.defineProperty accessor post-pass: resolve this-dispatch inside
1683+
// getter/setter functions registered via Object.defineProperty.
1684+
buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
15501685
// Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
15511686
// The native Rust engine has no knowledge of the CHA context, so this/self
15521687
// calls and interface dispatch are not expanded to concrete implementations.

src/domain/wasm-worker-entry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,7 @@ function serializeExtractorOutput(
805805
dataflow: symbols.dataflow,
806806
astNodes,
807807
...(symbols.fnRefBindings?.length ? { fnRefBindings: symbols.fnRefBindings } : {}),
808+
...(symbols.paramBindings?.length ? { paramBindings: symbols.paramBindings } : {}),
808809
...(symbols.arrayElemBindings?.length ? { arrayElemBindings: symbols.arrayElemBindings } : {}),
809810
...(symbols.spreadArgBindings?.length ? { spreadArgBindings: symbols.spreadArgBindings } : {}),
810811
...(symbols.forOfBindings?.length ? { forOfBindings: symbols.forOfBindings } : {}),
@@ -818,6 +819,9 @@ function serializeExtractorOutput(
818819
? { objectPropBindings: symbols.objectPropBindings }
819820
: {}),
820821
...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}),
822+
...(symbols.definePropertyReceivers?.size
823+
? { definePropertyReceivers: Array.from(symbols.definePropertyReceivers.entries()) }
824+
: {}),
821825
...(symbols.returnTypeMap?.size
822826
? { returnTypeMap: Array.from(symbols.returnTypeMap.entries()) }
823827
: {}),

src/domain/wasm-worker-pool.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp
107107
// visitor output is cast the same way.
108108
if (ser.astNodes !== undefined) out.astNodes = ser.astNodes as unknown as ASTNodeRow[];
109109
if (ser.fnRefBindings?.length) out.fnRefBindings = ser.fnRefBindings;
110+
if (ser.paramBindings?.length) out.paramBindings = ser.paramBindings;
110111
if (ser.arrayElemBindings?.length) out.arrayElemBindings = ser.arrayElemBindings;
111112
if (ser.spreadArgBindings?.length) out.spreadArgBindings = ser.spreadArgBindings;
112113
if (ser.forOfBindings?.length) out.forOfBindings = ser.forOfBindings;
@@ -115,6 +116,11 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp
115116
out.objectRestParamBindings = ser.objectRestParamBindings;
116117
if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings;
117118
if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions;
119+
if (ser.definePropertyReceivers?.length) {
120+
const m = new Map<string, string>();
121+
for (const [k, v] of ser.definePropertyReceivers) m.set(k, v);
122+
out.definePropertyReceivers = m;
123+
}
118124
if (ser.returnTypeMap?.length) {
119125
const returnTypeMap = new Map<string, TypeMapEntry>();
120126
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
@@ -72,6 +72,8 @@ export interface SerializedExtractorOutput {
7272
objectPropBindings?: import('../types.js').ObjectPropBinding[];
7373
paramBindings?: import('../types.js').ParamBinding[];
7474
newExpressions?: readonly string[];
75+
/** Serialized definePropertyReceivers map (funcName → receiverVarName) as tuple array. */
76+
definePropertyReceivers?: Array<[string, string]>;
7577
returnTypeMap?: Array<[string, TypeMapEntry]>;
7678
callAssignments?: CallAssignment[];
7779
}

src/extractors/javascript.ts

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

389+
// Object.defineProperty accessor receiver bindings
390+
const definePropertyReceivers: Map<string, string> = new Map();
391+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
392+
389393
return {
390394
definitions,
391395
calls,
@@ -404,6 +408,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
404408
objectRestParamBindings,
405409
objectPropBindings,
406410
newExpressions,
411+
...(definePropertyReceivers.size > 0 ? { definePropertyReceivers } : {}),
407412
};
408413
}
409414

@@ -682,6 +687,10 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
682687
const newExpressions: string[] = [];
683688
extractNewExpressionsWalk(tree.rootNode, newExpressions);
684689
ctx.newExpressions = newExpressions;
690+
// Object.defineProperty accessor receiver bindings
691+
const definePropertyReceivers: Map<string, string> = new Map();
692+
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
693+
if (definePropertyReceivers.size > 0) ctx.definePropertyReceivers = definePropertyReceivers;
685694
return ctx;
686695
}
687696

@@ -1468,6 +1477,79 @@ function extractNewExpressionsWalk(rootNode: TreeSitterNode, newExpressions: str
14681477
walk(rootNode, 0);
14691478
}
14701479

1480+
/**
1481+
* Walk the AST to find `Object.defineProperty(obj, "bar", { get: getter })` patterns
1482+
* and record which functions are used as getter/setter accessors for which objects.
1483+
*
1484+
* Result is stored in the provided map as `funcName → receiverVarName`.
1485+
*/
1486+
function extractDefinePropertyReceiversWalk(
1487+
rootNode: TreeSitterNode,
1488+
out: Map<string, string>,
1489+
): void {
1490+
function walk(node: TreeSitterNode, depth: number): void {
1491+
if (depth >= MAX_WALK_DEPTH) return;
1492+
if (node.type === 'call_expression') {
1493+
const fn = node.childForFieldName('function');
1494+
// Match `Object.defineProperty`
1495+
if (fn?.type === 'member_expression') {
1496+
const obj = fn.childForFieldName('object');
1497+
const prop = fn.childForFieldName('property');
1498+
if (
1499+
obj?.type === 'identifier' &&
1500+
obj.text === 'Object' &&
1501+
prop?.text === 'defineProperty'
1502+
) {
1503+
const argsNode = node.childForFieldName('arguments') ?? findChild(node, 'arguments');
1504+
if (argsNode) {
1505+
// Collect non-punctuation children: arg0 (target obj), arg1 (prop name string), arg2 (descriptor)
1506+
const argChildren: TreeSitterNode[] = [];
1507+
for (let i = 0; i < argsNode.childCount; i++) {
1508+
const c = argsNode.child(i);
1509+
if (!c) continue;
1510+
if (c.type === ',' || c.type === '(' || c.type === ')') continue;
1511+
argChildren.push(c);
1512+
}
1513+
if (argChildren.length >= 3) {
1514+
const targetObj = argChildren[0];
1515+
const descriptor = argChildren[2];
1516+
if (targetObj?.type === 'identifier' && descriptor?.type === 'object') {
1517+
const targetName = targetObj.text;
1518+
// Walk the descriptor object's pair children looking for get/set
1519+
for (let i = 0; i < descriptor.childCount; i++) {
1520+
const pair = descriptor.child(i);
1521+
if (pair?.type !== 'pair') continue;
1522+
const key = pair.childForFieldName('key');
1523+
const val = pair.childForFieldName('value');
1524+
if (
1525+
key &&
1526+
(key.text === 'get' || key.text === 'set') &&
1527+
val?.type === 'identifier' &&
1528+
!BUILTIN_GLOBALS.has(val.text)
1529+
) {
1530+
// Known limitation: if the same function is registered as an
1531+
// accessor on multiple objects, last-write-wins — only the
1532+
// last target object is retained. This is an unusual pattern
1533+
// (sharing one function across multiple defineProperty calls)
1534+
// and covering it would require Map<string, string[]> which
1535+
// changes the consumer API. Tracked as a known edge case.
1536+
out.set(val.text, targetName);
1537+
}
1538+
}
1539+
}
1540+
}
1541+
}
1542+
}
1543+
}
1544+
}
1545+
for (let i = 0; i < node.childCount; i++) {
1546+
const child = node.child(i);
1547+
if (child) walk(child, depth + 1);
1548+
}
1549+
}
1550+
walk(rootNode, 0);
1551+
}
1552+
14711553
/**
14721554
* Extract variable-to-type assignments into a per-file type map.
14731555
*

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,16 @@ export interface ExtractorOutput {
679679
* project-wide instantiated-types set for Rapid Type Analysis filtering.
680680
*/
681681
newExpressions?: readonly string[];
682+
/**
683+
* Object.defineProperty receiver bindings: maps function name → target object name.
684+
* Records `Object.defineProperty(obj, "bar", { get: getter })` so the edge builder
685+
* can resolve `this.X()` calls inside `getter` as `obj.X()` (this === obj when the
686+
* accessor is invoked through the property).
687+
*
688+
* Example: `Object.defineProperty(obj, "bar", { get: getter })` emits
689+
* `definePropertyReceivers.set("getter", "obj")`.
690+
*/
691+
definePropertyReceivers?: Map<string, string>;
682692
/** WASM tree retained for downstream analysis (complexity, CFG, dataflow). */
683693
_tree?: TreeSitterTree;
684694
/** 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)