Skip to content

Commit 194507c

Browse files
authored
fix(native): add prototype method extraction to Rust engine (#1327) (#1339)
* fix(native): add prototype method extraction to Rust engine (#1327) 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 * 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. * 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. * 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). * 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. * 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. * 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. * 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. * fix(bench): sync JS fixture names and exclude benchmark fixtures from biome lint (#1339) 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.
1 parent 058f0f4 commit 194507c

5 files changed

Lines changed: 337 additions & 15 deletions

File tree

biome.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,13 @@
2525
"semicolons": "always",
2626
"trailingCommas": "all"
2727
}
28-
}
28+
},
29+
"overrides": [
30+
{
31+
"includes": ["tests/benchmarks/resolution/fixtures/**"],
32+
"linter": {
33+
"enabled": false
34+
}
35+
}
36+
]
2937
}

crates/codegraph-core/src/edge_builder.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,13 +407,36 @@ fn resolve_call_targets<'a>(
407407
};
408408
let type_lookup = type_map.get(effective_receiver)
409409
.or_else(|| type_map.get(receiver.as_str()));
410-
if let Some(&(type_name, _conf)) = type_lookup {
410+
// Inline new-expression receiver: `(new Foo).bar()` — extract the constructor name
411+
// when no typeMap entry exists for the complex receiver expression.
412+
// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` in call-resolver.ts.
413+
let inline_new_type = if type_lookup.is_none() {
414+
extract_inline_new_type(receiver)
415+
} else {
416+
None
417+
};
418+
// Use typeMap-resolved type or inline-new-extracted type, whichever is available.
419+
let resolved_type = type_lookup.map(|&(t, _)| t).or(inline_new_type.as_deref());
420+
if let Some(type_name) = resolved_type {
411421
let qualified = format!("{}.{}", type_name, call.name);
412422
let typed: Vec<&NodeInfo> = ctx.nodes_by_name
413423
.get(qualified.as_str())
414424
.map(|v| v.iter().filter(|n| n.kind == "method").copied().collect())
415425
.unwrap_or_default();
416426
if !typed.is_empty() { return typed; }
427+
// Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = identifier.
428+
// After the direct method lookup misses (no definition emitted for this method),
429+
// check if the typeMap holds an alias to a standalone function.
430+
// Mirrors the protoAlias fallback in resolveByMethodOrGlobal in call-resolver.ts.
431+
if let Some(&(proto_target, _)) = type_map.get(qualified.as_str()) {
432+
let resolved: Vec<&NodeInfo> = ctx.nodes_by_name
433+
.get(proto_target)
434+
.map(|v| v.iter()
435+
.filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5)
436+
.copied().collect())
437+
.unwrap_or_default();
438+
if !resolved.is_empty() { return resolved; }
439+
}
417440
}
418441
// 4.5. Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop']
419442
let composite_key = format!("{}.{}", receiver, call.name);
@@ -489,6 +512,31 @@ fn resolve_call_targets<'a>(
489512
Vec::new()
490513
}
491514

