Skip to content

Commit 34c74fd

Browse files
committed
feat(resolver): resolve prototype-based method calls (#1317)
Three patterns now resolved for pre-ES6 OOP: - `Foo.prototype.bar = function(){}` → emits Foo.bar as a method definition - `Foo.prototype.bar = identifier` → seeds typeMap['Foo.bar'] for alias dispatch - `Foo.prototype = { bar: fn }` → emits definitions per property Extractor: new extractPrototypeMethodsWalk pass, called from both the query path and manual-walk path in the JS extractor. Resolver: resolveByMethodOrGlobal gains two new fallbacks: - Inline new-expression receivers: extracts the class name from `new Foo()` when typeMap has no entry for the raw receiver text - Prototype alias lookup: after a symbol-DB miss for TypeName.method, checks typeMap['TypeName.method'] for identifier-aliased methods Adds prototypes.js and prototypes2.js benchmark fixtures; JavaScript benchmark stays at 100% precision / 100% recall. Filed #1327 for native Rust engine parity. Closes #1317
1 parent aa8d6f8 commit 34c74fd

5 files changed

Lines changed: 236 additions & 8 deletions

File tree

src/domain/graph/builder/call-resolver.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,40 @@ export function resolveByMethodOrGlobal(
6767
): ReadonlyArray<{ id: number; file: string }> {
6868
if (call.receiver) {
6969
const typeEntry = typeMap.get(call.receiver);
70-
const typeName = typeEntry
70+
let typeName = typeEntry
7171
? typeof typeEntry === 'string'
7272
? typeEntry
7373
: (typeEntry as { type?: string }).type
7474
: null;
75+
76+
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
77+
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
78+
// produces receiver='(new A)'. Extract the constructor name directly.
79+
if (!typeName && call.receiver) {
80+
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
81+
if (m?.[1]) typeName = m[1];
82+
}
83+
7584
if (typeName) {
7685
const typed = lookup.byName(`${typeName}.${call.name}`).filter((n) => n.kind === 'method');
7786
if (typed.length > 0) return typed;
87+
88+
// Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = { type: identifier }.
89+
// Checked after the symbol-DB lookup so an actual method definition always wins.
90+
const protoEntry = typeMap.get(`${typeName}.${call.name}`);
91+
const protoTarget = protoEntry
92+
? typeof protoEntry === 'string'
93+
? protoEntry
94+
: (protoEntry as { type?: string }).type
95+
: null;
96+
if (protoTarget) {
97+
const resolved = lookup
98+
.byName(protoTarget)
99+
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
100+
if (resolved.length > 0) return resolved;
101+
}
78102
}
103+
79104
// Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] = { type: 'fn' }.
80105
// When a call site references `obj.prop` as a callback, resolve directly to the target fn.
81106
const compositeEntry = typeMap.get(`${call.receiver}.${call.name}`);

src/extractors/javascript.ts

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
345345
// Extract typeMap with intra-file return-type propagation
346346
extractTypeMapWalk(tree.rootNode, typeMap, returnTypeMap, callAssignments, fnRefBindings);
347347

348+
// Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
349+
extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap);
350+
348351
// Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis
349352
extractParamBindingsWalk(tree.rootNode, paramBindings);
350353

@@ -476,7 +479,7 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti
476479
if (declarator?.type !== 'variable_declarator') continue;
477480
const nameN = declarator.childForFieldName('name');
478481
const valueN = declarator.childForFieldName('value');
479-
if (!nameN || nameN.type !== 'identifier' || !valueN) continue;
482+
if (nameN?.type !== 'identifier' || !valueN) continue;
480483
// Skip functions — already captured by query patterns
481484
const valType = valueN.type;
482485
if (valType === 'arrow_function' || valType === 'function_expression' || valType === 'function')
@@ -612,6 +615,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
612615
ctx.callAssignments,
613616
ctx.fnRefBindings,
614617
);
618+
// Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
619+
extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!);
615620
// Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis
616621
extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!);
617622
// Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking
@@ -1162,7 +1167,7 @@ function extractSimpleTypeName(typeAnnotationNode: TreeSitterNode): string | nul
11621167
}
11631168

