From 654532c1ccbe6deb79ab609e9522be8b4cb6f311 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 18:20:19 -0600 Subject: [PATCH 1/9] fix(native): add prototype method extraction to Rust engine (#1327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement parity with the WASM JS extractor for pre-ES6 prototype OOP patterns. Extractor (crates/codegraph-core/src/extractors/javascript.rs): - `Foo.prototype.bar = function(){}` → emits `Foo.bar` definition (kind: method) - `Foo.prototype.bar = identifier` → seeds typeMap['Foo.bar'] = identifier (confidence 0.9) - `Foo.prototype = { bar: fn, ... }` → same rules per property (pair, method_definition, shorthand_property_identifier) Built-in globals (Array, Object, …) are excluded via `is_js_builtin_global` guard. Adds 6 unit tests covering all three patterns plus edge cases. Edge builder (crates/codegraph-core/src/edge_builder.rs): - After a typeMap-resolved type lookup, check typeMap['TypeName.method'] for prototype aliases (`Foo.prototype.bar = identifierAlias`), mirroring the protoAlias fallback added to call-resolver.ts in the WASM path. - Inline new-expression receiver: extract class name from `(new Foo).bar()` receivers using string parsing (mirrors the `^\(?\s*new\s+[A-Z...]` regex in call-resolver.ts), enabling resolution without a named variable binding. Verified against the integration test in tests/integration/prototype-method-resolution.test.ts (all 3 tests pass with native engine). docs check acknowledged Closes #1327 --- crates/codegraph-core/src/edge_builder.rs | 50 ++++- .../src/extractors/javascript.rs | 212 ++++++++++++++++++ 2 files changed, 261 insertions(+), 1 deletion(-) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index f504cdf2..787615a7 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 PascalCase (uppercase-initial) names to avoid false positives on +/// lowercase constructor calls (rare but present in legacy code). +fn extract_inline_new_type(receiver: &str) -> Option { + let s = receiver.trim_start_matches('(').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 { diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 6569ea1f..df6d5b5f 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 @@ -292,6 +294,143 @@ 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 = if key_node.kind() == "string" { + node_text(&key_node, source).replace(['\'', '"'], "") + } else { + node_text(&key_node, source).to_string() + }; + 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 @@ -2292,4 +2431,77 @@ mod tests { "compute call should have receiver='calc'" ); } + + // ── 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); + } } From 0c26030ed8e7bf9dee4f09ec2a2710bc2c464242 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 20:10:00 -0600 Subject: [PATCH 2/9] fix(native): fix parity divergence in extract_inline_new_type Use strip_prefix('(').unwrap_or(receiver) instead of trim_start_matches('(') to strip at most one leading paren, matching the JS regex ^\(?. Also update the doc comment to reflect that _ and $ prefixes are also accepted. --- crates/codegraph-core/src/edge_builder.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 787615a7..00ab47b8 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -506,10 +506,10 @@ fn resolve_call_targets<'a>( /// 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 PascalCase (uppercase-initial) names to avoid false positives on -/// lowercase constructor calls (rare but present in legacy code). +/// 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.trim_start_matches('(').trim_start(); + 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(); From 41d78118cf3956dc8274773bf8586bb79d922478 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 23:42:32 -0600 Subject: [PATCH 3/9] fix(native): strip one surrounding quote pair in prototype object-literal key `trim_matches` was stripping ALL quote chars (e.g. `"it's"` became `its`). Replace with strip_prefix + strip_suffix to remove exactly the outermost matching quote pair. --- crates/codegraph-core/src/extractors/javascript.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index df6d5b5f..63944401 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -417,13 +417,19 @@ fn extract_js_prototype_object_literal(class_name: &str, obj_node: &Node, source 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 = if key_node.kind() == "string" { - node_text(&key_node, source).replace(['\'', '"'], "") + 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).to_string() + node_text(&key_node, source) }; if method_name.is_empty() { continue; } - emit_js_prototype_method(class_name, &method_name, &value_node, source, symbols); + emit_js_prototype_method(class_name, method_name, &value_node, source, symbols); } } _ => {} From a004e252d49d704d429909e54cbe5913fdfdc55d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 23:42:37 -0600 Subject: [PATCH 4/9] fix(extractor): remove duplicate extractPrototypeMethodsWalk calls Both extractSymbolsQuery and extractSymbolsWalk had a second call to extractPrototypeMethodsWalk appended at the bottom, causing prototype methods to be extracted twice. Remove the duplicate from each path. The duplication caused a ~44% WASM benchmark regression on the query path (used by wasm-worker-entry.js in benchmarks). --- src/extractors/javascript.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 5d13e777..5ff09bc7 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -354,9 +354,6 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Extract definitions from destructured bindings (query patterns don't match object_pattern) extractDestructuredBindingsWalk(tree.rootNode, definitions); - // Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` - extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap); - // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking const newExpressions: string[] = []; extractNewExpressionsWalk(tree.rootNode, newExpressions); @@ -622,8 +619,6 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!); // Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!); - // Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` - extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!); // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking const newExpressions: string[] = []; extractNewExpressionsWalk(tree.rootNode, newExpressions); From 8dcc76044a643ef33ad7eae6adbd364e3beb639b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 23:47:10 -0600 Subject: [PATCH 5/9] style: fix biome format violations inherited from base branch merge Long lines in wasm-worker-entry.ts, wasm-worker-pool.ts and two fixture files were not wrapped per the 100-char line width rule. --- src/domain/wasm-worker-entry.ts | 4 +++- src/domain/wasm-worker-pool.ts | 3 ++- .../resolution/fixtures/jelly-micro/rest/rest.js | 4 ++-- .../resolution/fixtures/jelly-micro/this/this.js | 10 ++++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index 501453c0..bcf58cb2 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -813,7 +813,9 @@ function serializeExtractorOutput( ...(symbols.objectRestParamBindings?.length ? { objectRestParamBindings: symbols.objectRestParamBindings } : {}), - ...(symbols.objectPropBindings?.length ? { objectPropBindings: symbols.objectPropBindings } : {}), + ...(symbols.objectPropBindings?.length + ? { objectPropBindings: symbols.objectPropBindings } + : {}), ...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index defa166f..55934fbf 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -112,7 +112,8 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp if (ser.spreadArgBindings?.length) out.spreadArgBindings = ser.spreadArgBindings; if (ser.forOfBindings?.length) out.forOfBindings = ser.forOfBindings; if (ser.arrayCallbackBindings?.length) out.arrayCallbackBindings = ser.arrayCallbackBindings; - if (ser.objectRestParamBindings?.length) out.objectRestParamBindings = ser.objectRestParamBindings; + if (ser.objectRestParamBindings?.length) + out.objectRestParamBindings = ser.objectRestParamBindings; if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; return out; diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js index 933d4444..2559c72e 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js @@ -8,7 +8,7 @@ function e4() {} const obj = { e1, e2, e3, e4 }; function f3({ e1: eee1, ...eerest }) { - eee1(); - eerest.e4(); // eerest.e4 === obj.e4 === e4 when called as f3(obj) + eee1(); + eerest.e4(); // eerest.e4 === obj.e4 === e4 when called as f3(obj) } f3(obj); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js index eba94b22..fa51b0c4 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js @@ -1,8 +1,10 @@ // Jelly micro-test: this — function-as-object property methods, this-dispatch function f() {} -f.g = function() { console.log("2"); } -f.h = function() { - this.g(); // this === f when called as f.h() -} +f.g = function () { + console.log('2'); +}; +f.h = function () { + this.g(); // this === f when called as f.h() +}; f.h(); From 5baf98c44fd2e4531aae020feea921ed8d6d8aa7 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 23:52:13 -0600 Subject: [PATCH 6/9] perf(native): remove .prototype. files from WASM post-pass filter The Rust engine now extracts `Foo.prototype.bar = fn` definitions natively (PR #1327). Remove the `.prototype.` text filter from the `runPostNativePrototypeMethods` pre-filter so those files are no longer WASM-reparsed on every native build. The function-as-object-property pattern (`fn.method = function(){}`) is still not handled by Rust and continues to use the WASM post-pass. This eliminates the 422% Build ms/file regression seen on CI. --- .../builder/stages/native-orchestrator.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index b918c676..32ef332e 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\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 From 4e579b0598f1699c3f32077a3a31b17c8ecdcde4 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 00:29:37 -0600 Subject: [PATCH 7/9] fix(native): exclude prototype patterns from WASM post-pass pre-filter The regex /\b\w+\.\w+\s*=\s*function/ matched the substring 'prototype.bar = function' inside 'Foo.prototype.bar = function(){}', causing prototype files to be queued for WASM re-processing even though the Rust engine now handles those patterns natively. Added a negative lookahead to exclude the prototype shape, matching only function-as-object-property patterns like 'fn.method = function'. Fixes the duplicate-node risk flagged in Greptile review of #1339. --- src/domain/graph/builder/stages/native-orchestrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 32ef332e..75b25b56 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -598,7 +598,7 @@ async function runPostNativePrototypeMethods( const protoFiles = jsFiles.filter((relPath) => { try { const content = readFileSafe(path.join(rootDir, relPath)); - return /\b\w+\.\w+\s*=\s*function/.test(content); + return /\b(?!prototype\.)\w+\.\w+\s*=\s*function/.test(content); } catch { return false; } From 074ced4d5223f0e14e71f4402dbbaf8204836ae3 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 00:30:58 -0600 Subject: [PATCH 8/9] test(native): add unit tests for extract_inline_new_type edge cases Cover the string-parsing logic in extract_inline_new_type: (new Foo), (new Foo('arg')), no-parens form, _ and $ prefixes, lowercase rejection, plain identifier, and the newFoo-not-a-keyword case. --- crates/codegraph-core/src/edge_builder.rs | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 00ab47b8..74578ee3 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -1406,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); + } +} From fceb2bf669920c12d1f96c468b0a9f7d410aec6c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 02:10:59 -0600 Subject: [PATCH 9/9] fix(bench): sync JS fixture names and exclude benchmark fixtures from biome lint (#1339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4ed709e's biome auto-fix renamed defProp/defProps/create to _defProp/_defProps/_create (unused-variable prefix), but the expected-edges.json manifest still referenced the old names. This caused 5 false positives and 5 false negatives in the JS benchmark, dropping precision to 84.4% (below the 100% threshold) and recall to 81.8% (below 90%). Also fixes the class-inheritance DoubleCounter fixture: the code used Counter.count() (a direct static call) but the manifest expected a class-inheritance edge via super.count(). Changed to super.count() so the fixture tests what the manifest documents. Prevent recurrence by adding a biome.json override that disables lint for tests/benchmarks/resolution/fixtures/** — fixture files are hand-written sample code that must use specific patterns (including apparently-unused functions and super calls) to exercise resolution. --- biome.json | 10 +++++++++- .../resolution/fixtures/javascript/expected-edges.json | 10 +++++----- .../resolution/fixtures/javascript/inheritance.js | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) 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/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index e60d89db..a10cf77a 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -130,35 +130,35 @@ "notes": "new UserService() — class instantiation tracked as consumption" }, { - "source": { "name": "defProp", "file": "define-property.js" }, + "source": { "name": "_defProp", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f() — resolved via Object.defineProperty(obj, \"f\", { value: f1 })" }, { - "source": { "name": "defProps", "file": "define-property.js" }, + "source": { "name": "_defProps", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f1() — resolved via Object.defineProperties(obj, { \"f1\": { value: f1 } })" }, { - "source": { "name": "defProps", "file": "define-property.js" }, + "source": { "name": "_defProps", "file": "define-property.js" }, "target": { "name": "f2", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f2() — resolved via Object.defineProperties(obj, { \"f2\": { value: f2 } })" }, { - "source": { "name": "create", "file": "define-property.js" }, + "source": { "name": "_create", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-create-prototype", "notes": "obj.f1() — resolved via Object.create({ f1, f2 })" }, { - "source": { "name": "create", "file": "define-property.js" }, + "source": { "name": "_create", "file": "define-property.js" }, "target": { "name": "f2", "file": "define-property.js" }, "kind": "calls", "mode": "pts-create-prototype", diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index c3c26b2c..7ec19aa8 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -29,6 +29,6 @@ export class Counter { export class DoubleCounter extends Counter { static count() { - return Counter.count() * 2; // static super.method() → Counter.count + return super.count() * 2; // static super.count() → Counter.count via CHA parents map } }