Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
238 changes: 220 additions & 18 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep
if value_n.kind() == "call_expression" {
seed_object_create_entries(var_name, &value_n, source, symbols);
}
// Phase 8.3f parity: seed composite typeMap keys for ALL object-literal
// declarations (`const`, `let`, `var`) when at non-function scope.
// Mirrors WASM handleVarDeclaratorTypeMap (no isConst guard there).
// For `const`, extract_object_literal_functions already seeds these entries;
// dedup_type_map collapses any duplicates at equal confidence.
if value_n.kind() == "object" && find_parent_of_types(node, &[
"function_declaration", "arrow_function", "function_expression",
"method_definition", "generator_function_declaration", "generator_function",
]).is_none() {
seed_objlit_type_map_entries(var_name, &value_n, source, symbols);
}
}
}
}
Expand Down Expand Up @@ -555,16 +566,100 @@ fn extract_object_literal_functions(
}
}

/// Seed composite typeMap keys from an object literal for ALL declaration kinds
/// (`const`, `let`, `var`) at non-function scope.
///
/// Mirrors WASM `handleVarDeclaratorTypeMap`'s object-literal branch (no `isConst` guard).
/// Called from `match_js_type_map` so that `let obj = { f() {} }` and
/// `var routes = { get: handler }` resolve correctly just like `const` variants.
///
/// For `const` declarations this produces the same entries as `extract_object_literal_functions`,
/// but `dedup_type_map` collapses duplicates at equal confidence.
fn seed_objlit_type_map_entries(var_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() {
"shorthand_property_identifier" => {
let prop_name = node_text(&child, source);
symbols.type_map.push(TypeMapEntry {
name: format!("{}.{}", var_name, prop_name),
type_name: prop_name.to_string(),
confidence: 0.85,
});
}
"pair" => {
let Some(key_n) = child.child_by_field_name("key") else { continue };
let Some(val_n) = child.child_by_field_name("value") else { continue };
let key = if key_n.kind() == "string" {
extract_string_fragment(&key_n, source).map(|s| s.to_string())
} else {
Some(node_text(&key_n, source).to_string())
};
let Some(key) = key else { continue };
let qualified = format!("{}.{}", var_name, key);
match val_n.kind() {
"arrow_function" | "function_expression" | "function" => {
// Store qualified name as value so the resolver finds the qualified def.
// Mirrors WASM: setTypeMapEntry(typeMap, qualifiedKey, qualifiedKey, 0.85).
// For `const`, `extract_object_literal_functions` creates the matching definition.
// For `let`/`var`, `match_js_objlit_qualified_method_defs` creates it in its
// deferred second pass (now covers all declaration kinds, not just `const`).
symbols.type_map.push(TypeMapEntry {
name: qualified.clone(),
type_name: qualified,
confidence: 0.85,
});
Comment on lines +600 to +611

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 pair+function arm seeds typeMap but omits the Definition, breaking call resolution

For let api = { save: () => {} }, this arm seeds TypeMapEntry { name: "api.save", type_name: "api.save" } but never creates the matching Definition { name: "api.save", ... }. The const path in extract_object_literal_functions (line 519–534) creates both the definition and the typeMap entry. When the resolver follows typeMap["api.save"] → "api.save" and then searches symbols.definitions for a node named "api.save", it finds nothing and drops the call edge.

Before this PR typeMap["api.save"] didn't exist, so api.save() took a direct-call path. After this PR the resolver enters the two-step dispatch and dead-ends on the missing definition, which is a regression for let/var objects with arrow/function-expression property values. The test s_let_arrow includes api.save() in the source but has no call-edge assertion, masking the broken state — unlike s_let_method which was correctly upgraded to assert call-edge creation.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — extended match_js_objlit_qualified_method_defs to emit a qualified definition for let/var pair+arrow/function values (commit 0320b97). For const, extract_object_literal_functions already creates the definition, so const pairs are excluded to avoid duplicates. The test now asserts both that the qualified definition 'api.save' exists in definitions and that api.save() produces a call edge with receiver='api'.

}
Comment on lines +601 to +612

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 pair + inline function value seeds an unresolvable typeMap entry for let/var

seed_objlit_type_map_entries sets typeMap["api.save"] = "api.save" (qualified → qualified), but extract_object_literal_functions — the only place that creates a qualified definition like api.save(function) — is called exclusively from the is_const-gated branch at line 1312. For let/var, no definition named "api.save" is ever pushed to symbols.definitions. The resolver will find the typeMap entry, look up "api.save" in definitions, find nothing, and drop the call edge. The s_let_arrow test (pair + arrow) checks only that the typeMap key exists, not that api.save() produces a call edge, so this gap is invisible to the test suite. Either extend match_js_objlit_qualified_method_defs (or a new analog) to emit definitions for let/var arrow/function pair values, or align type_name with the bare strategy used by method shorthand to point at something that actually exists.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same root cause as the sibling comment. Extended match_js_objlit_qualified_method_defs (commit 0320b97) to emit qualified definitions for let/var pair+arrow/function values. The typeMap entry 'api.save' → 'api.save' (qualified→qualified) now resolves correctly because the matching 'api.save' definition is created by the deferred second-pass walker. The s_let_arrow test now asserts definition existence and end-to-end call-edge creation.

"identifier" => {
let target = node_text(&val_n, source);
symbols.type_map.push(TypeMapEntry {
name: qualified,
type_name: target.to_string(),
confidence: 0.85,
});
}
_ => {}
}
}
"method_definition" => {
// Method shorthand: `let obj = { baz() {} }` → typeMap['obj.baz'] = 'baz'
// Points to the bare-name definition so the two-step accessor dispatch resolves
// via the bare node. `handle_method_def` always creates a bare definition for
// method_definition nodes; `match_js_objlit_qualified_method_defs` (which now
// covers all declaration kinds) adds the qualified definition in its deferred
// second pass. Using the bare name here keeps resolution consistent across all
// declaration kinds (const/let/var).
let Some(method_name) = resolve_method_def_name(&child, source) else { continue };
let qualified = format!("{}.{}", var_name, method_name);
symbols.type_map.push(TypeMapEntry {
name: qualified,
type_name: method_name.to_string(),
confidence: 0.85,
});
Comment on lines +624 to +638

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 type_name mismatch breaks call resolution for let/var method shorthand

For let/var declarations, match_js_objlit_qualified_method_defs is still guarded by is_const (line 665), so no qualified definition (e.g. "obj.baz") is ever created. Only the bare definition "baz" exists (from handle_method_def). When a caller resolves obj.baz(), the resolver looks up typeMap["obj.baz"]"obj.baz", then tries to find a definition named "obj.baz" — which doesn't exist for let/var. The call edge is never created, leaving issue #1551 effectively unfixed for the method-shorthand case.

extract_object_literal_functions (which runs for const) uses type_name: method_name (bare name) precisely to point at the bare definition, and the inline comment there says "points to the bare-name definition so the two-step accessor dispatch resolves via the bare node." The method_definition arm here should use type_name: method_name.to_string() instead of type_name: qualified, to consistently resolve against the bare definition that actually exists for let/var method shorthand.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — changed type_name from the qualified name to the bare method name, matching the const path in extract_object_literal_functions. For let/var, no qualified definition exists (match_js_objlit_qualified_method_defs is const-only), so pointing at 'obj.baz' would leave resolution broken.

}
_ => {}
}
}
}

