diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 09d55e78..255cfafb 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -138,6 +138,17 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep if value_n.kind() == "call_expression" { seed_object_create_entries(var_name, &value_n, source, symbols); } + // Phase 8.3f parity: seed composite typeMap keys for ALL object-literal + // declarations (`const`, `let`, `var`) when at non-function scope. + // Mirrors WASM handleVarDeclaratorTypeMap (no isConst guard there). + // For `const`, extract_object_literal_functions already seeds these entries; + // dedup_type_map collapses any duplicates at equal confidence. + if value_n.kind() == "object" && find_parent_of_types(node, &[ + "function_declaration", "arrow_function", "function_expression", + "method_definition", "generator_function_declaration", "generator_function", + ]).is_none() { + seed_objlit_type_map_entries(var_name, &value_n, source, symbols); + } } } } @@ -555,9 +566,86 @@ fn extract_object_literal_functions( } } +/// Seed composite typeMap keys from an object literal for ALL declaration kinds +/// (`const`, `let`, `var`) at non-function scope. +/// +/// Mirrors WASM `handleVarDeclaratorTypeMap`'s object-literal branch (no `isConst` guard). +/// Called from `match_js_type_map` so that `let obj = { f() {} }` and +/// `var routes = { get: handler }` resolve correctly just like `const` variants. +/// +/// For `const` declarations this produces the same entries as `extract_object_literal_functions`, +/// but `dedup_type_map` collapses duplicates at equal confidence. +fn seed_objlit_type_map_entries(var_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 }; + 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" => { + // Store qualified name as value so the resolver finds the qualified def. + // Mirrors WASM: setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85). + // For `const`, `extract_object_literal_functions` creates the matching definition. + // For `let`/`var`, `match_js_objlit_qualified_method_defs` creates it in its + // deferred second pass (now covers all declaration kinds, not just `const`). + 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" => { + // Method shorthand: `let obj = { baz() {} }` → typeMap['obj.baz'] = 'baz' + // Points to the bare-name definition so the two-step accessor dispatch resolves + // via the bare node. `handle_method_def` always creates a bare definition for + // method_definition nodes; `match_js_objlit_qualified_method_defs` (which now + // covers all declaration kinds) adds the qualified definition in its deferred + // second pass. Using the bare name here keeps resolution consistent across all + // declaration kinds (const/let/var). + let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; + let qualified = format!("{}.{}", var_name, method_name); + symbols.type_map.push(TypeMapEntry { + name: qualified, + type_name: method_name.to_string(), + confidence: 0.85, + }); + } + _ => {} + } + } +} + /// Second-pass walker: emit qualified `obj.method(function)` definitions for -/// `method_definition` children of object literals. +/// `method_definition` and (for `let`/`var`) `pair+arrow/function` children of object literals. /// +/// **method_definition** (all declaration kinds — `const`, `let`, `var`): /// This must run AFTER the main `match_js_node` walk so that the bare `f(method)` node /// created by `handle_method_def` appears BEFORE the qualified `obj.f(function)` node /// in `symbols.definitions`. `findCaller` picks the narrowest-span enclosing definition; @@ -565,6 +653,13 @@ fn extract_object_literal_functions( /// and call-edge attribution matches WASM (which runs `handleMethodCapture` via the query /// path before `extractObjectLiteralFunctions` via `runCollectorWalk`). /// +/// **pair + arrow_function / function_expression / function** (`let`/`var` only): +/// For `const`, `extract_object_literal_functions` already creates the qualified definition; +/// repeating it here would produce a duplicate. For `let`/`var`, no other pass emits the +/// qualified definition, so we must emit it here. Without the definition, the typeMap entry +/// seeded by `seed_objlit_type_map_entries` (`"api.save" → "api.save"`) dead-ends: the +/// resolver finds the typeMap entry but then fails to locate a node named `"api.save"`. +/// /// WASM produces both nodes — the qualified one via `extractObjectLiteralFunctions` and the /// bare one via `handleMethodCapture`. This pass mirrors that by adding only the qualified /// definitions, deferred so ordering is correct. @@ -583,7 +678,6 @@ fn match_js_objlit_qualified_method_defs( return; } let is_const = node.child(0).map(|c| node_text(&c, source) == "const").unwrap_or(false); - if !is_const { return; } for i in 0..node.child_count() { let Some(declarator) = node.child(i) else { continue }; if declarator.kind() != "variable_declarator" { continue; } @@ -593,22 +687,54 @@ fn match_js_objlit_qualified_method_defs( let var_name = node_text(&name_n, source); for j in 0..value_n.child_count() { let Some(child) = value_n.child(j) else { continue }; - if child.kind() != "method_definition" { continue; } - // Use resolve_method_def_name to strip brackets from computed string keys - // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]). - let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; - let qualified = format!("{}.{}", var_name, method_name); - let body = child.child_by_field_name("body"); - symbols.definitions.push(Definition { - name: qualified, - 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, - }); + match child.kind() { + "method_definition" => { + // Emit qualified definition for ALL declaration kinds. + // Use resolve_method_def_name to strip brackets from computed string keys + // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]). + let Some(method_name) = resolve_method_def_name(&child, source) else { continue }; + let qualified = format!("{}.{}", var_name, method_name); + let body = child.child_by_field_name("body"); + symbols.definitions.push(Definition { + name: qualified, + 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, + }); + } + "pair" if !is_const => { + // Emit qualified definition for `let`/`var` pair+arrow/function values only. + // For `const`, `extract_object_literal_functions` already creates this definition; + // creating it again here would be a duplicate. + 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 !matches!(val_n.kind(), "arrow_function" | "function_expression" | "function") { + 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); + symbols.definitions.push(Definition { + name: qualified, + 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, + }); + } + _ => {} + } } } } @@ -4108,6 +4234,82 @@ mod tests { assert_eq!(tm_f.unwrap().type_name, "f"); } + /// Issue #1551: `let` and `var` object-literal declarations must seed composite typeMap keys + /// just like `const` declarations. Regression test for the parity gap where native bailed + /// early for non-`const` declarations in the object-literal typeMap walk. + #[test] + fn let_var_objlit_seeds_type_map_entries() { + // Method shorthand: `let obj = { f() {} }` → typeMap['obj.f'] present + let s_let_method = parse_js( + "let obj = { f() { return 1; } };\n\ + obj.f();", + ); + let tm = s_let_method.type_map.iter().find(|e| e.name == "obj.f"); + assert!(tm.is_some(), "let obj method: typeMap 'obj.f' missing; got: {:?}", + s_let_method.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(tm.unwrap().type_name, "f", + "typeMap 'obj.f' must point at bare name 'f', not the qualified key"); + let call = s_let_method.calls.iter().find(|c| c.name == "f" && c.receiver.as_deref() == Some("obj")); + assert!(call.is_some(), + "calls must contain obj.f() with receiver='obj'; got: {:?}", + s_let_method.calls.iter().map(|c| (&c.name, &c.receiver)).collect::>()); + + // Shorthand property: `var obj = { e4 }` → typeMap['obj.e4'] = 'e4' + let s_var_shorthand = parse_js( + "function e4() {}\n\ + var obj = { e4 };", + ); + let tm2 = s_var_shorthand.type_map.iter().find(|e| e.name == "obj.e4"); + assert!(tm2.is_some(), "var obj shorthand: typeMap 'obj.e4' missing; got: {:?}", + s_var_shorthand.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(tm2.unwrap().type_name, "e4"); + + // Pair with identifier value: `var routes = { get: handler }` → typeMap['routes.get'] = 'handler' + let s_var_pair = parse_js( + "function handler() {}\n\ + var routes = { get: handler };", + ); + let tm3 = s_var_pair.type_map.iter().find(|e| e.name == "routes.get"); + assert!(tm3.is_some(), "var routes pair: typeMap 'routes.get' missing; got: {:?}", + s_var_pair.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(tm3.unwrap().type_name, "handler"); + + // Pair with arrow value: `let api = { save: () => {} }` → typeMap['api.save'] = 'api.save' + // and a qualified definition 'api.save' must exist (emitted by the deferred + // match_js_objlit_qualified_method_defs pass for non-const pair+arrow/function). + let s_let_arrow = parse_js( + "let api = { save: () => {} };\n\ + api.save();", + ); + let tm4 = s_let_arrow.type_map.iter().find(|e| e.name == "api.save"); + assert!(tm4.is_some(), "let api arrow: typeMap 'api.save' missing; got: {:?}", + s_let_arrow.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(tm4.unwrap().type_name, "api.save", + "typeMap 'api.save' must point at the qualified name 'api.save' (qualified definition exists)"); + assert!( + s_let_arrow.definitions.iter().any(|d| d.name == "api.save"), + "let api arrow: qualified definition 'api.save' missing; got: {:?}", + s_let_arrow.definitions.iter().map(|d| &d.name).collect::>() + ); + let call4 = s_let_arrow.calls.iter().find(|c| c.name == "save" && c.receiver.as_deref() == Some("api")); + assert!(call4.is_some(), + "calls must contain api.save() with receiver='api'; got: {:?}", + s_let_arrow.calls.iter().map(|c| (&c.name, &c.receiver)).collect::>()); + + // Scope guard: object literal inside a function body must NOT seed module-level typeMap. + let s_scoped = parse_js( + "function init() {\n\ + let local = { run() {} };\n\ + local.run();\n\ + }", + ); + assert!( + s_scoped.type_map.iter().all(|e| e.name != "local.run"), + "function-scoped let obj must not pollute typeMap; got: {:?}", + s_scoped.type_map.iter().map(|e| &e.name).collect::>() + ); + } + /// Phase 8.3e: call receiver is correctly recorded for obj.f() inside defProp body. #[test] fn call_receiver_for_define_property() { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index c903d5c5..5ba523fa 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1007,6 +1007,15 @@ describe('JavaScript parser', () => { expect(symbols.typeMap.get('routes.get')).toEqual({ type: 'handler', confidence: 0.85 }); }); + // Issue #1551: let/var object-literal method definitions must seed typeMap entries + it('seeds composite typeMap keys for let-declared object-literal method shorthand', () => { + const symbols = parseJS(` + let obj = { f() { return 1; } }; + obj.f(); + `); + expect(symbols.typeMap.get('obj.f')).toBeDefined(); + }); + it('extracts rest binding from a class method', () => { const symbols = parseJS(` class Service {