515+
/// Extract the constructor name from an inline `new` receiver expression.
516+
///
517+
/// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts.
518+
/// Handles `(new Foo)` and `(new Foo('arg'))` receivers that arise when the call site
519+
/// is `(new Foo).method()` without a named variable binding.
520+
///
521+
/// Only extracts names that start with an uppercase letter, `_`, or `$` to avoid
522+
/// false positives on plain lowercase constructor calls (rare but present in legacy code).
523+
fn extract_inline_new_type(receiver: &str) -> Option<String> {
524+
let s = receiver.strip_prefix('(').unwrap_or(receiver).trim_start();
525+
let s = s.strip_prefix("new")?;
526+
if !s.starts_with(|c: char| c.is_whitespace()) { return None; }
527+
let s = s.trim_start();
528+
let end = s.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$')
529+
.unwrap_or(s.len());
530+
let name = &s[..end];
531+
if name.is_empty() { return None; }
532+
let first = name.chars().next()?;
533+
if first.is_uppercase() || first == '_' || first == '$' {
534+
Some(name.to_string())
535+
} else {
536+
None
537+
}
538+
}
539+
492540
/// Sort targets by confidence descending.
493541
fn sort_targets_by_confidence(targets: &mut Vec<&NodeInfo>, rel_path: &str, imported_from: Option<&str>) {
494542
if targets.len() > 1 {
@@ -1370,3 +1418,52 @@ mod call_edge_tests {
13701418
assert_eq!(receiver_edge.unwrap().target_id, 2);
13711419
}
13721420
}
1421+
1422+
#[cfg(test)]
1423+
mod inline_new_type_tests {
1424+
use super::extract_inline_new_type;
1425+
1426+
#[test]
1427+
fn parens_new_uppercase() {
1428+
assert_eq!(extract_inline_new_type("(new Foo)"), Some("Foo".to_string()));
1429+
}
1430+
1431+
#[test]
1432+
fn parens_new_with_args() {
1433+
// (new Foo('arg')) — parens and constructor args
1434+
assert_eq!(extract_inline_new_type("(new Foo('arg'))"), Some("Foo".to_string()));
1435+
}
1436+
1437+
#[test]
1438+
fn no_parens_new_uppercase() {
1439+
assert_eq!(extract_inline_new_type("new Bar"), Some("Bar".to_string()));
1440+
}
1441+
1442+
#[test]
1443+
fn underscore_prefix_accepted() {
1444+
assert_eq!(extract_inline_new_type("new _Factory"), Some("_Factory".to_string()));
1445+
}
1446+
1447+
#[test]
1448+
fn dollar_prefix_accepted() {
1449+
assert_eq!(extract_inline_new_type("new $Service"), Some("$Service".to_string()));
1450+
}
1451+
1452+
#[test]
1453+
fn lowercase_constructor_rejected() {
1454+
// `new foo()` — lowercase, should return None to avoid false positives
1455+
assert_eq!(extract_inline_new_type("new foo"), None);
1456+
}
1457+
1458+
#[test]
1459+
fn not_a_new_expression() {
1460+
// plain receiver name — no `new` keyword
1461+
assert_eq!(extract_inline_new_type("myVar"), None);
1462+
}
1463+
1464+
#[test]
1465+
fn new_without_whitespace_is_not_new_keyword() {
1466+
// `newFoo` — not a `new` keyword, just an identifier
1467+
assert_eq!(extract_inline_new_type("newFoo"), None);
1468+
}
1469+
}

crates/codegraph-core/src/extractors/javascript.rs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ impl SymbolExtractor for JsExtractor {
2929
walk_ast_nodes(&tree.root_node(), source, &mut symbols.ast_nodes);
3030
walk_tree(&tree.root_node(), source, &mut symbols, match_js_type_map);
3131
walk_tree(&tree.root_node(), source, &mut symbols, match_js_return_type_map);
32+
// Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
33+
walk_tree(&tree.root_node(), source, &mut symbols, match_js_prototype_methods);
3234
// call_assignments runs after type_map is populated (needs receiver types)
3335
walk_tree(&tree.root_node(), source, &mut symbols, match_js_call_assignments);
3436
symbols
@@ -445,6 +447,149 @@ fn push_return_type_entry(symbols: &mut FileSymbols, fn_name: &str, type_name: &
445447
});
446448
}
447449

