Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions crates/codegraph-core/src/edge_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,27 @@ fn resolve_call_targets<'a>(
|| call.receiver.as_deref() == Some("self")
|| call.receiver.as_deref() == Some("super")
{
// Phase 8.3f: accessor this-dispatch via Object.defineProperty.
// When a plain function (no class prefix in caller_name) is registered as a get/set
// accessor for `obj`, typeMap seeds 'callerName:this' = 'obj'. Resolve this.method()
// via typeMap['obj.method'] → the concrete definition. Runs before the broad exact-name
// lookup to avoid false positives from unrelated same-file definitions.
if call.receiver.as_deref() == Some("this") && !caller_name.contains('.') {

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 Missing empty-string guard on caller_name

The Rust guard !caller_name.contains('.') does not exclude empty string, whereas the TypeScript counterpart explicitly checks callerName && !callerName.includes('.'). When caller_name == "", the lookup key becomes ":this". No entry is ever seeded under that key, so no incorrect edge is produced today, but adding the guard makes the parity explicit and removes the latent risk if seeding logic ever changes.

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 !caller_name.is_empty() to the Rust guard in edge_builder.rs:442, bringing it to exact parity with the TypeScript check callerName && !callerName.includes('.'). Commit: d2b55b6.

let accessor_key = format!("{}:this", caller_name);
if let Some(&(obj_name, _)) = type_map.get(accessor_key.as_str()) {
let obj_method_key = format!("{}.{}", obj_name, call.name);
if let Some(&(target_fn, _)) = type_map.get(obj_method_key.as_str()) {
let accessor_resolved: Vec<&NodeInfo> = ctx.nodes_by_name
.get(target_fn)
.map(|v| v.iter()
.filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5)
.copied().collect())
.unwrap_or_default();
if !accessor_resolved.is_empty() { return accessor_resolved; }
}
}
}

// First try exact name match (e.g. an unqualified function named "area").
let exact: Vec<&NodeInfo> = ctx.nodes_by_name
.get(call.name.as_str())
Expand Down
129 changes: 122 additions & 7 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,17 +229,27 @@ fn seed_define_property_entries(node: &Node, source: &[u8], symbols: &mut FileSy
}

if method == "defineProperty" {
// Object.defineProperty(obj, "key", { value: fn })
// Object.defineProperty(obj, "key", { value: fn }) or { get: getter }
if args.len() < 3 { return; }
if args[0].kind() != "identifier" { return; }
let obj_name = node_text(&args[0], source);
let Some(key) = extract_string_fragment(&args[1], source) else { return };
let Some(target) = find_descriptor_value(&args[2], source) else { return };
symbols.type_map.push(TypeMapEntry {
name: format!("{}.{}", obj_name, key),
type_name: target.to_string(),
confidence: 0.85,
});
// Phase 8.3e: { value: fn } → obj.key pts to fn
if let Some(target) = find_descriptor_value(&args[2], source) {
symbols.type_map.push(TypeMapEntry {
name: format!("{}.{}", obj_name, key),
type_name: target.to_string(),
confidence: 0.85,
});
}
// Phase 8.3f: { get: getter } or { set: setter } → this inside getter/setter === obj
if let Some(accessor) = find_descriptor_accessor(&args[2], source) {
symbols.type_map.push(TypeMapEntry {
name: format!("{}:this", accessor),
type_name: obj_name.to_string(),
confidence: 0.85,
});
}
} else {
// Object.defineProperties(obj, { "key": { value: fn }, ... })
if args.len() < 2 { return; }
Expand Down Expand Up @@ -347,6 +357,104 @@ fn find_descriptor_value<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a st
None
}

/// Phase 8.3f: return the identifier text of a `get` or `set` accessor in a property descriptor.
/// `{ get: getter }` → Some("getter"); `{ set: setter }` → Some("setter").
fn find_descriptor_accessor<'a>(node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> {
if node.kind() != "object" { return None; }
for i in 0..node.child_count() {
let Some(child) = node.child(i) else { continue };
if child.kind() != "pair" { continue; }
let Some(key) = child.child_by_field_name("key") else { continue };
let key_text = node_text(&key, source);
if key_text != "get" && key_text != "set" { continue; }
let Some(val) = child.child_by_field_name("value") else { continue };
if val.kind() == "identifier" {
return Some(node_text(&val, source));
}
}
None
}

