Skip to content

Commit f0db64c

Browse files
authored
feat(resolver): resolve calls through Object.defineProperty / defineProperties / create (#1328)
* feat(resolver): track constructor-assigned property types for receiver-typed resolution (JS/TS) Closes #1306 Extends `handlePropWriteTypeMap` to seed the points-to type map from `this.prop = new ClassName(...)` assignments, enabling resolution of `this.prop.method()` calls through the existing receiver-typed path. Before: `this.logger.error/info/warn()` in UserService were unresolvable because only `obj.prop = identifier` writes were tracked (Phase 8.3d). After: `this.logger = new Logger(...)` seeds `typeMap['this.logger'] = Logger` with confidence 1.0 (same as `const x = new Ctor()` in variable declarators). The existing `resolveByMethodOrGlobal` and native `edge_builder.rs` fallback (`or_else(|| type_map.get(receiver))`) then pick up `this.logger` and resolve `this.logger.error()` → `Logger.error`. Impact: JS fixture receiver-typed recall 2/5 → 5/5 (40% → 100%). Ratchet JS benchmark gate: precision 0.85→1.0, recall 0.5→0.9. * fix(resolver): resolve this.method() to ClassName.method in JS/TS WASM path (#1314) When a class method calls this.method() or super.method(), the WASM/JS resolution path failed to find the target because methods are stored as qualified names (e.g. Logger._write) but lookups used the unqualified name (_write). The native Rust engine resolved this implicitly via its class-scoped symbol table. Fix: in buildFileCallEdges, after resolveCallTargets returns no targets for a this/super receiver call, extract the class name prefix from the caller's qualified name (e.g. Logger.info → Logger) and retry with the fully-qualified method name (Logger._write) in the same file. Fixes the JS benchmark recall (83.3% → 100%) and TS benchmark recall (69.4% → 72.2%), satisfying CI thresholds of 90% and 72% respectively. * docs(bench): explain JS/TS threshold values in resolution-benchmark (#1314) Add inline comments explaining why JS recall is 0.9 and TS recall is 0.72, linking the thresholds to the Phase 8.3e same-class this.method() fix and noting which remaining gaps (interface-dispatch, CHA) are tracked in future phases. Addresses Greptile P2 review comment on the threshold bump. * fix(resolver): restrict same-class fallback to this receiver, exclude super (#1314) * feat(resolver): resolve calls through Object.defineProperty / defineProperties / create Seed composite pts keys from three property descriptor APIs so that obj.key() can be traced back to the original function reference: - Object.defineProperty(obj, "key", { value: fn }) → type_map["obj.key"] = "fn" - Object.defineProperties(obj, { "k1": { value: v1 }, ... }) → type_map["obj.k1"] = "v1", … - const obj = Object.create({ f1, f2 }) (shorthand or pair) → type_map["obj.f1"] = "f1", type_map["obj.f2"] = "f2" Implemented in both the Rust extractor (native engine) and the TS extractor (wasm engine) for parity. Adds a `define-property.js` fixture with 5 expected edges (3 defineProperty/defineProperties + 2 create) that all now resolve at 100% recall. Closes #1320 * fix(extractor): add string-type guard and anchored regex for defineProperty key extraction (#1328) - defineProperty path: guard arg1.type !== 'string' before extraction, matching the Rust seed_define_property_entries behaviour (which returns None for non-string AST nodes) - defineProperties path: use anchored /^['"]|['"]$/ to strip only boundary quotes, not quotes within the key value, with string-type guard - seedProtoProperties: same anchored regex fix for pair keys Closes the TS/Rust engine parity gap flagged in Greptile review. (docs check acknowledged) * fix(test): flush deferred DB close before temp-dir removal in embedding regression (#1328) On Windows, buildGraph uses closeDbDeferred which schedules the SQLite WAL checkpoint via setImmediate. When afterAll runs before the deferred close executes, fs.rmSync throws EBUSY on graph.db. Call flushDeferredClose before the temp-dir cleanup to ensure the handle is closed synchronously. * fix(test): apply biome formatting to define-property.js fixture * docs(bench): update JS edge count comment to reflect 30 total expected edges after merge * fix(test): use fs.rmSync maxRetries for robust EBUSY handling on Windows (#1328)
1 parent a233110 commit f0db64c

7 files changed

Lines changed: 424 additions & 16 deletions

File tree

crates/codegraph-core/src/extractors/javascript.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,18 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep
9393
});
9494
}
9595
}
96+
// Phase 8.3e: Object.create({ key: fn }) → composite pts key per property
97+
if value_n.kind() == "call_expression" {
98+
seed_object_create_entries(var_name, &value_n, source, symbols);
99+
}
96100
}
97101
}
98102
}
99103
}
104+
// Phase 8.3e: Object.defineProperty / defineProperties → composite pts key
105+
"call_expression" => {
106+
seed_define_property_entries(node, source, symbols);
107+
}
100108
"required_parameter" | "optional_parameter" => {
101109
let name_node = node.child_by_field_name("pattern")
102110
.or_else(|| node.child_by_field_name("left"))
@@ -194,6 +202,151 @@ fn is_js_builtin_global(name: &str) -> bool {
194202
)
195203
}
196204

