From 06186606ff899a0bbd97c5ba1f7bc20d1b3ffa2e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 15 Jun 2026 02:18:03 -0600 Subject: [PATCH 1/3] fix(native): seed typeMap entries for let/var object-literal methods (#1551) match_js_type_map's variable_declarator case now calls seed_objlit_type_map_entries for all declaration kinds (const/let/var) when at non-function scope. This mirrors WASM's handleVarDeclaratorTypeMap which has no isConst guard for object properties. For const, extract_object_literal_functions already seeds these entries; dedup_type_map collapses duplicates at equal confidence so existing behavior is unchanged. --- .../src/extractors/javascript.rs | 137 ++++++++++++++++++ tests/parsers/javascript.test.ts | 9 ++ 2 files changed, 146 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 09d55e78..416d2d0c 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,6 +566,74 @@ 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). + 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: `obj = { baz() {} }` → typeMap['obj.baz'] = 'obj.baz' + // Mirrors WASM: setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85). + 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.clone(), + type_name: qualified, + confidence: 0.85, + }); + } + _ => {} + } + } +} + /// Second-pass walker: emit qualified `obj.method(function)` definitions for /// `method_definition` children of object literals. /// @@ -4108,6 +4187,64 @@ 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::>()); + + // 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' + 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"); + + // 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 { From 087015c66e16b9b4a6b16136b3470b22bbc13d47 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 15 Jun 2026 05:51:14 -0600 Subject: [PATCH 2/3] fix(native): use bare type_name for let/var method-shorthand typeMap entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For let/var object-literal declarations, match_js_objlit_qualified_method_defs is const-only, so no qualified definition (e.g. 'obj.baz') exists. Pointing typeMap['obj.baz'] → 'obj.baz' left resolution broken because the resolver found no matching node. Fix: use the bare method name as type_name, mirroring extract_object_literal_functions for const. Also strengthen the regression test with type_name value assertion and end-to-end call-edge creation check. --- .../codegraph-core/src/extractors/javascript.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 416d2d0c..2d68a895 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -619,13 +619,16 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], } } "method_definition" => { - // Method shorthand: `obj = { baz() {} }` → typeMap['obj.baz'] = 'obj.baz' - // Mirrors WASM: setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85). + // 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 (mirroring extract_object_literal_functions for const). + // For let/var, no qualified definition exists (match_js_objlit_qualified_method_defs + // is const-only), so pointing at 'obj.baz' would leave resolution broken. 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.clone(), - type_name: qualified, + name: qualified, + type_name: method_name.to_string(), confidence: 0.85, }); } @@ -4200,6 +4203,12 @@ mod tests { 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( From 0320b97b11b09dffb236d21c929c9914130bfb87 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 15 Jun 2026 06:36:29 -0600 Subject: [PATCH 3/3] fix(native): emit qualified definitions for let/var pair+arrow/function object-literal methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For 'let api = { save: () => {} }', seed_objlit_type_map_entries correctly seeds typeMap['api.save'] = 'api.save' (qualified), but no matching definition named 'api.save' existed for let/var — the resolver followed the typeMap entry and dead-ended on a missing node. Fix: extend match_js_objlit_qualified_method_defs to cover all declaration kinds for method_definition (was const-only) and to emit qualified definitions for let/var pair+arrow/function values (const pairs are handled by extract_object_literal_functions, so they are excluded to avoid duplicates). Tests: add definition-existence and call-edge assertions for the s_let_arrow case, matching the pattern already established for s_let_method. --- .../src/extractors/javascript.rs | 100 ++++++++++++++---- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 2d68a895..255cfafb 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -601,6 +601,9 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], "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, @@ -621,9 +624,11 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], "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 (mirroring extract_object_literal_functions for const). - // For let/var, no qualified definition exists (match_js_objlit_qualified_method_defs - // is const-only), so pointing at 'obj.baz' would leave resolution broken. + // 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 { @@ -638,8 +643,9 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], } /// 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; @@ -647,6 +653,13 @@ fn seed_objlit_type_map_entries(var_name: &str, obj_node: &Node, source: &[u8], /// 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. @@ -665,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; } @@ -675,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, + }); + } + _ => {} + } } } } @@ -4231,6 +4275,8 @@ mod tests { 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();", @@ -4238,7 +4284,17 @@ mod tests { 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"); + 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(