/// Phase 8.3f: extract function/arrow properties from an object literal as standalone definitions
/// and seed composite typeMap keys so that `this.method()` inside Object.defineProperty accessors
/// can resolve them.
///
/// `const obj = { baz: () => {} }` → Definition { name: "baz", kind: "function" }
/// + TypeMapEntry { name: "obj.baz", type_name: "baz" }
/// `const obj = { baz }` (shorthand) → TypeMapEntry { name: "obj.baz", type_name: "baz" }
fn extract_object_literal_functions(
obj_node: &Node,
source: &[u8],
var_name: &str,
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 };
match val_n.kind() {
"arrow_function" | "function_expression" | "function" => {
symbols.definitions.push(Definition {
name: key.clone(),
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,
});
symbols.type_map.push(TypeMapEntry {
name: format!("{}.{}", var_name, key),
type_name: key,
confidence: 0.85,
});
}
"identifier" => {
let target = node_text(&val_n, source);
symbols.type_map.push(TypeMapEntry {
name: format!("{}.{}", var_name, key),
type_name: target.to_string(),
confidence: 0.85,
});
}
_ => {}
}
}
"method_definition" => {
let Some(name_n) = child.child_by_field_name("name") else { continue };
symbols.definitions.push(Definition {
name: node_text(&name_n, source).to_string(),
kind: "function".to_string(),
line: start_line(&child),
end_line: Some(end_line(&child)),
decorators: None,
complexity: None,
cfg: None,
children: None,
});
}
_ => {}
}
}
}

// ── Return-type map extraction (Phase 8.2 parity) ───────────────────────────

/// Walk the AST collecting function/method return types into `symbols.return_type_map`.
Expand Down Expand Up @@ -690,6 +798,13 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
cfg: None,
children: None,
});
// Phase 8.3f: extract function/arrow properties from object literals and seed
// typeMap composite keys so that this.method() inside Object.defineProperty
// accessor functions can resolve them.
if value_n.kind() == "object" && name_n.kind() == "identifier" {
let var_name = node_text(&name_n, source);
extract_object_literal_functions(&value_n, source, var_name, symbols);
}
} else if name_n.kind() == "identifier" && value_n.kind() == "identifier" {
// Phase 8.3: `const alias = handler` — record for pts analysis.
// Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so
Expand Down
29 changes: 29 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ export function resolveByMethodOrGlobal(
call.receiver === 'self' ||
call.receiver === 'super'
) {
// Phase 8.3f: accessor this-dispatch via Object.defineProperty.
// When a plain function (no class prefix) is registered as a get/set accessor for `obj`
// via Object.defineProperty, typeMap seeds 'callerName:this' = 'obj'.
// We then resolve this.method() → typeMap['obj.method'] → the concrete definition.
// This runs before the broad exact-name lookup to avoid false positives from
// unrelated same-file definitions.
if (call.receiver === 'this' && callerName && !callerName.includes('.')) {
const accessorThisEntry = typeMap.get(`${callerName}:this`);
const objName = accessorThisEntry
? typeof accessorThisEntry === 'string'
? accessorThisEntry
: (accessorThisEntry as { type?: string }).type
: null;
if (objName) {
const objMethodEntry = typeMap.get(`${objName}.${call.name}`);
const targetFn = objMethodEntry
? typeof objMethodEntry === 'string'
? objMethodEntry
: (objMethodEntry as { type?: string }).type
: null;
if (targetFn) {
const resolved = lookup
.byName(targetFn)
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
if (resolved.length > 0) return resolved;
}
}
}

const exact = lookup
.byName(call.name)
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
Expand Down
114 changes: 111 additions & 3 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,10 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti
line: nodeStartLine(declNode),
endLine: nodeEndLine(declNode),
});
// Phase 8.3f: extract function/arrow properties from object literals.
if (valueN.type === 'object') {
extractObjectLiteralFunctions(valueN, definitions);
}
}
}
}
Expand Down Expand Up @@ -834,6 +838,11 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
line: nodeStartLine(node),
endLine: nodeEndLine(node),
});
// Phase 8.3f: extract function/arrow properties from object literals so that
// this.method() calls inside Object.defineProperty accessors can resolve them.
if (valueN.type === 'object') {
extractObjectLiteralFunctions(valueN, ctx.definitions);
}
Comment on lines 911 to +921

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 Missing hasFunctionScopeAncestor guard

