From 592da5d2c850736b0397fab79d274f04cf9eaaa6 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 17 Jun 2026 12:05:55 -0600 Subject: [PATCH 1/3] fix(parity): align WASM/native extraction for swift and jelly-micro fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift: - WASM extractors/swift.ts: navigation_expression calls used navigation_suffix child text (".save") rather than descending into the simple_identifier. Adds seedSwiftPropertyTypeMap() to also set typeMap from class-body property type annotations so receiver edges resolve correctly (repo -> UserRepository). - Rust extractors/swift.rs: mirrors the navigation_suffix fix — descends into find_child(&n, "simple_identifier") to extract the bare method name. - Both engines now produce identical calls (name="save", receiver="repo") and the 3 missing receiver + calls edges in the swift fixture. jelly-micro: - WASM extractors/javascript.ts: extractObjectLiteralFunctions was only called for const declarations. Adds extractLetVarObjLiteralDeclarators() to emit qualified method definitions (var.method) for let/var object literals, called from extractConstantsWalk (which provides the function-scope guard). Also adds the equivalent in handleVariableDecl for the walk path. - 12 missing nodes (x.a, q1.m1, k1.a1, x12.f13, etc.) now produced by WASM. Note: no Rust change needed — match_js_objlit_qualified_method_defs already handles let/var correctly. erlang divergence pre-dates this run — no WASM grammar available (issue #1582 filed). All other 35/36 fixtures are now wasm == native. Verification: cargo test (419 pass), vitest (3125 pass), resolution benchmark (176 pass), parity audit (35/36 clean, erlang excluded per #1582). --- crates/codegraph-core/src/extractors/swift.rs | 40 +++++++++++++-- src/extractors/javascript.ts | 40 +++++++++++++++ src/extractors/swift.ts | 49 +++++++++++++++++-- tests/parsers/javascript.test.ts | 31 ++++++++++++ tests/parsers/swift.test.ts | 23 +++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-core/src/extractors/swift.rs b/crates/codegraph-core/src/extractors/swift.rs index 12358f53a..898bfb425 100644 --- a/crates/codegraph-core/src/extractors/swift.rs +++ b/crates/codegraph-core/src/extractors/swift.rs @@ -299,9 +299,22 @@ fn match_swift_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dept } "navigation_expression" => { let last = fn_node.child(fn_node.child_count().saturating_sub(1)); - let name = last - .map(|n| node_text(&n, source).to_string()) - .unwrap_or_else(|| node_text(&fn_node, source).to_string()); + // Swift's grammar wraps the method name in a `navigation_suffix` node + // (e.g. `.save` text), not a bare `simple_identifier`. Descend into + // navigation_suffix to get the inner simple_identifier so the resolved + // name is "save" not ".save". Mirrors WASM extractors/swift.ts fix. + let name = last.map(|n| { + if n.kind() == "navigation_suffix" { + find_child(&n, "simple_identifier") + .map(|id| node_text(&id, source).to_string()) + .unwrap_or_else(|| { + let t = node_text(&n, source); + t.trim_start_matches('.').to_string() + }) + } else { + node_text(&n, source).to_string() + } + }).unwrap_or_else(|| node_text(&fn_node, source).to_string()); let receiver = fn_node.child(0) .map(|n| node_text(&n, source).to_string()); symbols.calls.push(Call { @@ -376,4 +389,25 @@ mod tests { assert_eq!(s.imports[0].source, "Foundation"); assert!(s.imports[0].swift_import.unwrap()); } + + /// navigation_expression uses a `navigation_suffix` child — the method name + /// must be extracted as a bare identifier ("save"), not ".save". + #[test] + fn extracts_navigation_call_bare_name() { + let s = parse_swift("func f() { repo.save(x) }"); + let call = s.calls.iter().find(|c| c.receiver.as_deref() == Some("repo")).unwrap(); + assert_eq!(call.name, "save", "method name must not include leading dot"); + assert_eq!(call.receiver.as_deref(), Some("repo")); + } + + /// Class property with a type annotation seeds the typeMap so that + /// receiver-typed call edges can be resolved. + #[test] + fn class_property_type_annotation_seeds_type_map() { + let s = parse_swift("class Service { private let repo: Repository }"); + let entry = s.type_map.iter().find(|e| e.name == "repo"); + assert!(entry.is_some(), "repo should appear in type_map"); + assert_eq!(entry.unwrap().type_name, "Repository"); + assert_eq!(entry.unwrap().confidence, 0.9); + } } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 4fef18da3..c255fa071 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -474,6 +474,7 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]): } extractConstDeclarators(declNode, definitions); + extractLetVarObjLiteralDeclarators(declNode, definitions); // Recurse into non-function, non-export-statement children (blocks, if-statements, etc.) if (child.type !== 'export_statement') { @@ -578,6 +579,33 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti } } +/** + * Extract qualified method definitions from `let`/`var` object-literal declarations. + * Mirrors Rust match_js_objlit_qualified_method_defs which emits qualified definitions + * for method_definition (all declaration kinds) and pair+arrow/function (let/var only, + * since const is already handled by extractConstDeclarators → extractObjectLiteralFunctions). + * + * Called from extractConstantsWalk which already provides the function-scope guard. + * `var q1 = { m1() {} }` → emits Definition { name: 'q1.m1', kind: 'function' } + */ +function extractLetVarObjLiteralDeclarators( + declNode: TreeSitterNode, + definitions: Definition[], +): void { + const t = declNode.type; + if (t !== 'lexical_declaration' && t !== 'variable_declaration') return; + if (declNode.text.startsWith('const ')) return; // handled by extractConstDeclarators + + for (let j = 0; j < declNode.childCount; j++) { + const declarator = declNode.child(j); + if (declarator?.type !== 'variable_declarator') continue; + const nameN = declarator.childForFieldName('name'); + const valueN = declarator.childForFieldName('value'); + if (nameN?.type !== 'identifier' || !valueN || valueN.type !== 'object') continue; + extractObjectLiteralFunctions(valueN, nameN.text, definitions); + } +} + /** * Recursive walk to find dynamic import() calls. * Query patterns match call_expression with identifier/member_expression/subscript_expression @@ -1036,6 +1064,18 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { if (valueN.type === 'object') { extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions); } + } else if ( + !isConst && + nameN.type === 'identifier' && + valueN.type === 'object' && + !hasFunctionScopeAncestor(node) + ) { + // `let`/`var` object literals: extract qualified method definitions so that + // `obj.method()` calls resolve correctly. Mirrors Rust match_js_objlit_qualified_method_defs + // which emits method_definition qualified names for ALL declaration kinds and + // pair+arrow/function for let/var only (const is already handled above). + // Scope guard prevents local object properties from polluting the global index. + extractObjectLiteralFunctions(valueN, nameN.text, ctx.definitions); } 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 diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index a31acc5bc..41d996587 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -42,6 +42,7 @@ function walkSwiftNode(node: TreeSitterNode, ctx: ExtractorOutput): void { handleSwiftCallExpression(node, ctx); break; case 'property_declaration': + seedSwiftPropertyTypeMap(node, ctx); handleSwiftPropertyDecl(node, ctx); break; } @@ -250,11 +251,26 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): if (!funcNode) return; const call: Call = { name: '', line: node.startPosition.row + 1 }; if (funcNode.type === 'navigation_expression') { - // obj.method(...) + // obj.method(...) — Swift's tree-sitter grammar wraps the suffix in a + // `navigation_suffix` node: navigation_expression > [simple_identifier, navigation_suffix]. + // We must descend into navigation_suffix to get the bare method name. + // Mirrors Rust match_swift_node which reads node_text of the last child directly + // (which equals ".method") and the receiver from child(0). const lastChild = funcNode.child(funcNode.childCount - 1); const firstChild = funcNode.child(0); - if (lastChild && lastChild.type === 'simple_identifier' && firstChild) { - call.name = lastChild.text; + if (lastChild && firstChild) { + // Resolve the method name: descend into navigation_suffix to find the + // simple_identifier, or fall back to stripping the leading dot from the text. + let methodName: string; + if (lastChild.type === 'simple_identifier') { + methodName = lastChild.text; + } else if (lastChild.type === 'navigation_suffix') { + const inner = findChild(lastChild, 'simple_identifier'); + methodName = inner ? inner.text : lastChild.text.replace(/^\./, ''); + } else { + methodName = lastChild.text; + } + call.name = methodName; call.receiver = firstChild.text; } } else if (funcNode.type === 'simple_identifier') { @@ -265,6 +281,33 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): if (call.name) ctx.calls.push(call); } +/** + * Seed the typeMap for a property_declaration with a type annotation. + * This runs for ALL property_declaration nodes (including class-body ones) + * so that `repo.method()` calls can be resolved to the correct class. + * Mirrors Rust match_swift_type_map which walks all nodes unconditionally. + */ +function seedSwiftPropertyTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { + const typeAnn = findChild(node, 'type_annotation'); + if (!typeAnn) return; + // type_annotation: ":" + // The last child is the actual type node. + const lastChild = typeAnn.child(typeAnn.childCount - 1); + if (!lastChild) return; + // For "user_type > type_identifier", grab the inner identifier text; + // for a plain simple_identifier, use it directly. + const typeNode = + lastChild.type === 'user_type' ? findChild(lastChild, 'type_identifier') : lastChild; + if (!typeNode) return; + const typeName = typeNode.text; + if (!typeName) return; + const pattern = findChild(node, 'pattern'); + if (!pattern) return; + const varName = findChild(pattern, 'simple_identifier')?.text ?? pattern.text; + if (!varName) return; + ctx.typeMap.set(varName, { type: typeName, confidence: 0.9 }); +} + function handleSwiftPropertyDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { // Only handle top-level properties (class properties are handled inline) if ( diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 5ba523fa2..5d53eceef 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -891,6 +891,37 @@ describe('JavaScript parser', () => { ); }); + // let/var object-literal method definitions + it('extracts qualified definitions from var object-literal arrow functions', () => { + // `var x = { a: function() {} }` — native produces `x.a`, WASM must too. + // Parity fix: extractLetVarObjLiteralDeclarators covers let/var (const already + // handled by extractConstDeclarators → extractObjectLiteralFunctions). + const symbols = parseJS(`var x = { a: function() {}, b: () => {} };`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x.a', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x.b', kind: 'function' }), + ); + }); + + it('extracts qualified definitions from let object-literal shorthand methods', () => { + // `let x12 = { f13() {} }` — matches jelly-micro classes.js fixtures. + const symbols = parseJS(`let x12 = { f13() {}, f14: () => {} };`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x12.f13', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x12.f14', kind: 'function' }), + ); + }); + + it('does not extract let/var object-literal definitions inside function scope', () => { + // Scope guard mirrors const path — skips object literals inside function bodies. + const symbols = parseJS(`function setup() { var local = { f() {} }; }`); + expect(symbols.definitions).not.toContainEqual(expect.objectContaining({ name: 'local.f' })); + }); + // Line range verification it('sets correct line and endLine on callback definition', () => { const code = [ diff --git a/tests/parsers/swift.test.ts b/tests/parsers/swift.test.ts index 5541bf20f..d08eed6ec 100644 --- a/tests/parsers/swift.test.ts +++ b/tests/parsers/swift.test.ts @@ -85,4 +85,27 @@ describe('Swift parser', () => { expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'print' })); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'bar' })); }); + + it('extracts navigation_expression calls with bare method name and receiver', () => { + // navigation_expression uses a navigation_suffix child node — method name + // must be "save" not ".save" so the call resolver can find UserRepository.save. + const symbols = parseSwift(`func f() { repo.save(x) }`); + const call = symbols.calls.find((c) => c.receiver === 'repo'); + expect(call).toBeDefined(); + expect(call!.name).toBe('save'); + expect(call!.receiver).toBe('repo'); + }); + + it('seeds typeMap from class property type annotations', () => { + // `private let repo: UserRepository` in a class body must seed typeMap + // so that receiver-typed call edges (repo.save → UserRepository) can resolve. + const symbols = parseSwift(`class Service { + private let repo: UserRepository + func createUser() { repo.save(x) } +}`); + const entry = symbols.typeMap.get('repo'); + expect(entry).toBeDefined(); + expect(entry!.type).toBe('UserRepository'); + expect(entry!.confidence).toBe(0.9); + }); }); From b0f5fd032d7acca0af19a96766c5ae801f7f465b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 17 Jun 2026 18:49:38 -0600 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20fix=20stale=20comment=20=E2=80=94?= =?UTF-8?q?=20both=20TS=20and=20Rust=20now=20descend=20navigation=5Fsuffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/extractors/swift.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index 41d996587..9b2f6e90f 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -253,9 +253,9 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): if (funcNode.type === 'navigation_expression') { // obj.method(...) — Swift's tree-sitter grammar wraps the suffix in a // `navigation_suffix` node: navigation_expression > [simple_identifier, navigation_suffix]. - // We must descend into navigation_suffix to get the bare method name. - // Mirrors Rust match_swift_node which reads node_text of the last child directly - // (which equals ".method") and the receiver from child(0). + // We must descend into navigation_suffix to get the bare method name (e.g. "save" not ".save"). + // Mirrors Rust match_swift_node which also descends into navigation_suffix via find_child + // to extract the inner simple_identifier, with a trim_start_matches('.') fallback. const lastChild = funcNode.child(funcNode.childCount - 1); const firstChild = funcNode.child(0); if (lastChild && firstChild) { From 0301a61679dcaa9ad4ca3856a57ed3ca12a76ce2 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 17 Jun 2026 18:49:41 -0600 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20clarify=20object-literal=20method?= =?UTF-8?q?=20extraction=20comment=20=E2=80=94=20mirrors=20existing=20Rust?= =?UTF-8?q?=20equivalent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/extractors/javascript.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index c255fa071..29abdba7f 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -581,9 +581,10 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti /** * Extract qualified method definitions from `let`/`var` object-literal declarations. - * Mirrors Rust match_js_objlit_qualified_method_defs which emits qualified definitions - * for method_definition (all declaration kinds) and pair+arrow/function (let/var only, - * since const is already handled by extractConstDeclarators → extractObjectLiteralFunctions). + * Mirrors `match_js_objlit_qualified_method_defs` in `javascript.rs`, which emits + * qualified definitions for `method_definition` (all declaration kinds) and + * `pair+arrow/function` (`let`/`var` only, since `const` is already handled by + * `extractConstDeclarators` → `extractObjectLiteralFunctions`). * * Called from extractConstantsWalk which already provides the function-scope guard. * `var q1 = { m1() {} }` → emits Definition { name: 'q1.m1', kind: 'function' }