From 58c5ba9ea086b041b75e5d899b9f2b59a8bc9bf0 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 24 Sep 2026 01:34:28 +0530 Subject: [PATCH] fix(graph): resolve C++ calls in their enclosing namespaces --- src/graph/extract/cpp.ts | 209 +++++++++++++++---- tests/shared/graph/cpp.test.ts | 355 +++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 44 deletions(-) diff --git a/src/graph/extract/cpp.ts b/src/graph/extract/cpp.ts index 73af9f581..e1376dd5a 100644 --- a/src/graph/extract/cpp.ts +++ b/src/graph/extract/cpp.ts @@ -47,8 +47,11 @@ export function extractCpp( result.nodes.push(moduleNode); const declByName = new Map(); - collectCppDecls(root, relativePath, result, declByName, moduleNode, null); - collectCppCalls(root, result, declByName); + // function_definition start position → the node emitted for it, so callers + // are identified by their exact declaration rather than a bare-name lookup. + const fnDeclByPos = new Map(); + collectCppDecls(root, relativePath, result, declByName, fnDeclByPos, moduleNode, null); + collectCppCalls(root, result, declByName, fnDeclByPos, new Map()); return result; } @@ -60,6 +63,7 @@ function collectCppDecls( relativePath: string, result: FileExtraction, declByName: Map, + fnDeclByPos: Map, moduleNode: GraphNode, enclosingClass: string | null, enclosingNamespace: string | null = null, @@ -73,7 +77,6 @@ function collectCppDecls( const name = extractFunctionName(child); /* c8 ignore next */ if (name === null) continue; - /* c8 ignore next */ const nsPrefix = enclosingNamespace !== null ? `${enclosingNamespace}::` : ""; /* c8 ignore next */ const key = enclosingClass !== null ? `${nsPrefix}${enclosingClass}::${name}` : `${nsPrefix}${name}`; @@ -88,7 +91,10 @@ function collectCppDecls( language: LANG, exported: true, }; + // pushNode dedups by id (e.g. overloads), so map to the node actually kept. + const kept = result.nodes.find((n) => n.id === decl.id); pushNode(result, declByName, decl, key); + fnDeclByPos.set(posKey(child), kept ?? decl); /* c8 ignore next */ if (enclosingClass !== null) { result.edges.push({ @@ -109,23 +115,25 @@ function collectCppDecls( const body = child.childForFieldName("body"); /* c8 ignore next */ if (body !== null) { - collectCppDecls(body, relativePath, result, declByName, moduleNode, name, enclosingNamespace); + collectCppDecls(body, relativePath, result, declByName, fnDeclByPos, moduleNode, name, enclosingNamespace); } } } else if (child.type === "namespace_definition") { - /* c8 ignore next */ - const name = child.childForFieldName("name")?.text ?? null; - /* c8 ignore next */ - if (name !== null && name.length > 0) { - pushNode(result, declByName, makeNode(relativePath, name, "module", child, true, LANG)); + // Anonymous namespaces have no name field and add no qualifier. + const name = child.childForFieldName("name")?.text ?? ""; + let ns = enclosingNamespace; + if (name.length > 0) { + // Compose the full path (outer::inner, or the C++17 `A::B` spelling) so + // A::detail and B::detail stay distinct. namespacePath() mirrors this. + ns = enclosingNamespace !== null ? `${enclosingNamespace}::${name}` : name; + pushNode(result, declByName, makeNode(relativePath, ns, "module", child, true, LANG)); } const body = child.childForFieldName("body"); /* c8 ignore next */ if (body !== null) { - // Pass the namespace name so declarations inside are keyed as `ns::symbol`, - // matching the `scope::name` format used by collectCppCalls for qualified calls. - /* c8 ignore next */ - collectCppDecls(body, relativePath, result, declByName, moduleNode, enclosingClass, name ?? enclosingNamespace); + // Declarations inside are keyed as `full::ns::symbol`, matching the + // `scope::name` format used by collectCppCalls for qualified calls. + collectCppDecls(body, relativePath, result, declByName, fnDeclByPos, moduleNode, enclosingClass, ns); } } else if (child.type === "template_declaration") { // Unwrap template to get the underlying declaration @@ -145,7 +153,7 @@ function collectCppDecls( namedChild: (_: number) => inner, namedChildren: [inner], } as unknown as TSNode; - collectCppDecls(wrapper, relativePath, result, declByName, moduleNode, enclosingClass, enclosingNamespace); + collectCppDecls(wrapper, relativePath, result, declByName, fnDeclByPos, moduleNode, enclosingClass, enclosingNamespace); } } } else if (child.type === "preproc_include") { @@ -177,7 +185,7 @@ function collectCppDecls( } } else { // recurse into field_declaration_list, translation_unit, etc. - collectCppDecls(child, relativePath, result, declByName, moduleNode, enclosingClass, enclosingNamespace); + collectCppDecls(child, relativePath, result, declByName, fnDeclByPos, moduleNode, enclosingClass, enclosingNamespace); } } } @@ -188,14 +196,22 @@ function collectCppCalls( node: TSNode, result: FileExtraction, declByName: Map, + fnDeclByPos: Map, + bindingCache: Map>, ): void { if (node.type === "call_expression") { const fn = node.childForFieldName("function"); /* c8 ignore next */ if (fn !== null) { let key: string | null = null; + // bare: unqualified name, subject to local shadowing. + // scoped: looked up relative to the enclosing namespaces first. + let bare = false; + let scoped = false; if (fn.type === "identifier") { key = fn.text; + bare = true; + scoped = true; } else if (fn.type === "field_expression") { const field = fn.childForFieldName("field"); const obj = fn.childForFieldName("argument"); @@ -206,24 +222,30 @@ function collectCppCalls( key = cn !== null ? `${cn}::${field.text}` : field.text; } } else if (fn.type === "qualified_identifier") { - // Foo::bar() + // Foo::bar(), or ::bar() / ::Foo::bar() with no scope field. const scope = fn.childForFieldName("scope"); const name = fn.childForFieldName("name"); /* c8 ignore next */ - if (scope !== null && name !== null) key = `${scope.text}::${name.text}`; + if (name !== null) { + // A leading `::` names the global scope explicitly, so it is never + // resolved relative to the enclosing namespace. + key = scope !== null ? `${scope.text}::${name.text}` : name.text; + scoped = scope !== null; + } } - /* c8 ignore next */ if (key !== null) { - const target = declByName.get(key); - const caller = findEnclosingFnCpp(fn, declByName); - /* c8 ignore next */ - if (target !== undefined && caller !== null) { - result.edges.push({ - source: caller.id, - target: target.id, - relation: "calls", - confidence: "EXTRACTED", - }); + const caller = findCaller(fn, fnDeclByPos); + // A parameter or local of the same name shadows every outer function. + if (caller !== null && !(bare && localBindings(caller.def, bindingCache).has(key))) { + const target = scoped ? resolveScopedCallee(fn, key, declByName) : declByName.get(key); + if (target !== undefined) { + result.edges.push({ + source: caller.decl.id, + target: target.id, + relation: "calls", + confidence: "EXTRACTED", + }); + } } } } @@ -231,8 +253,110 @@ function collectCppCalls( for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); /* c8 ignore next */ - if (child !== null) collectCppCalls(child, result, declByName); + if (child !== null) collectCppCalls(child, result, declByName, fnDeclByPos, bindingCache); + } +} + +function posKey(node: TSNode): string { + return `${node.startPosition.row}:${node.startPosition.column}`; +} + +/** + * Full namespace path of a node, outermost first, composed exactly like the + * keys built by collectCppDecls (anonymous namespaces add nothing; a C++17 + * `namespace A::B` contributes both `A` and `B`). + */ +function namespacePath(node: TSNode): string[] { + const parts: string[] = []; + let cur: TSNode | null = node.parent; + while (cur !== null) { + if (cur.type === "namespace_definition") { + const ns = cur.childForFieldName("name")?.text ?? ""; + if (ns.length > 0) parts.unshift(...ns.split("::")); + } + cur = cur.parent; + } + return parts; +} + +/** + * Resolve a callee written without a leading `::` the way C++ lookup walks + * scopes: innermost enclosing namespace first, then outer namespaces, then + * the global key. This is name lookup only — no type or overload inference. + */ +function resolveScopedCallee( + node: TSNode, + key: string, + declByName: Map, +): GraphNode | undefined { + const parts = namespacePath(node); + for (let i = parts.length; i > 0; i--) { + const found = declByName.get(`${parts.slice(0, i).join("::")}::${key}`); + // Namespace-prefixed keys also name nested namespace (module) nodes. + if (found !== undefined && (found.kind === "function" || found.kind === "method")) return found; + } + return declByName.get(key); +} + +// Nodes whose declarator children introduce a local name: variables, the +// callable's own and catch/lambda parameters, and range-for variables. +const BINDING_TYPES = new Set([ + "declaration", + "parameter_declaration", + "optional_parameter_declaration", + "for_range_loop", +]); + +const DECLARATOR_WRAPPERS = new Set([ + "init_declarator", + "pointer_declarator", + "reference_declarator", + "array_declarator", + "function_declarator", + "parenthesized_declarator", +]); + +/** Collect names introduced by a declarator without traversing its initializer. */ +function declaratorNames(node: TSNode | null | undefined, names: Set): void { + if (node == null) return; + if (node.type === "identifier") { + names.add(node.text); + return; + } + if (node.type === "structured_binding_declarator") { + for (const child of node.namedChildren) { + if (child.type === "identifier") names.add(child.text); + } + return; + } + if (!DECLARATOR_WRAPPERS.has(node.type)) return; + // Partial buffers may lack the inner declarator entirely. + declaratorNames(node.childForFieldName("declarator") ?? node.namedChildren[node.namedChildCount - 1], names); +} + +function collectBindings(node: TSNode, names: Set): Set { + for (const child of node.namedChildren) { + if (BINDING_TYPES.has(node.type)) { + declaratorNames(child, names); + } + collectBindings(child, names); } + return names; +} + +/** + * Every name a function definition binds locally, anywhere in its body. + * Deliberately conservative: a same-named local in any block (or declared + * after the call) suppresses the edge rather than risk inventing one. + */ +function localBindings(def: TSNode, cache: Map>): Set { + const key = posKey(def); + let names = cache.get(key); + if (names === undefined) { + names = collectBindings(def, new Set()); + cache.set(key, names); + } + return names; } function findEnclosingClass(node: TSNode): string | null { @@ -249,27 +373,24 @@ function findEnclosingClass(node: TSNode): string | null { return null; } -function findEnclosingFnCpp( +/** + * The graph node for the callable whose body lexically contains `node`. + * Only the innermost callable counts: a call inside a lambda or a skipped + * definition (inline/local class methods, out-of-class `C::m`) belongs to that + * unrepresented body, so it is dropped rather than credited to an outer function. + */ +function findCaller( node: TSNode, - declByName: Map, -): GraphNode | null { + fnDeclByPos: Map, +): { def: TSNode; decl: GraphNode } | null { let cur: TSNode | null = node.parent; while (cur !== null) { + if (cur.type === "lambda_expression") return null; if (cur.type === "function_definition") { - const name = extractFunctionName(cur); - /* c8 ignore next */ - if (name !== null) { - const cn = findEnclosingClass(cur); - /* c8 ignore next */ - const key = cn !== null ? `${cn}::${name}` : name; - /* c8 ignore next */ - const found = declByName.get(key) ?? declByName.get(name); - /* c8 ignore next */ - if (found !== undefined) return found; - } + const decl = fnDeclByPos.get(posKey(cur)); + return decl !== undefined ? { def: cur, decl } : null; } cur = cur.parent; } - /* c8 ignore next */ return null; } diff --git a/tests/shared/graph/cpp.test.ts b/tests/shared/graph/cpp.test.ts index 8bfece32f..134652dcf 100644 --- a/tests/shared/graph/cpp.test.ts +++ b/tests/shared/graph/cpp.test.ts @@ -62,6 +62,340 @@ describe("C++ extraction", () => { expect(call).toBeDefined(); }); + describe("calls inside namespaces", () => { + const callsFrom = (ex: ReturnType, source: string) => + ex.edges.filter(e => e.relation === "calls" && e.source === source).map(e => e.target).sort(); + + it("resolves bare N::a -> N::b", () => { + const ex = extractCpp( + `namespace N {\n void b() {}\n void a() { b(); }\n}\n`, + "src/n.cpp", + ); + expect(callsFrom(ex, "src/n.cpp:N::a:function")).toEqual(["src/n.cpp:N::b:function"]); + }); + + it("resolves bare calls to a namespace function declared later", () => { + const ex = extractCpp( + `namespace N {\n void a() { b(); }\n void b() {}\n}\n`, + "src/n.cpp", + ); + expect(callsFrom(ex, "src/n.cpp:N::a:function")).toEqual(["src/n.cpp:N::b:function"]); + }); + + it("resolves qualified N::b() from inside N::a", () => { + const ex = extractCpp( + `namespace N {\n void b() {}\n void a() { N::b(); }\n}\n`, + "src/n.cpp", + ); + expect(callsFrom(ex, "src/n.cpp:N::a:function")).toEqual(["src/n.cpp:N::b:function"]); + }); + + it("falls back to global functions from inside a namespace", () => { + const ex = extractCpp( + `void log() {}\nnamespace N {\n void a() { log(); }\n}\n`, + "src/n.cpp", + ); + expect(callsFrom(ex, "src/n.cpp:N::a:function")).toEqual(["src/n.cpp:log:function"]); + }); + + it("keeps global-to-global calls unchanged", () => { + const ex = extractCpp( + `void helper() {}\nvoid run() { helper(); }\nnamespace N {\n void helper() {}\n}\n`, + "src/n.cpp", + ); + expect(callsFrom(ex, "src/n.cpp:run:function")).toEqual(["src/n.cpp:helper:function"]); + }); + + it("does not cross-link same-named symbols in separate namespaces", () => { + const ex = extractCpp( + [ + "void helper() {}", + "void run() { helper(); }", + "namespace A {", + " void helper() {}", + " void run() { helper(); }", + "}", + "namespace B {", + " void helper() {}", + " void run() { helper(); }", + "}", + "", + ].join("\n"), + "src/s.cpp", + ); + expect(callsFrom(ex, "src/s.cpp:run:function")).toEqual(["src/s.cpp:helper:function"]); + expect(callsFrom(ex, "src/s.cpp:A::run:function")).toEqual(["src/s.cpp:A::helper:function"]); + expect(callsFrom(ex, "src/s.cpp:B::run:function")).toEqual(["src/s.cpp:B::helper:function"]); + }); + + it("does not resolve a bare call to a function only in a sibling namespace", () => { + const ex = extractCpp( + `namespace A {\n void only_a() {}\n}\nnamespace B {\n void run() { only_a(); }\n}\n`, + "src/s.cpp", + ); + expect(ex.nodes.some(n => n.id === "src/s.cpp:B::run:function")).toBe(true); + expect(ex.edges.filter(e => e.relation === "calls")).toEqual([]); + }); + + it("resolves through nested namespace blocks, innermost first", () => { + const ex = extractCpp( + [ + "namespace Outer {", + " void top() {}", + " void b() {}", + " namespace Inner {", + " void b() {}", + " void a() { b(); top(); }", + " }", + "}", + "", + ].join("\n"), + "src/nest.cpp", + ); + // Nested members are keyed by their full namespace path. + expect(ex.nodes.some(n => n.id === "src/nest.cpp:Inner::a:function")).toBe(false); + expect(ex.nodes.some(n => n.id === "src/nest.cpp:Outer::Inner:module")).toBe(true); + expect(callsFrom(ex, "src/nest.cpp:Outer::Inner::a:function")).toEqual([ + "src/nest.cpp:Outer::Inner::b:function", + "src/nest.cpp:Outer::top:function", + ]); + }); + + it("keeps same-named inner namespaces of distinct outer namespaces apart", () => { + const ex = extractCpp( + [ + "namespace A {", + " namespace detail {", + " void f() {}", + " void g() { f(); }", + " }", + " void run() { detail::f(); }", + "}", + "namespace B {", + " namespace detail {", + " void f() {}", + " void g() { f(); }", + " }", + " void run() { detail::f(); }", + "}", + "void top() { A::detail::f(); B::detail::f(); }", + "", + ].join("\n"), + "src/sib.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + const ids = ex.nodes.map(n => n.id); + expect(ids).toContain("src/sib.cpp:A::detail:module"); + expect(ids).toContain("src/sib.cpp:B::detail:module"); + expect(ids).toContain("src/sib.cpp:A::detail::f:function"); + expect(ids).toContain("src/sib.cpp:B::detail::f:function"); + // No member collapses onto the incomplete inner-only `detail` identity. + expect(ids.filter(id => id.startsWith("src/sib.cpp:detail"))).toEqual([]); + expect(ex.nodes.find(n => n.id === "src/sib.cpp:B::detail::f:function")!.source_location).toBe("L10"); + expect(callsFrom(ex, "src/sib.cpp:A::detail::g:function")).toEqual(["src/sib.cpp:A::detail::f:function"]); + expect(callsFrom(ex, "src/sib.cpp:B::detail::g:function")).toEqual(["src/sib.cpp:B::detail::f:function"]); + // Qualified calls are looked up relative to the enclosing namespace first. + expect(callsFrom(ex, "src/sib.cpp:A::run:function")).toEqual(["src/sib.cpp:A::detail::f:function"]); + expect(callsFrom(ex, "src/sib.cpp:B::run:function")).toEqual(["src/sib.cpp:B::detail::f:function"]); + expect(callsFrom(ex, "src/sib.cpp:top:function")).toEqual([ + "src/sib.cpp:A::detail::f:function", + "src/sib.cpp:B::detail::f:function", + ]); + }); + + it("gives C++17 and ordinary nested spellings the same identity", () => { + const ex = extractCpp( + [ + "namespace A::B {", + " void h() {}", + "}", + "namespace A {", + " namespace B {", + " void f() { h(); }", + " }", + "}", + "namespace X {", + " namespace A::B {", + " void h() {}", + " void f() { h(); }", + " }", + "}", + "", + ].join("\n"), + "src/mix.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + expect(callsFrom(ex, "src/mix.cpp:A::B::f:function")).toEqual(["src/mix.cpp:A::B::h:function"]); + expect(callsFrom(ex, "src/mix.cpp:X::A::B::f:function")).toEqual(["src/mix.cpp:X::A::B::h:function"]); + expect(ex.nodes.some(n => n.id === "src/mix.cpp:X::A::B:module")).toBe(true); + }); + + it("qualifies anonymous-namespace members by the enclosing named namespace", () => { + const ex = extractCpp( + `namespace N {\n namespace {\n void b() {}\n }\n void a() { b(); }\n}\n`, + "src/anon.cpp", + ); + expect(callsFrom(ex, "src/anon.cpp:N::a:function")).toEqual(["src/anon.cpp:N::b:function"]); + }); + + it("never resolves a bare call to a nested namespace node", () => { + const ex = extractCpp( + `namespace A {\n namespace detail {\n void f() {}\n }\n void run() { detail(); }\n}\n`, + "src/mod.cpp", + ); + expect(ex.nodes.some(n => n.id === "src/mod.cpp:A::run:function")).toBe(true); + expect(ex.edges.filter(e => e.relation === "calls")).toEqual([]); + }); + + it("honours explicit global qualification inside a namespace", () => { + const ex = extractCpp( + [ + "void helper() {}", + "namespace A {", + " void f() {}", + "}", + "namespace N {", + " void helper() {}", + " namespace A {", + " void f() {}", + " }", + " void run() { ::helper(); helper(); }", + " void run2() { ::A::f(); A::f(); }", + "}", + "", + ].join("\n"), + "src/g.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + expect(callsFrom(ex, "src/g.cpp:N::run:function")).toEqual([ + "src/g.cpp:N::helper:function", + "src/g.cpp:helper:function", + ]); + expect(callsFrom(ex, "src/g.cpp:N::run2:function")).toEqual([ + "src/g.cpp:A::f:function", + "src/g.cpp:N::A::f:function", + ]); + }); + + it("attributes every overload's calls to the single kept node", () => { + const ex = extractCpp( + `namespace N {\n void h() {}\n void f(int) { h(); }\n void f(double) { h(); }\n}\n`, + "src/ov.cpp", + ); + expect(ex.nodes.filter(n => n.id === "src/ov.cpp:N::f:function")).toHaveLength(1); + expect(callsFrom(ex, "src/ov.cpp:N::f:function")).toEqual([ + "src/ov.cpp:N::h:function", + "src/ov.cpp:N::h:function", + ]); + }); + + it("does not link a bare call whose name is a parameter or local", () => { + const ex = extractCpp( + [ + "void helper() {}", + "void g(void (*helper)()) { helper(); }", + "namespace N {", + " void helper() {}", + " void byPointer(void (*helper)()) { helper(); }", + " template", + " void byRef(F&& helper) { helper(); }", + " void byLocal() { auto helper = [] {}; helper(); }", + " void byRange(Handlers hs) { for (auto helper : hs) helper(); }", + " void qualified(void (*helper)()) { N::helper(); ::helper(); helper(); }", + " void control(int other) { int x = 0; helper(); }", + "}", + "", + ].join("\n"), + "src/shadow.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + for (const fn of ["g", "N::byPointer", "N::byRef", "N::byLocal", "N::byRange"]) { + expect(ex.nodes.some(n => n.id === `src/shadow.cpp:${fn}:function`)).toBe(true); + expect(callsFrom(ex, `src/shadow.cpp:${fn}:function`)).toEqual([]); + } + // Qualified names bypass locals; the bare call alone is suppressed. + expect(callsFrom(ex, "src/shadow.cpp:N::qualified:function")).toEqual([ + "src/shadow.cpp:N::helper:function", + "src/shadow.cpp:helper:function", + ]); + expect(callsFrom(ex, "src/shadow.cpp:N::control:function")).toEqual(["src/shadow.cpp:N::helper:function"]); + }); + + it("does not credit calls in lambdas or local class methods to the outer function", () => { + const ex = extractCpp( + [ + "namespace N {", + " void helper() {}", + " void other() {}", + " void outer() {", + " auto cb = [] { other(); };", + " struct Local { void m() { other(); } };", + " helper();", + " cb();", + " }", + "}", + "", + ].join("\n"), + "src/nested.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + expect(callsFrom(ex, "src/nested.cpp:N::outer:function")).toEqual(["src/nested.cpp:N::helper:function"]); + expect(ex.edges.filter(e => e.relation === "calls")).toHaveLength(1); + }); + + it("emits no call edge for calls outside any function body", () => { + const ex = extractCpp(`int compute() { return 1; }\nint x = compute();\n`, "src/init.cpp"); + expect(ex.nodes.some(n => n.id === "src/init.cpp:compute:function")).toBe(true); + expect(ex.edges.filter(e => e.relation === "calls")).toEqual([]); + }); + + it("resolves inside C++17 nested namespace definitions", () => { + const ex = extractCpp( + [ + "namespace A {", + " void g() {}", + "}", + "namespace A::B {", + " void h() {}", + " void f() { h(); g(); }", + "}", + "", + ].join("\n"), + "src/c17.cpp", + ); + expect(ex.parse_errors).toHaveLength(0); + expect(callsFrom(ex, "src/c17.cpp:A::B::f:function")).toEqual([ + "src/c17.cpp:A::B::h:function", + "src/c17.cpp:A::g:function", + ]); + }); + + it("attributes calls in namespaced template functions to the qualified node", () => { + const ex = extractCpp( + `namespace N {\n void b() {}\n template\n T a(T x) { b(); return x; }\n}\n`, + "src/t.cpp", + ); + expect(callsFrom(ex, "src/t.cpp:N::a:function")).toEqual(["src/t.cpp:N::b:function"]); + }); + + it("keeps anonymous-namespace members keyed and resolved unqualified", () => { + const ex = extractCpp( + `namespace {\n void b() {}\n void a() { b(); }\n}\n`, + "src/anon.cpp", + ); + expect(callsFrom(ex, "src/anon.cpp:a:function")).toEqual(["src/anon.cpp:b:function"]); + }); + + it("still emits no call edges from inline class methods (unsupported)", () => { + const ex = extractCpp( + `void helper() {}\nnamespace N {\n class C {\n public:\n void m() { helper(); }\n };\n}\n`, + "src/c.cpp", + ); + expect(ex.nodes.some(n => n.kind === "method")).toBe(false); + expect(ex.edges.filter(e => e.relation === "calls")).toEqual([]); + }); + }); + it("extracts #include as imports edge", () => { const ex = extractCpp(`#include \nvoid f() {}\n`, "src/a.cpp"); const imp = ex.edges.find(e => e.relation === "imports" && e.target === "external:vector"); @@ -120,3 +454,24 @@ describe("C++ extraction", () => { expect(ex.parse_errors).toHaveLength(0); }); }); + + +describe("C++ namespace call binding controls", () => { + it("structured bindings shadow namespace functions", () => { + const ex = extractCpp(`namespace N { +void helper() {} +void run(Pair callbacks) { auto [helper, other] = callbacks; helper(); } +void control() { helper(); } +}`, "bindings.cpp"); + expect(ex.parse_errors).toEqual([]); + expect(ex.edges.filter(e => e.relation === "calls" && e.source === "bindings.cpp:N::run:function")).toEqual([]); + expect(ex.edges.some(e => e.relation === "calls" && e.source === "bindings.cpp:N::control:function" && e.target === "bindings.cpp:N::helper:function")).toBe(true); + }); + + it.each([ + "namespace N { void helper() {} void run() { auto & = helper(); } }", + "namespace N { void helper() {} void run() { int (*); helper(); } }", + ])("keeps partial declarators from terminating extraction: %s", (source) => { + expect(() => extractCpp(source, "partial.cpp")).not.toThrow(); + }); +});