The inline comment added at line 501–504 explicitly warns: "Do not call extractObjectLiteralFunctions from any other context without adding a hasFunctionScopeAncestor guard first." This call in handleVariableDecl omits the guard. Because walkJavaScriptNode recurses into every child without filtering on scope — unlike extractConstantsWalk, which skips FUNCTION_SCOPE_TYPES — this branch fires for const localObj = { fn: () => {} } inside any function body. That registers localObj.fn into the global definition index, where it could be found by lookup.byName('localObj.fn') if an unrelated accessor coincidentally seeds typeMap['getter:this'] = 'localObj'.

The fix is the same guard already used in the sibling destructured-binding branch: !hasFunctionScopeAncestor(node).

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 !hasFunctionScopeAncestor(node) to the condition at line 838 in handleVariableDecl, matching the Rust path's find_parent_of_types check and the sibling destructured-binding branch below. The walk path now correctly skips object literals inside function bodies. Also updated the comment at the query path call site to reflect that the walk path now has the guard. Commit: e0663f2.

} else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) {
// Destructured bindings: const { handleToken, checkPermissions } = initAuth(...)
// Each destructured property becomes a function definition so it can be
Expand All @@ -854,6 +863,50 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
}
}

/**
* Phase 8.3f: extract function/arrow function properties from an object literal as standalone
* definitions so that `this.method()` calls inside Object.defineProperty accessor functions can
* resolve them via the normal same-file definition lookup.
*
* `const obj = { baz: () => {} }` → emits Definition { name: 'baz', kind: 'function' }
*/
function extractObjectLiteralFunctions(objNode: TreeSitterNode, definitions: Definition[]): void {
for (let i = 0; i < objNode.childCount; i++) {
const child = objNode.child(i);
if (!child) continue;
if (child.type === 'pair') {
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (!keyNode || !valueNode) continue;
const keyName =
keyNode.type === 'string' ? keyNode.text.replace(/^['"]|['"]$/g, '') : keyNode.text;
if (!keyName) continue;
if (
valueNode.type === 'arrow_function' ||
valueNode.type === 'function_expression' ||
valueNode.type === 'function'
) {
definitions.push({
name: keyName,
kind: 'function',
line: nodeStartLine(child),
endLine: nodeEndLine(valueNode),
});
}
} else if (child.type === 'method_definition') {
const nameNode = child.childForFieldName('name');
if (nameNode) {
definitions.push({
name: nameNode.text,
kind: 'function',
line: nodeStartLine(child),
endLine: nodeEndLine(child),
});
}
}
}
}

function handleEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
const nameNode = node.childForFieldName('name');
if (!nameNode) return;
Comment on lines 939 to 997

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 Object property names added to the global definition index under bare property keys

extractObjectLiteralFunctions registers each function-valued property as a Definition { name: keyName, kind: 'function' } — e.g. const obj = { render: () => {} } adds a definition named render. This name is indexed under nodes_by_name['render'] alongside any top-level function render declarations, so any unqualified call to render() elsewhere in a proximate file will now include this anonymous property as a candidate.

The benchmark precision stays at 1.0 because the fixture properties (baz, accessMethod) are not shared with other call sites. In real-world codebases with common property names (init, update, start, render), this global-index pollution could produce false positive edges. The same issue exists in the Rust counterpart extract_object_literal_functions.

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 — extractObjectLiteralFunctions (TS) and extract_object_literal_functions (Rust) now emit definitions under qualified names (obj.baz instead of bare baz), preventing global-index pollution from common property names. The typeMap value for function/arrow entries is also updated to the qualified name so the Phase 8.3f resolver path calls lookup.byName('obj.baz'). The method_definition branch in Rust also now computes complexity metrics for parity with the pair branch. All tests (jelly-micro, JS benchmark, parser, integration) pass. Commit: 5ef11f6.

Expand Down Expand Up @@ -1582,6 +1635,37 @@ function handleVarDeclaratorTypeMap(
}
}
}