11641169
function extractNewExprTypeName(newExprNode: TreeSitterNode): string | null {
1165-
if (!newExprNode || newExprNode.type !== 'new_expression') return null;
1170+
if (newExprNode?.type !== 'new_expression') return null;
11661171
const ctor = newExprNode.childForFieldName('constructor') || newExprNode.child(1);
11671172
if (!ctor) return null;
11681173
if (ctor.type === 'identifier') return ctor.text;
@@ -1275,7 +1280,7 @@ function storeReturnType(
12751280
function findReturnNewExprType(bodyNode: TreeSitterNode): string | null {
12761281
for (let i = 0; i < bodyNode.childCount; i++) {
12771282
const child = bodyNode.child(i);
1278-
if (!child || child.type !== 'return_statement') continue;
1283+
if (child?.type !== 'return_statement') continue;
12791284
for (let j = 0; j < child.childCount; j++) {
12801285
const expr = child.child(j);
12811286
if (expr?.type === 'new_expression') return extractNewExprTypeName(expr);
@@ -1442,7 +1447,7 @@ function handleVarDeclaratorTypeMap(
14421447
fnRefBindings?: FnRefBinding[],
14431448
): void {
14441449
const nameN = node.childForFieldName('name');
1445-
if (!nameN || nameN.type !== 'identifier') return;
1450+
if (nameN?.type !== 'identifier') return;
14461451

14471452
const typeAnno = findChild(node, 'type_annotation');
14481453
const valueN = node.childForFieldName('value');
@@ -1531,7 +1536,7 @@ function handleVarDeclaratorTypeMap(
15311536
function handleParamTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEntry>): void {
15321537
const nameNode =
15331538
node.childForFieldName('pattern') || node.childForFieldName('left') || node.child(0);
1534-
if (!nameNode || nameNode.type !== 'identifier') return;
1539+
if (nameNode?.type !== 'identifier') return;
15351540
const typeAnno = findChild(node, 'type_annotation');
15361541
if (typeAnno) {
15371542
const typeName = extractSimpleTypeName(typeAnno);
@@ -1924,7 +1929,7 @@ function extractCallbackDefinition(
19241929
fn?: TreeSitterNode | null,
19251930
): Definition | null {
19261931
if (!fn) fn = callNode.childForFieldName('function');
1927-
if (!fn || fn.type !== 'member_expression') return null;
1932+
if (fn?.type !== 'member_expression') return null;
19281933

19291934
const prop = fn.childForFieldName('property');
19301935
if (!prop) return null;
@@ -2035,7 +2040,7 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] {
20352040
// Skip await_expression wrapper if present
20362041
if (current && current.type === 'await_expression') current = current.parent;
20372042
// We should now be at a variable_declarator (or not, if standalone import())
2038-
if (!current || current.type !== 'variable_declarator') return [];
2043+
if (current?.type !== 'variable_declarator') return [];
20392044

20402045
const nameNode = current.childForFieldName('name');
20412046
if (!nameNode) return [];
@@ -2078,3 +2083,152 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] {
20782083

20792084
return [];
20802085
}
2086+
2087+
// ── Phase 8.X: Prototype-based method extraction ────────────────────────────
2088+
2089+
/**
2090+
* Walk the AST and extract prototype-based method definitions and aliases.
2091+
*
2092+
* Handles three patterns:
2093+
* 1. `Foo.prototype.bar = function(){...}` — emits Foo.bar as method definition
2094+
* 2. `Foo.prototype.bar = identifier` — sets typeMap['Foo.bar'] = { type: identifier }
2095+
* 3. `Foo.prototype = { bar: fn, ... }` — emits defs and typeMap entries per property
2096+
*
2097+
* Emitting definitions under the canonical `ClassName.methodName` name lets the
2098+
* existing typeMap-based call resolver find them when a typed receiver dispatches
2099+
* `instance.method()` (lookup.byName('C.foo') in resolveByMethodOrGlobal).
2100+
*
2101+
* typeMap entries for identifier aliases (`Foo.bar → { type: 'someId' }`) are
2102+
* consumed by the prototype-alias fallback added to resolveByMethodOrGlobal.
2103+
*/
2104+
function extractPrototypeMethodsWalk(
2105+
rootNode: TreeSitterNode,
2106+
definitions: Definition[],
2107+
typeMap: Map<string, TypeMapEntry>,
2108+
): void {
2109+
function walk(node: TreeSitterNode, depth: number): void {
2110+
if (depth >= MAX_WALK_DEPTH) return;
2111+
if (node.type === 'expression_statement') {
2112+
const expr = node.child(0);
2113+
if (expr?.type === 'assignment_expression') {
2114+
const lhs = expr.childForFieldName('left');
2115+
const rhs = expr.childForFieldName('right');
2116+
if (lhs && rhs) handlePrototypeAssignment(lhs, rhs, definitions, typeMap);
2117+
}
2118+
}
2119+
for (let i = 0; i < node.childCount; i++) {
2120+
walk(node.child(i)!, depth + 1);
2121+
}
2122+
}
2123+
walk(rootNode, 0);
2124+
}
2125+
2126+
/**
2127+
* Handle an assignment_expression that may be a prototype assignment.
2128+
*
2129+
* Matches:
2130+
* - `Foo.prototype.bar = rhs` (lhs ends in .prototype.bar)
2131+
* - `Foo.prototype = { ... }` (lhs ends in .prototype, rhs is object literal)
2132+
*/
2133+
function handlePrototypeAssignment(
2134+
lhs: TreeSitterNode,
2135+
rhs: TreeSitterNode,
2136+
definitions: Definition[],
2137+
typeMap: Map<string, TypeMapEntry>,
2138+
): void {
2139+
if (lhs.type !== 'member_expression') return;
2140+
2141+
const lhsObj = lhs.childForFieldName('object');
2142+
const lhsProp = lhs.childForFieldName('property');
2143+
if (!lhsObj || !lhsProp) return;
2144+
2145+
// Pattern 1: `Foo.prototype.bar = rhs`
2146+
// lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar`
2147+
if (
2148+
lhsObj.type === 'member_expression' &&
2149+
(lhsProp.type === 'property_identifier' || lhsProp.type === 'identifier')
2150+
) {
2151+
const protoObj = lhsObj.childForFieldName('object');
2152+
const protoProp = lhsObj.childForFieldName('property');
2153+
if (
2154+
protoObj?.type === 'identifier' &&
2155+
protoProp?.text === 'prototype' &&
2156+
!BUILTIN_GLOBALS.has(protoObj.text)
2157+
) {
2158+
emitPrototypeMethod(protoObj.text, lhsProp.text, rhs, definitions, typeMap);
2159+
}
2160+
return;
2161+
}
2162+
2163+
// Pattern 2: `Foo.prototype = { bar: fn, ... }`
2164+
// lhs.object is `Foo` (identifier), lhs.property is `prototype`
2165+
if (
2166+
lhsObj.type === 'identifier' &&
2167+
lhsProp.text === 'prototype' &&
2168+
!BUILTIN_GLOBALS.has(lhsObj.text) &&
2169+
rhs.type === 'object'
2170+
) {
2171+
extractPrototypeObjectLiteral(lhsObj.text, rhs, definitions, typeMap);
2172+
}
2173+
}
2174+
2175+
/** Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`. */
2176+
function emitPrototypeMethod(
2177+
className: string,
2178+
methodName: string,
2179+
rhs: TreeSitterNode,
2180+
definitions: Definition[],
2181+
typeMap: Map<string, TypeMapEntry>,
2182+
): void {
2183+
const fullName = `${className}.${methodName}`;
2184+
if (rhs.type === 'function_expression' || rhs.type === 'arrow_function') {
2185+
definitions.push({
2186+
name: fullName,
2187+
kind: 'method',
2188+
line: nodeStartLine(rhs),
2189+
endLine: nodeEndLine(rhs),
2190+
});
2191+
} else if (rhs.type === 'identifier' && !BUILTIN_GLOBALS.has(rhs.text)) {
2192+
// Prototype alias: `A.prototype.t = f` → typeMap['A.t'] = { type: 'f' }
2193+
// Consumed by the prototype-alias fallback in resolveByMethodOrGlobal.
2194+
setTypeMapEntry(typeMap, fullName, rhs.text, 0.9);
2195+
}
2196+
}
2197+
2198+
/** Iterate over an object literal assigned to `Foo.prototype` and emit defs/aliases. */
2199+
function extractPrototypeObjectLiteral(
2200+
className: string,
2201+
objNode: TreeSitterNode,
2202+
definitions: Definition[],
2203+
typeMap: Map<string, TypeMapEntry>,
2204+
): void {
2205+
for (let i = 0; i < objNode.childCount; i++) {
2206+
const child = objNode.child(i);
2207+
if (!child) continue;
2208+
2209+
if (child.type === 'method_definition') {
2210+
// Shorthand method: `Foo.prototype = { bar() {} }`
2211+
const nameNode = child.childForFieldName('name');
2212+
if (nameNode) {
2213+
definitions.push({
2214+
name: `${className}.${nameNode.text}`,
2215+
kind: 'method',
2216+
line: nodeStartLine(child),
2217+
endLine: nodeEndLine(child),
2218+
});
2219+
}
2220+
continue;
2221+
}
2222+
2223+
if (child.type !== 'pair') continue;
2224+
2225+
const keyNode = child.childForFieldName('key');
2226+
const valueNode = child.childForFieldName('value');
2227+
if (!keyNode || !valueNode) continue;
2228+
2229+
const methodName = keyNode.type === 'string' ? keyNode.text.replace(/['"]/g, '') : keyNode.text;
2230+
if (!methodName) continue;
2231+
2232+
emitPrototypeMethod(className, methodName, valueNode, definitions, typeMap);
2233+
}
2234+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@
149149
"kind": "calls",
150150
"mode": "class-inheritance",
151151
"notes": "static super.count() in DoubleCounter.count resolves to Counter.count via CHA parents map"
152+
},
153+
{
154+
"source": { "name": "runPrototypes", "file": "prototypes.js" },
155+
"target": { "name": "C", "file": "prototypes.js" },
156+
"kind": "calls",
157+
"mode": "constructor",
158+
"notes": "new C() — constructor call to function-based constructor"
159+
},
160+
{
161+
"source": { "name": "runPrototypes", "file": "prototypes.js" },
162+
"target": { "name": "C.foo", "file": "prototypes.js" },
163+
"kind": "calls",
164+
"mode": "receiver-typed",
165+
"notes": "v.foo() — v typed as C via new C(), C.foo defined via C.prototype = { foo: fn }"
166+
},
167+
{
168+
"source": { "name": "testPrototypeAlias", "file": "prototypes2.js" },
169+
"target": { "name": "A", "file": "prototypes2.js" },
170+
"kind": "calls",
171+
"mode": "constructor",
172+
"notes": "new A() — inline constructor call in new A().t()"
173+
},
174+
{
175+
"source": { "name": "testPrototypeAlias", "file": "prototypes2.js" },
176+
"target": { "name": "f", "file": "prototypes2.js" },
177+
"kind": "calls",
178+
"mode": "receiver-typed",
179+
"notes": "new A().t() — A.prototype.t = f alias; inline new receiver resolved to type A, typeMap['A.t'] = f"
152180
}
153181
]
154182
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Pre-ES6 OOP via constructor function + prototype object literal.
2+
function C() {}
3+
4+
C.prototype = {
5+
foo: () => {},
6+
};
7+
8+
export function runPrototypes() {
9+
var v = new C();
10+
v.foo();
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Pre-ES6 OOP via prototype property assignment with identifier alias.
2+
const f = () => {};
3+
4+
class A {}
5+
6+
A.prototype.t = f;
7+
8+
export function testPrototypeAlias() {
9+
new A().t();
10+
}

0 commit comments

Comments
 (0)