205+
// ── Phase 8.3e: Object.defineProperty / defineProperties / create ────────────
206+
207+
/// Seed composite pts keys for `Object.defineProperty(obj, "key", { value: fn })`
208+
/// and `Object.defineProperties(obj, { "key": { value: fn }, ... })`.
209+
fn seed_define_property_entries(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
210+
let Some(callee) = node.child_by_field_name("function") else { return };
211+
if callee.kind() != "member_expression" { return; }
212+
let Some(callee_obj) = callee.child_by_field_name("object") else { return };
213+
if node_text(&callee_obj, source) != "Object" { return; }
214+
let Some(callee_prop) = callee.child_by_field_name("property") else { return };
215+
let method = node_text(&callee_prop, source);
216+
if method != "defineProperty" && method != "defineProperties" { return; }
217+
218+
let args_node = node.child_by_field_name("arguments")
219+
.or_else(|| find_child(node, "arguments"));
220+
let Some(args_node) = args_node else { return };
221+
222+
// Collect non-punctuation argument nodes in order
223+
let mut args: Vec<Node> = Vec::new();
224+
for i in 0..args_node.child_count() {
225+
let Some(child) = args_node.child(i) else { continue };
226+
if !matches!(child.kind(), "(" | ")" | ",") {
227+
args.push(child);
228+
}
229+
}
230+
231+
if method == "defineProperty" {
232+
// Object.defineProperty(obj, "key", { value: fn })
233+
if args.len() < 3 { return; }
234+
if args[0].kind() != "identifier" { return; }
235+
let obj_name = node_text(&args[0], source);
236+
let Some(key) = extract_string_fragment(&args[1], source) else { return };
237+
let Some(target) = find_descriptor_value(&args[2], source) else { return };
238+
symbols.type_map.push(TypeMapEntry {
239+
name: format!("{}.{}", obj_name, key),
240+
type_name: target.to_string(),
241+
confidence: 0.85,
242+
});
243+
} else {
244+
// Object.defineProperties(obj, { "key": { value: fn }, ... })
245+
if args.len() < 2 { return; }
246+
if args[0].kind() != "identifier" { return; }
247+
let obj_name = node_text(&args[0], source).to_string();
248+
if args[1].kind() != "object" { return; }
249+
seed_descriptor_object(&obj_name, &args[1], source, symbols);
250+
}
251+
}
252+
253+
/// Seed composite pts keys from `const obj = Object.create({ f1, f2 })`.
254+
fn seed_object_create_entries(var_name: &str, call_node: &Node, source: &[u8], symbols: &mut FileSymbols) {
255+
let Some(callee) = call_node.child_by_field_name("function") else { return };
256+
if callee.kind() != "member_expression" { return; }
257+
let Some(callee_obj) = callee.child_by_field_name("object") else { return };
258+
if node_text(&callee_obj, source) != "Object" { return; }
259+
let Some(callee_prop) = callee.child_by_field_name("property") else { return };
260+
if node_text(&callee_prop, source) != "create" { return; }
261+
262+
let args_node = call_node.child_by_field_name("arguments")
263+
.or_else(|| find_child(call_node, "arguments"));
264+
let Some(args_node) = args_node else { return };
265+
266+
// First non-punctuation argument = prototype object
267+
let proto = (0..args_node.child_count())
268+
.filter_map(|i| args_node.child(i))
269+
.find(|n| !matches!(n.kind(), "(" | ")" | ","));
270+
let Some(proto) = proto else { return };
271+
if proto.kind() != "object" { return };
272+
273+
for i in 0..proto.child_count() {
274+
let Some(child) = proto.child(i) else { continue };
275+
match child.kind() {
276+
"shorthand_property_identifier" => {
277+
// { f1 } shorthand — property name equals value name
278+
let name = node_text(&child, source);
279+
symbols.type_map.push(TypeMapEntry {
280+
name: format!("{}.{}", var_name, name),
281+
type_name: name.to_string(),
282+
confidence: 0.85,
283+
});
284+
}
285+
"pair" => {
286+
let Some(key_n) = child.child_by_field_name("key") else { continue };
287+
let Some(val_n) = child.child_by_field_name("value") else { continue };
288+
if val_n.kind() != "identifier" { continue; }
289+
let key = if key_n.kind() == "string" {
290+
extract_string_fragment(&key_n, source).map(|s| s.to_string())
291+
} else {
292+
Some(node_text(&key_n, source).to_string())
293+
};
294+
let Some(key) = key else { continue };
295+
symbols.type_map.push(TypeMapEntry {
296+
name: format!("{}.{}", var_name, key),
297+
type_name: node_text(&val_n, source).to_string(),
298+
confidence: 0.85,
299+
});
300+
}
301+
_ => {}
302+
}
303+
}
304+
}
305+
306+
/// Iterate over the properties of a `defineProperties` descriptor object and seed the type_map.
307+
fn seed_descriptor_object(obj_name: &str, obj_node: &Node, source: &[u8], symbols: &mut FileSymbols) {
308+
for i in 0..obj_node.child_count() {
309+
let Some(child) = obj_node.child(i) else { continue };
310+
if child.kind() != "pair" { continue; }
311+
let Some(key_n) = child.child_by_field_name("key") else { continue };
312+
let Some(val_n) = child.child_by_field_name("value") else { continue };
313+
let key = if key_n.kind() == "string" {
314+
extract_string_fragment(&key_n, source).map(|s| s.to_string())
315+
} else {
316+
Some(node_text(&key_n, source).to_string())
317+
};
318+
let Some(key) = key else { continue };
319+
let Some(target) = find_descriptor_value(&val_n, source) else { continue };
320+
symbols.type_map.push(TypeMapEntry {
321+
name: format!("{}.{}", obj_name, key),
322+
type_name: target.to_string(),
323+
confidence: 0.85,
324+
});
325+
}
326+
}
327+
328+
/// Extract the text of the `string_fragment` child of a string node, i.e. content without quotes.
329+
fn extract_string_fragment<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> {
330+
if node.kind() != "string" { return None; }
331+
find_child(node, "string_fragment").map(|n| node_text(&n, source))
332+
}
333+
334+
/// Find the `value` identifier in a property descriptor object `{ value: fn }`.
335+
fn find_descriptor_value<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> {
336+
if node.kind() != "object" { return None; }
337+
for i in 0..node.child_count() {
338+
let Some(child) = node.child(i) else { continue };
339+
if child.kind() != "pair" { continue; }
340+
let Some(key) = child.child_by_field_name("key") else { continue };
341+
if node_text(&key, source) != "value" { continue; }
342+
let Some(val) = child.child_by_field_name("value") else { continue };
343+
if val.kind() == "identifier" {
344+
return Some(node_text(&val, source));
345+
}
346+
}
347+
None
348+
}
349+
197350
// ── Return-type map extraction (Phase 8.2 parity) ───────────────────────────
198351