450+
// ── Prototype-method extraction ─────────────────────────────────────────────
451+
452+
/// Walk the AST collecting pre-ES6 prototype assignments.
453+
///
454+
/// Mirrors `extractPrototypeMethodsWalk` in `src/extractors/javascript.ts`.
455+
///
456+
/// Three patterns are handled:
457+
/// 1. `Foo.prototype.bar = function(){}` → emits `Foo.bar` as a method definition
458+
/// 2. `Foo.prototype.bar = identifier` → seeds `typeMap['Foo.bar'] = identifier`
459+
/// 3. `Foo.prototype = { bar: fn, ... }` → same rules applied per property
460+
fn match_js_prototype_methods(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) {
461+
if node.kind() != "expression_statement" { return; }
462+
let Some(expr) = node.child(0) else { return };
463+
if expr.kind() != "assignment_expression" { return; }
464+
let lhs = expr.child_by_field_name("left");
465+
let rhs = expr.child_by_field_name("right");
466+
if let (Some(lhs), Some(rhs)) = (lhs, rhs) {
467+
handle_js_prototype_assignment(&lhs, &rhs, source, symbols);
468+
}
469+
}
470+
471+
fn handle_js_prototype_assignment(lhs: &Node, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) {
472+
if lhs.kind() != "member_expression" { return; }
473+
let Some(lhs_obj) = lhs.child_by_field_name("object") else { return };
474+
let Some(lhs_prop) = lhs.child_by_field_name("property") else { return };
475+
476+
// Pattern 1: `Foo.prototype.bar = rhs`
477+
// lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar`
478+
if lhs_obj.kind() == "member_expression"
479+
&& matches!(lhs_prop.kind(), "property_identifier" | "identifier")
480+
{
481+
let proto_obj = lhs_obj.child_by_field_name("object");
482+
let proto_prop = lhs_obj.child_by_field_name("property");
483+
if let (Some(proto_obj), Some(proto_prop)) = (proto_obj, proto_prop) {
484+
if proto_obj.kind() == "identifier"
485+
&& node_text(&proto_prop, source) == "prototype"
486+
&& !is_js_builtin_global(node_text(&proto_obj, source))
487+
{
488+
emit_js_prototype_method(
489+
node_text(&proto_obj, source),
490+
node_text(&lhs_prop, source),
491+
rhs,
492+
source,
493+
symbols,
494+
);
495+
}
496+
}
497+
return;
498+
}
499+
500+
// Pattern 2: `Foo.prototype = { bar: fn, ... }`
501+
// lhs.object is `Foo` (identifier), lhs.property is `prototype`, rhs is object literal
502+
if lhs_obj.kind() == "identifier"
503+
&& node_text(&lhs_prop, source) == "prototype"
504+
&& !is_js_builtin_global(node_text(&lhs_obj, source))
505+
&& rhs.kind() == "object"
506+
{
507+
extract_js_prototype_object_literal(node_text(&lhs_obj, source), rhs, source, symbols);
508+
}
509+
}
510+
511+
/// Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`.
512+
///
513+
/// Mirrors `emitPrototypeMethod` in `src/extractors/javascript.ts`.
514+
fn emit_js_prototype_method(class_name: &str, method_name: &str, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) {
515+
let full_name = format!("{}.{}", class_name, method_name);
516+
match rhs.kind() {
517+
"function_expression" | "arrow_function" => {
518+
symbols.definitions.push(Definition {
519+
name: full_name,
520+
kind: "method".to_string(),
521+
line: start_line(rhs),
522+
end_line: Some(end_line(rhs)),
523+
decorators: None,
524+
complexity: None,
525+
cfg: None,
526+
children: None,
527+
});
528+
}
529+
"identifier" => {
530+
let rhs_name = node_text(rhs, source);
531+
if !is_js_builtin_global(rhs_name) {
532+
push_type_map_entry(symbols, full_name, rhs_name.to_string());
533+
}
534+
}
535+
_ => {}
536+
}
537+
}
538+
539+
/// Iterate over an object literal assigned to `Foo.prototype` and emit definitions/aliases.
540+
///
541+
/// Mirrors `extractPrototypeObjectLiteral` in `src/extractors/javascript.ts`.
542+
fn extract_js_prototype_object_literal(class_name: &str, obj_node: &Node, source: &[u8], symbols: &mut FileSymbols) {
543+
for i in 0..obj_node.child_count() {
544+
let Some(child) = obj_node.child(i) else { continue };
545+
match child.kind() {
546+
"method_definition" => {
547+
let Some(name_node) = child.child_by_field_name("name") else { continue };
548+
symbols.definitions.push(Definition {
549+
name: format!("{}.{}", class_name, node_text(&name_node, source)),
550+
kind: "method".to_string(),
551+
line: start_line(&child),
552+
end_line: Some(end_line(&child)),
553+
decorators: None,
554+
complexity: None,
555+
cfg: None,
556+
children: None,
557+
});
558+
}
559+
"shorthand_property_identifier" => {
560+
let prop_name = node_text(&child, source);
561+
if !is_js_builtin_global(prop_name) {
562+
push_type_map_entry(
563+
symbols,
564+
format!("{}.{}", class_name, prop_name),
565+
prop_name.to_string(),
566+
);
567+
}
568+
}
569+
"pair" => {
570+
let key_node = child.child_by_field_name("key");
571+
let value_node = child.child_by_field_name("value");
572+
if let (Some(key_node), Some(value_node)) = (key_node, value_node) {
573+
let method_name: &str = if key_node.kind() == "string" {
574+
let s = node_text(&key_node, source);
575+
// Strip exactly one matching pair of surrounding quote characters.
576+
// `trim_matches` would also strip embedded quotes; we only want the
577+
// outermost delimiter pair so `"it's"` stays `it's`.
578+
s.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
579+
.or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
580+
.unwrap_or(s)
581+
} else {
582+
node_text(&key_node, source)
583+
};
584+
if method_name.is_empty() { continue; }
585+
emit_js_prototype_method(class_name, method_name, &value_node, source, symbols);
586+
}
587+
}
588+
_ => {}
589+
}
590+
}
591+
}
592+
448593
// ── Call-assignment extraction (Phase 8.2 parity) ───────────────────────────
449594

