diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 68f04a40f..6e8dca0c1 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -457,6 +457,27 @@ fn resolve_call_targets<'a>( || call.receiver.as_deref() == Some("self") || call.receiver.as_deref() == Some("super") { + // Phase 8.3f: accessor this-dispatch via Object.defineProperty. + // When a plain function (no class prefix in caller_name) is registered as a get/set + // accessor for `obj`, typeMap seeds 'callerName:this' = 'obj'. Resolve this.method() + // via typeMap['obj.method'] → the concrete definition. Runs before the broad exact-name + // lookup to avoid false positives from unrelated same-file definitions. + if call.receiver.as_deref() == Some("this") && !caller_name.is_empty() && !caller_name.contains('.') { + let accessor_key = format!("{}:this", caller_name); + if let Some(&(obj_name, _)) = type_map.get(accessor_key.as_str()) { + let obj_method_key = format!("{}.{}", obj_name, call.name); + if let Some(&(target_fn, _)) = type_map.get(obj_method_key.as_str()) { + let accessor_resolved: Vec<&NodeInfo> = ctx.nodes_by_name + .get(target_fn) + .map(|v| v.iter() + .filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) + .unwrap_or_default(); + if !accessor_resolved.is_empty() { return accessor_resolved; } + } + } + } + // First try exact name match (e.g. an unqualified function named "area"). let exact: Vec<&NodeInfo> = ctx.nodes_by_name .get(call.name.as_str()) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 2dd28221d..31b375adc 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -231,17 +231,27 @@ fn seed_define_property_entries(node: &Node, source: &[u8], symbols: &mut FileSy } if method == "defineProperty" { - // Object.defineProperty(obj, "key", { value: fn }) + // Object.defineProperty(obj, "key", { value: fn }) or { get: getter } if args.len() < 3 { return; } if args[0].kind() != "identifier" { return; } let obj_name = node_text(&args[0], source); let Some(key) = extract_string_fragment(&args[1], source) else { return }; - let Some(target) = find_descriptor_value(&args[2], source) else { return }; - symbols.type_map.push(TypeMapEntry { - name: format!("{}.{}", obj_name, key), - type_name: target.to_string(), - confidence: 0.85, - }); + // Phase 8.3e: { value: fn } → obj.key pts to fn + if let Some(target) = find_descriptor_value(&args[2], source) { + symbols.type_map.push(TypeMapEntry { + name: format!("{}.{}", obj_name, key), + type_name: target.to_string(), + confidence: 0.85, + }); + } + // Phase 8.3f: { get: getter } and/or { set: setter } → this inside each accessor === obj + for accessor in find_descriptor_accessors(&args[2], source) { + symbols.type_map.push(TypeMapEntry { + name: format!("{}:this", accessor), + type_name: obj_name.to_string(), + confidence: 0.85, + }); + } } else { // Object.defineProperties(obj, { "key": { value: fn }, ... }) if args.len() < 2 { return; } @@ -349,6 +359,124 @@ fn find_descriptor_value<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a st None } +/// Phase 8.3f: return the identifier texts of all `get` and `set` accessors in a property +/// descriptor. `{ get: getter, set: setter }` → ["getter", "setter"]. +/// Returns all accessors so that each one gets a `callerName:this = obj` typeMap entry. +fn find_descriptor_accessors<'a>(node: &Node<'a>, source: &'a [u8]) -> Vec<&'a str> { + if node.kind() != "object" { return Vec::new(); } + let mut result = Vec::new(); + for i in 0..node.child_count() { + let Some(child) = node.child(i) else { continue }; + if child.kind() != "pair" { continue; } + let Some(key) = child.child_by_field_name("key") else { continue }; + let key_text = node_text(&key, source); + if key_text != "get" && key_text != "set" { continue; } + let Some(val) = child.child_by_field_name("value") else { continue }; + if val.kind() == "identifier" { + result.push(node_text(&val, source)); + } + } + result +} + +/// Phase 8.3f: extract function/arrow properties from an object literal as standalone definitions +/// and seed composite typeMap keys so that `this.method()` inside Object.defineProperty accessors +/// can resolve them. +/// +/// Definitions are emitted under qualified names (`obj.baz`) to avoid polluting the global +/// definition index with common property names like `init`, `run`, or `render`. The typeMap +/// value for function/arrow properties also uses the qualified name so the resolver calls +/// `lookup.byName("obj.baz")` rather than `lookup.byName("baz")`. +/// +/// `const obj = { baz: () => {} }` → Definition { name: "obj.baz", kind: "function" } +/// + TypeMapEntry { name: "obj.baz", type_name: "obj.baz" } +/// `const obj = { baz }` (shorthand) → TypeMapEntry { name: "obj.baz", type_name: "baz" } +fn extract_object_literal_functions( + obj_node: &Node, + source: &[u8], + var_name: &str, + symbols: &mut FileSymbols, +) { + for i in 0..obj_node.child_count() { + let Some(child) = obj_node.child(i) else { continue }; + match child.kind() { + "shorthand_property_identifier" => { + let prop_name = node_text(&child, source); + symbols.type_map.push(TypeMapEntry { + name: format!("{}.{}", var_name, prop_name), + type_name: prop_name.to_string(), + confidence: 0.85, + }); + } + "pair" => { + let Some(key_n) = child.child_by_field_name("key") else { continue }; + let Some(val_n) = child.child_by_field_name("value") else { continue }; + let key = if key_n.kind() == "string" { + extract_string_fragment(&key_n, source).map(|s| s.to_string()) + } else { + Some(node_text(&key_n, source).to_string()) + }; + let Some(key) = key else { continue }; + let qualified = format!("{}.{}", var_name, key); + match val_n.kind() { + "arrow_function" | "function_expression" | "function" => { + // Use qualified name for the definition so it doesn't collide with + // unrelated top-level functions sharing the same property name. + symbols.definitions.push(Definition { + name: qualified.clone(), + kind: "function".to_string(), + line: start_line(&child), + end_line: Some(end_line(&val_n)), + decorators: None, + complexity: compute_all_metrics(&val_n, source, "javascript"), + cfg: build_function_cfg(&val_n, "javascript", source), + children: None, + }); + // Store qualified name as value so resolver looks up the qualified def. + symbols.type_map.push(TypeMapEntry { + name: qualified.clone(), + type_name: qualified, + confidence: 0.85, + }); + } + "identifier" => { + let target = node_text(&val_n, source); + symbols.type_map.push(TypeMapEntry { + name: qualified, + type_name: target.to_string(), + confidence: 0.85, + }); + } + _ => {} + } + } + "method_definition" => { + let Some(name_n) = child.child_by_field_name("name") else { continue }; + let qualified = format!("{}.{}", var_name, node_text(&name_n, source)); + let body = child.child_by_field_name("body"); + symbols.definitions.push(Definition { + name: qualified.clone(), + kind: "function".to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")), + cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)), + children: None, + }); + // Seed typeMap so the two-step accessor dispatch can find the qualified def. + // `const obj = { baz() {} }` → typeMap['obj.baz'] = 'obj.baz' + symbols.type_map.push(TypeMapEntry { + name: qualified.clone(), + type_name: qualified, + confidence: 0.85, + }); + } + _ => {} + } + } +} + // ── Return-type map extraction (Phase 8.2 parity) ─────────────────────────── /// 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) { cfg: None, children: None, }); + // Phase 8.3f: extract function/arrow properties from object literals and seed + // typeMap composite keys so that this.method() inside Object.defineProperty + // accessor functions can resolve them. + if value_n.kind() == "object" && name_n.kind() == "identifier" { + let var_name = node_text(&name_n, source); + extract_object_literal_functions(&value_n, source, var_name, symbols); + } } else if name_n.kind() == "identifier" && value_n.kind() == "identifier" { // Phase 8.3: `const alias = handler` — record for pts analysis. // Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index a88b9dd54..464365db7 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -131,6 +131,35 @@ export function resolveByMethodOrGlobal( call.receiver === 'self' || call.receiver === 'super' ) { + // Phase 8.3f: accessor this-dispatch via Object.defineProperty. + // When a plain function (no class prefix) is registered as a get/set accessor for `obj` + // via Object.defineProperty, typeMap seeds 'callerName:this' = 'obj'. + // We then resolve this.method() → typeMap['obj.method'] → the concrete definition. + // This runs before the broad exact-name lookup to avoid false positives from + // unrelated same-file definitions. + if (call.receiver === 'this' && callerName && !callerName.includes('.')) { + const accessorThisEntry = typeMap.get(`${callerName}:this`); + const objName = accessorThisEntry + ? typeof accessorThisEntry === 'string' + ? accessorThisEntry + : (accessorThisEntry as { type?: string }).type + : null; + if (objName) { + const objMethodEntry = typeMap.get(`${objName}.${call.name}`); + const targetFn = objMethodEntry + ? typeof objMethodEntry === 'string' + ? objMethodEntry + : (objMethodEntry as { type?: string }).type + : null; + if (targetFn) { + const resolved = lookup + .byName(targetFn) + .filter((t) => computeConfidence(relPath, t.file, null) >= 0.5); + if (resolved.length > 0) return resolved; + } + } + } + const exact = lookup .byName(call.name) .filter((t) => computeConfidence(relPath, t.file, null) >= 0.5); diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 622fe0109..9b6260c18 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -535,6 +535,14 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti line: nodeStartLine(declNode), endLine: nodeEndLine(declNode), }); + // Phase 8.3f: extract function/arrow properties from object literals. + // Scope guard: extractConstDeclarators is only called from extractConstantsWalk, which + // already skips const declarations inside function scopes (line ~412). So these definitions + // are always top-level. Any new call site must add a hasFunctionScopeAncestor guard + // (the walk path at handleVariableDecl does this). + if (valueN.type === 'object') { + extractObjectLiteralFunctions(valueN, nameN.text, definitions); + } } } } @@ -889,13 +897,28 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { endLine: nodeEndLine(valueN), children: varFnChildren.length > 0 ? varFnChildren : undefined, }); - } else if (isConst && nameN.type === 'identifier' && isConstantValue(valueN)) { + } else if ( + isConst && + nameN.type === 'identifier' && + isConstantValue(valueN) && + !hasFunctionScopeAncestor(node) + ) { ctx.definitions.push({ name: nameN.text, kind: 'constant', line: nodeStartLine(node), endLine: nodeEndLine(node), }); + // Phase 8.3f: extract function/arrow properties from object literals so that + // this.method() calls inside Object.defineProperty accessors can resolve them. + // Scope guard: hasFunctionScopeAncestor mirrors the Rust path's find_parent_of_types + // check and the sibling destructured-binding branch below — skips object literals + // inside function bodies to avoid polluting the global definition index with + // local variable properties (e.g. `localObj.fn` from `const localObj = { fn: ... }` + // inside a function). + if (valueN.type === 'object') { + extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions); + } } else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) { // Destructured bindings: const { handleToken, checkPermissions } = initAuth(...) // Each destructured property becomes a function definition so it can be @@ -916,6 +939,59 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { } } +/** + * Phase 8.3f: extract function/arrow function properties from an object literal as standalone + * definitions so that `this.method()` calls inside Object.defineProperty accessor functions can + * resolve them via the same-file definition lookup. + * + * Definitions are emitted as qualified names (`obj.baz` rather than bare `baz`) to avoid + * polluting the global definition index with common property names like `init`, `run`, or + * `render`. The typeMap value stored by the caller also uses the qualified name so the resolver + * looks up `lookup.byName('obj.baz')` rather than `lookup.byName('baz')`. + * + * `const obj = { baz: () => {} }` → emits Definition { name: 'obj.baz', kind: 'function' } + */ +function extractObjectLiteralFunctions( + objNode: TreeSitterNode, + varName: string, + definitions: Definition[], +): void { + for (let i = 0; i < objNode.childCount; i++) { + const child = objNode.child(i); + if (!child) continue; + if (child.type === 'pair') { + const keyNode = child.childForFieldName('key'); + const valueNode = child.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + const keyName = + keyNode.type === 'string' ? keyNode.text.replace(/^['"]|['"]$/g, '') : keyNode.text; + if (!keyName) continue; + if ( + valueNode.type === 'arrow_function' || + valueNode.type === 'function_expression' || + valueNode.type === 'function' + ) { + definitions.push({ + name: `${varName}.${keyName}`, + kind: 'function', + line: nodeStartLine(child), + endLine: nodeEndLine(valueNode), + }); + } + } else if (child.type === 'method_definition') { + const nameNode = child.childForFieldName('name'); + if (nameNode) { + definitions.push({ + name: `${varName}.${nameNode.text}`, + kind: 'function', + line: nodeStartLine(child), + endLine: nodeEndLine(child), + }); + } + } + } +} + function handleEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; @@ -1717,6 +1793,56 @@ function handleVarDeclaratorTypeMap( } } } + + // Phase 8.3f: seed composite pts keys for object literal properties. + // `const obj = { baz: () => {} }` → typeMap['obj.baz'] = 'obj.baz' + // `const obj = { baz }` (shorthand) → typeMap['obj.baz'] = 'baz' (bare identifier target) + // `const obj = { baz: otherFn }` → typeMap['obj.baz'] = 'otherFn' (identifier alias) + // + // For function/arrow values, the value is the qualified name ('obj.baz') because + // extractObjectLiteralFunctions now registers definitions under that qualified name to avoid + // polluting the global index with bare property names like 'init', 'run', or 'render'. + // Enables accessor this-dispatch: when typeMap['getter:this'] = 'obj', + // resolving this.baz() inside getter → typeMap['obj.baz'] → 'obj.baz' → lookup.byName('obj.baz'). + // + // Scope guard: mirrors Rust handle_var_decl's find_parent_of_types check — skip object literals + // inside function bodies so function-scoped `const localObj = { fn: ... }` never seeds + // the typeMap (which would shadow a module-level `const obj` with the same property names). + if (valueN.type === 'object' && !hasFunctionScopeAncestor(node)) { + for (let i = 0; i < valueN.childCount; i++) { + const child = valueN.child(i); + if (!child) continue; + if (child.type === 'shorthand_property_identifier') { + setTypeMapEntry(typeMap, `${nameN.text}.${child.text}`, child.text, 0.85); + } else if (child.type === 'pair') { + const keyNode = child.childForFieldName('key'); + const valNode = child.childForFieldName('value'); + if (!keyNode || !valNode) continue; + const keyName = + keyNode.type === 'string' ? keyNode.text.replace(/^['"]|['"]$/g, '') : keyNode.text; + if (!keyName) continue; + const qualifiedKey = `${nameN.text}.${keyName}`; + if ( + valNode.type === 'arrow_function' || + valNode.type === 'function_expression' || + valNode.type === 'function' + ) { + // Store the qualified name so the resolver finds the qualified definition. + setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85); + } else if (valNode.type === 'identifier') { + setTypeMapEntry(typeMap, qualifiedKey, valNode.text, 0.85); + } + } else if (child.type === 'method_definition') { + // Method shorthand: `const obj = { baz() {} }` → typeMap['obj.baz'] = 'obj.baz' + // extractObjectLiteralFunctions registers a definition under the qualified name; + // seed the matching typeMap entry so the two-step accessor dispatch finds it. + const nameNode = child.childForFieldName('name'); + if (!nameNode) continue; + const qualifiedKey = `${nameN.text}.${nameNode.text}`; + setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85); + } + } + } } /** Extract type info from a required_parameter or optional_parameter. */ @@ -1769,10 +1895,11 @@ function handlePropWriteTypeMap(node: TreeSitterNode, typeMap: Map:this' — colon is a reserved separator used only by this phase. + // JS identifiers cannot contain ':', so this key never collides with real variable names. + for (const accessor of findDescriptorAccessors(arg2)) { + setTypeMapEntry(typeMap, `${accessor}:this`, arg0.text, 0.85); + } } else { // defineProperties if (args.length < 2) return; @@ -1841,6 +1976,26 @@ function findDescriptorValue(desc: TreeSitterNode): string | undefined { return undefined; } +/** + * Phase 8.3f: return the identifier texts of all `get` and `set` accessors in a property + * descriptor. `{ get: getter, set: setter }` → ['getter', 'setter']. + * Returns all accessors so that each one gets a `callerName:this = obj` typeMap entry. + */ +function findDescriptorAccessors(desc: TreeSitterNode): string[] { + if (desc.type !== 'object') return []; + const result: string[] = []; + for (let i = 0; i < desc.childCount; i++) { + const pair = desc.child(i); + if (pair?.type !== 'pair') continue; + const key = pair.childForFieldName('key'); + const val = pair.childForFieldName('value'); + if ((key?.text === 'get' || key?.text === 'set') && val?.type === 'identifier') { + result.push(val.text); + } + } + return result; +} + /** Seed composite pts keys for each property in a prototype object literal. */ function seedProtoProperties( varName: string, diff --git a/tests/benchmarks/resolution/fixtures/javascript/define-property-accessor.js b/tests/benchmarks/resolution/fixtures/javascript/define-property-accessor.js new file mode 100644 index 000000000..f3ccf6ddf --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/javascript/define-property-accessor.js @@ -0,0 +1,17 @@ +// Object.defineProperty accessor this-dispatch fixture (issue #1335). +// When a function is registered as a get accessor via Object.defineProperty, +// this inside that function refers to the target object. + +const accessorTarget = { + accessMethod: () => 42, +}; + +function accessorGetter() { + this.accessMethod(); +} + +Object.defineProperty(accessorTarget, 'computed', { get: accessorGetter }); + +export function runAccessorThisDispatch() { + return accessorTarget.computed; +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index 45cfaa2ae..0352f960d 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -240,6 +240,13 @@ "kind": "calls", "mode": "receiver-typed", "notes": "new A().t() — A.prototype.t = f alias; inline new receiver resolved to type A, typeMap['A.t'] = f" + }, + { + "source": { "name": "accessorGetter", "file": "define-property-accessor.js" }, + "target": { "name": "accessorTarget.accessMethod", "file": "define-property-accessor.js" }, + "kind": "calls", + "mode": "defineProperty-accessor", + "notes": "this.accessMethod() inside accessorGetter — this === accessorTarget (get accessor via Object.defineProperty); accessorTarget.accessMethod is the qualified node name for the arrow function property of accessorTarget" } ] } diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/accessors3.js b/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/accessors3.js new file mode 100644 index 000000000..2edec63cc --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/accessors3.js @@ -0,0 +1,17 @@ +// Micro-test: Object.defineProperty accessor this-dispatch. +// When getter is registered as a get accessor for obj, this inside getter === obj. +// So this.baz() inside getter must resolve to baz (the arrow function on obj). + +const obj = { + baz: () => { + console.log('baz'); + }, +}; + +function getter() { + this.baz(); +} + +Object.defineProperty(obj, 'bar', { get: getter }); + +const _x = obj.bar; diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/expected-edges.json new file mode 100644 index 000000000..5c7d7794c --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/accessors3/expected-edges.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../../expected-edges.schema.json", + "language": "javascript", + "description": "Hand-annotated call edges for Object.defineProperty accessor this-dispatch", + "edges": [ + { + "source": { "name": "getter", "file": "accessors3.js" }, + "target": { "name": "obj.baz", "file": "accessors3.js" }, + "kind": "calls", + "mode": "defineProperty-accessor", + "notes": "this.baz() inside getter — this === obj (get accessor via Object.defineProperty); obj.baz is the qualified node name for the arrow function property of obj" + } + ] +} diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index d0c337d2b..1e78c0ee8 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -119,8 +119,9 @@ const THRESHOLDS: Record = { // (5 new edges in define-property.js) + Phase 8.5 adds class-inheritance and prototype edges // (inheritance.js, prototypes.js, prototypes2.js), lifting total expected to 30. Phase 8.3f // adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33. - // Phase 8.3g adds Object.defineProperty accessor this-dispatch (1 new edge in define-property.js), - // total expected now 34. + // Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in + // define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js, + // total expected now 35. javascript: { precision: 1.0, recall: 0.9 }, // TS 0.72: Phase 8.3e adds this.method() same-class resolution (Shape.describe → Shape.area), // lifting recall from 69.4% to 72.2%. Remaining gap (interface-dispatch, CHA) is tracked