Skip to content

Commit ec78d95

Browse files
authored
feat(resolver): resolve this-dispatch inside Object.defineProperty accessor functions (JS) (#1351)
* feat(resolver): resolve this-dispatch inside Object.defineProperty accessor functions (JS) Closes #1335 When a function is registered as a `get`/`set` accessor via `Object.defineProperty(obj, key, { get: getter })`, `this` inside `getter` equals `obj`. Codegraph previously emitted no edge for `this.method()` calls inside such accessors when the callee was defined as an object literal property. Three layered mechanisms introduced: 1. **Object literal function extraction** — `const obj = { baz: () => {} }` now extracts `baz` as a named `function` definition (WASM query path via `extractConstDeclarators`, walk path via `handleVariableDecl`, Rust path via `extract_object_literal_functions`). This makes the callee visible to the same-file definition lookup used by `resolveCallTargets` step 2. 2. **Composite typeMap seeding for object literal properties** — `typeMap['obj.baz'] = 'baz'` seeded for shorthand properties (`{ baz }`), identifier values (`{ baz: fn }`), and inline function/arrow values. Enables the accessor receiver path. 3. **Accessor receiver tracking** — `Object.defineProperty(obj, key, { get: getter })` seeds `typeMap['getter:this'] = 'obj'`. `resolveByMethodOrGlobal` (WASM/TS) and `resolve_call_targets` (Rust) check this entry first for plain-function callers (no class prefix), then look up `typeMap['obj.method']` to reach the concrete target — providing precision over the broad same-file fallback. Benchmark impact: - Jelly micro-test `accessors3`: 0% -> 100% recall (TP=1, FP=0, FN=0) - JS fixture `define-property-accessor.js`: new edge `accessorGetter -> accessMethod` - JS benchmark total expected edges: 33 -> 34 (precision 1.0 maintained, recall >= 0.9) * fix(wasm-worker): wire paramBindings, returnTypeMap, callAssignments through worker boundary These three ExtractorOutput fields were populated by the JS/TS extractor but never included in SerializedExtractorOutput, causing them to be silently dropped when results crossed the Worker thread boundary. The main thread always received undefined for these fields regardless of what the worker computed, breaking cross-file return-type propagation, param binding resolution, and call-assignment tracking in the WASM engine. Fixes #1348 * Revert "fix(wasm-worker): wire paramBindings, returnTypeMap, callAssignments through worker boundary" This reverts commit 6402681. * fix(resolver): add empty-string guard to Rust caller_name check for accessor this-dispatch (#1351) Parity with the TypeScript guard: `callerName && !callerName.includes('.')`. When caller_name is empty the key would be ':this' which is never seeded, so no incorrect edge is produced today — but adding the guard removes the latent risk and makes the two engines explicitly equivalent. * fix(resolver): qualify object literal defs and seed all get/set accessors (#1351) Two fixes: 1. findDescriptorAccessors (TS) / find_descriptor_accessors (Rust): changed from returning the first accessor to returning all of them. For { get: getter, set: setter }, both 'getter:this = obj' and 'setter:this = obj' are now seeded so both functions can resolve this-dispatch via Phase 8.3f. 2. extractObjectLiteralFunctions (TS) / extract_object_literal_functions (Rust): definitions are now emitted under qualified names ('obj.baz' instead of bare 'baz') to avoid polluting the global definition index with common property names that could produce false-positive edges via the broad exact-name fallback. The typeMap value for function/arrow entries is also updated to the qualified name so the resolver calls lookup.byName('obj.baz'). Also adds complexity metrics for method_definition nodes in the Rust branch (parity with the pair branch which already computed them). * fix(resolver): clarify scope guard and :this key convention in accessor this-dispatch (#1351) - Add comment to extractConstDeclarators explaining that the function-scope guard is upheld by the caller (extractConstantsWalk) — future refactors must add a hasFunctionScopeAncestor check before calling extractObjectLiteralFunctions from any other context. - Add comment at the setTypeMapEntry call noting that ':' is a reserved separator for Phase 8.3f keys and cannot collide with real JS identifier names. * fix(bench): use qualified node name accessorTarget.accessMethod in JS expected-edges (#1351) The graph registers object literal arrow properties under qualified names (e.g. 'accessorTarget.accessMethod' not bare 'accessMethod') since extractObjectLiteralFunctions was updated to avoid polluting the global definition index. The expected edge must match the stored node name so the precision check passes for both WASM and native engines. * fix(extractor): scope guard for object literal walk path and jelly-micro target names (#1351) Two fixes: 1. handleVariableDecl (walk path): add !hasFunctionScopeAncestor(node) guard before calling extractObjectLiteralFunctions, matching the Rust path's find_parent_of_types check and the sibling destructured-binding branch. Without the guard, const object literals inside function bodies registered their qualified properties (e.g. localObj.fn) into the global definition index. 2. jelly-micro accessors3/expected-edges.json: update target name from bare 'baz' to qualified 'obj.baz' to match the actual node name stored by extractObjectLiteralFunctions. recall was 0% (TP=0) since qualified names were introduced; now recall=100%. * fix(extractor): seed typeMap entry for method-shorthand object properties (#1351) Method-shorthand properties (`const obj = { baz() {} }`) produced a qualified definition `obj.baz` via extractObjectLiteralFunctions but no matching typeMap entry. The two-step Phase 8.3f lookup (callerName:this → obj → obj.baz) would find the typeMap key but then lookup.byName('obj.baz') would fail because no typeMap['obj.baz'] was ever seeded. Add a method_definition branch in handleVarDeclaratorTypeMap (TS) and in extract_object_literal_functions (Rust) to seed typeMap['obj.baz'] = 'obj.baz', matching the pair (arrow/function) branch. * chore: include ROADMAP.md updates from main merge (#1351) * fix(extractor): add scope guard to object-literal typeMap seeding and fix phase label (#1351) - Add !hasFunctionScopeAncestor(node) guard to the object-literal seeding block in handleVarDeclaratorTypeMap, matching Rust handle_var_decl's find_parent_of_types check. Prevents function-scoped const obj = { fn: ... } from seeding typeMap entries that could shadow module-level declarations with the same property names. - Fix Phase 8.3g → Phase 8.3f label in resolution-benchmark.test.ts comment (all implementation files use Phase 8.3f; the test comment was inconsistent).
1 parent 428edb4 commit ec78d95

9 files changed

Lines changed: 409 additions & 13 deletions

File tree

crates/codegraph-core/src/edge_builder.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,27 @@ fn resolve_call_targets<'a>(
457457
|| call.receiver.as_deref() == Some("self")
458458
|| call.receiver.as_deref() == Some("super")
459459
{
460+
// Phase 8.3f: accessor this-dispatch via Object.defineProperty.
461+
// When a plain function (no class prefix in caller_name) is registered as a get/set
462+
// accessor for `obj`, typeMap seeds 'callerName:this' = 'obj'. Resolve this.method()
463+
// via typeMap['obj.method'] → the concrete definition. Runs before the broad exact-name
464+
// lookup to avoid false positives from unrelated same-file definitions.
465+
if call.receiver.as_deref() == Some("this") && !caller_name.is_empty() && !caller_name.contains('.') {
466+
let accessor_key = format!("{}:this", caller_name);
467+
if let Some(&(obj_name, _)) = type_map.get(accessor_key.as_str()) {
468+
let obj_method_key = format!("{}.{}", obj_name, call.name);
469+
if let Some(&(target_fn, _)) = type_map.get(obj_method_key.as_str()) {
470+
let accessor_resolved: Vec<&NodeInfo> = ctx.nodes_by_name
471+
.get(target_fn)
472+
.map(|v| v.iter()
473+
.filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5)
474+
.copied().collect())
475+
.unwrap_or_default();
476+
if !accessor_resolved.is_empty() { return accessor_resolved; }
477+
}
478+
}
479+
}
480+
460481
// First try exact name match (e.g. an unqualified function named "area").
461482
let exact: Vec<&NodeInfo> = ctx.nodes_by_name
462483
.get(call.name.as_str())

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

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,17 +231,27 @@ fn seed_define_property_entries(node: &Node, source: &[u8], symbols: &mut FileSy
231231
}
232232

233233
if method == "defineProperty" {
234-
// Object.defineProperty(obj, "key", { value: fn })
234+
// Object.defineProperty(obj, "key", { value: fn }) or { get: getter }
235235
if args.len() < 3 { return; }
236236
if args[0].kind() != "identifier" { return; }
237237
let obj_name = node_text(&args[0], source);
238238
let Some(key) = extract_string_fragment(&args[1], source) else { return };
239-
let Some(target) = find_descriptor_value(&args[2], source) else { return };
240-
symbols.type_map.push(TypeMapEntry {
241-
name: format!("{}.{}", obj_name, key),
242-
type_name: target.to_string(),
243-
confidence: 0.85,
244-
});
239+
// Phase 8.3e: { value: fn } → obj.key pts to fn
240+
if let Some(target) = find_descriptor_value(&args[2], source) {
241+
symbols.type_map.push(TypeMapEntry {
242+
name: format!("{}.{}", obj_name, key),
243+
type_name: target.to_string(),
244+
confidence: 0.85,
245+
});
246+
}
247+
// Phase 8.3f: { get: getter } and/or { set: setter } → this inside each accessor === obj
248+
for accessor in find_descriptor_accessors(&args[2], source) {
249+
symbols.type_map.push(TypeMapEntry {
250+
name: format!("{}:this", accessor),
251+
type_name: obj_name.to_string(),
252+
confidence: 0.85,
253+
});
254+
}
245255
} else {
246256
// Object.defineProperties(obj, { "key": { value: fn }, ... })
247257
if args.len() < 2 { return; }
@@ -349,6 +359,124 @@ fn find_descriptor_value<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a st
349359
None
350360
}
351361

362+
/// Phase 8.3f: return the identifier texts of all `get` and `set` accessors in a property
363+
/// descriptor. `{ get: getter, set: setter }` → ["getter", "setter"].
364+
/// Returns all accessors so that each one gets a `callerName:this = obj` typeMap entry.
365+
fn find_descriptor_accessors<'a>(node: &Node<'a>, source: &'a [u8]) -> Vec<&'a str> {
366+
if node.kind() != "object" { return Vec::new(); }
367+
let mut result = Vec::new();
368+
for i in 0..node.child_count() {
369+
let Some(child) = node.child(i) else { continue };
370+
if child.kind() != "pair" { continue; }
371+
let Some(key) = child.child_by_field_name("key") else { continue };
372+
let key_text = node_text(&key, source);
373+
if key_text != "get" && key_text != "set" { continue; }
374+
let Some(val) = child.child_by_field_name("value") else { continue };
375+
if val.kind() == "identifier" {
376+
result.push(node_text(&val, source));
377+
}
378+
}
379+
result
380+
}
381+
382+
/// Phase 8.3f: extract function/arrow properties from an object literal as standalone definitions
383+
/// and seed composite typeMap keys so that `this.method()` inside Object.defineProperty accessors
384+
/// can resolve them.
385+
///
386+
/// Definitions are emitted under qualified names (`obj.baz`) to avoid polluting the global
387+
/// definition index with common property names like `init`, `run`, or `render`. The typeMap
388+
/// value for function/arrow properties also uses the qualified name so the resolver calls
389+
/// `lookup.byName("obj.baz")` rather than `lookup.byName("baz")`.
390+
///
391+
/// `const obj = { baz: () => {} }` → Definition { name: "obj.baz", kind: "function" }
392+
/// + TypeMapEntry { name: "obj.baz", type_name: "obj.baz" }
393+
/// `const obj = { baz }` (shorthand) → TypeMapEntry { name: "obj.baz", type_name: "baz" }
394+
fn extract_object_literal_functions(
395+
obj_node: &Node,
396+
source: &[u8],
397+
var_name: &str,
398+
symbols: &mut FileSymbols,
399+
) {
400+
for i in 0..obj_node.child_count() {
401+
let Some(child) = obj_node.child(i) else { continue };
402+
match child.kind() {
403+
"shorthand_property_identifier" => {
404+
let prop_name = node_text(&child, source);
405+
symbols.type_map.push(TypeMapEntry {
406+
name: format!("{}.{}", var_name, prop_name),
407+
type_name: prop_name.to_string(),
408+
confidence: 0.85,
409+
});
410+
}
411+
"pair" => {
412+
let Some(key_n) = child.child_by_field_name("key") else { continue };
413+
let Some(val_n) = child.child_by_field_name("value") else { continue };
414+
let key = if key_n.kind() == "string" {
415+
extract_string_fragment(&key_n, source).map(|s| s.to_string())
416+
} else {
417+
Some(node_text(&key_n, source).to_string())
418+
};
419+
let Some(key) = key else { continue };
420+
let qualified = format!("{}.{}", var_name, key);
421+
match val_n.kind() {
422+
"arrow_function" | "function_expression" | "function" => {
423+
// Use qualified name for the definition so it doesn't collide with
424+
// unrelated top-level functions sharing the same property name.
425+
symbols.definitions.push(Definition {
426+
name: qualified.clone(),
427+
kind: "function".to_string(),
428+
line: start_line(&child),
429+
end_line: Some(end_line(&val_n)),
430+
decorators: None,
431+
complexity: compute_all_metrics(&val_n, source, "javascript"),
432+
cfg: build_function_cfg(&val_n, "javascript", source),
433+
children: None,
434+
});
435+
// Store qualified name as value so resolver looks up the qualified def.
436+
symbols.type_map.push(TypeMapEntry {
437+
name: qualified.clone(),
438+
type_name: qualified,
439+
confidence: 0.85,
440+
});
441+
}
442+
"identifier" => {
443+
let target = node_text(&val_n, source);
444+
symbols.type_map.push(TypeMapEntry {
445+
name: qualified,
446+
type_name: target.to_string(),
447+
confidence: 0.85,
448+
});
449+
}
450+
_ => {}
451+
}
452+
}
453+
"method_definition" => {
454+
let Some(name_n) = child.child_by_field_name("name") else { continue };
455+
let qualified = format!("{}.{}", var_name, node_text(&name_n, source));
456+
let body = child.child_by_field_name("body");
457+
symbols.definitions.push(Definition {
458+
name: qualified.clone(),
459+
kind: "function".to_string(),
460+
line: start_line(&child),
461+
end_line: Some(end_line(&child)),
462+
decorators: None,
463+
complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")),
464+
cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)),
465+
children: None,
466+
});
467+
// Seed typeMap so the two-step accessor dispatch can find the qualified def.
468+
// `const obj = { baz() {} }` → typeMap['obj.baz'] = 'obj.baz'
469+
symbols.type_map.push(TypeMapEntry {
470+
name: qualified.clone(),
471+
type_name: qualified,
472+
confidence: 0.85,
473+
});
474+
}
475+
_ => {}
476+
}
477+
}
478+
}
479+
352480
// ── Return-type map extraction (Phase 8.2 parity) ───────────────────────────
353481