// Phase 8.3f: seed composite pts keys for object literal properties.
// `const obj = { baz: () => {} }` → typeMap['obj.baz'] = 'baz'
// `const obj = { baz }` (shorthand) → typeMap['obj.baz'] = 'baz'
// Enables accessor this-dispatch: when typeMap['getter:this'] = 'obj',
// resolving this.baz() inside getter → typeMap['obj.baz'] → 'baz'.
if (valueN.type === 'object') {
for (let i = 0; i < valueN.childCount; i++) {
const child = valueN.child(i);
if (!child) continue;
if (child.type === 'shorthand_property_identifier') {
setTypeMapEntry(typeMap, `${nameN.text}.${child.text}`, child.text, 0.85);
} else if (child.type === 'pair') {
const keyNode = child.childForFieldName('key');
const valNode = child.childForFieldName('value');
if (!keyNode || !valNode) continue;
const keyName =
keyNode.type === 'string' ? keyNode.text.replace(/^['"]|['"]$/g, '') : keyNode.text;
if (!keyName) continue;
if (
valNode.type === 'arrow_function' ||
valNode.type === 'function_expression' ||
valNode.type === 'function'
) {
setTypeMapEntry(typeMap, `${nameN.text}.${keyName}`, keyName, 0.85);
} else if (valNode.type === 'identifier') {
setTypeMapEntry(typeMap, `${nameN.text}.${keyName}`, valNode.text, 0.85);
}
}
}
}
}

/** Extract type info from a required_parameter or optional_parameter. */
Expand Down Expand Up @@ -1634,10 +1718,11 @@ function handlePropWriteTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeM
}

/**
* Phase 8.3e: seed composite pts keys from Object.defineProperty / defineProperties.
* Phase 8.3e/8.3f: seed composite pts keys from Object.defineProperty / defineProperties.
*
* `Object.defineProperty(obj, "key", { value: fn })` → typeMap.set('obj.key', fn, 0.85)
* `Object.defineProperties(obj, { "k1": { value: v1 } })` → typeMap.set('obj.k1', v1, 0.85)
* `Object.defineProperty(obj, "key", { get: getter })` → typeMap.set('getter:this', obj, 0.85)
*/
function handleDefinePropertyTypeMap(
node: TreeSitterNode,
Expand Down Expand Up @@ -1669,9 +1754,16 @@ function handleDefinePropertyTypeMap(
if (arg1.type !== 'string') return;
const key = arg1.text.replace(/^['"]|['"]$/g, '');
if (!key) return;
// Phase 8.3e: { value: fn } → obj.key pts to fn
const target = findDescriptorValue(arg2);
if (!target) return;
setTypeMapEntry(typeMap, `${arg0.text}.${key}`, target, 0.85);
if (target) {
setTypeMapEntry(typeMap, `${arg0.text}.${key}`, target, 0.85);
}
// Phase 8.3f: { get: getter } or { set: setter } → this inside getter/setter is arg0 (obj)
const accessor = findDescriptorAccessor(arg2);
if (accessor) {
setTypeMapEntry(typeMap, `${accessor}:this`, arg0.text, 0.85);
}
} else {
// defineProperties
if (args.length < 2) return;
Expand Down Expand Up @@ -1706,6 +1798,22 @@ function findDescriptorValue(desc: TreeSitterNode): string | undefined {
return undefined;
}

/**
* Phase 8.3f: return the identifier text of a `get` or `set` accessor in a property descriptor.
* `{ get: getter }` → 'getter'; `{ set: setter }` → 'setter'.
*/
function findDescriptorAccessor(desc: TreeSitterNode): string | undefined {
if (desc.type !== 'object') return undefined;
for (let i = 0; i < desc.childCount; i++) {
const pair = desc.child(i);
if (pair?.type !== 'pair') continue;
Comment on lines 1976 to +1989

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 Only the first get/set accessor per descriptor is seeded

findDescriptorAccessor returns on the first matching get or set pair and ignores any remaining accessors. For a descriptor with both { get: getter, set: setter }, only getter:this = obj (or setter:this = obj, depending on parse order) is seeded; the other function receives no this-dispatch hint and falls through to the broader exact-name lookup. The same early-return applies to the Rust counterpart in find_descriptor_accessor. If both functions call this.method(), only one will resolve precisely via Phase 8.3f; the other relies on same-file fallback and may pick up a false positive if the method name is not unique.

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 — renamed findDescriptorAccessorfindDescriptorAccessors (TS) and find_descriptor_accessorfind_descriptor_accessors (Rust) to return all matching get/set entries instead of the first. The caller now iterates all results and seeds a callerName:this = obj entry for each accessor. Commit: 5ef11f6.

const key = pair.childForFieldName('key');
const val = pair.childForFieldName('value');
if ((key?.text === 'get' || key?.text === 'set') && val?.type === 'identifier') return val.text;
}
return undefined;
}

/** Seed composite pts keys for each property in a prototype object literal. */
function seedProtoProperties(
varName: string,
Expand Down
Loading
Loading