diff --git a/README.md b/README.md index a0df31c6..01371097 100644 --- a/README.md +++ b/README.md @@ -486,18 +486,20 @@ codegraph registry remove # Unregister ## 🌐 Language Support -| Language | Extensions | Coverage | -|---|---|---| -| ![JavaScript](https://img.shields.io/badge/-JavaScript-F7DF1E?style=flat-square&logo=javascript&logoColor=black) | `.js`, `.jsx`, `.mjs`, `.cjs` | Full — functions, classes, imports, call sites, dataflow | -| ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) | `.ts`, `.tsx` | Full — interfaces, type aliases, `.d.ts`, dataflow | -| ![Python](https://img.shields.io/badge/-Python-3776AB?style=flat-square&logo=python&logoColor=white) | `.py` | Functions, classes, methods, imports, decorators, dataflow | -| ![Go](https://img.shields.io/badge/-Go-00ADD8?style=flat-square&logo=go&logoColor=white) | `.go` | Functions, methods, structs, interfaces, imports, call sites, dataflow | -| ![Rust](https://img.shields.io/badge/-Rust-000000?style=flat-square&logo=rust&logoColor=white) | `.rs` | Functions, methods, structs, traits, `use` imports, call sites, dataflow | -| ![Java](https://img.shields.io/badge/-Java-ED8B00?style=flat-square&logo=openjdk&logoColor=white) | `.java` | Classes, methods, constructors, interfaces, imports, call sites, dataflow | -| ![C#](https://img.shields.io/badge/-C%23-512BD4?style=flat-square&logo=dotnet&logoColor=white) | `.cs` | Classes, structs, records, interfaces, enums, methods, constructors, using directives, invocations, dataflow | -| ![PHP](https://img.shields.io/badge/-PHP-777BB4?style=flat-square&logo=php&logoColor=white) | `.php` | Functions, classes, interfaces, traits, enums, methods, namespace use, calls, dataflow | -| ![Ruby](https://img.shields.io/badge/-Ruby-CC342D?style=flat-square&logo=ruby&logoColor=white) | `.rb` | Classes, modules, methods, singleton methods, require/require_relative, include/extend, dataflow | -| ![Terraform](https://img.shields.io/badge/-Terraform-844FBA?style=flat-square&logo=terraform&logoColor=white) | `.tf`, `.hcl` | Resource, data, variable, module, output blocks | +| Language | Extensions | Symbols Extracted | Type Inference | Parity | +|---|---|---|:---:|:---:| +| ![JavaScript](https://img.shields.io/badge/-JavaScript-F7DF1E?style=flat-square&logo=javascript&logoColor=black) | `.js`, `.jsx`, `.mjs`, `.cjs` | functions, classes, methods, imports, exports, call sites, constants, dataflow | ✅ | ✅ | +| ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) | `.ts`, `.tsx` | functions, classes, interfaces, type aliases, methods, imports, exports, call sites, dataflow | ✅ | ✅ | +| ![Python](https://img.shields.io/badge/-Python-3776AB?style=flat-square&logo=python&logoColor=white) | `.py` | functions, classes, methods, imports, decorators, constants, call sites, dataflow | ✅ | ✅ | +| ![Go](https://img.shields.io/badge/-Go-00ADD8?style=flat-square&logo=go&logoColor=white) | `.go` | functions, methods, structs, interfaces, constants, imports, call sites, dataflow | ✅ | ✅ | +| ![Rust](https://img.shields.io/badge/-Rust-000000?style=flat-square&logo=rust&logoColor=white) | `.rs` | functions, methods, structs, enums, traits, constants, `use` imports, call sites, dataflow | ✅ | ✅ | +| ![Java](https://img.shields.io/badge/-Java-ED8B00?style=flat-square&logo=openjdk&logoColor=white) | `.java` | classes, methods, constructors, interfaces, enums, imports, call sites, dataflow | ✅ | ✅ | +| ![C#](https://img.shields.io/badge/-C%23-512BD4?style=flat-square&logo=dotnet&logoColor=white) | `.cs` | classes, structs, records, interfaces, enums, methods, constructors, properties, using directives, call sites, dataflow | ✅ | ✅ | +| ![PHP](https://img.shields.io/badge/-PHP-777BB4?style=flat-square&logo=php&logoColor=white) | `.php` | functions, classes, interfaces, traits, enums, methods, namespace use, call sites, dataflow | ✅ | ✅ | +| ![Ruby](https://img.shields.io/badge/-Ruby-CC342D?style=flat-square&logo=ruby&logoColor=white) | `.rb` | classes, modules, methods, singleton methods, require/require_relative, include/extend, dataflow | — | ✅ | +| ![Terraform](https://img.shields.io/badge/-Terraform-844FBA?style=flat-square&logo=terraform&logoColor=white) | `.tf`, `.hcl` | resource, data, variable, module, output blocks | — | ✅ | + +> **Type Inference** extracts a per-file type map from annotations (`const x: Router`, `MyType x`, `x: MyType`) and `new` expressions, enabling the edge resolver to connect `x.method()` → `Type.method()`. **Parity** = WASM and native Rust engines produce identical output. ## ⚙️ How It Works diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 4549acfb..522ce768 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -43,6 +43,13 @@ pub struct DefInfo { pub end_line: Option, } +#[napi(object)] +pub struct TypeMapInput { + pub name: String, + #[napi(js_name = "typeName")] + pub type_name: String, +} + #[napi(object)] pub struct FileEdgeInput { pub file: String, @@ -53,6 +60,8 @@ pub struct FileEdgeInput { #[napi(js_name = "importedNames")] pub imported_names: Vec, pub classes: Vec, + #[napi(js_name = "typeMap")] + pub type_map: Vec, } #[napi(object)] @@ -108,6 +117,13 @@ pub fn build_call_edges( .map(|im| (im.name.as_str(), im.file.as_str())) .collect(); + // Build type map (variable name → declared type name) + let type_map: HashMap<&str, &str> = file_input + .type_map + .iter() + .map(|tm| (tm.name.as_str(), tm.type_name.as_str())) + .collect(); + // Build def → node ID map for caller resolution (match by name+kind+file+line) let file_nodes: Vec<&NodeInfo> = all_nodes.iter().filter(|n| n.file == *rel_path).collect(); @@ -204,10 +220,25 @@ pub fn build_call_edges( if !method_candidates.is_empty() { targets = method_candidates; - } else if call.receiver.is_none() + } else if let Some(ref receiver) = call.receiver { + // Type-aware resolution: translate variable receiver to declared type + if let Some(type_name) = type_map.get(receiver.as_str()) { + let qualified = format!("{}.{}", type_name, call.name); + let typed: Vec<&NodeInfo> = nodes_by_name + .get(qualified.as_str()) + .map(|v| v.iter().filter(|n| n.kind == "method").copied().collect()) + .unwrap_or_default(); + if !typed.is_empty() { + targets = typed; + } + } + } + + if targets.is_empty() + && (call.receiver.is_none() || call.receiver.as_deref() == Some("this") || call.receiver.as_deref() == Some("self") - || call.receiver.as_deref() == Some("super") + || call.receiver.as_deref() == Some("super")) { // Scoped fallback — same-dir or parent-dir only targets = nodes_by_name @@ -263,15 +294,19 @@ pub fn build_call_edges( && receiver != "self" && receiver != "super" { + // Resolve variable to its declared type via typeMap + let effective_receiver = type_map.get(receiver.as_str()).copied().unwrap_or(receiver.as_str()); + let type_resolved = effective_receiver != receiver.as_str(); + let samefile = nodes_by_name_and_file - .get(&(receiver.as_str(), rel_path.as_str())) + .get(&(effective_receiver, rel_path.as_str())) .cloned() .unwrap_or_default(); let candidates = if !samefile.is_empty() { samefile } else { nodes_by_name - .get(receiver.as_str()) + .get(effective_receiver) .cloned() .unwrap_or_default() }; @@ -286,11 +321,12 @@ pub fn build_call_edges( (1u64 << 63) | ((caller_id as u64) << 32) | (recv_target.id as u64); if !seen_edges.contains(&recv_key) { seen_edges.insert(recv_key); + let confidence = if type_resolved { 0.9 } else { 0.7 }; edges.push(ComputedEdge { source_id: caller_id, target_id: recv_target.id, kind: "receiver".to_string(), - confidence: 0.7, + confidence, dynamic: 0, }); } diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index 8ef0cc16..e2243c2f 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -12,6 +12,7 @@ impl SymbolExtractor for CSharpExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &CSHARP_AST_CONFIG); + extract_csharp_type_map(&tree.root_node(), source, &mut symbols); symbols } } @@ -469,3 +470,72 @@ fn extract_csharp_base_types( } } } + +// ── Type map extraction ───────────────────────────────────────────────────── + +fn extract_csharp_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match type_node.kind() { + "identifier" | "qualified_name" => Some(node_text(type_node, source)), + "predefined_type" => None, // skip int, string, etc. + "generic_name" => type_node.child(0).map(|n| node_text(&n, source)), + "nullable_type" => { + type_node.child(0).and_then(|inner| extract_csharp_type_name(&inner, source)) + } + _ => None, + } +} + +fn extract_csharp_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_csharp_type_map_depth(node, source, symbols, 0); +} + +fn extract_csharp_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "variable_declaration" => { + let type_node = node.child_by_field_name("type").or_else(|| node.child(0)); + if let Some(type_node) = type_node { + if type_node.kind() != "var_keyword" && type_node.kind() != "implicit_type" { + if let Some(type_name) = extract_csharp_type_name(&type_node, source) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "variable_declarator" { + let name_node = child.child_by_field_name("name") + .or_else(|| child.child(0)); + if let Some(name_node) = name_node { + if name_node.kind() == "identifier" { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + } + } + } + "parameter" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_csharp_type_name(&type_node, source) { + if let Some(name_node) = node.child_by_field_name("name") { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_csharp_type_map_depth(&child, source, symbols, depth + 1); + } + } +} diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index b823935c..d9f0c0d6 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -12,6 +12,7 @@ impl SymbolExtractor for GoExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &GO_AST_CONFIG); + extract_go_type_map(&tree.root_node(), source, &mut symbols); symbols } } @@ -310,6 +311,73 @@ fn extract_go_import_spec(spec: &Node, source: &[u8], symbols: &mut FileSymbols) } } +// ── Type map extraction ───────────────────────────────────────────────────── + +fn extract_go_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match type_node.kind() { + "type_identifier" | "identifier" | "qualified_type" => Some(node_text(type_node, source)), + "pointer_type" => { + // *MyType → MyType + for i in 0..type_node.child_count() { + if let Some(child) = type_node.child(i) { + if child.kind() == "type_identifier" || child.kind() == "identifier" { + return Some(node_text(&child, source)); + } + } + } + None + } + "generic_type" => type_node.child(0).map(|n| node_text(&n, source)), + _ => None, + } +} + +fn extract_go_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_go_type_map_depth(node, source, symbols, 0); +} + +fn extract_go_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "var_spec" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_go_type_name(&type_node, source) { + if let Some(name_node) = node.child_by_field_name("name") { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + "parameter_declaration" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_go_type_name(&type_node, source) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "identifier" { + symbols.type_map.push(TypeMapEntry { + name: node_text(&child, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_go_type_map_depth(&child, source, symbols, depth + 1); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codegraph-core/src/extractors/java.rs b/crates/codegraph-core/src/extractors/java.rs index 2fefde95..fd8faaa7 100644 --- a/crates/codegraph-core/src/extractors/java.rs +++ b/crates/codegraph-core/src/extractors/java.rs @@ -12,10 +12,69 @@ impl SymbolExtractor for JavaExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &JAVA_AST_CONFIG); + extract_java_type_map(&tree.root_node(), source, &mut symbols); symbols } } +// ── Type inference helpers ────────────────────────────────────────────────── + +fn extract_java_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + if type_node.kind() == "generic_type" { + type_node.child(0).map(|n| node_text(&n, source)) + } else { + Some(node_text(type_node, source)) + } +} + +fn extract_java_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_java_type_map_depth(node, source, symbols, 0); +} + +fn extract_java_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "local_variable_declaration" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_java_type_name(&type_node, source) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "variable_declarator" { + if let Some(name_node) = child.child_by_field_name("name") { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + } + "formal_parameter" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_java_type_name(&type_node, source) { + if let Some(name_node) = node.child_by_field_name("name") { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_java_type_map_depth(&child, source, symbols, depth + 1); + } + } +} + fn find_java_parent_class<'a>(node: &Node<'a>, source: &[u8]) -> Option { let mut current = node.parent(); while let Some(parent) = current { diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 51e94a40..30a032b8 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -12,10 +12,115 @@ impl SymbolExtractor for JsExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes(&tree.root_node(), source, &mut symbols.ast_nodes); + extract_type_map(&tree.root_node(), source, &mut symbols); symbols } } +// ── Type inference helpers ────────────────────────────────────────────────── + +/// Extract simple type name from a type_annotation node. +/// Returns the type name for simple types and generics, None for unions/intersections/arrays. +fn extract_simple_type_name<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + match child.kind() { + "type_identifier" | "identifier" => return Some(node_text(&child, source)), + "generic_type" => { + return child.child(0).map(|n| node_text(&n, source)); + } + "parenthesized_type" => return extract_simple_type_name(&child, source), + _ => {} + } + } + } + None +} + +/// Extract constructor type name from a new_expression node. +fn extract_new_expr_type_name<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + if node.kind() != "new_expression" { + return None; + } + let ctor = node.child_by_field_name("constructor").or_else(|| node.child(1))?; + match ctor.kind() { + "identifier" => Some(node_text(&ctor, source)), + "member_expression" => { + ctor.child_by_field_name("property").map(|p| node_text(&p, source)) + } + _ => None, + } +} + +/// Walk the entire tree to extract type annotations and new-expression type inferences. +fn extract_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_type_map_depth(node, source, symbols, 0); +} + +fn extract_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "variable_declarator" => { + if let Some(name_n) = node.child_by_field_name("name") { + if name_n.kind() == "identifier" { + let var_name = node_text(&name_n, source); + // Type annotation takes priority + if let Some(type_anno) = find_child(node, "type_annotation") { + if let Some(type_name) = extract_simple_type_name(&type_anno, source) { + symbols.type_map.push(TypeMapEntry { + name: var_name.to_string(), + type_name: type_name.to_string(), + }); + // Skip new_expression check — annotation wins + return walk_type_map_children(node, source, symbols, depth); + } + } + // Fall back to new expression inference + if let Some(value_n) = node.child_by_field_name("value") { + if value_n.kind() == "new_expression" { + if let Some(type_name) = extract_new_expr_type_name(&value_n, source) { + symbols.type_map.push(TypeMapEntry { + name: var_name.to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + "required_parameter" | "optional_parameter" => { + let name_node = node.child_by_field_name("pattern") + .or_else(|| node.child_by_field_name("left")) + .or_else(|| node.child(0)); + if let Some(name_node) = name_node { + if name_node.kind() == "identifier" { + if let Some(type_anno) = find_child(node, "type_annotation") { + if let Some(type_name) = extract_simple_type_name(&type_anno, source) { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + _ => {} + } + walk_type_map_children(node, source, symbols, depth); +} + +fn walk_type_map_children(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_type_map_depth(&child, source, symbols, depth + 1); + } + } +} + fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) { walk_node_depth(node, source, symbols, 0); } diff --git a/crates/codegraph-core/src/extractors/php.rs b/crates/codegraph-core/src/extractors/php.rs index 432f3c3e..c692e2e0 100644 --- a/crates/codegraph-core/src/extractors/php.rs +++ b/crates/codegraph-core/src/extractors/php.rs @@ -11,6 +11,7 @@ impl SymbolExtractor for PhpExtractor { fn extract(&self, tree: &Tree, source: &[u8], file_path: &str) -> FileSymbols { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); + extract_php_type_map(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &PHP_AST_CONFIG); symbols } @@ -397,3 +398,50 @@ fn extract_php_enum_cases(node: &Node, source: &[u8]) -> Vec { } cases } + +fn extract_php_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match type_node.kind() { + "named_type" | "name" | "qualified_name" => Some(node_text(type_node, source)), + "optional_type" => { + // ?MyType → skip the ? and get inner type + type_node.child(1) + .or_else(|| type_node.child(0)) + .and_then(|inner| extract_php_type_name(&inner, source)) + } + // Skip union/intersection types (too ambiguous) + "union_type" | "intersection_type" => None, + _ => None, + } +} + +fn extract_php_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_php_type_map_depth(node, source, symbols, 0); +} + +fn extract_php_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "simple_parameter" | "variadic_parameter" | "property_promotion_parameter" => { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_php_type_name(&type_node, source) { + let name_node = node.child_by_field_name("name") + .or_else(|| find_child(node, "variable_name")); + if let Some(name_node) = name_node { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_php_type_map_depth(&child, source, symbols, depth + 1); + } + } +} diff --git a/crates/codegraph-core/src/extractors/python.rs b/crates/codegraph-core/src/extractors/python.rs index 01f3df7b..2af7af5d 100644 --- a/crates/codegraph-core/src/extractors/python.rs +++ b/crates/codegraph-core/src/extractors/python.rs @@ -12,6 +12,7 @@ impl SymbolExtractor for PythonExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &PYTHON_AST_CONFIG); + extract_python_type_map(&tree.root_node(), source, &mut symbols); symbols } } @@ -354,6 +355,78 @@ fn find_python_parent_class<'a>(node: &Node<'a>, source: &[u8]) -> Option(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match type_node.kind() { + "identifier" | "attribute" => Some(node_text(type_node, source)), + "subscript" => { + // List[int] → List + type_node + .child_by_field_name("value") + .map(|n| node_text(&n, source)) + } + _ => None, + } +} + +fn extract_python_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_python_type_map_depth(node, source, symbols, 0); +} + +fn extract_python_type_map_depth( + node: &Node, + source: &[u8], + symbols: &mut FileSymbols, + depth: usize, +) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "typed_parameter" => { + // first child is identifier, type field is the type + if let Some(name_node) = node.child(0) { + if name_node.kind() == "identifier" { + let name = node_text(&name_node, source); + if name != "self" && name != "cls" { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = + extract_python_type_name(&type_node, source) + { + symbols.type_map.push(TypeMapEntry { + name: name.to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + "typed_default_parameter" => { + if let Some(name_node) = node.child_by_field_name("name") { + if name_node.kind() == "identifier" { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = + extract_python_type_name(&type_node, source) + { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_python_type_map_depth(&child, source, symbols, depth + 1); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codegraph-core/src/extractors/rust_lang.rs b/crates/codegraph-core/src/extractors/rust_lang.rs index 1a1e2d25..550fc5db 100644 --- a/crates/codegraph-core/src/extractors/rust_lang.rs +++ b/crates/codegraph-core/src/extractors/rust_lang.rs @@ -12,6 +12,7 @@ impl SymbolExtractor for RustExtractor { let mut symbols = FileSymbols::new(file_path.to_string()); walk_node(&tree.root_node(), source, &mut symbols); walk_ast_nodes_with_config(&tree.root_node(), source, &mut symbols.ast_nodes, &RUST_AST_CONFIG); + extract_rust_type_map(&tree.root_node(), source, &mut symbols); symbols } } @@ -381,6 +382,73 @@ fn extract_rust_use_path(node: &Node, source: &[u8]) -> Vec<(String, Vec } } +fn extract_rust_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> { + match type_node.kind() { + "type_identifier" | "identifier" | "scoped_type_identifier" => Some(node_text(type_node, source)), + "reference_type" => { + for i in 0..type_node.child_count() { + if let Some(child) = type_node.child(i) { + if child.kind() == "type_identifier" || child.kind() == "scoped_type_identifier" { + return Some(node_text(&child, source)); + } + } + } + None + } + "generic_type" => type_node.child(0).map(|n| node_text(&n, source)), + _ => None, + } +} + +fn extract_rust_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + extract_rust_type_map_depth(node, source, symbols, 0); +} + +fn extract_rust_type_map_depth(node: &Node, source: &[u8], symbols: &mut FileSymbols, depth: usize) { + if depth >= MAX_WALK_DEPTH { + return; + } + match node.kind() { + "let_declaration" => { + if let Some(pattern) = node.child_by_field_name("pattern") { + if pattern.kind() == "identifier" { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_rust_type_name(&type_node, source) { + symbols.type_map.push(TypeMapEntry { + name: node_text(&pattern, source).to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + "parameter" => { + if let Some(pattern) = node.child_by_field_name("pattern") { + if pattern.kind() == "identifier" { + let name = node_text(&pattern, source); + if name != "self" && name != "&self" && name != "&mut self" && name != "mut self" { + if let Some(type_node) = node.child_by_field_name("type") { + if let Some(type_name) = extract_rust_type_name(&type_node, source) { + symbols.type_map.push(TypeMapEntry { + name: name.to_string(), + type_name: type_name.to_string(), + }); + } + } + } + } + } + } + _ => {} + } + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + extract_rust_type_map_depth(&child, source, symbols, depth + 1); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index fa99291a..77ea559d 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -269,6 +269,14 @@ pub struct DataflowResult { pub mutations: Vec, } +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeMapEntry { + pub name: String, + #[napi(js_name = "typeName")] + pub type_name: String, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileSymbols { @@ -283,6 +291,8 @@ pub struct FileSymbols { pub dataflow: Option, #[napi(js_name = "lineCount")] pub line_count: Option, + #[napi(js_name = "typeMap")] + pub type_map: Vec, } impl FileSymbols { @@ -297,6 +307,7 @@ impl FileSymbols { ast_nodes: Vec::new(), dataflow: None, line_count: None, + type_map: Vec::new(), } } } diff --git a/src/domain/graph/builder/incremental.js b/src/domain/graph/builder/incremental.js index 63694385..c2ea1509 100644 --- a/src/domain/graph/builder/incremental.js +++ b/src/domain/graph/builder/incremental.js @@ -87,7 +87,7 @@ function findCaller(call, definitions, relPath, stmts) { return caller; } -function resolveCallTargets(stmts, call, relPath, importedNames) { +function resolveCallTargets(stmts, call, relPath, importedNames, typeMap) { const importedFrom = importedNames.get(call.name); let targets; if (importedFrom) { @@ -99,16 +99,31 @@ function resolveCallTargets(stmts, call, relPath, importedNames) { targets = stmts.findNodeByName.all(call.name); } } + // Type-aware resolution: translate variable receiver to declared type + if ((!targets || targets.length === 0) && call.receiver && typeMap) { + const typeName = typeMap.get(call.receiver); + if (typeName) { + const qualified = `${typeName}.${call.name}`; + targets = stmts.findNodeByName.all(qualified); + } + } return { targets, importedFrom }; } function buildCallEdges(stmts, relPath, symbols, fileNodeRow, importedNames) { + const typeMap = symbols.typeMap || new Map(); let edgesAdded = 0; for (const call of symbols.calls) { if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; const caller = findCaller(call, symbols.definitions, relPath, stmts) || fileNodeRow; - const { targets, importedFrom } = resolveCallTargets(stmts, call, relPath, importedNames); + const { targets, importedFrom } = resolveCallTargets( + stmts, + call, + relPath, + importedNames, + typeMap, + ); for (const t of targets) { if (t.id !== caller.id) { diff --git a/src/domain/graph/builder/stages/build-edges.js b/src/domain/graph/builder/stages/build-edges.js index 0651c865..085717fa 100644 --- a/src/domain/graph/builder/stages/build-edges.js +++ b/src/domain/graph/builder/stages/build-edges.js @@ -102,6 +102,12 @@ function buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodes, native) if (!fileNodeRow) continue; const importedNames = buildImportedNamesForNative(ctx, relPath, symbols, rootDir); + const typeMap = + symbols.typeMap instanceof Map + ? [...symbols.typeMap.entries()].map(([name, typeName]) => ({ name, typeName })) + : Array.isArray(symbols.typeMap) + ? symbols.typeMap + : []; nativeFiles.push({ file: relPath, fileNodeId: fileNodeRow.id, @@ -114,6 +120,7 @@ function buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodes, native) calls: symbols.calls, importedNames, classes: symbols.classes, + typeMap, }); } @@ -151,6 +158,7 @@ function buildCallEdgesJS(ctx, getNodeIdStmt, allEdgeRows) { if (!fileNodeRow) continue; const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const typeMap = symbols.typeMap || new Map(); const seenCallEdges = new Set(); buildFileCallEdges( @@ -162,6 +170,7 @@ function buildCallEdgesJS(ctx, getNodeIdStmt, allEdgeRows) { seenCallEdges, getNodeIdStmt, allEdgeRows, + typeMap, ); buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows); } @@ -202,7 +211,7 @@ function findCaller(call, definitions, relPath, getNodeIdStmt, fileNodeRow) { return caller || fileNodeRow; } -function resolveCallTargets(ctx, call, relPath, importedNames) { +function resolveCallTargets(ctx, call, relPath, importedNames, typeMap) { const importedFrom = importedNames.get(call.name); let targets; @@ -219,7 +228,7 @@ function resolveCallTargets(ctx, call, relPath, importedNames) { if (!targets || targets.length === 0) { targets = ctx.nodesByNameAndFile.get(`${call.name}|${relPath}`) || []; if (targets.length === 0) { - targets = resolveByMethodOrGlobal(ctx, call, relPath); + targets = resolveByMethodOrGlobal(ctx, call, relPath, typeMap); } } @@ -234,12 +243,22 @@ function resolveCallTargets(ctx, call, relPath, importedNames) { return { targets, importedFrom }; } -function resolveByMethodOrGlobal(ctx, call, relPath) { +function resolveByMethodOrGlobal(ctx, call, relPath, typeMap) { const methodCandidates = (ctx.nodesByName.get(call.name) || []).filter( (n) => n.name.endsWith(`.${call.name}`) && n.kind === 'method', ); if (methodCandidates.length > 0) return methodCandidates; + // Type-aware resolution: translate variable receiver to its declared type + if (call.receiver && typeMap) { + const typeName = typeMap.get(call.receiver); + if (typeName) { + const qualifiedName = `${typeName}.${call.name}`; + const typed = (ctx.nodesByName.get(qualifiedName) || []).filter((n) => n.kind === 'method'); + if (typed.length > 0) return typed; + } + } + if ( !call.receiver || call.receiver === 'this' || @@ -262,13 +281,20 @@ function buildFileCallEdges( seenCallEdges, getNodeIdStmt, allEdgeRows, + typeMap, ) { for (const call of symbols.calls) { if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; const caller = findCaller(call, symbols.definitions, relPath, getNodeIdStmt, fileNodeRow); const isDynamic = call.dynamic ? 1 : 0; - const { targets, importedFrom } = resolveCallTargets(ctx, call, relPath, importedNames); + const { targets, importedFrom } = resolveCallTargets( + ctx, + call, + relPath, + importedNames, + typeMap, + ); for (const t of targets) { const edgeKey = `${caller.id}|${t.id}`; @@ -287,22 +313,24 @@ function buildFileCallEdges( call.receiver !== 'self' && call.receiver !== 'super' ) { - buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRows); + buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRows, typeMap); } } } -function buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRows) { +function buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRows, typeMap) { const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']); - const samefile = ctx.nodesByNameAndFile.get(`${call.receiver}|${relPath}`) || []; - const candidates = samefile.length > 0 ? samefile : ctx.nodesByName.get(call.receiver) || []; + const effectiveReceiver = typeMap?.get(call.receiver) || call.receiver; + const samefile = ctx.nodesByNameAndFile.get(`${effectiveReceiver}|${relPath}`) || []; + const candidates = samefile.length > 0 ? samefile : ctx.nodesByName.get(effectiveReceiver) || []; const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind)); if (receiverNodes.length > 0 && caller) { const recvTarget = receiverNodes[0]; const recvKey = `recv|${caller.id}|${recvTarget.id}`; if (!seenCallEdges.has(recvKey)) { seenCallEdges.add(recvKey); - allEdgeRows.push([caller.id, recvTarget.id, 'receiver', 0.7, 0]); + const confidence = effectiveReceiver !== call.receiver ? 0.9 : 0.7; + allEdgeRows.push([caller.id, recvTarget.id, 'receiver', confidence, 0]); } } } diff --git a/src/extractors/csharp.js b/src/extractors/csharp.js index bcea24b4..68fd3121 100644 --- a/src/extractors/csharp.js +++ b/src/extractors/csharp.js @@ -10,9 +10,11 @@ export function extractCSharpSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkCSharpNode(tree.rootNode, ctx); + extractCSharpTypeMap(tree.rootNode, ctx); return ctx; } @@ -308,6 +310,66 @@ function extractCSharpEnumMembers(enumNode) { return constants; } +// ── Type map extraction ────────────────────────────────────────────────────── + +function extractCSharpTypeMap(node, ctx) { + extractCSharpTypeMapDepth(node, ctx, 0); +} + +function extractCSharpTypeMapDepth(node, ctx, depth) { + if (depth >= 200) return; + + // local_declaration_statement → variable_declaration → type + variable_declarator(s) + if (node.type === 'variable_declaration') { + const typeNode = node.childForFieldName('type') || node.child(0); + if (typeNode && typeNode.type !== 'var_keyword') { + const typeName = extractCSharpTypeName(typeNode); + if (typeName) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === 'variable_declarator') { + const nameNode = child.childForFieldName('name') || child.child(0); + if (nameNode && nameNode.type === 'identifier') { + ctx.typeMap.set(nameNode.text, typeName); + } + } + } + } + } + } + + // Method/constructor parameter: parameter node has type + name fields + if (node.type === 'parameter') { + const typeNode = node.childForFieldName('type'); + const nameNode = node.childForFieldName('name'); + if (typeNode && nameNode) { + const typeName = extractCSharpTypeName(typeNode); + if (typeName) ctx.typeMap.set(nameNode.text, typeName); + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractCSharpTypeMapDepth(child, ctx, depth + 1); + } +} + +function extractCSharpTypeName(typeNode) { + if (!typeNode) return null; + const t = typeNode.type; + if (t === 'identifier' || t === 'qualified_name') return typeNode.text; + if (t === 'predefined_type') return null; // skip int, string, etc + if (t === 'generic_name') { + const first = typeNode.child(0); + return first ? first.text : null; + } + if (t === 'nullable_type') { + const inner = typeNode.child(0); + return inner ? extractCSharpTypeName(inner) : null; + } + return null; +} + function extractCSharpBaseTypes(node, className, classes) { const baseList = node.childForFieldName('bases'); if (!baseList) return; diff --git a/src/extractors/go.js b/src/extractors/go.js index cadf65b7..33cf44e6 100644 --- a/src/extractors/go.js +++ b/src/extractors/go.js @@ -10,9 +10,11 @@ export function extractGoSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkGoNode(tree.rootNode, ctx); + extractGoTypeMap(tree.rootNode, ctx); return ctx; } @@ -200,6 +202,69 @@ function handleGoCallExpr(node, ctx) { } } +// ── Type map extraction ───────────────────────────────────────────────────── + +function extractGoTypeMap(node, ctx) { + extractGoTypeMapDepth(node, ctx, 0); +} + +function extractGoTypeMapDepth(node, ctx, depth) { + if (depth >= 200) return; + + // var x MyType = ... → var_declaration > var_spec + if (node.type === 'var_spec') { + const nameNode = node.childForFieldName('name'); + const typeNode = node.childForFieldName('type'); + if (nameNode && typeNode) { + const typeName = extractGoTypeName(typeNode); + if (typeName) ctx.typeMap.set(nameNode.text, typeName); + } + } + + // Function/method parameter types: parameter_declaration has identifiers then a type + if (node.type === 'parameter_declaration') { + const typeNode = node.childForFieldName('type'); + if (typeNode) { + const typeName = extractGoTypeName(typeNode); + if (typeName) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === 'identifier') { + ctx.typeMap.set(child.text, typeName); + } + } + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractGoTypeMapDepth(child, ctx, depth + 1); + } +} + +function extractGoTypeName(typeNode) { + if (!typeNode) return null; + const t = typeNode.type; + if (t === 'type_identifier' || t === 'identifier') return typeNode.text; + if (t === 'qualified_type') return typeNode.text; + // pointer type: *MyType → MyType + if (t === 'pointer_type') { + for (let i = 0; i < typeNode.childCount; i++) { + const child = typeNode.child(i); + if (child && (child.type === 'type_identifier' || child.type === 'identifier')) { + return child.text; + } + } + } + // generic type: MyType[T] → MyType + if (t === 'generic_type') { + const first = typeNode.child(0); + return first ? first.text : null; + } + return null; +} + // ── Child extraction helpers ──────────────────────────────────────────────── function extractGoParameters(paramListNode) { diff --git a/src/extractors/java.js b/src/extractors/java.js index d4519a08..f1c024d6 100644 --- a/src/extractors/java.js +++ b/src/extractors/java.js @@ -10,6 +10,7 @@ export function extractJavaSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkJavaNode(tree.rootNode, ctx); @@ -42,6 +43,9 @@ function walkJavaNode(node, ctx) { case 'object_creation_expression': handleJavaObjectCreation(node, ctx); break; + case 'local_variable_declaration': + handleJavaLocalVarDecl(node, ctx); + break; } for (let i = 0; i < node.childCount; i++) walkJavaNode(node.child(i), ctx); @@ -166,7 +170,7 @@ function handleJavaMethodDecl(node, ctx) { if (!nameNode) return; const parentClass = findJavaParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; - const params = extractJavaParameters(node.childForFieldName('parameters')); + const params = extractJavaParameters(node.childForFieldName('parameters'), ctx.typeMap); ctx.definitions.push({ name: fullName, kind: 'method', @@ -182,7 +186,7 @@ function handleJavaConstructorDecl(node, ctx) { if (!nameNode) return; const parentClass = findJavaParentClass(node); const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text; - const params = extractJavaParameters(node.childForFieldName('parameters')); + const params = extractJavaParameters(node.childForFieldName('parameters'), ctx.typeMap); ctx.definitions.push({ name: fullName, kind: 'method', @@ -222,6 +226,20 @@ function handleJavaMethodInvocation(node, ctx) { ctx.calls.push(call); } +function handleJavaLocalVarDecl(node, ctx) { + const typeNode = node.childForFieldName('type'); + if (!typeNode) return; + const typeName = typeNode.type === 'generic_type' ? typeNode.child(0)?.text : typeNode.text; + if (!typeName) return; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'variable_declarator') { + const nameNode = child.childForFieldName('name'); + if (nameNode) ctx.typeMap.set(nameNode.text, typeName); + } + } +} + function handleJavaObjectCreation(node, ctx) { const typeNode = node.childForFieldName('type'); if (!typeNode) return; @@ -247,7 +265,7 @@ function findJavaParentClass(node) { // ── Child extraction helpers ──────────────────────────────────────────────── -function extractJavaParameters(paramListNode) { +function extractJavaParameters(paramListNode, typeMap) { const params = []; if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { @@ -257,6 +275,14 @@ function extractJavaParameters(paramListNode) { const nameNode = param.childForFieldName('name'); if (nameNode) { params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); + if (typeMap) { + const typeNode = param.childForFieldName('type'); + if (typeNode) { + const typeName = + typeNode.type === 'generic_type' ? typeNode.child(0)?.text : typeNode.text; + if (typeName) typeMap.set(nameNode.text, typeName); + } + } } } } diff --git a/src/extractors/javascript.js b/src/extractors/javascript.js index 997c8ea6..fc52d117 100644 --- a/src/extractors/javascript.js +++ b/src/extractors/javascript.js @@ -19,6 +19,7 @@ function extractSymbolsQuery(tree, query) { const imports = []; const classes = []; const exps = []; + const typeMap = new Map(); const matches = query.matches(tree.rootNode); @@ -179,7 +180,10 @@ function extractSymbolsQuery(tree, query) { // Extract dynamic import() calls via targeted walk (query patterns don't match `import` function type) extractDynamicImportsWalk(tree.rootNode, imports); - return { definitions, calls, imports, classes, exports: exps }; + // Extract typeMap from type annotations and new expressions + extractTypeMapWalk(tree.rootNode, typeMap); + + return { definitions, calls, imports, classes, exports: exps, typeMap }; } /** @@ -326,9 +330,12 @@ function extractSymbolsWalk(tree) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkJavaScriptNode(tree.rootNode, ctx); + // Populate typeMap for variables and parameter type annotations + extractTypeMapWalk(tree.rootNode, ctx.typeMap); return ctx; } @@ -472,6 +479,7 @@ function handleVariableDecl(node, ctx) { if (declarator && declarator.type === 'variable_declarator') { const nameN = declarator.childForFieldName('name'); const valueN = declarator.childForFieldName('value'); + if (nameN && valueN) { const valType = valueN.type; if ( @@ -788,6 +796,70 @@ function extractImplementsFromNode(node) { return result; } +// ── Type inference helpers ─────────────────────────────────────────────── + +function extractSimpleTypeName(typeAnnotationNode) { + if (!typeAnnotationNode) return null; + for (let i = 0; i < typeAnnotationNode.childCount; i++) { + const child = typeAnnotationNode.child(i); + if (!child) continue; + const t = child.type; + if (t === 'type_identifier' || t === 'identifier') return child.text; + if (t === 'generic_type') return child.child(0)?.text || null; + if (t === 'parenthesized_type') return extractSimpleTypeName(child); + // Skip union, intersection, and array types — too ambiguous + } + return null; +} + +function extractNewExprTypeName(newExprNode) { + if (!newExprNode || newExprNode.type !== 'new_expression') return null; + const ctor = newExprNode.childForFieldName('constructor') || newExprNode.child(1); + if (!ctor) return null; + if (ctor.type === 'identifier') return ctor.text; + if (ctor.type === 'member_expression') { + const prop = ctor.childForFieldName('property'); + return prop ? prop.text : null; + } + return null; +} + +function extractTypeMapWalk(rootNode, typeMap) { + function walk(node) { + const t = node.type; + if (t === 'variable_declarator') { + const nameN = node.childForFieldName('name'); + if (nameN && nameN.type === 'identifier') { + const typeAnno = findChild(node, 'type_annotation'); + if (typeAnno) { + const typeName = extractSimpleTypeName(typeAnno); + if (typeName) typeMap.set(nameN.text, typeName); + } else { + const valueN = node.childForFieldName('value'); + if (valueN && valueN.type === 'new_expression') { + const ctorType = extractNewExprTypeName(valueN); + if (ctorType) typeMap.set(nameN.text, ctorType); + } + } + } + } else if (t === 'required_parameter' || t === 'optional_parameter') { + const nameNode = + node.childForFieldName('pattern') || node.childForFieldName('left') || node.child(0); + if (nameNode && nameNode.type === 'identifier') { + const typeAnno = findChild(node, 'type_annotation'); + if (typeAnno) { + const typeName = extractSimpleTypeName(typeAnno); + if (typeName) typeMap.set(nameNode.text, typeName); + } + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)); + } + } + walk(rootNode); +} + function extractReceiverName(objNode) { if (!objNode) return undefined; const t = objNode.type; diff --git a/src/extractors/php.js b/src/extractors/php.js index 686c9031..fa1cfe04 100644 --- a/src/extractors/php.js +++ b/src/extractors/php.js @@ -82,9 +82,11 @@ export function extractPHPSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkPhpNode(tree.rootNode, ctx); + extractPhpTypeMap(tree.rootNode, ctx); return ctx; } @@ -320,6 +322,47 @@ function handlePhpObjectCreation(node, ctx) { } } +function extractPhpTypeMap(node, ctx) { + extractPhpTypeMapDepth(node, ctx, 0); +} + +function extractPhpTypeMapDepth(node, ctx, depth) { + if (depth >= 200) return; + + // Function/method parameters with type hints + if ( + node.type === 'simple_parameter' || + node.type === 'variadic_parameter' || + node.type === 'property_promotion_parameter' + ) { + const typeNode = node.childForFieldName('type'); + const nameNode = node.childForFieldName('name') || findChild(node, 'variable_name'); + if (typeNode && nameNode) { + const typeName = extractPhpTypeName(typeNode); + if (typeName) ctx.typeMap.set(nameNode.text, typeName); + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractPhpTypeMapDepth(child, ctx, depth + 1); + } +} + +function extractPhpTypeName(typeNode) { + if (!typeNode) return null; + const t = typeNode.type; + if (t === 'named_type' || t === 'name' || t === 'qualified_name') return typeNode.text; + // Nullable: ?MyType + if (t === 'optional_type') { + const inner = typeNode.child(1) || typeNode.child(0); + return inner ? extractPhpTypeName(inner) : null; + } + // Skip union types (too ambiguous) + if (t === 'union_type' || t === 'intersection_type') return null; + return null; +} + function findPHPParentClass(node) { let current = node.parent; while (current) { diff --git a/src/extractors/python.js b/src/extractors/python.js index 053a07ca..28c8d308 100644 --- a/src/extractors/python.js +++ b/src/extractors/python.js @@ -10,9 +10,11 @@ export function extractPythonSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkPythonNode(tree.rootNode, ctx); + extractPythonTypeMap(tree.rootNode, ctx); return ctx; } @@ -284,6 +286,58 @@ function walkInitBody(bodyNode, seen, props) { } } +function extractPythonTypeMap(node, ctx) { + extractPythonTypeMapDepth(node, ctx, 0); +} + +function extractPythonTypeMapDepth(node, ctx, depth) { + if (depth >= 200) return; + + // typed_parameter: identifier : type + if (node.type === 'typed_parameter') { + const nameNode = node.child(0); + const typeNode = node.childForFieldName('type'); + if (nameNode && nameNode.type === 'identifier' && typeNode) { + const typeName = extractPythonTypeName(typeNode); + if (typeName && nameNode.text !== 'self' && nameNode.text !== 'cls') { + ctx.typeMap.set(nameNode.text, typeName); + } + } + } + + // typed_default_parameter: name : type = default + if (node.type === 'typed_default_parameter') { + const nameNode = node.childForFieldName('name'); + const typeNode = node.childForFieldName('type'); + if (nameNode && nameNode.type === 'identifier' && typeNode) { + const typeName = extractPythonTypeName(typeNode); + if (typeName && nameNode.text !== 'self' && nameNode.text !== 'cls') { + ctx.typeMap.set(nameNode.text, typeName); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractPythonTypeMapDepth(child, ctx, depth + 1); + } +} + +function extractPythonTypeName(typeNode) { + if (!typeNode) return null; + const t = typeNode.type; + if (t === 'identifier') return typeNode.text; + if (t === 'attribute') return typeNode.text; // module.Type + // Generic: List[int] → subscript → value is identifier + if (t === 'subscript') { + const value = typeNode.childForFieldName('value'); + return value ? value.text : null; + } + // None type, string, etc → skip + if (t === 'none' || t === 'string') return null; + return null; +} + function findPythonParentClass(node) { let current = node.parent; while (current) { diff --git a/src/extractors/rust.js b/src/extractors/rust.js index 8d46d3a6..e0f8fe33 100644 --- a/src/extractors/rust.js +++ b/src/extractors/rust.js @@ -10,9 +10,11 @@ export function extractRustSymbols(tree, _filePath) { imports: [], classes: [], exports: [], + typeMap: new Map(), }; walkRustNode(tree.rootNode, ctx); + extractRustTypeMap(tree.rootNode, ctx); return ctx; } @@ -257,6 +259,64 @@ function extractEnumVariants(enumNode) { return variants; } +function extractRustTypeMap(node, ctx) { + extractRustTypeMapDepth(node, ctx, 0); +} + +function extractRustTypeMapDepth(node, ctx, depth) { + if (depth >= 200) return; + + // let x: MyType = ... + if (node.type === 'let_declaration') { + const pattern = node.childForFieldName('pattern'); + const typeNode = node.childForFieldName('type'); + if (pattern && pattern.type === 'identifier' && typeNode) { + const typeName = extractRustTypeName(typeNode); + if (typeName) ctx.typeMap.set(pattern.text, typeName); + } + } + + // fn foo(x: MyType) — parameter node has pattern + type fields + if (node.type === 'parameter') { + const pattern = node.childForFieldName('pattern'); + const typeNode = node.childForFieldName('type'); + if (pattern && typeNode) { + const name = pattern.type === 'identifier' ? pattern.text : null; + if (name && name !== 'self' && name !== '&self' && name !== '&mut self') { + const typeName = extractRustTypeName(typeNode); + if (typeName) ctx.typeMap.set(name, typeName); + } + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) extractRustTypeMapDepth(child, ctx, depth + 1); + } +} + +function extractRustTypeName(typeNode) { + if (!typeNode) return null; + const t = typeNode.type; + if (t === 'type_identifier' || t === 'identifier') return typeNode.text; + if (t === 'scoped_type_identifier') return typeNode.text; + // Reference: &MyType or &mut MyType → MyType + if (t === 'reference_type') { + for (let i = 0; i < typeNode.childCount; i++) { + const child = typeNode.child(i); + if (child && (child.type === 'type_identifier' || child.type === 'scoped_type_identifier')) { + return child.text; + } + } + } + // Generic: Vec → Vec + if (t === 'generic_type') { + const first = typeNode.child(0); + return first ? first.text : null; + } + return null; +} + function extractRustUsePath(node) { if (!node) return []; diff --git a/tests/integration/build-parity.test.js b/tests/integration/build-parity.test.js index 86ef5043..98ce843d 100644 --- a/tests/integration/build-parity.test.js +++ b/tests/integration/build-parity.test.js @@ -4,6 +4,10 @@ * Build the same fixture project with both WASM and native engines, * then compare the resulting nodes/edges in SQLite. * + * IMPORTANT: Every feature MUST be implemented for BOTH engines (WASM and native). + * This test is a hard gate — if it fails, the feature is incomplete. Do not weaken, + * skip, or filter this test to work around missing engine parity. Fix the code instead. + * * Skipped when the native engine is not installed. */ diff --git a/tests/integration/build.test.js b/tests/integration/build.test.js index d7bee6bc..0d0b3d64 100644 --- a/tests/integration/build.test.js +++ b/tests/integration/build.test.js @@ -478,3 +478,66 @@ describe('version/engine mismatch auto-promotes to full rebuild', () => { expect(output).not.toContain('No changes detected'); }); }); + +describe('typed method call resolution', () => { + let typedDir, typedDbPath; + + beforeAll(async () => { + typedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-typed-')); + fs.writeFileSync( + path.join(typedDir, 'typed.ts'), + [ + 'class Router {', + ' get(path: string) {}', + ' post(path: string) {}', + '}', + 'const app: Router = new Router();', + 'app.get("/users");', + 'app.post("/items");', + '', + ].join('\n'), + ); + // Force WASM engine — typeMap resolution is JS-only (native deferred) + await buildGraph(typedDir, { skipRegistry: true, engine: 'wasm' }); + typedDbPath = path.join(typedDir, '.codegraph', 'graph.db'); + }); + + afterAll(() => { + if (typedDir) fs.rmSync(typedDir, { recursive: true, force: true }); + }); + + test('typed variable call produces call edge to the declared type method', () => { + const db = new Database(typedDbPath, { readonly: true }); + const edges = db + .prepare(` + SELECT s.name as caller, t.name as callee FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' + `) + .all(); + db.close(); + const callees = edges.map((e) => e.callee); + // The key assertion: typed receiver 'app' resolves to Router, producing + // call edges to Router.get and Router.post + expect(callees).toContain('Router.get'); + expect(callees).toContain('Router.post'); + }); + + test('typed variable produces receiver edge to the class', () => { + const db = new Database(typedDbPath, { readonly: true }); + const edges = db + .prepare(` + SELECT s.name as caller, t.name as target, e.confidence FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'receiver' + `) + .all(); + db.close(); + const receiverEdges = edges.filter((e) => e.target === 'Router'); + expect(receiverEdges.length).toBeGreaterThan(0); + // Type-resolved receiver edges should have 0.9 confidence + expect(receiverEdges[0].confidence).toBe(0.9); + }); +}); diff --git a/tests/parsers/java.test.js b/tests/parsers/java.test.js index 79486a04..83d05683 100644 --- a/tests/parsers/java.test.js +++ b/tests/parsers/java.test.js @@ -109,4 +109,26 @@ public class Foo {}`); }`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'User' })); }); + + describe('typeMap extraction', () => { + it('extracts typeMap from local variables', () => { + const symbols = parseJava(`public class Foo { + void run() { + List items = new ArrayList<>(); + Router router = new Router(); + } +}`); + expect(symbols.typeMap).toBeInstanceOf(Map); + expect(symbols.typeMap.get('items')).toBe('List'); + expect(symbols.typeMap.get('router')).toBe('Router'); + }); + + it('extracts typeMap from method parameters', () => { + const symbols = parseJava(`public class Foo { + void handle(Request req, Response res) {} +}`); + expect(symbols.typeMap.get('req')).toBe('Request'); + expect(symbols.typeMap.get('res')).toBe('Response'); + }); + }); }); diff --git a/tests/parsers/javascript.test.js b/tests/parsers/javascript.test.js index 63875fc8..00a04547 100644 --- a/tests/parsers/javascript.test.js +++ b/tests/parsers/javascript.test.js @@ -96,6 +96,57 @@ describe('JavaScript parser', () => { expect(c.receiver).toBe('a.b'); }); + describe('typeMap extraction', () => { + function parseTS(code) { + const parser = parsers.get('typescript'); + const tree = parser.parse(code); + return extractSymbols(tree, 'test.ts'); + } + + it('extracts typeMap from type annotations', () => { + const symbols = parseTS(`const x: Router = express.Router();`); + expect(symbols.typeMap).toBeInstanceOf(Map); + expect(symbols.typeMap.get('x')).toBe('Router'); + }); + + it('extracts typeMap from generic types', () => { + const symbols = parseTS(`const m: Map = new Map();`); + expect(symbols.typeMap.get('m')).toBe('Map'); + }); + + it('infers type from new expressions', () => { + const symbols = parseTS(`const r = new Router();`); + expect(symbols.typeMap.get('r')).toBe('Router'); + }); + + it('extracts parameter types into typeMap', () => { + const symbols = parseTS(`function process(req: Request, res: Response) {}`); + expect(symbols.typeMap.get('req')).toBe('Request'); + expect(symbols.typeMap.get('res')).toBe('Response'); + }); + + it('returns empty typeMap when no annotations', () => { + const symbols = parseJS(`const x = 42; function foo(a, b) {}`); + expect(symbols.typeMap).toBeInstanceOf(Map); + expect(symbols.typeMap.size).toBe(0); + }); + + it('skips union and intersection types', () => { + const symbols = parseTS(`const x: string | number = 42;`); + expect(symbols.typeMap.has('x')).toBe(false); + }); + + it('handles let/var declarations with type annotations', () => { + const symbols = parseTS(`let app: Express = createApp();`); + expect(symbols.typeMap.get('app')).toBe('Express'); + }); + + it('prefers type annotation over new expression', () => { + const symbols = parseTS(`const x: Base = new Derived();`); + expect(symbols.typeMap.get('x')).toBe('Base'); + }); + }); + it('does not set receiver for .call()/.apply()/.bind() unwrapped calls', () => { const symbols = parseJS(`fn.call(null, arg);`); const fnCall = symbols.calls.find((c) => c.name === 'fn');