/// Second-pass walker: emit qualified `obj.method(function)` definitions for
/// `method_definition` children of object literals.
/// `method_definition` and (for `let`/`var`) `pair+arrow/function` children of object literals.
///
/// **method_definition** (all declaration kinds — `const`, `let`, `var`):
/// This must run AFTER the main `match_js_node` walk so that the bare `f(method)` node
/// created by `handle_method_def` appears BEFORE the qualified `obj.f(function)` node
/// in `symbols.definitions`. `findCaller` picks the narrowest-span enclosing definition;
/// when spans are equal it keeps the first inserted one (strict `<`), so `f(method)` wins
/// and call-edge attribution matches WASM (which runs `handleMethodCapture` via the query
/// path before `extractObjectLiteralFunctions` via `runCollectorWalk`).
///
/// **pair + arrow_function / function_expression / function** (`let`/`var` only):
/// For `const`, `extract_object_literal_functions` already creates the qualified definition;
/// repeating it here would produce a duplicate. For `let`/`var`, no other pass emits the
/// qualified definition, so we must emit it here. Without the definition, the typeMap entry
/// seeded by `seed_objlit_type_map_entries` (`"api.save" → "api.save"`) dead-ends: the
/// resolver finds the typeMap entry but then fails to locate a node named `"api.save"`.
///
/// WASM produces both nodes — the qualified one via `extractObjectLiteralFunctions` and the
/// bare one via `handleMethodCapture`. This pass mirrors that by adding only the qualified
/// definitions, deferred so ordering is correct.
Expand All @@ -583,7 +678,6 @@ fn match_js_objlit_qualified_method_defs(
return;
}
let is_const = node.child(0).map(|c| node_text(&c, source) == "const").unwrap_or(false);
if !is_const { return; }
for i in 0..node.child_count() {
let Some(declarator) = node.child(i) else { continue };
if declarator.kind() != "variable_declarator" { continue; }
Expand All @@ -593,22 +687,54 @@ fn match_js_objlit_qualified_method_defs(
let var_name = node_text(&name_n, source);
for j in 0..value_n.child_count() {
let Some(child) = value_n.child(j) else { continue };
if child.kind() != "method_definition" { continue; }
// Use resolve_method_def_name to strip brackets from computed string keys
// (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]).
let Some(method_name) = resolve_method_def_name(&child, source) else { continue };
let qualified = format!("{}.{}", var_name, method_name);
let body = child.child_by_field_name("body");
symbols.definitions.push(Definition {
name: qualified,
kind: "function".to_string(),
line: start_line(&child),
end_line: Some(end_line(&child)),
decorators: None,
complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")),
cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)),
children: None,
});
match child.kind() {
"method_definition" => {
// Emit qualified definition for ALL declaration kinds.
// Use resolve_method_def_name to strip brackets from computed string keys
// (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]).
let Some(method_name) = resolve_method_def_name(&child, source) else { continue };
let qualified = format!("{}.{}", var_name, method_name);
let body = child.child_by_field_name("body");
symbols.definitions.push(Definition {
name: qualified,
kind: "function".to_string(),
line: start_line(&child),
end_line: Some(end_line(&child)),
decorators: None,
complexity: body.and_then(|b| compute_all_metrics(&b, source, "javascript")),
cfg: body.and_then(|b| build_function_cfg(&b, "javascript", source)),
children: None,
});
}
"pair" if !is_const => {
// Emit qualified definition for `let`/`var` pair+arrow/function values only.
// For `const`, `extract_object_literal_functions` already creates this definition;
// creating it again here would be a duplicate.
let Some(key_n) = child.child_by_field_name("key") else { continue };
let Some(val_n) = child.child_by_field_name("value") else { continue };
if !matches!(val_n.kind(), "arrow_function" | "function_expression" | "function") {
continue;
}
let key = if key_n.kind() == "string" {
extract_string_fragment(&key_n, source).map(|s| s.to_string())
} else {
Some(node_text(&key_n, source).to_string())
};
let Some(key) = key else { continue };
let qualified = format!("{}.{}", var_name, key);
symbols.definitions.push(Definition {
name: qualified,
kind: "function".to_string(),
line: start_line(&child),
end_line: Some(end_line(&val_n)),
decorators: None,
complexity: compute_all_metrics(&val_n, source, "javascript"),
cfg: build_function_cfg(&val_n, "javascript", source),
children: None,
});
}
_ => {}
}
}
}
}
Expand Down Expand Up @@ -4108,6 +4234,82 @@ mod tests {
assert_eq!(tm_f.unwrap().type_name, "f");
}

