Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
11e70c8
feat(resolver): resolve prototype-based method calls (Foo.prototype.b…
carlos-alm Jun 5, 2026
a458aad
test(cha): add 3-level hierarchy fixture for transitive CHA closure (…
carlos-alm Jun 5, 2026
1f1b1c3
fix: resolve merge conflicts with main
carlos-alm Jun 5, 2026
e4fa7c2
fix: remove duplicate prototype extractor functions and fix format
carlos-alm Jun 5, 2026
832f7fc
fix: address review feedback — shorthand prototype props, inline-new …
carlos-alm Jun 5, 2026
253bd71
feat(resolver): track array spread and Array.from/concat/flat callbac…
carlos-alm Jun 6, 2026
4fe4f71
fix: add native orchestrator post-pass for prototype method resolution
carlos-alm Jun 6, 2026
0ffb24e
style: format test fixtures and pts test call sites
carlos-alm Jun 6, 2026
1b84435
fix: scan all files for prototype call edges, not just definition fil…
carlos-alm Jun 6, 2026
d926cf3
perf: pre-filter prototype files and remove dead seenByPair DB load (…
carlos-alm Jun 6, 2026
a6c5d2d
feat(resolver): resolve this-dispatch on function-as-object property …
carlos-alm Jun 6, 2026
c484fd1
feat(resolver): resolve property calls on object destructuring rest p…
carlos-alm Jun 6, 2026
cdc84cf
Merge remote-tracking branch 'origin/main' into feat/prototype-resolv…
carlos-alm Jun 6, 2026
4ed709e
fix(lint): apply biome auto-fixes across extractors and domain files
carlos-alm Jun 6, 2026
66b899a
fix(pts): resolve module-level for-of and class-method for-of PTS keys
carlos-alm Jun 6, 2026
667866e
fix(bench): sync JS fixture names and use super.count() in DoubleCoun…
carlos-alm Jun 6, 2026
058f0f4
Merge branch 'main' into feat/prototype-resolver-1317
carlos-alm Jun 6, 2026
194507c
fix(native): add prototype method extraction to Rust engine (#1327) (…
carlos-alm Jun 6, 2026
c036834
fix: resolve merge conflicts with main and fix duplicate paramBinding…
carlos-alm Jun 6, 2026
8407fcf
fix: extend native post-pass pre-filter to include arrow-function pro…
carlos-alm Jun 6, 2026
9fb1a8b
test(extractor): verify exported arrow function funcStack tracking in…
carlos-alm Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,13 @@
"semicolons": "always",
"trailingCommas": "all"
}
}
},
"overrides": [
{
"includes": ["tests/benchmarks/resolution/fixtures/**"],
"linter": {
"enabled": false
}
}
]
}
99 changes: 98 additions & 1 deletion crates/codegraph-core/src/edge_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -489,6 +512,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<String> {
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 {
Expand Down Expand Up @@ -1370,3 +1418,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);
}
}
218 changes: 218 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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() {
Expand Down
3 changes: 3 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export function resolveByMethodOrGlobal(
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
// produces receiver='(new A)'. Extract the constructor name directly.
// The regex intentionally restricts to uppercase-initial names ([A-Z_$]) as a heuristic
// to distinguish constructors (PascalCase) from regular functions — avoiding false positives
// on `(new xmlParser()).parse()` style calls which are rare in practice.
if (!typeName && call.receiver) {
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
if (m?.[1]) typeName = m[1];
Expand Down
Loading
Loading