Skip to content

Commit 1967fcd

Browse files
authored
feat(resolver): resolve prototype-based method calls, spread/iteration callbacks, func-prop this-dispatch, and object-rest param dispatch (JS) (#1331)
* feat(resolver): resolve prototype-based method calls (Foo.prototype.bar = fn) Teach the JS extractor and call resolver about pre-ES6 prototype OOP patterns: Extractor (javascript.ts): - `Foo.prototype.bar = function(){}` → emits `Foo.bar` definition (kind: method) - `Foo.prototype.bar = f` → seeds typeMap['Foo.bar'] = { type: 'f', confidence: 0.9 } - `Foo.prototype = { bar: fn, baz: f }` → same rules per object-literal property Built-in globals (Array, Object, …) are excluded via BUILTIN_GLOBALS guard. Call resolver (call-resolver.ts): - After a symbol-DB miss on a typed receiver, checks typeMap['Type.method'] for prototype aliases (covers `A.prototype.t = f` → call resolves to f) - Extracts the class name from inline `(new Foo)` receivers so `(new A).t()` resolves without a named variable binding Both paths (query + walk) are covered. Adds 7 unit tests. Closes #1317 * test(cha): add 3-level hierarchy fixture for transitive CHA closure (#1313) Extends the TypeScript resolution benchmark hierarchy fixture with Ellipse extends Circle (Shape → Circle → Ellipse) and makeEllipse to provide RTA evidence. Adds the transitive Shape.describe → Ellipse.area expected edge, validating the BFS-based runChaPostPass expansion. Closes #1313 * fix: remove duplicate prototype extractor functions and fix format * fix: address review feedback — shorthand prototype props, inline-new doc, and tests * feat(resolver): track array spread and Array.from/concat/flat callbacks (JS) Phase 8.3e — array-element points-to analysis for JS/TS. Closes #1321 ## What's resolved `f(...arr)`, `for (x of arr)`, `Array.from(arr, cb)`, and `new Set(arr)` patterns now produce call edges where function references flow through array operations: const arr = [a, b]; f(...arr); // f→a, f→b via spread for (x of arr) x() // outer→a, outer→b via iteration Recall on Jelly micro-test fixtures: spread 0→100%, more1 0→100%. ## Implementation - **types.ts** — 4 new interfaces: `ArrayElemBinding`, `SpreadArgBinding`, `ForOfBinding`, `ArrayCallbackBinding` - **extractors/javascript.ts** — `extractArrayElemBindingsWalk` + `extractSpreadForOfWalk` hooked into both query and walk paths - **points-to.ts** — array-element seeding, wildcard constraints, per-index spread constraints, for-of and callback constraints - **build-edges.ts** — passes new bindings to pts map builder; `buildParamFlowPtsPostPass` extended to handle all pts binding types - **wasm-worker-{protocol,entry,pool}.ts** — serializes/deserializes new bindings across the WASM Worker thread boundary - **tests/** — pts unit tests + jelly-micro fixtures for spread/more1 * fix: add native orchestrator post-pass for prototype method resolution The Rust engine does not recognise Foo.prototype.bar = function(){} as a method definition, so prototype-based method nodes were absent from the DB when the native orchestrator ran. This causes the integration tests to fail on all platforms where the native addon is available. Fix two issues: 1. Remove duplicate extractPrototypeMethodsWalk call in extractSymbolsQuery that was inflating the definitions array (identified by Greptile) 2. Add runPostNativePrototypeMethods post-pass to native-orchestrator.ts: - Re-parses JS/TS files via WASM after native build - Inserts any method nodes missing from the DB (prototype patterns) - Resolves and inserts call edges to those new nodes using the WASM typeMap and the call-resolver * style: format test fixtures and pts test call sites * fix: scan all files for prototype call edges, not just definition files (#1331) The newDefFiles guard restricted call scanning to only files that define new prototype methods, silently dropping call edges from files that only call those methods. A foo.speak() call in app.js to Foo.speak defined in lib.js would never produce an edge. Remove the guard — the newNodeIds check inside the loop already prevents duplicate edges. Also hoist db.prepare() outside the loop to avoid re-preparing the same statement on every iteration. * perf: pre-filter prototype files and remove dead seenByPair DB load (#1331) * feat(resolver): resolve this-dispatch on function-as-object property methods (JS) Extract `fn.method = function() {}` assignments as `method` definitions in both the query-based and walk-based JS extraction paths, enabling `this.other()` calls inside such methods to resolve via the existing callerName-based this-dispatch logic in `resolveByMethodOrGlobal`. Extend the native-engine prototype backfill post-pass to also trigger on files containing `fn.prop = function` patterns so the same resolution applies when the Rust orchestrator runs. Closes #1334 * feat(resolver): resolve property calls on object destructuring rest parameters (JS) Adds Phase 8.3f: when a function parameter uses object destructuring with a rest element (`function f3({ e1: eee1, ...eerest })`), and the rest object's property is called (`eerest.e4()`), resolve the callee via a three-hop chain: ObjectRestParamBinding (eerest ← f3 param 0) + ParamBinding (f3(obj) → obj at index 0) + ObjectPropBinding (obj = { e4 } → obj.e4 = e4) → pts["eerest.e4"] = {"e4"} → calls edge f3 → e4 Changes: - types.ts: add ObjectRestParamBinding and ObjectPropBinding interfaces - javascript.ts: extractObjectRestParamBindingsWalk (finds rest params in object-destructured function params) and extractObjectPropBindingsWalk (finds shorthand/identifier properties in object literals); wired into both extractSymbolsQuery and extractSymbolsWalk paths - wasm-worker-{protocol,entry,pool}.ts: serialize new binding arrays - points-to.ts: seed pts["rest.propName"] = {"fn"} from the three-hop chain - build-edges.ts: new Phase 8.3f receiver-pts fallback — when a receiver call is unresolved, check pts["receiver.name"] for rest-dispatch targets; also include new bindings in buildPointsToMapForFile null-check guard Jelly micro-test benchmark (rest fixture): recall=100% TP=1 FN=0 FP=0 Closes #1336 * fix(lint): apply biome auto-fixes across extractors and domain files useOptionalChain rewrites and formatting fixes flagged by Biome in CI. * fix(pts): resolve module-level for-of and class-method for-of PTS keys Two bugs in the forOfBindings points-to resolution path: 1. <module> sentinel never consumed: extractSpreadForOfWalk emits ForOfBinding with enclosingFunc='<module>' for top-level for-of loops, but build-edges.ts only looked up scopedPtsKey (null at module level). Add a modulePtsKey fallback that checks '<module>::call.name' so `for (const f of arr) { f(); }` at module scope resolves correctly. 2. method_definition pushes unqualified name: funcStack.push('bar') but findCaller returns callerName='Foo.bar' from the definitions array. Add a classStack to extractSpreadForOfWalk so method_definition nodes push the qualified name 'Foo.bar', matching the PTS key the lookup uses. * fix(bench): sync JS fixture names and use super.count() in DoubleCounter (#1331) Two bugs introduced by the fix(lint) commit (4ed709e): 1. define-property.js functions were renamed defProp/defProps/create → _defProp/ _defProps/_create (to suppress biome noUnusedVariables), but expected-edges.json was not updated. This caused 5 false positives and 5 false negatives in the benchmark (precision 84.4%, recall 81.8%). 2. DoubleCounter.count was changed from super.count() to Counter.count() by the same lint fix commit. The fixture is meant to test static class-inheritance resolution via super.count(); reverting to Counter.count() made the edge a plain same-file call, causing the class-inheritance recall to drop to 2/3. Fix: update expected-edges.json names to match renamed functions; restore super.count() in inheritance.js with a biome-ignore suppression explaining the intent. * 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. * fix: extend native post-pass pre-filter to include arrow-function property assignments (#1331) The pre-filter regex only matched `fn.method = function(){}` patterns, silently skipping files where all func-prop assignments use arrow functions (`fn.method = () => {}`). Such files were never WASM-reparsed and their method definitions were not inserted by the post-pass. Extend the regex to match both traditional function expressions and arrow function expressions (both `() => {}` and `param => {}` forms). * test(extractor): verify exported arrow function funcStack tracking in extractSpreadForOfWalk (#1359) * test(extractor): verify exported arrow func funcStack tracking in extractSpreadForOfWalk (#1354) Add regression tests confirming that `export const f = (arr) => { for (const x of arr) x(); }` correctly pushes `f` onto the funcStack so for-of bindings record the right enclosing caller. The recursive walk visits `variable_declarator` regardless of whether it is nested under a plain `lexical_declaration` or an `export_statement`, so the gap reported in the PR #1331 review was already closed by commit a6c5d2d. These tests document and gate that behavior. Closes #1354 * fix: remove duplicate paramBindings in SerializedExtractorOutput, rename process identifier The merge at 3c164f2 introduced a second `paramBindings` field (using the top-level ParamBinding import) alongside the existing inline-import form at line 68, causing TS2300 duplicate-identifier errors that broke every CI job. Removed the duplicate and the now-unused ParamBinding top-level import. Also renamed the `process` arrow-function identifier in the Phase 8.3e test to `handleItems` — `process` is a Node.js global and its presence in the test obscures that the test is solely about the export-wrapping code path. * fix(wasm-worker): restore paramBindings in SerializedExtractorOutput, remove dup in pool (#1331) The previous commit removed paramBindings from SerializedExtractorOutput to fix a TS2300 duplicate-identifier error, but left two references to ser.paramBindings in wasm-worker-pool.ts (lines 110 and 125), causing TS2339 errors that broke every CI job. Restore paramBindings as an inline import in the protocol interface (matching the style of the other binding fields), and remove the duplicate line 125 copy in pool.ts. * fix(extractor): resolve arrow-function rest-param bindings via enclosing variable_declarator (#1331) extractObjectRestParamBindingsWalk checked childForFieldName('name') on every function node, but arrow_function and function_expression nodes have no name field in the tree-sitter grammar — so const f = ({ ...rest }) => {} always produced an undefined funcName and silently skipped the entire parameter scan. Fix: when childForFieldName('name') returns null and the parent node is a variable_declarator, fall back to the declarator's own name field. This mirrors the same pattern used in extractSpreadForOfWalk for arrow functions. Closes the gap reported in the Greptile review (comment 3367102930). * fix(wasm-worker): remove duplicate paramBindings serialization in wasm-worker-entry (#1331) paramBindings was serialized twice (lines 808 and 826 in the original code). The second spread was a no-op since it overwrites the first with the same value, but the duplicate caused confusion and was flagged in the Greptile review as evidence of an incomplete field migration.
1 parent 81ef6fa commit 1967fcd

54 files changed

Lines changed: 2059 additions & 89 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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() {

src/domain/graph/builder/call-resolver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export function resolveByMethodOrGlobal(
8282
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
8383
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
8484
// produces receiver='(new A)'. Extract the constructor name directly.
85+
// The regex intentionally restricts to uppercase-initial names ([A-Z_$]) as a heuristic
86+
// to distinguish constructors (PascalCase) from regular functions — avoiding false positives
87+
// on `(new xmlParser()).parse()` style calls which are rare in practice.
8588
if (!typeName && call.receiver) {
8689
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
8790
if (m?.[1]) typeName = m[1];

0 commit comments

Comments
 (0)