199352
/// Walk the AST collecting function/method return types into `symbols.return_type_map`.
@@ -2292,4 +2445,76 @@ mod tests {
22922445
"compute call should have receiver='calc'"
22932446
);
22942447
}
2448+
2449+
/// Phase 8.3e: Object.defineProperty seeds composite type_map key.
2450+
#[test]
2451+
fn type_map_from_define_property() {
2452+
let s = parse_js(
2453+
"function f1() {}\n\
2454+
const obj = {};\n\
2455+
Object.defineProperty(obj, \"f\", { value: f1 });",
2456+
);
2457+
let entry = s.type_map.iter().find(|e| e.name == "obj.f");
2458+
assert!(entry.is_some(), "type_map should contain 'obj.f'; got: {:?}", s.type_map);
2459+
assert_eq!(entry.unwrap().type_name, "f1");
2460+
}
2461+
2462+
/// Phase 8.3e: Object.defineProperties seeds composite type_map keys.
2463+
#[test]
2464+
fn type_map_from_define_properties() {
2465+
let s = parse_js(
2466+
"function f1() {}\n\
2467+
function f2() {}\n\
2468+
const obj = {};\n\
2469+
Object.defineProperties(obj, {\n\
2470+
\"f1\": { value: f1 },\n\
2471+
\"f2\": { value: f2 },\n\
2472+
});",
2473+
);
2474+
let e1 = s.type_map.iter().find(|e| e.name == "obj.f1");
2475+
let e2 = s.type_map.iter().find(|e| e.name == "obj.f2");
2476+
assert!(e1.is_some(), "type_map should contain 'obj.f1'; got: {:?}", s.type_map);
2477+
assert!(e2.is_some(), "type_map should contain 'obj.f2'; got: {:?}", s.type_map);
2478+
assert_eq!(e1.unwrap().type_name, "f1");
2479+
assert_eq!(e2.unwrap().type_name, "f2");
2480+
}
2481+
2482+
/// Phase 8.3e: Object.create seeds composite type_map keys from shorthand proto.
2483+
#[test]
2484+
fn type_map_from_object_create() {
2485+
let s = parse_js(
2486+
"function f1() {}\n\
2487+
function f2() {}\n\
2488+
const obj = Object.create({ f1, f2 });",
2489+
);
2490+
let e1 = s.type_map.iter().find(|e| e.name == "obj.f1");
2491+
let e2 = s.type_map.iter().find(|e| e.name == "obj.f2");
2492+
assert!(e1.is_some(), "type_map should contain 'obj.f1'; got: {:?}", s.type_map);
2493+
assert!(e2.is_some(), "type_map should contain 'obj.f2'; got: {:?}", s.type_map);
2494+
assert_eq!(e1.unwrap().type_name, "f1");
2495+
assert_eq!(e2.unwrap().type_name, "f2");
2496+
}
2497+
2498+
/// Phase 8.3e: call receiver is correctly recorded for obj.f() inside defProp body.
2499+
#[test]
2500+
fn call_receiver_for_define_property() {
2501+
let s = parse_js(
2502+
"function f1() {}\n\
2503+
function defProp() {\n\
2504+
const obj = {};\n\
2505+
Object.defineProperty(obj, \"f\", { value: f1 });\n\
2506+
obj.f();\n\
2507+
}",
2508+
);
2509+
let tm = s.type_map.iter().find(|e| e.name == "obj.f");
2510+
assert!(tm.is_some(), "type_map should contain 'obj.f'; got: {:?}", s.type_map);
2511+
assert_eq!(tm.unwrap().type_name, "f1");
2512+
2513+
let call = s.calls.iter().find(|c| c.name == "f" && c.receiver.as_deref() == Some("obj"));
2514+
assert!(
2515+
call.is_some(),
2516+
"calls should contain obj.f() with receiver='obj'; got: {:?}",
2517+
s.calls.iter().map(|c| (&c.name, &c.receiver)).collect::<Vec<_>>()
2518+
);
2519+
}
22952520
}

src/extractors/csharp.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ function extractCSharpParameters(paramListNode: TreeSitterNode | null): SubDecla
279279
if (!paramListNode) return params;
280280
for (let i = 0; i < paramListNode.childCount; i++) {
281281
const param = paramListNode.child(i);
282-
if (!param || param.type !== 'parameter') continue;
282+
if (param?.type !== 'parameter') continue;
283283
const nameNode = param.childForFieldName('name');
284284
if (nameNode) {
285285
params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 });
@@ -294,12 +294,12 @@ function extractCSharpClassFields(classNode: TreeSitterNode): SubDeclaration[] {
294294
if (!body) return fields;
295295
for (let i = 0; i < body.childCount; i++) {
296296
const member = body.child(i);
297-
if (!member || member.type !== 'field_declaration') continue;
297+
if (member?.type !== 'field_declaration') continue;
298298
const varDecl = findChild(member, 'variable_declaration');
299299
if (!varDecl) continue;
300300
for (let j = 0; j < varDecl.childCount; j++) {
301301
const child = varDecl.child(j);
302-
if (!child || child.type !== 'variable_declarator') continue;
302+
if (child?.type !== 'variable_declarator') continue;
303303
const nameNode = child.childForFieldName('name');
304304
if (nameNode) {
305305
fields.push({
@@ -337,7 +337,7 @@ function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
337337
if (!typeName) return;
338338
for (let i = 0; i < node.childCount; i++) {
339339
const child = node.child(i);
340-
if (!child || child.type !== 'variable_declarator') continue;
340+
if (child?.type !== 'variable_declarator') continue;
341341
const nameNode = child.childForFieldName('name') || child.child(0);
342342
if (nameNode && nameNode.type === 'identifier' && ctx.typeMap) {
343343
setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);

0 commit comments

Comments
 (0)