354482
/// Walk the AST collecting function/method return types into `symbols.return_type_map`.
@@ -837,6 +965,13 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
837965
cfg: None,
838966
children: None,
839967
});
968+
// Phase 8.3f: extract function/arrow properties from object literals and seed
969+
// typeMap composite keys so that this.method() inside Object.defineProperty
970+
// accessor functions can resolve them.
971+
if value_n.kind() == "object" && name_n.kind() == "identifier" {
972+
let var_name = node_text(&name_n, source);
973+
extract_object_literal_functions(&value_n, source, var_name, symbols);
974+
}
840975
} else if name_n.kind() == "identifier" && value_n.kind() == "identifier" {
841976
// Phase 8.3: `const alias = handler` — record for pts analysis.
842977
// Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,35 @@ export function resolveByMethodOrGlobal(
131131
call.receiver === 'self' ||
132132
call.receiver === 'super'
133133
) {
134+
// Phase 8.3f: accessor this-dispatch via Object.defineProperty.
135+
// When a plain function (no class prefix) is registered as a get/set accessor for `obj`
136+
// via Object.defineProperty, typeMap seeds 'callerName:this' = 'obj'.
137+
// We then resolve this.method() → typeMap['obj.method'] → the concrete definition.
138+
// This runs before the broad exact-name lookup to avoid false positives from
139+
// unrelated same-file definitions.
140+
if (call.receiver === 'this' && callerName && !callerName.includes('.')) {
141+
const accessorThisEntry = typeMap.get(`${callerName}:this`);
142+
const objName = accessorThisEntry
143+
? typeof accessorThisEntry === 'string'
144+
? accessorThisEntry
145+
: (accessorThisEntry as { type?: string }).type
146+
: null;
147+
if (objName) {
148+
const objMethodEntry = typeMap.get(`${objName}.${call.name}`);
149+
const targetFn = objMethodEntry
150+
? typeof objMethodEntry === 'string'
151+
? objMethodEntry
152+
: (objMethodEntry as { type?: string }).type
153+
: null;
154+
if (targetFn) {
155+
const resolved = lookup
156+
.byName(targetFn)
157+
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
158+
if (resolved.length > 0) return resolved;
159+
}
160+
}
161+
}
162+
134163
const exact = lookup
135164
.byName(call.name)
136165
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);

0 commit comments

Comments
 (0)