diff --git a/biome.json b/biome.json index 688f8ddb..bceeec55 100644 --- a/biome.json +++ b/biome.json @@ -25,5 +25,13 @@ "semicolons": "always", "trailingCommas": "all" } - } + }, + "overrides": [ + { + "includes": ["tests/benchmarks/resolution/fixtures/**"], + "linter": { + "enabled": false + } + } + ] } diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index f504cdf2..74578ee3 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -407,13 +407,36 @@ fn resolve_call_targets<'a>( }; let type_lookup = type_map.get(effective_receiver) .or_else(|| type_map.get(receiver.as_str())); - if let Some(&(type_name, _conf)) = type_lookup { + // Inline new-expression receiver: `(new Foo).bar()` — extract the constructor name + // when no typeMap entry exists for the complex receiver expression. + // Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` in call-resolver.ts. + let inline_new_type = if type_lookup.is_none() { + extract_inline_new_type(receiver) + } else { + None + }; + // Use typeMap-resolved type or inline-new-extracted type, whichever is available. + let resolved_type = type_lookup.map(|&(t, _)| t).or(inline_new_type.as_deref()); + if let Some(type_name) = resolved_type { let qualified = format!("{}.{}", type_name, call.name); let typed: Vec<&NodeInfo> = ctx.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() { return typed; } + // Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = identifier. + // After the direct method lookup misses (no definition emitted for this method), + // check if the typeMap holds an alias to a standalone function. + // Mirrors the protoAlias fallback in resolveByMethodOrGlobal in call-resolver.ts. + if let Some(&(proto_target, _)) = type_map.get(qualified.as_str()) { + let resolved: Vec<&NodeInfo> = ctx.nodes_by_name + .get(proto_target) + .map(|v| v.iter() + .filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) + .unwrap_or_default(); + if !resolved.is_empty() { return resolved; } + } } // 4.5. Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] let composite_key = format!("{}.{}", receiver, call.name); @@ -477,6 +500,31 @@ fn resolve_call_targets<'a>( Vec::new() } +/// Extract the constructor name from an inline `new` receiver expression. +/// +/// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts. +/// Handles `(new Foo)` and `(new Foo('arg'))` receivers that arise when the call site +/// is `(new Foo).method()` without a named variable binding. +/// +/// Only extracts names that start with an uppercase letter, `_`, or `$` to avoid +/// false positives on plain lowercase constructor calls (rare but present in legacy code). +fn extract_inline_new_type(receiver: &str) -> Option { + let s = receiver.strip_prefix('(').unwrap_or(receiver).trim_start(); + let s = s.strip_prefix("new")?; + if !s.starts_with(|c: char| c.is_whitespace()) { return None; } + let s = s.trim_start(); + let end = s.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') + .unwrap_or(s.len()); + let name = &s[..end]; + if name.is_empty() { return None; } + let first = name.chars().next()?; + if first.is_uppercase() || first == '_' || first == '$' { + Some(name.to_string()) + } else { + None + } +} + /// Sort targets by confidence descending. fn sort_targets_by_confidence(targets: &mut Vec<&NodeInfo>, rel_path: &str, imported_from: Option<&str>) { if targets.len() > 1 { @@ -1358,3 +1406,52 @@ mod call_edge_tests { assert_eq!(receiver_edge.unwrap().target_id, 2); } } + +#[cfg(test)] +mod inline_new_type_tests { + use super::extract_inline_new_type; + + #[test] + fn parens_new_uppercase() { + assert_eq!(extract_inline_new_type("(new Foo)"), Some("Foo".to_string())); + } + + #[test] + fn parens_new_with_args() { + // (new Foo('arg')) — parens and constructor args + assert_eq!(extract_inline_new_type("(new Foo('arg'))"), Some("Foo".to_string())); + } + + #[test] + fn no_parens_new_uppercase() { + assert_eq!(extract_inline_new_type("new Bar"), Some("Bar".to_string())); + } + + #[test] + fn underscore_prefix_accepted() { + assert_eq!(extract_inline_new_type("new _Factory"), Some("_Factory".to_string())); + } + + #[test] + fn dollar_prefix_accepted() { + assert_eq!(extract_inline_new_type("new $Service"), Some("$Service".to_string())); + } + + #[test] + fn lowercase_constructor_rejected() { + // `new foo()` — lowercase, should return None to avoid false positives + assert_eq!(extract_inline_new_type("new foo"), None); + } + + #[test] + fn not_a_new_expression() { + // plain receiver name — no `new` keyword + assert_eq!(extract_inline_new_type("myVar"), None); + } + + #[test] + fn new_without_whitespace_is_not_new_keyword() { + // `newFoo` — not a `new` keyword, just an identifier + assert_eq!(extract_inline_new_type("newFoo"), None); + } +} diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index acd1006b..8c7a48bf 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -29,6 +29,8 @@ impl SymbolExtractor for JsExtractor { walk_ast_nodes(&tree.root_node(), source, &mut symbols.ast_nodes); walk_tree(&tree.root_node(), source, &mut symbols, match_js_type_map); walk_tree(&tree.root_node(), source, &mut symbols, match_js_return_type_map); + // Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` + walk_tree(&tree.root_node(), source, &mut symbols, match_js_prototype_methods); // call_assignments runs after type_map is populated (needs receiver types) walk_tree(&tree.root_node(), source, &mut symbols, match_js_call_assignments); symbols @@ -445,6 +447,149 @@ fn push_return_type_entry(symbols: &mut FileSymbols, fn_name: &str, type_name: & }); } +// ── Prototype-method extraction ───────────────────────────────────────────── + +/// Walk the AST collecting pre-ES6 prototype assignments. +/// +/// Mirrors `extractPrototypeMethodsWalk` in `src/extractors/javascript.ts`. +/// +/// Three patterns are handled: +/// 1. `Foo.prototype.bar = function(){}` → emits `Foo.bar` as a method definition +/// 2. `Foo.prototype.bar = identifier` → seeds `typeMap['Foo.bar'] = identifier` +/// 3. `Foo.prototype = { bar: fn, ... }` → same rules applied per property +fn match_js_prototype_methods(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "expression_statement" { return; } + let Some(expr) = node.child(0) else { return }; + if expr.kind() != "assignment_expression" { return; } + let lhs = expr.child_by_field_name("left"); + let rhs = expr.child_by_field_name("right"); + if let (Some(lhs), Some(rhs)) = (lhs, rhs) { + handle_js_prototype_assignment(&lhs, &rhs, source, symbols); + } +} + +fn handle_js_prototype_assignment(lhs: &Node, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) { + if lhs.kind() != "member_expression" { return; } + let Some(lhs_obj) = lhs.child_by_field_name("object") else { return }; + let Some(lhs_prop) = lhs.child_by_field_name("property") else { return }; + + // Pattern 1: `Foo.prototype.bar = rhs` + // lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar` + if lhs_obj.kind() == "member_expression" + && matches!(lhs_prop.kind(), "property_identifier" | "identifier") + { + let proto_obj = lhs_obj.child_by_field_name("object"); + let proto_prop = lhs_obj.child_by_field_name("property"); + if let (Some(proto_obj), Some(proto_prop)) = (proto_obj, proto_prop) { + if proto_obj.kind() == "identifier" + && node_text(&proto_prop, source) == "prototype" + && !is_js_builtin_global(node_text(&proto_obj, source)) + { + emit_js_prototype_method( + node_text(&proto_obj, source), + node_text(&lhs_prop, source), + rhs, + source, + symbols, + ); + } + } + return; + } + + // Pattern 2: `Foo.prototype = { bar: fn, ... }` + // lhs.object is `Foo` (identifier), lhs.property is `prototype`, rhs is object literal + if lhs_obj.kind() == "identifier" + && node_text(&lhs_prop, source) == "prototype" + && !is_js_builtin_global(node_text(&lhs_obj, source)) + && rhs.kind() == "object" + { + extract_js_prototype_object_literal(node_text(&lhs_obj, source), rhs, source, symbols); + } +} + +/// Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`. +/// +/// Mirrors `emitPrototypeMethod` in `src/extractors/javascript.ts`. +fn emit_js_prototype_method(class_name: &str, method_name: &str, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) { + let full_name = format!("{}.{}", class_name, method_name); + match rhs.kind() { + "function_expression" | "arrow_function" => { + symbols.definitions.push(Definition { + name: full_name, + kind: "method".to_string(), + line: start_line(rhs), + end_line: Some(end_line(rhs)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); + } + "identifier" => { + let rhs_name = node_text(rhs, source); + if !is_js_builtin_global(rhs_name) { + push_type_map_entry(symbols, full_name, rhs_name.to_string()); + } + } + _ => {} + } +} + +/// Iterate over an object literal assigned to `Foo.prototype` and emit definitions/aliases. +/// +/// Mirrors `extractPrototypeObjectLiteral` in `src/extractors/javascript.ts`. +fn extract_js_prototype_object_literal(class_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() { + "method_definition" => { + let Some(name_node) = child.child_by_field_name("name") else { continue }; + symbols.definitions.push(Definition { + name: format!("{}.{}", class_name, node_text(&name_node, source)), + kind: "method".to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); + } + "shorthand_property_identifier" => { + let prop_name = node_text(&child, source); + if !is_js_builtin_global(prop_name) { + push_type_map_entry( + symbols, + format!("{}.{}", class_name, prop_name), + prop_name.to_string(), + ); + } + } + "pair" => { + let key_node = child.child_by_field_name("key"); + let value_node = child.child_by_field_name("value"); + if let (Some(key_node), Some(value_node)) = (key_node, value_node) { + let method_name: &str = if key_node.kind() == "string" { + let s = node_text(&key_node, source); + // Strip exactly one matching pair of surrounding quote characters. + // `trim_matches` would also strip embedded quotes; we only want the + // outermost delimiter pair so `"it's"` stays `it's`. + s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) + .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(s) + } else { + node_text(&key_node, source) + }; + if method_name.is_empty() { continue; } + emit_js_prototype_method(class_name, method_name, &value_node, source, symbols); + } + } + _ => {} + } + } +} + // ── Call-assignment extraction (Phase 8.2 parity) ─────────────────────────── /// Walk the AST recording variable assignments from call expressions into @@ -2446,6 +2591,79 @@ mod tests { ); } + // ── Prototype-method extraction ───────────────────────────────────────── + + #[test] + fn prototype_direct_method_emits_definition() { + let s = parse_js( + "function C() {}\n\ + C.prototype.foo = function() { return 1; };", + ); + let def = s.definitions.iter().find(|d| d.name == "C.foo"); + assert!(def.is_some(), "C.foo definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::>()); + assert_eq!(def.unwrap().kind, "method"); + } + + #[test] + fn prototype_identifier_alias_seeds_type_map() { + let s = parse_js( + "let f = () => {};\n\ + class A {}\n\ + A.prototype.t = f;", + ); + let entry = s.type_map.iter().find(|e| e.name == "A.t"); + assert!(entry.is_some(), "type_map entry A.t missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(entry.unwrap().type_name, "f"); + } + + #[test] + fn prototype_object_literal_emits_definitions() { + let s = parse_js( + "function C() {}\n\ + C.prototype = {\n\ + foo: function() {},\n\ + bar: function() {},\n\ + };", + ); + let foo = s.definitions.iter().find(|d| d.name == "C.foo"); + let bar = s.definitions.iter().find(|d| d.name == "C.bar"); + assert!(foo.is_some(), "C.foo missing"); + assert_eq!(foo.unwrap().kind, "method"); + assert!(bar.is_some(), "C.bar missing"); + } + + #[test] + fn prototype_object_literal_shorthand_method() { + let s = parse_js( + "function C() {}\n\ + C.prototype = {\n\ + greet() { return 'hi'; },\n\ + };", + ); + let def = s.definitions.iter().find(|d| d.name == "C.greet"); + assert!(def.is_some(), "C.greet definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::>()); + assert_eq!(def.unwrap().kind, "method"); + } + + #[test] + fn prototype_object_literal_shorthand_property_seeds_type_map() { + let s = parse_js( + "function helper() {}\n\ + function C() {}\n\ + C.prototype = { helper };", + ); + let entry = s.type_map.iter().find(|e| e.name == "C.helper"); + assert!(entry.is_some(), "type_map entry C.helper missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(entry.unwrap().type_name, "helper"); + } + + #[test] + fn prototype_builtin_globals_are_excluded() { + let s = parse_js("Array.prototype.custom = function() {};"); + let def = s.definitions.iter().find(|d| d.name.contains("Array")); + assert!(def.is_none(), "built-in prototype assignment should be ignored; got: {:?}", def); + } + /// Phase 8.3e: Object.defineProperty seeds composite type_map key. #[test] fn type_map_from_define_property() { diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index b918c676..75b25b56 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -576,7 +576,7 @@ async function runPostNativePrototypeMethods( db: BetterSqlite3Database, rootDir: string, ): Promise { - // Collect JS/TS file paths from the DB — only extensions where prototype + // Collect JS/TS file paths from the DB — only extensions where these // patterns can appear. const jsExts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']); const fileRows = db @@ -591,15 +591,14 @@ async function runPostNativePrototypeMethods( if (jsFiles.length === 0) return; - // Quick pre-filter: only re-parse files that actually contain prototype or - // function-as-object-property patterns to avoid an expensive WASM re-parse of - // every JS/TS file in large repos. Covers: - // - `.prototype.` — classical prototype method assignment - // - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`) + // Pre-filter: only re-parse files that contain the function-as-object-property + // pattern (`fn.method = function() {}`). The Rust engine now handles + // `Foo.prototype.bar = fn` natively, so `.prototype.` files no longer need + // a WASM re-parse here. const protoFiles = jsFiles.filter((relPath) => { try { const content = readFileSafe(path.join(rootDir, relPath)); - return content.includes('.prototype.') || /\b\w+\.\w+\s*=\s*function/.test(content); + return /\b(?!prototype\.)\w+\.\w+\s*=\s*function/.test(content); } catch { return false; } @@ -1390,13 +1389,14 @@ export async function tryNativeOrchestrator( } } - // Prototype method post-pass: the Rust engine does not recognise pre-ES6 - // `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via - // WASM to insert missing method nodes and their call edges. + // Function-as-object-property post-pass: the Rust engine does not yet recognise + // `fn.method = function() {}` patterns. Re-parse only those JS/TS files via + // WASM to insert missing method nodes. `Foo.prototype.bar = fn` is now + // handled natively by the Rust extractor and no longer needs a WASM re-parse. try { await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir); } catch (err) { - debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`); + debug(`Function-prop methods post-pass failed: ${toErrorMessage(err)}`); } // Backfill the `technique` column on `calls` edges written by the Rust diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index 9a0a106d..7ec19aa8 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -29,7 +29,6 @@ export class Counter { export class DoubleCounter extends Counter { static count() { - // biome-ignore lint/complexity/noThisInStatic: intentional super call for class-inheritance resolution test - return super.count() * 2; // static super.method() → Counter.count + return super.count() * 2; // static super.count() → Counter.count via CHA parents map } }