diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 6569ea1fe..a4390f809 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -93,10 +93,18 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep }); } } + // Phase 8.3e: Object.create({ key: fn }) → composite pts key per property + if value_n.kind() == "call_expression" { + seed_object_create_entries(var_name, &value_n, source, symbols); + } } } } } + // Phase 8.3e: Object.defineProperty / defineProperties → composite pts key + "call_expression" => { + seed_define_property_entries(node, source, symbols); + } "required_parameter" | "optional_parameter" => { let name_node = node.child_by_field_name("pattern") .or_else(|| node.child_by_field_name("left")) @@ -194,6 +202,151 @@ fn is_js_builtin_global(name: &str) -> bool { ) } +// ── Phase 8.3e: Object.defineProperty / defineProperties / create ──────────── + +/// Seed composite pts keys for `Object.defineProperty(obj, "key", { value: fn })` +/// and `Object.defineProperties(obj, { "key": { value: fn }, ... })`. +fn seed_define_property_entries(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(callee) = node.child_by_field_name("function") else { return }; + if callee.kind() != "member_expression" { return; } + let Some(callee_obj) = callee.child_by_field_name("object") else { return }; + if node_text(&callee_obj, source) != "Object" { return; } + let Some(callee_prop) = callee.child_by_field_name("property") else { return }; + let method = node_text(&callee_prop, source); + if method != "defineProperty" && method != "defineProperties" { return; } + + let args_node = node.child_by_field_name("arguments") + .or_else(|| find_child(node, "arguments")); + let Some(args_node) = args_node else { return }; + + // Collect non-punctuation argument nodes in order + let mut args: Vec = Vec::new(); + for i in 0..args_node.child_count() { + let Some(child) = args_node.child(i) else { continue }; + if !matches!(child.kind(), "(" | ")" | ",") { + args.push(child); + } + } + + if method == "defineProperty" { + // Object.defineProperty(obj, "key", { value: fn }) + 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, + }); + } else { + // Object.defineProperties(obj, { "key": { value: fn }, ... }) + if args.len() < 2 { return; } + if args[0].kind() != "identifier" { return; } + let obj_name = node_text(&args[0], source).to_string(); + if args[1].kind() != "object" { return; } + seed_descriptor_object(&obj_name, &args[1], source, symbols); + } +} + +/// Seed composite pts keys from `const obj = Object.create({ f1, f2 })`. +fn seed_object_create_entries(var_name: &str, call_node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(callee) = call_node.child_by_field_name("function") else { return }; + if callee.kind() != "member_expression" { return; } + let Some(callee_obj) = callee.child_by_field_name("object") else { return }; + if node_text(&callee_obj, source) != "Object" { return; } + let Some(callee_prop) = callee.child_by_field_name("property") else { return }; + if node_text(&callee_prop, source) != "create" { return; } + + let args_node = call_node.child_by_field_name("arguments") + .or_else(|| find_child(call_node, "arguments")); + let Some(args_node) = args_node else { return }; + + // First non-punctuation argument = prototype object + let proto = (0..args_node.child_count()) + .filter_map(|i| args_node.child(i)) + .find(|n| !matches!(n.kind(), "(" | ")" | ",")); + let Some(proto) = proto else { return }; + if proto.kind() != "object" { return }; + + for i in 0..proto.child_count() { + let Some(child) = proto.child(i) else { continue }; + match child.kind() { + "shorthand_property_identifier" => { + // { f1 } shorthand — property name equals value name + let name = node_text(&child, source); + symbols.type_map.push(TypeMapEntry { + name: format!("{}.{}", var_name, name), + type_name: 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 }; + if val_n.kind() != "identifier" { 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 }; + symbols.type_map.push(TypeMapEntry { + name: format!("{}.{}", var_name, key), + type_name: node_text(&val_n, source).to_string(), + confidence: 0.85, + }); + } + _ => {} + } + } +} + +/// Iterate over the properties of a `defineProperties` descriptor object and seed the type_map. +fn seed_descriptor_object(obj_name: &str, obj_node: &Node, source: &[u8], symbols: &mut FileSymbols) { + for i in 0..obj_node.child_count() { + let Some(child) = obj_node.child(i) else { continue }; + if child.kind() != "pair" { continue; } + 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 Some(target) = find_descriptor_value(&val_n, source) else { continue }; + symbols.type_map.push(TypeMapEntry { + name: format!("{}.{}", obj_name, key), + type_name: target.to_string(), + confidence: 0.85, + }); + } +} + +/// Extract the text of the `string_fragment` child of a string node, i.e. content without quotes. +fn extract_string_fragment<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + if node.kind() != "string" { return None; } + find_child(node, "string_fragment").map(|n| node_text(&n, source)) +} + +/// Find the `value` identifier in a property descriptor object `{ value: fn }`. +fn find_descriptor_value<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + if node.kind() != "object" { return None; } + 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 }; + if node_text(&key, source) != "value" { continue; } + let Some(val) = child.child_by_field_name("value") else { continue }; + if val.kind() == "identifier" { + return Some(node_text(&val, source)); + } + } + None +} + // ── Return-type map extraction (Phase 8.2 parity) ─────────────────────────── /// Walk the AST collecting function/method return types into `symbols.return_type_map`. @@ -2292,4 +2445,76 @@ mod tests { "compute call should have receiver='calc'" ); } + + /// Phase 8.3e: Object.defineProperty seeds composite type_map key. + #[test] + fn type_map_from_define_property() { + let s = parse_js( + "function f1() {}\n\ + const obj = {};\n\ + Object.defineProperty(obj, \"f\", { value: f1 });", + ); + let entry = s.type_map.iter().find(|e| e.name == "obj.f"); + assert!(entry.is_some(), "type_map should contain 'obj.f'; got: {:?}", s.type_map); + assert_eq!(entry.unwrap().type_name, "f1"); + } + + /// Phase 8.3e: Object.defineProperties seeds composite type_map keys. + #[test] + fn type_map_from_define_properties() { + let s = parse_js( + "function f1() {}\n\ + function f2() {}\n\ + const obj = {};\n\ + Object.defineProperties(obj, {\n\ + \"f1\": { value: f1 },\n\ + \"f2\": { value: f2 },\n\ + });", + ); + let e1 = s.type_map.iter().find(|e| e.name == "obj.f1"); + let e2 = s.type_map.iter().find(|e| e.name == "obj.f2"); + assert!(e1.is_some(), "type_map should contain 'obj.f1'; got: {:?}", s.type_map); + assert!(e2.is_some(), "type_map should contain 'obj.f2'; got: {:?}", s.type_map); + assert_eq!(e1.unwrap().type_name, "f1"); + assert_eq!(e2.unwrap().type_name, "f2"); + } + + /// Phase 8.3e: Object.create seeds composite type_map keys from shorthand proto. + #[test] + fn type_map_from_object_create() { + let s = parse_js( + "function f1() {}\n\ + function f2() {}\n\ + const obj = Object.create({ f1, f2 });", + ); + let e1 = s.type_map.iter().find(|e| e.name == "obj.f1"); + let e2 = s.type_map.iter().find(|e| e.name == "obj.f2"); + assert!(e1.is_some(), "type_map should contain 'obj.f1'; got: {:?}", s.type_map); + assert!(e2.is_some(), "type_map should contain 'obj.f2'; got: {:?}", s.type_map); + assert_eq!(e1.unwrap().type_name, "f1"); + assert_eq!(e2.unwrap().type_name, "f2"); + } + + /// Phase 8.3e: call receiver is correctly recorded for obj.f() inside defProp body. + #[test] + fn call_receiver_for_define_property() { + let s = parse_js( + "function f1() {}\n\ + function defProp() {\n\ + const obj = {};\n\ + Object.defineProperty(obj, \"f\", { value: f1 });\n\ + obj.f();\n\ + }", + ); + let tm = s.type_map.iter().find(|e| e.name == "obj.f"); + assert!(tm.is_some(), "type_map should contain 'obj.f'; got: {:?}", s.type_map); + assert_eq!(tm.unwrap().type_name, "f1"); + + let call = s.calls.iter().find(|c| c.name == "f" && c.receiver.as_deref() == Some("obj")); + assert!( + call.is_some(), + "calls should contain obj.f() with receiver='obj'; got: {:?}", + s.calls.iter().map(|c| (&c.name, &c.receiver)).collect::>() + ); + } } diff --git a/src/extractors/csharp.ts b/src/extractors/csharp.ts index 689184b75..52f47cb46 100644 --- a/src/extractors/csharp.ts +++ b/src/extractors/csharp.ts @@ -279,7 +279,7 @@ function extractCSharpParameters(paramListNode: TreeSitterNode | null): SubDecla if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter') continue; + if (param?.type !== 'parameter') continue; const nameNode = param.childForFieldName('name'); if (nameNode) { params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); @@ -294,12 +294,12 @@ function extractCSharpClassFields(classNode: TreeSitterNode): SubDeclaration[] { if (!body) return fields; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'field_declaration') continue; + if (member?.type !== 'field_declaration') continue; const varDecl = findChild(member, 'variable_declaration'); if (!varDecl) continue; for (let j = 0; j < varDecl.childCount; j++) { const child = varDecl.child(j); - if (!child || child.type !== 'variable_declarator') continue; + if (child?.type !== 'variable_declarator') continue; const nameNode = child.childForFieldName('name'); if (nameNode) { fields.push({ @@ -337,7 +337,7 @@ function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { if (!typeName) return; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (!child || child.type !== 'variable_declarator') continue; + if (child?.type !== 'variable_declarator') continue; const nameNode = child.childForFieldName('name') || child.child(0); if (nameNode && nameNode.type === 'identifier' && ctx.typeMap) { setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9); diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index cb3ab014b..f76f5f012 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1430,6 +1430,8 @@ function extractTypeMapWalk( handleParamTypeMap(node, typeMap); } else if (t === 'assignment_expression') { handlePropWriteTypeMap(node, typeMap); + } else if (t === 'call_expression') { + handleDefinePropertyTypeMap(node, typeMap); } for (let i = 0; i < node.childCount; i++) { walk(node.child(i)!, depth + 1); @@ -1515,6 +1517,29 @@ function handleVarDeclaratorTypeMap( if (valueN.type === 'new_expression') return; if (valueN.type === 'call_expression') { + // Phase 8.3e: Object.create({ f1, f2 }) — seed composite pts keys obj.f1 → f1, etc. + const createFn = valueN.childForFieldName('function'); + if (createFn?.type === 'member_expression') { + const createObj = createFn.childForFieldName('object'); + const createProp = createFn.childForFieldName('property'); + if (createObj?.text === 'Object' && createProp?.text === 'create') { + const createArgs = valueN.childForFieldName('arguments') || findChild(valueN, 'arguments'); + if (createArgs) { + let proto: TreeSitterNode | null = null; + for (let i = 0; i < createArgs.childCount; i++) { + const n = createArgs.child(i); + if (n && n.type !== '(' && n.type !== ')' && n.type !== ',') { + proto = n; + break; + } + } + if (proto?.type === 'object') { + seedProtoProperties(nameN.text, proto, typeMap); + } + } + return; + } + } // Phase 8.2: inter-procedural propagation — try to resolve return type from // the local returnTypeMap before falling back to factory heuristics. if (returnTypeMap) { @@ -1595,6 +1620,100 @@ function handlePropWriteTypeMap(node: TreeSitterNode, typeMap: Map, +): void { + const fn = node.childForFieldName('function'); + if (fn?.type !== 'member_expression') return; + const fnObj = fn.childForFieldName('object'); + const fnProp = fn.childForFieldName('property'); + if (fnObj?.text !== 'Object') return; + const method = fnProp?.text; + if (method !== 'defineProperty' && method !== 'defineProperties') return; + + const argsNode = node.childForFieldName('arguments') || findChild(node, 'arguments'); + if (!argsNode) return; + + const args: TreeSitterNode[] = []; + for (let i = 0; i < argsNode.childCount; i++) { + const n = argsNode.child(i); + if (n && n.type !== '(' && n.type !== ')' && n.type !== ',') args.push(n); + } + + if (method === 'defineProperty') { + if (args.length < 3) return; + const arg0 = args[0]!, + arg1 = args[1]!, + arg2 = args[2]!; + if (arg0.type !== 'identifier') return; + if (arg1.type !== 'string') return; + const key = arg1.text.replace(/^['"]|['"]$/g, ''); + if (!key) return; + const target = findDescriptorValue(arg2); + if (!target) return; + setTypeMapEntry(typeMap, `${arg0.text}.${key}`, target, 0.85); + } else { + // defineProperties + if (args.length < 2) return; + const arg0 = args[0]!, + arg1 = args[1]!; + if (arg0.type !== 'identifier') return; + if (arg1.type !== 'object') return; + for (let i = 0; i < arg1.childCount; i++) { + const pair = arg1.child(i); + if (pair?.type !== 'pair') continue; + const keyN = pair.childForFieldName('key'); + const valN = pair.childForFieldName('value'); + if (!keyN || !valN) continue; + const key = keyN.type === 'string' ? keyN.text.replace(/^['"]|['"]$/g, '') : keyN.text; + const target = findDescriptorValue(valN); + if (!target) continue; + setTypeMapEntry(typeMap, `${arg0.text}.${key}`, target, 0.85); + } + } +} + +/** Return the identifier text of the `value` field in a property descriptor object. */ +function findDescriptorValue(desc: TreeSitterNode): string | undefined { + if (desc.type !== 'object') return undefined; + 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 === 'value' && val?.type === 'identifier') return val.text; + } + return undefined; +} + +/** Seed composite pts keys for each property in a prototype object literal. */ +function seedProtoProperties( + varName: string, + proto: TreeSitterNode, + typeMap: Map, +): void { + for (let i = 0; i < proto.childCount; i++) { + const child = proto.child(i); + if (!child) continue; + if (child.type === 'shorthand_property_identifier') { + setTypeMapEntry(typeMap, `${varName}.${child.text}`, child.text, 0.85); + } else if (child.type === 'pair') { + const keyN = child.childForFieldName('key'); + const valN = child.childForFieldName('value'); + if (!keyN || !valN || valN.type !== 'identifier') continue; + const key = keyN.type === 'string' ? keyN.text.replace(/^['"]|['"]$/g, '') : keyN.text; + setTypeMapEntry(typeMap, `${varName}.${key}`, valN.text, 0.85); + } + } +} + /** * Phase 8.3c: record argument-to-parameter bindings at call sites. * diff --git a/tests/benchmarks/resolution/fixtures/javascript/define-property.js b/tests/benchmarks/resolution/fixtures/javascript/define-property.js new file mode 100644 index 000000000..58948d385 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/javascript/define-property.js @@ -0,0 +1,32 @@ +// Targets referenced through property descriptor APIs. +function f1() { + return 1; +} +function f2() { + return 2; +} + +// Object.defineProperty(obj, "key", { value: fn }) → obj.key() resolves to fn +function defProp() { + const obj = {}; + Object.defineProperty(obj, 'f', { value: f1 }); + obj.f(); +} + +// Object.defineProperties(obj, { key: { value: fn } }) → obj.key() resolves to fn +function defProps() { + const obj = {}; + Object.defineProperties(obj, { + f1: { value: f1 }, + f2: { value: f2 }, + }); + obj.f1(); + obj.f2(); +} + +// Object.create({ key: fn }) → obj.key() resolves via prototype +function create() { + const obj = Object.create({ f1, f2 }); + obj.f1(); + obj.f2(); +} diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index e3eed60eb..e60d89dba 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -129,6 +129,41 @@ "mode": "constructor", "notes": "new UserService() — class instantiation tracked as consumption" }, + { + "source": { "name": "defProp", "file": "define-property.js" }, + "target": { "name": "f1", "file": "define-property.js" }, + "kind": "calls", + "mode": "pts-define-property", + "notes": "obj.f() — resolved via Object.defineProperty(obj, \"f\", { value: f1 })" + }, + { + "source": { "name": "defProps", "file": "define-property.js" }, + "target": { "name": "f1", "file": "define-property.js" }, + "kind": "calls", + "mode": "pts-define-property", + "notes": "obj.f1() — resolved via Object.defineProperties(obj, { \"f1\": { value: f1 } })" + }, + { + "source": { "name": "defProps", "file": "define-property.js" }, + "target": { "name": "f2", "file": "define-property.js" }, + "kind": "calls", + "mode": "pts-define-property", + "notes": "obj.f2() — resolved via Object.defineProperties(obj, { \"f2\": { value: f2 } })" + }, + { + "source": { "name": "create", "file": "define-property.js" }, + "target": { "name": "f1", "file": "define-property.js" }, + "kind": "calls", + "mode": "pts-create-prototype", + "notes": "obj.f1() — resolved via Object.create({ f1, f2 })" + }, + { + "source": { "name": "create", "file": "define-property.js" }, + "target": { "name": "f2", "file": "define-property.js" }, + "kind": "calls", + "mode": "pts-create-prototype", + "notes": "obj.f2() — resolved via Object.create({ f1, f2 })" + }, { "source": { "name": "runBind", "file": "bind-call-apply.js" }, "target": { "name": "greet", "file": "bind-call-apply.js" }, diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index a124f45c5..1b0129bac 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -91,6 +91,8 @@ const TECHNIQUE_MAP: Record = { callback: 'points-to', dynamic: 'points-to', 'points-to': 'points-to', + 'pts-define-property': 'points-to', + 'pts-create-prototype': 'points-to', }; // ── Configuration ──────────────────────────────────────────────────────── @@ -112,6 +114,10 @@ const THRESHOLDS: Record = { // immediately, which is intentional. If a new fixture addition causes a genuine FP // (i.e. the code resolves an edge that is arguably correct but not in expected-edges), // the correct fix is to add it to expected-edges — not to lower the threshold. + // JS recall 0.9: Phase 8.3e adds Object.defineProperty/defineProperties/create composite pts keys + // (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. 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 diff --git a/tests/search/embedding-regression.test.ts b/tests/search/embedding-regression.test.ts index ed26139e6..aedf8b499 100644 --- a/tests/search/embedding-regression.test.ts +++ b/tests/search/embedding-regression.test.ts @@ -85,19 +85,10 @@ describe.skipIf(!hasTransformers)('embedding regression (real model)', () => { if (!tmpDir) return; // Flush any deferred DB closes before deleting the temp directory. // On Windows, SQLite WAL files can remain locked briefly after db.close(), - // causing intermittent EBUSY errors. Retry up to 3 times with a short delay. + // causing intermittent EBUSY errors. Node's built-in maxRetries handles + // retrying EBUSY/EMFILE automatically with retryDelay ms between attempts. flushDeferredClose(); - const sharedBuf = new SharedArrayBuffer(4); - const sharedArr = new Int32Array(sharedBuf); - for (let attempt = 0; attempt < 3; attempt++) { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - return; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EBUSY' || attempt === 2) throw err; - Atomics.wait(sharedArr, 0, 0, 100); - } - } + fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); describe('smoke tests', () => {