/// Issue #1551: `let` and `var` object-literal declarations must seed composite typeMap keys
/// just like `const` declarations. Regression test for the parity gap where native bailed
/// early for non-`const` declarations in the object-literal typeMap walk.
#[test]
fn let_var_objlit_seeds_type_map_entries() {
// Method shorthand: `let obj = { f() {} }` → typeMap['obj.f'] present
let s_let_method = parse_js(
"let obj = { f() { return 1; } };\n\
obj.f();",
);
let tm = s_let_method.type_map.iter().find(|e| e.name == "obj.f");
assert!(tm.is_some(), "let obj method: typeMap 'obj.f' missing; got: {:?}",
s_let_method.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
assert_eq!(tm.unwrap().type_name, "f",
"typeMap 'obj.f' must point at bare name 'f', not the qualified key");
let call = s_let_method.calls.iter().find(|c| c.name == "f" && c.receiver.as_deref() == Some("obj"));
assert!(call.is_some(),
"calls must contain obj.f() with receiver='obj'; got: {:?}",
s_let_method.calls.iter().map(|c| (&c.name, &c.receiver)).collect::<Vec<_>>());

// Shorthand property: `var obj = { e4 }` → typeMap['obj.e4'] = 'e4'
let s_var_shorthand = parse_js(
"function e4() {}\n\
var obj = { e4 };",
);
let tm2 = s_var_shorthand.type_map.iter().find(|e| e.name == "obj.e4");
assert!(tm2.is_some(), "var obj shorthand: typeMap 'obj.e4' missing; got: {:?}",
s_var_shorthand.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
assert_eq!(tm2.unwrap().type_name, "e4");

// Pair with identifier value: `var routes = { get: handler }` → typeMap['routes.get'] = 'handler'
let s_var_pair = parse_js(
"function handler() {}\n\
var routes = { get: handler };",
);
let tm3 = s_var_pair.type_map.iter().find(|e| e.name == "routes.get");
assert!(tm3.is_some(), "var routes pair: typeMap 'routes.get' missing; got: {:?}",
s_var_pair.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
assert_eq!(tm3.unwrap().type_name, "handler");

// Pair with arrow value: `let api = { save: () => {} }` → typeMap['api.save'] = 'api.save'
// and a qualified definition 'api.save' must exist (emitted by the deferred
// match_js_objlit_qualified_method_defs pass for non-const pair+arrow/function).
let s_let_arrow = parse_js(
"let api = { save: () => {} };\n\
api.save();",
);
let tm4 = s_let_arrow.type_map.iter().find(|e| e.name == "api.save");
assert!(tm4.is_some(), "let api arrow: typeMap 'api.save' missing; got: {:?}",
s_let_arrow.type_map.iter().map(|e| &e.name).collect::<Vec<_>>());
assert_eq!(tm4.unwrap().type_name, "api.save",
"typeMap 'api.save' must point at the qualified name 'api.save' (qualified definition exists)");
assert!(
s_let_arrow.definitions.iter().any(|d| d.name == "api.save"),
"let api arrow: qualified definition 'api.save' missing; got: {:?}",
s_let_arrow.definitions.iter().map(|d| &d.name).collect::<Vec<_>>()
);
let call4 = s_let_arrow.calls.iter().find(|c| c.name == "save" && c.receiver.as_deref() == Some("api"));
assert!(call4.is_some(),
"calls must contain api.save() with receiver='api'; got: {:?}",
s_let_arrow.calls.iter().map(|c| (&c.name, &c.receiver)).collect::<Vec<_>>());

// Scope guard: object literal inside a function body must NOT seed module-level typeMap.
let s_scoped = parse_js(
"function init() {\n\
let local = { run() {} };\n\
local.run();\n\
}",
);
assert!(
s_scoped.type_map.iter().all(|e| e.name != "local.run"),
"function-scoped let obj must not pollute typeMap; got: {:?}",
s_scoped.type_map.iter().map(|e| &e.name).collect::<Vec<_>>()
);
}

/// Phase 8.3e: call receiver is correctly recorded for obj.f() inside defProp body.
#[test]
fn call_receiver_for_define_property() {
Comment on lines +4240 to 4315

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests verify typeMap key existence but not call-edge creation

The five new unit tests assert that typeMap["obj.f"] is Some, but none verify that a call to obj.f() actually produces a call edge (symbols.calls). A typeMap entry with a type_name pointing to a nonexistent definition silently passes all these assertions while leaving resolution broken. Adding at least one assertion like assert!(s_let_method.calls.iter().any(|c| c.callee == "obj.f" || c.callee == "f")) would catch the type_name mismatch described above and prevent future regressions in call resolution for let/var object-literal methods.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added assertions for both type_name value ('f', not 'obj.f') and call-edge creation (calls contains name='f' with receiver='obj'). The type_name assertion directly catches the regression fixed in the sibling comment.

Expand Down
9 changes: 9 additions & 0 deletions tests/parsers/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,15 @@ describe('JavaScript parser', () => {
expect(symbols.typeMap.get('routes.get')).toEqual({ type: 'handler', confidence: 0.85 });
});

// Issue #1551: let/var object-literal method definitions must seed typeMap entries
it('seeds composite typeMap keys for let-declared object-literal method shorthand', () => {
const symbols = parseJS(`
let obj = { f() { return 1; } };
obj.f();
`);
expect(symbols.typeMap.get('obj.f')).toBeDefined();
});

it('extracts rest binding from a class method', () => {
const symbols = parseJS(`
class Service {
Expand Down
Loading