450595
/// Walk the AST recording variable assignments from call expressions into
@@ -2446,6 +2591,79 @@ mod tests {
24462591
);
24472592
}
24482593

2594+
// ── Prototype-method extraction ─────────────────────────────────────────
2595+
2596+
#[test]
2597+
fn prototype_direct_method_emits_definition() {
2598+
let s = parse_js(
2599+
"function C() {}\n\
2600+
C.prototype.foo = function() { return 1; };",
2601+
);
2602+
let def = s.definitions.iter().find(|d| d.name == "C.foo");
2603+
assert!(def.is_some(), "C.foo definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::<Vec<_>>());
2604+
assert_eq!(def.unwrap().kind, "method");
2605+
}
2606+
2607+
#[test]
2608+
fn prototype_identifier_alias_seeds_type_map() {
2609+
let s = parse_js(
2610+
"let f = () => {};\n\
2611+
class A {}\n\
2612+
A.prototype.t = f;",
2613+
);
2614+
let entry = s.type_map.iter().find(|e| e.name == "A.t");
2615+
assert!(entry.is_some(), "type_map entry A.t missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
2616+
assert_eq!(entry.unwrap().type_name, "f");
2617+
}
2618+
2619+
#[test]
2620+
fn prototype_object_literal_emits_definitions() {
2621+
let s = parse_js(
2622+
"function C() {}\n\
2623+
C.prototype = {\n\
2624+
foo: function() {},\n\
2625+
bar: function() {},\n\
2626+
};",
2627+
);
2628+
let foo = s.definitions.iter().find(|d| d.name == "C.foo");
2629+
let bar = s.definitions.iter().find(|d| d.name == "C.bar");
2630+
assert!(foo.is_some(), "C.foo missing");
2631+
assert_eq!(foo.unwrap().kind, "method");
2632+
assert!(bar.is_some(), "C.bar missing");
2633+
}
2634+
2635+
#[test]
2636+
fn prototype_object_literal_shorthand_method() {
2637+
let s = parse_js(
2638+
"function C() {}\n\
2639+
C.prototype = {\n\
2640+
greet() { return 'hi'; },\n\
2641+
};",
2642+
);
2643+
let def = s.definitions.iter().find(|d| d.name == "C.greet");
2644+
assert!(def.is_some(), "C.greet definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::<Vec<_>>());
2645+
assert_eq!(def.unwrap().kind, "method");
2646+
}
2647+
2648+
#[test]
2649+
fn prototype_object_literal_shorthand_property_seeds_type_map() {
2650+
let s = parse_js(
2651+
"function helper() {}\n\
2652+
function C() {}\n\
2653+
C.prototype = { helper };",
2654+
);
2655+
let entry = s.type_map.iter().find(|e| e.name == "C.helper");
2656+
assert!(entry.is_some(), "type_map entry C.helper missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
2657+
assert_eq!(entry.unwrap().type_name, "helper");
2658+
}
2659+
2660+
#[test]
2661+
fn prototype_builtin_globals_are_excluded() {
2662+
let s = parse_js("Array.prototype.custom = function() {};");
2663+
let def = s.definitions.iter().find(|d| d.name.contains("Array"));
2664+
assert!(def.is_none(), "built-in prototype assignment should be ignored; got: {:?}", def);
2665+
}
2666+
24492667
/// Phase 8.3e: Object.defineProperty seeds composite type_map key.
24502668
#[test]
24512669
fn type_map_from_define_property() {

0 commit comments

Comments
 (0)