From a170894b10d294fb825cf1062d0da6776d763ad7 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 11:49:30 -0600 Subject: [PATCH 01/13] fix(kernel): a C++ specialization chooses only among its own template's candidates A specialization of `a::f` no longer removes `b::f` before the call's scope picks between them, so `f(1)` inside namespace `b` reaches `b::f`. The template arguments a call writes are read after the name it calls, not after a receiver of that name: `foo.foo()` is `foo`, not `foo`. --- __tests__/cpp-specialization-names.test.ts | 63 +++++++++++++++++++ .../src/resolve/cpp_specializations.rs | 32 +++++++--- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/__tests__/cpp-specialization-names.test.ts b/__tests__/cpp-specialization-names.test.ts index c51642fd13..88de82530b 100644 --- a/__tests__/cpp-specialization-names.test.ts +++ b/__tests__/cpp-specialization-names.test.ts @@ -463,3 +463,66 @@ describe('a C++ call reaches the specialization its template arguments name', () expect(overriders('pb::Field::Visit', 13)).toEqual([]); }); }); + +/** + * Explicit specializations choose among one template's candidates only: a + * specialization of `a::f` does not crowd out `b::f` for a call inside namespace + * `b`. And the template arguments a call writes are read after the name it calls, + * not after a receiver of the same name (`foo.foo()`). + */ +describe('a C++ specialization chooses only among its own template and the name the call writes', () => { + const FILES: Record = { + 'a.h': lines( + 'namespace a {', + 'template void f(T v) {}', + 'template <> void f(int v) {}', + '}', + 'namespace b {', + 'template void f(T v) {}', + 'void g() { f(1); }', + '}', + 'struct Foo {', + ' template void foo() {}', + '};', + 'template <> void Foo::foo() {}', + 'template <> void Foo::foo() {}', + ), + 'a.cc': lines( + '#include "a.h"', + 'void sameName(Foo& foo) { foo.foo(); }', + 'void otherName(Foo& x) { x.foo(); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-specialization-family-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('a call in namespace `b` reaches `b::f`, not a specialization of `a::f`', () => { + expect(callees('b::g')).toEqual(['b::f@a.h:6']); + }); + it('`foo.foo()` reads the arguments after the method, not the receiver', () => { + expect(callees('sameName')).toEqual(['Foo::foo@a.h:13']); + expect(callees('otherName')).toEqual(['Foo::foo@a.h:13']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_specializations.rs b/codegraph-kernel/src/resolve/cpp_specializations.rs index e079ed72e7..d100b3e2f9 100644 --- a/codegraph-kernel/src/resolve/cpp_specializations.rs +++ b/codegraph-kernel/src/resolve/cpp_specializations.rs @@ -17,17 +17,29 @@ impl KernelResolver { /// - a call that writes none, or another reference, reaches a specialization only when there /// is nothing else. /// - /// The candidates as they are when none is a specialization. + /// The candidates as they are when none is a specialization. Each template's specializations + /// choose among the candidates of its own qualified name only: one of `a::f` leaves a call's + /// `b::f` in place, for the call's scope to choose between them. pub(super) fn cpp_specialization_pool(&mut self, candidates: Vec>, r: &ResolveRefIn) -> Vec> { if r.language != "cpp" { return candidates; } let written: Vec>>> = candidates.iter().map(|n| self.cpp_specialization_arguments(n)).collect(); if written.iter().all(Option::is_none) { return candidates; } - let others: Vec> = candidates.iter().zip(&written).filter(|(_, w)| w.is_none()).map(|(n, _)| n.clone()).collect(); - let Some(args) = self.cpp_call_template_arguments(r) else { return if others.is_empty() { candidates } else { others } }; - let named: Vec> = candidates.iter().zip(&written) - .filter(|(_, w)| w.as_ref().is_some_and(|w| cpp_template_arguments_match(w, &args))) - .map(|(n, _)| n.clone()).collect(); - if named.is_empty() { others } else { named } + let args = self.cpp_call_template_arguments(r); + let mut families: Vec<&str> = Vec::new(); + for n in &candidates { if !families.contains(&n.qualified_name.as_str()) { families.push(&n.qualified_name); } } + let mut kept: HashSet = HashSet::new(); + for family in families { + let members: Vec = (0..candidates.len()).filter(|&i| candidates[i].qualified_name == family).collect(); + let others: Vec = members.iter().copied().filter(|&i| written[i].is_none()).collect(); + if others.len() == members.len() { kept.extend(members); continue; } + let Some(args) = &args else { + kept.extend(if others.is_empty() { members } else { others }); + continue; + }; + let named: Vec = members.iter().copied().filter(|&i| written[i].as_ref().is_some_and(|w| cpp_template_arguments_match(w, args))).collect(); + kept.extend(if named.is_empty() { others } else { named }); + } + candidates.into_iter().enumerate().filter(|(i, _)| kept.contains(i)).map(|(_, n)| n).collect() } /// The template arguments the C++ function or method `n` is an explicit specialization for, @@ -72,7 +84,8 @@ impl KernelResolver { /// The template arguments `text` writes right after `name`, the first time from `from` on it is /// written as a word of its own, as they compare (cpp_template_argument_key). None when it writes -/// none there, or `<>`. +/// none there, or `<>`. A receiver or qualifier of the same name is passed over: in +/// `foo.foo()` the arguments follow the second `foo`. fn cpp_template_arguments_after(text: &str, name: &str, from: usize) -> Option> { if name.is_empty() { return None; } let bytes = text.as_bytes(); @@ -80,7 +93,8 @@ fn cpp_template_arguments_after(text: &str, name: &str, from: usize) -> Option|::)").is_match(text.get(open..).unwrap_or("")); + if !word(start.checked_sub(1).and_then(|i| bytes.get(i))) && !word(bytes.get(open)) && !receiver { let end = if bytes.get(open) == Some(&b'<') { cpp_template_arguments_end(bytes, open) } else { None }; let args: Vec = end.map(|end| split_cpp_top_level(&text[open + 1..end]).iter().map(|a| cpp_template_argument_key(a)).collect()).unwrap_or_default(); return (!args.is_empty()).then_some(args); From 02a2e49785bad9ef378477ba43376b7d17b23a5e Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 11:50:05 -0600 Subject: [PATCH 02/13] fix(kernel): a C++ call to a member template its class only specializes for other arguments links nothing A bare or `this->` call such as `Put()`, in a class whose only indexed `Put` is the `Put` specialization, reaches the class's own template, which has no body in the index. Lookup used to go on to a base class's `Put`, and stopping it alone let name matching link a namesake. The implicit-this step now refuses the reference, as construction and plain receiver calls already do. --- __tests__/cpp-implicit-this-calls.test.ts | 56 +++++++++++++++++++ .../src/resolve/cpp_implicit_this.rs | 29 ++++++---- codegraph-kernel/src/resolve/pipeline.rs | 4 +- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/__tests__/cpp-implicit-this-calls.test.ts b/__tests__/cpp-implicit-this-calls.test.ts index 7d7edc9230..35935f52e0 100644 --- a/__tests__/cpp-implicit-this-calls.test.ts +++ b/__tests__/cpp-implicit-this-calls.test.ts @@ -20,6 +20,8 @@ import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +const lines = (...l: string[]): string => [...l, ''].join('\n'); + function writeProject(prefix: string, files: Record): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); for (const [rel, content] of Object.entries(files)) { @@ -522,3 +524,57 @@ class IntList : public ListBase { expect(callsFrom(cg, 'src/util/list.h', 'util::IntList::Push')).toEqual(['Grow -> util::ListBase::Grow']); }); }); + +/** + * A bare or `this->` call to a member template whose class defines only explicit + * specializations of it: `Put()` in a class that specializes only `Put` + * calls the class's own template, which has no body here. Before, lookup went on + * past the class to the base class's `Put`. + */ +describe('a C++ call to a member template its class only specializes for other arguments', () => { + const FILES: Record = { + 'a.h': lines( + 'struct Base { template void Put() {} };', + 'struct Codec { void Put() {} };', + 'struct Writer : Base {', + ' template void Put();', + ' void Run() { Put(); }', + ' void RunThis() { this->Put(); }', + ' void RunInt() { Put(); }', + '};', + 'template <> void Writer::Put() {}', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-implicit-specialization-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it("links nothing: neither the base class's template nor a namesake", () => { + expect(callees('Writer::Run')).toEqual([]); + expect(callees('Writer::RunThis')).toEqual([]); + }); + it('still reaches the specialization the call names', () => { + expect(callees('Writer::RunInt')).toEqual(['Writer::Put@a.h:9']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_implicit_this.rs b/codegraph-kernel/src/resolve/cpp_implicit_this.rs index d14dda5bab..ae38ec1879 100644 --- a/codegraph-kernel/src/resolve/cpp_implicit_this.rs +++ b/codegraph-kernel/src/resolve/cpp_implicit_this.rs @@ -29,6 +29,10 @@ enum Lookup { Missing, /// The class may declare the name through a macro: lookup stops. Stop, + /// The class declares the name as a member template whose explicit specializations, none of + /// them the call's, are all the index has of it: the call reaches the template, which has no + /// body here, so no other method or function of that name is called. + Refused, } /// One class C++ lookup passes through, with the declarations that are the class there. @@ -112,16 +116,19 @@ pub(super) fn contains_word(text: &str, word: &str) -> bool { } impl KernelResolver { - /// The method a receiver-less (or `this`) C++ call reaches from the member function it is in. - pub(super) fn cpp_implicit_this(&mut self, r: &ResolveRefIn) -> Res> { + /// The method a receiver-less (or `this`) C++ call reaches from the member function it is in: + /// Some(None) when C++ lookup reaches a member template of the class whose specializations are + /// none the call's (Lookup::Refused), so nothing else of that name may be linked. + pub(super) fn cpp_implicit_this(&mut self, r: &ResolveRefIn) -> Res>> { let name = r.reference_name.as_str(); if r.language != "cpp" || r.reference_kind != "calls" || !re!(r"^[A-Za-z_][A-Za-z0-9_]*$").is_match(name) { return Ok(None); } let Some(receiver) = self.cpp_implicit_receiver(r) else { return Ok(None) }; let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; - let Some(node) = self.cpp_implicit_this_lookup(name, receiver, &caller, r)? else { return Ok(None) }; + let found = self.cpp_implicit_this_lookup(name, receiver, &caller, r)?; + if !matches!(found, Lookup::Found(_) | Lookup::Refused) { return Ok(None); } // (A recursive call names the function itself, which no local shadows.) if receiver == Receiver::Bare && name != caller.name && self.cpp_local_name(name, &caller, r, false)? { return Ok(None); } - Ok(Some(KCand { node, confidence: 0.9, resolved_by: "instance-method" })) + Ok(Some(match found { Lookup::Found(node) => Some(KCand { node, confidence: 0.9, resolved_by: "instance-method" }), _ => None })) } /// The method a C++ call of `name` written on `this` reaches from `caller` by C++ lookup, as @@ -130,26 +137,26 @@ impl KernelResolver { pub(super) fn cpp_implicit_this_method(&mut self, name: &str, caller: &Arc, r: &ResolveRefIn) -> Res>> { // A local of that name (a lambda, a callable) is what a bare call calls. if name != caller.name && self.cpp_local_name(name, caller, r, true)? { return Ok(None); } - self.cpp_implicit_this_lookup(name, Receiver::This, caller, r) + Ok(match self.cpp_implicit_this_lookup(name, Receiver::This, caller, r)? { Lookup::Found(node) => Some(node), _ => None }) } /// The method a C++ call of `name` with no receiver, or on `this`, reaches from `caller`: of the /// caller's class, a class it derives from, or one it is nested in. A gtest `TEST_F(Fixture, …)` /// or `TEST_P` body is a method of a class gtest derives from `Fixture`, so the fixture's methods - /// are its own: rocksdb's tests call `DBTestBase`'s `Put(…)` and `NewIterator(…)` bare. None when - /// the caller is no member function, or no class on the way has a method of that name. (Whether + /// are its own: rocksdb's tests call `DBTestBase`'s `Put(…)` and `NewIterator(…)` bare. Missing + /// when the caller is no member function, or no class on the way has a method of that name. (Whether /// a local of the caller's shadows it is the caller's to ask.) - fn cpp_implicit_this_lookup(&mut self, name: &str, receiver: Receiver, caller: &Arc, r: &ResolveRefIn) -> Res>> { + fn cpp_implicit_this_lookup(&mut self, name: &str, receiver: Receiver, caller: &Arc, r: &ResolveRefIn) -> Res { let owner = match self.cpp_member_owner(caller)? { Some(owner) => owner, - None => match self.cpp_gtest_fixture(caller, r)? { Some(fixture) => fixture, None => return Ok(None) }, + None => match self.cpp_gtest_fixture(caller, r)? { Some(fixture) => fixture, None => return Ok(Lookup::Missing) }, }; let mut found = Lookup::Missing; for scope in self.cpp_enclosing_classes(&owner, caller)? { found = self.cpp_scope_method(&scope, name, receiver, r)?; if !matches!(found, Lookup::Missing) || receiver == Receiver::This { break; } } - Ok(match found { Lookup::Found(node) => Some(node), _ => None }) + Ok(found) } /// How a C++ call the extractor recorded by its bare name is written, read at its column: @@ -283,7 +290,9 @@ impl KernelResolver { } if belongs { own.push(n); } } + let had = !own.is_empty(); let own = self.cpp_specialization_pool(own, r); + if own.is_empty() && had { return Ok(Lookup::Refused); } let holder = if scope.holds { scope.decls.first() } else { None }; let (inline, rest): (Vec<_>, Vec<_>) = match holder { Some(h) => own.into_iter().partition(|n| n.file_path == h.file_path && range_within(n, h)), diff --git a/codegraph-kernel/src/resolve/pipeline.rs b/codegraph-kernel/src/resolve/pipeline.rs index 3c958eeb25..4991c99d82 100644 --- a/codegraph-kernel/src/resolve/pipeline.rs +++ b/codegraph-kernel/src/resolve/pipeline.rs @@ -782,7 +782,9 @@ if super::lang_scope::is_dart_member_read(r) { if let Some(result) = self.cpp_plain_call(r)? { return match result { Some(candidate) => self.finish_pre_framework(r, candidate), None => Ok(self.refused()) }; } - if let Some(candidate) = self.cpp_implicit_this(r)? { return self.finish_pre_framework(r, candidate); } + if let Some(result) = self.cpp_implicit_this(r)? { + return match result { Some(candidate) => self.finish_pre_framework(r, candidate), None => Ok(self.refused()) }; + } if r.language == "cpp" && r.reference_kind == "calls" { if let McShape::Parsed { receiver, .. } = self.method_call_shape(r)? { if self.cpp_external_receiver(&receiver, r)? { return Ok(ResolveOutcome::unresolved()); } From df141234e92d97351ab12fbc4b8b0545e5fabd14 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 11:50:31 -0600 Subject: [PATCH 03/13] fix(kernel): count a C flag as set only by a real #define of the call's own component Three cases made a flag look set, so a header's flag-only definition kept the call instead of the definition the default build compiles: - an empty build file did not bound a component, so the search for a `#define` ran on into the parent component; - a `#define` inside a comment or a raw string counted; - a nested component's `#define` counted for the component around it. --- __tests__/c-feature-macro-variants.test.ts | 126 ++++++++++++++++++ .../src/resolve/c_feature_variants.rs | 13 +- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/__tests__/c-feature-macro-variants.test.ts b/__tests__/c-feature-macro-variants.test.ts index 4e8bf90d16..15fb73f8ce 100644 --- a/__tests__/c-feature-macro-variants.test.ts +++ b/__tests__/c-feature-macro-variants.test.ts @@ -184,3 +184,129 @@ describe('a C call past a definition only a flag nothing sets compiles', () => { expect(calls('client_version')).toEqual(['include/api.h:4']); }); }); + +/** + * Where a flag counts as set: an empty build file still bounds a component, a + * `#define` in a comment or a raw string sets nothing, and a nested component's + * `#define` does not set the flag for the component around it. + */ +describe('a C flag set only in a comment, a nested component or beyond an empty build file', () => { + const FILES: Record = { + 'lib/CMakeLists.txt': lines( + 'add_library(lib other.c)', + ), + 'lib/other.c': lines( + '#define USE_FAST 1', + 'int other(void) { return 0; }', + ), + 'lib/sub/CMakeLists.txt': '', + 'lib/sub/f.h': lines( + '#ifdef USE_FAST', + 'static inline int f(void) { return 1; }', + '#else', + 'int f(void);', + '#endif', + ), + 'lib/sub/f.c': lines( + 'int f(void) { return 0; }', + ), + 'lib/sub/use.c': lines( + '#include "f.h"', + 'int g(void) { return f(); }', + ), + 'doc/CMakeLists.txt': lines( + 'add_library(doc f.c)', + ), + 'doc/notes.c': lines( + '/*', + '#define USE_NOTE 1', + '*/', + 'int notes(void) { return 0; }', + ), + 'doc/raw.cc': lines( + 'const char* text = R"(', + '#define USE_RAW 1', + ')";', + ), + 'doc/f.h': lines( + '#ifdef USE_NOTE', + 'static inline int nt(void) { return 1; }', + '#else', + 'int nt(void);', + '#endif', + '#ifdef USE_RAW', + 'static inline int rw(void) { return 1; }', + '#else', + 'int rw(void);', + '#endif', + ), + 'doc/f.c': lines( + 'int nt(void) { return 0; }', + 'int rw(void) { return 0; }', + ), + 'doc/use.c': lines( + '#include "f.h"', + 'int note_use(void) { return nt(); }', + 'int raw_use(void) { return rw(); }', + ), + 'outer/CMakeLists.txt': lines( + 'add_library(outer f.c)', + ), + 'outer/plugin/CMakeLists.txt': lines( + 'add_library(plugin p.c)', + ), + 'outer/plugin/p.c': lines( + '#define USE_PLUGIN 1', + 'int p(void) { return 0; }', + ), + 'outer/f.h': lines( + '#ifdef USE_PLUGIN', + 'static inline int nf(void) { return 1; }', + '#else', + 'int nf(void);', + '#endif', + ), + 'outer/f.c': lines( + 'int nf(void) { return 0; }', + ), + 'outer/use.c': lines( + '#include "f.h"', + 'int ng(void) { return nf(); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-c-feature-components-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('an empty build file bounds a component', () => { + expect(callees('g')).toEqual(['f@lib/sub/f.c:1']); + }); + it('a `#define` in a comment or a raw string defines nothing', () => { + expect(callees('note_use')).toEqual(['nt@doc/f.c:1']); + expect(callees('raw_use')).toEqual(['rw@doc/f.c:2']); + }); + it("a nested component's `#define` is not the outer component's", () => { + expect(callees('ng')).toEqual(['nf@outer/f.c:1']); + }); +}); diff --git a/codegraph-kernel/src/resolve/c_feature_variants.rs b/codegraph-kernel/src/resolve/c_feature_variants.rs index f104bd278d..59680902fc 100644 --- a/codegraph-kernel/src/resolve/c_feature_variants.rs +++ b/codegraph-kernel/src/resolve/c_feature_variants.rs @@ -160,15 +160,18 @@ impl KernelResolver { text } - /// The nearest directory at or above `dir` with a build file, else the project root. + /// The nearest directory at or above `dir` with a build file, else the project root. An empty + /// build file bounds a component as well. fn variant_component(&mut self, dir: &str) -> String { if let Some(hit) = self.c_variants.components.get(dir) { return hit.clone(); } - let component = if dir.is_empty() || !self.variant_build_text(dir).trim().is_empty() { dir.to_string() } else { self.variant_component(dir_of(dir)) }; + let component = if dir.is_empty() || BUILD_FILES.iter().any(|name| self.file_exists(&format!("{dir}/{name}"))) { dir.to_string() } else { self.variant_component(dir_of(dir)) }; self.c_variants.components.insert(dir.to_string(), component.clone()); component } - /// Does a C or C++ file under `component` `#define` `name`? Read only from files whose bytes hold the name. + /// Does a C or C++ file of `component` `#define` `name`? Read only from files whose bytes hold + /// the name; a `#define` in a comment or raw string is none, and a file of a component nested + /// inside it (under a directory with a build file of its own) is that component's. fn variant_defined_in(&mut self, name: &str, component: &str) -> bool { let key = (name.to_string(), component.to_string()); if let Some(&hit) = self.c_variants.defines.get(&key) { return hit; } @@ -187,7 +190,9 @@ impl KernelResolver { let prefix = if component.is_empty() { String::new() } else { format!("{component}/") }; let defined = shared_regex(&format!(r"(?m)^[ \t]*#[ \t]*define[ \t]+{}\b", regex::escape(name))).is_ok_and(|define| { files.iter().any(|file| file.starts_with(&prefix) - && self.read_file_uncached(file).is_some_and(|s| s.text().contains(name) && define.is_match(s.text()))) + && self.read_file_uncached(file).is_some_and(|s| s.text().contains(name) && define.is_match(s.text()) + && directive_lines(s.text()).iter().any(|line| define.is_match(line))) + && self.variant_component(dir_of(file)) == component) }); self.c_variants.defines.insert(key, defined); defined From 89a4dc9bca06752fb9128dabe6e9c96f1d2e1cbe Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:08:15 -0600 Subject: [PATCH 04/13] fix(kernel): a static C function is visible only through an include before the call that the build compiles A call reached a `static` function in another source file whenever the calling file included that file anywhere. An include after the call, or one inside `#if 0`, now leaves the call unresolved, as a compiler does. Includes written before the call still count, directly or through the headers they include. --- __tests__/c-static-call-rescue.test.ts | 59 +++++++++++++++++++ codegraph-kernel/src/resolve/cpp_namespace.rs | 16 +++++ codegraph-kernel/src/resolve/names.rs | 10 ++-- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/__tests__/c-static-call-rescue.test.ts b/__tests__/c-static-call-rescue.test.ts index a175a3f334..f037c8501d 100644 --- a/__tests__/c-static-call-rescue.test.ts +++ b/__tests__/c-static-call-rescue.test.ts @@ -127,3 +127,62 @@ describe("a C call reaches the function its file sees, not another file's static expect(cg.getFileDependencies('src/sort.c')).not.toContain('deps/jemalloc/test/include/test/math.h'); }); }); + +/** + * A `static` function another source file defines is visible through an + * `#include` of that file only where the include is compiled and written before + * the call: not under `#if 0`, and not after the call. + */ +describe("a C call sees another source file's static only through an include before it", () => { + const FILES: Record = { + 'deps/dict.c': lines( + 'static unsigned dictHash(const char* k) { return 0; }', + ), + 'inactive.c': lines( + '#if 0', + '#include "deps/dict.c"', + '#endif', + 'unsigned h1(void) { return dictHash("a"); }', + ), + 'later.c': lines( + 'unsigned h2(void) { return dictHash("a"); }', + '#include "deps/dict.c"', + ), + 'active.c': lines( + '#include "deps/dict.c"', + 'unsigned h3(void) { return dictHash("a"); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-c-static-include-order-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('not through an include under `#if 0` or after the call', () => { + expect(callees('h1')).toEqual([]); + expect(callees('h2')).toEqual([]); + }); + it('through an include before the call', () => { + expect(callees('h3')).toEqual(['dictHash@deps/dict.c:1']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_namespace.rs b/codegraph-kernel/src/resolve/cpp_namespace.rs index ebe65d06cd..590e1b87e4 100644 --- a/codegraph-kernel/src/resolve/cpp_namespace.rs +++ b/codegraph-kernel/src/resolve/cpp_namespace.rs @@ -117,6 +117,22 @@ impl KernelResolver { let aliases:HashSet<_>=visible.iter().filter_map(|file|self.cpp_namespaces.aliases.get(file).and_then(|a|a.get(name))).cloned().collect(); Ok((aliases.len()==1).then(||aliases.into_iter().next().unwrap())) } + /// Whether the `#include`s `file` writes before line `line` bring in `target`, directly or + /// through what those files include. An include in a branch only a build with a flag nothing + /// sets compiles (`#if 0`) counts only for a line in such a branch too. + pub(super) fn cpp_included_before(&mut self,file:&str,line:i64,target:&str,language:&str)->Res { + let imports:Vec<(i64,char,String)>=self.nodes_in_file(file)?.iter() + .filter(|n|n.kind=="import" && matches!(n.language.as_str(),"c"|"cpp") && n.start_line Date: Fri, 16 Oct 2026 12:08:15 -0600 Subject: [PATCH 05/13] fix(kernel): a C++ namespace alias applies only from its declaration and inside its own block An alias a function declares no longer applies to the rest of the file, and an alias at namespace scope applies only from its own line on (or after the include that brings it in). An alias of `::lib` keeps the leading `::`, so it names the global `lib` rather than a `lib` nested in the namespace around the code. --- __tests__/cpp-global-alias-target.test.ts | 67 ++++++++++++++- codegraph-kernel/src/resolve/cpp_namespace.rs | 84 +++++++++++++++++-- codegraph-kernel/src/resolve/cpp_types.rs | 2 +- 3 files changed, 143 insertions(+), 10 deletions(-) diff --git a/__tests__/cpp-global-alias-target.test.ts b/__tests__/cpp-global-alias-target.test.ts index 5c6754c981..fb8bf7689b 100644 --- a/__tests__/cpp-global-alias-target.test.ts +++ b/__tests__/cpp-global-alias-target.test.ts @@ -11,7 +11,7 @@ * `arena_.Fuse(other.arena_)` fell back to the type's last name, landing on * the calling method `hpb::Arena::Fuse`. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -214,3 +214,68 @@ describe('other C++ names written from the global scope', () => { } }); }); + +/** + * A namespace alias applies where C++ declares it: from its declaration on, and + * only inside the function that declares it. An alias of `::lib` names the global + * `lib`, not a `lib` nested in the namespace around the code. + */ +describe('a C++ namespace alias applies where it is declared', () => { + const FILES: Record = { + 'lib.h': lines( + 'namespace lib { struct Type { void Run() {} }; }', + ), + 'a.cc': lines( + '#include "lib.h"', + 'void Setup() {', + ' namespace x = lib;', + '}', + 'void UseLocal(x::Type t) { t.Run(); }', + 'void UseBefore(y::Type t) { t.Run(); }', + 'namespace y = lib;', + 'namespace z = lib;', + 'void UseAfter(z::Type t) { t.Run(); }', + ), + 'scope.h': lines( + 'namespace glob { struct Type { void Run() {} }; }', + 'namespace scope {', + 'namespace glob { struct Type { void Run() {} }; }', + 'namespace a = ::glob;', + 'void Use(a::Type t) { t.Run(); }', + '}', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-alias-scope-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('not outside the function that declares it, nor before its declaration', () => { + expect(callees('UseLocal')).toEqual([]); + expect(callees('UseBefore')).toEqual([]); + expect(callees('UseAfter')).toEqual(['lib::Type::Run@lib.h:1']); + }); + it('an alias of `::glob` names the global namespace', () => { + expect(callees('scope::Use')).toEqual(['glob::Type::Run@scope.h:1']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_namespace.rs b/codegraph-kernel/src/resolve/cpp_namespace.rs index 590e1b87e4..e031e08988 100644 --- a/codegraph-kernel/src/resolve/cpp_namespace.rs +++ b/codegraph-kernel/src/resolve/cpp_namespace.rs @@ -7,7 +7,10 @@ pub(super) struct NamespaceCache { openers: HashMap<(String,String),Vec>, functions: HashSet<(String,String)>, closers: HashSet<(String,String)>, - aliases: HashMap>, + /// File → the namespace aliases it declares at namespace scope: name → (target, line). + aliases: HashMap>, + /// File → the aliases a function or other block declares: (name, target, first line, last line). + local_aliases: HashMap>, includes: HashMap>, replacements: HashMap<(String,String),String>, frames: HashMap)>>, @@ -24,7 +27,14 @@ impl KernelResolver { let code=super::awaited::strip_ts_comments(&source.text().replace("\\\r\n"," ").replace("\\\n"," ")); // (Of a file's alternatives for one build or another, `#if … namespace backend = …upb; // #elif … namespace backend = …cpp;`, the first.) - for m in re!(r"(?m)^\s*namespace\s+(\w+)\s*=\s*(?:::)?([\w:]+)\s*;").captures_iter(&code) {cache.aliases.entry(file.clone()).or_default().entry(m[1].to_string()).or_insert_with(|| m[2].to_string());} + if namespace_alias_pattern().is_match(&code) { + for (name,target,line,end) in cpp_namespace_alias_declarations(&super::awaited::strip_ts_comments(source.text())) { + match end { + Some(end)=>cache.local_aliases.entry(file.clone()).or_default().push((name,target,line,end)), + None=>{cache.aliases.entry(file.clone()).or_default().entry(name).or_insert((target,line));} + } + } + } for m in re!(r"(?m)^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)(\(\s*([A-Za-z_]\w*)?\s*\))?[ \t]+([^\n]*)$").captures_iter(&code) { let body=m[4].trim(); if re!(r"^(?:[A-Za-z_]\w*\s+)*\}(?:\s*\})*\s*;?$").is_match(body) {cache.closers.insert((file.clone(),m[1].to_string()));} @@ -62,6 +72,7 @@ impl KernelResolver { self.prepare_namespace_macros()?; let mut names: HashSet = self.cpp_namespaces.openers.values().filter_map(|path| path.first().cloned()).collect(); names.extend(self.cpp_namespaces.aliases.values().flat_map(|aliases| aliases.keys().cloned())); + names.extend(self.cpp_namespaces.local_aliases.values().flat_map(|aliases| aliases.iter().map(|(name,..)|name.clone()))); for node in self.table()?.nodes.iter() { if matches!(node.language.as_str(), "c" | "cpp") && matches!(node.kind.as_str(), "class" | "struct" | "union" | "enum" | "type_alias") { names.extend(node.qualified_name.split("::").filter(|s| !s.is_empty()).map(str::to_string)); @@ -108,13 +119,20 @@ impl KernelResolver { } self.cpp_namespaces.includes.insert(file.to_string(),seen.clone());Ok(seen) } - /// The namespace `name` is an alias of where `file` writes it: `pybind11` for `py` under - /// `namespace py = pybind11;`, declared in the file itself, else alike in the files it includes. - pub(super) fn cpp_namespace_alias(&mut self,file:&str,name:&str)->Res> { + /// The namespace `name` is an alias of where line `line` of `file` writes it: `pybind11` for + /// `py` under `namespace py = pybind11;`, declared before it in the block around it or in the + /// file itself, else alike in the files it includes before it. (`::lib` for an alias of the + /// global `lib`.) + pub(super) fn cpp_namespace_alias(&mut self,file:&str,name:&str,line:i64)->Res> { self.prepare_namespace_macros()?; - if let Some(local)=self.cpp_namespaces.aliases.get(file).and_then(|a|a.get(name)) {return Ok(Some(local.clone()));} + if let Some((_,target,..))=self.cpp_namespaces.local_aliases.get(file).and_then(|a|a.iter().rfind(|(n,_,from,to)|n==name && *from<=line && line<=*to)) {return Ok(Some(target.clone()));} + if let Some((target,_))=self.cpp_namespaces.aliases.get(file).and_then(|a|a.get(name)).filter(|(_,at)|*at<=line) {return Ok(Some(target.clone()));} let visible=self.namespace_visible_files(file,"cpp")?; - let aliases:HashSet<_>=visible.iter().filter_map(|file|self.cpp_namespaces.aliases.get(file).and_then(|a|a.get(name))).cloned().collect(); + let declaring:Vec<(String,String)>=visible.iter().filter(|f|f.as_str()!=file).filter_map(|f|self.cpp_namespaces.aliases.get(f).and_then(|a|a.get(name)).map(|(target,_)|(f.clone(),target.clone()))).collect(); + let mut aliases=HashSet::new(); + for (declarer,target) in declaring { + if self.cpp_included_before(file,line,&declarer,"cpp")? {aliases.insert(target);} + } Ok((aliases.len()==1).then(||aliases.into_iter().next().unwrap())) } /// Whether the `#include`s `file` writes before line `line` bring in `target`, directly or @@ -156,7 +174,7 @@ impl KernelResolver { let visible=self.namespace_visible_files(&r.file_path,&r.language)?; let mut parts:Vec=r.reference_name.split("::").filter(|p|!p.is_empty()).map(str::to_string).collect(); if let Some(first)=parts.first().cloned() { - if let Some(alias)=self.cpp_namespace_alias(&r.file_path,&first)? {parts.splice(0..1,alias.split("::").map(str::to_string));} + if let Some(alias)=self.cpp_namespace_alias(&r.file_path,&first,r.line)? {parts.splice(0..1,alias.split("::").filter(|p|!p.is_empty()).map(str::to_string));} } let Some(name)=parts.last() else {return Ok(None)}; let candidates=self.nodes_by_name(name)?; @@ -189,3 +207,53 @@ impl KernelResolver { Ok(best.map(|node|KCand{node,confidence:0.8,resolved_by:"qualified-name"})) } } + +/// `namespace name = target;` at the start of a line (`target` keeps a leading `::`). +fn namespace_alias_pattern() -> Rc { + re!(r"(?m)^\s*namespace\s+(\w+)\s*=\s*((?:::)?[\w:]+)\s*;") +} + +/// The namespace aliases `text` (comments blanked) declares, in order: name, target, line, and +/// for one a function or other block declares, the last line of that block, where it stops +/// applying. One a namespace's body or `extern "C"` declares is the file's. +fn cpp_namespace_alias_declarations(text: &str) -> Vec<(String, String, i64, Option)> { + let found: Vec<(usize, String, String)> = namespace_alias_pattern().captures_iter(text).filter_map(|m| Some((m.get(1)?.start(), m[1].to_string(), m[2].to_string()))).collect(); + let bytes = text.as_bytes(); + let mut out: Vec<(String, String, i64, Option)> = Vec::new(); + // Each open block: whether it is a namespace's (or `extern "C"`'s). + let mut blocks: Vec = Vec::new(); + // Aliases a block declares, with how many blocks were open around them. + let mut pending: Vec<(usize, usize)> = Vec::new(); + let (mut next, mut line, mut statement, mut i) = (0usize, 1i64, 0usize, 0usize); + while i < bytes.len() { + while next < found.len() && found[next].0 <= i { + let (_, name, target) = &found[next]; + out.push((name.clone(), target.clone(), line, None)); + if blocks.iter().any(|namespace| !namespace) { pending.push((out.len() - 1, blocks.len())); } + next += 1; + } + match bytes[i] { + b'\n' => line += 1, + quote @ (b'"' | b'\'') => { + let mut j = i + 1; + while j < bytes.len() && bytes[j] != quote && bytes[j] != b'\n' { j += if bytes[j] == b'\\' { 2 } else { 1 }; } + i = j.min(bytes.len() - 1); + if bytes[i] == b'\n' { continue; } + } + b'{' => { + blocks.push(re!(r#"(?:\bnamespace\b[^;{}]*|\bextern\s*"[^"]*"\s*)$"#).is_match(&text[statement..i])); + statement = i + 1; + } + b'}' => { + blocks.pop(); + pending.retain(|&(at, depth)| if depth > blocks.len() { out[at].3 = Some(line); false } else { true }); + statement = i + 1; + } + b';' => statement = i + 1, + _ => {} + } + i += 1; + } + for (at, _) in pending { out[at].3 = Some(line); } + out +} diff --git a/codegraph-kernel/src/resolve/cpp_types.rs b/codegraph-kernel/src/resolve/cpp_types.rs index 0f5f9be8ed..1fee99f285 100644 --- a/codegraph-kernel/src/resolve/cpp_types.rs +++ b/codegraph-kernel/src/resolve/cpp_types.rs @@ -144,7 +144,7 @@ impl KernelResolver { // A name written through a namespace alias another file declares: protobuf hpb's // `backend::Arena` under `namespace backend = hpb::internal::backend::upb;`. if let Some((head, rest)) = ty.split_once("::").filter(|_| !raw.trim_start().starts_with("::")) { - if let Some(target) = self.cpp_namespace_alias(&r.file_path, head)? { + if let Some(target) = self.cpp_namespace_alias(&r.file_path, head, r.line)? { if let Some(found) = self.cpp_type_owner_visible(&format!("{target}::{rest}"), r, depth + 1, constructor, included)? { return Ok(Some(found)); } } } From 16b95ca26d1f2ccf71d67cff6ee26b46802b2d0f Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:08:15 -0600 Subject: [PATCH 06/13] fix(kernel): a C++ name macro spells a namespace only where it is defined `ROCKSDB_NAMESPACE::DB` written before `#define ROCKSDB_NAMESPACE rocksdb`, or after an `#undef` of it, no longer resolves as `rocksdb::DB`. The macro counts where the reference's own file defines it on an earlier line, or where an include before the reference brings in a file that defines it. --- __tests__/cpp-macro-namespace.test.ts | 52 +++++++++++++++++++ codegraph-kernel/src/resolve/cpp_namespace.rs | 45 +++++++++++++--- codegraph-kernel/src/resolve/cpp_types.rs | 2 +- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/__tests__/cpp-macro-namespace.test.ts b/__tests__/cpp-macro-namespace.test.ts index ca53c9fa96..23b692a535 100644 --- a/__tests__/cpp-macro-namespace.test.ts +++ b/__tests__/cpp-macro-namespace.test.ts @@ -13,6 +13,8 @@ import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +const lines = (...l: string[]): string => [...l, ''].join('\n'); + let root = ''; let cg: CodeGraph; @@ -137,3 +139,53 @@ describe('C++ namespaces opened by a macro', () => { ]); }); }); + +/** + * A name macro spells a namespace only from its `#define` on: a reference written + * before `#define ROCKSDB_NAMESPACE rocksdb` is not `rocksdb::DB`. + */ +describe('a C++ name macro applies from its definition on', () => { + const FILES: Record = { + 'db.h': lines( + 'namespace rocksdb { struct DB { void Get() {} }; }', + ), + 'use.cc': lines( + '#include "db.h"', + 'void Before(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + '#define ROCKSDB_NAMESPACE rocksdb', + 'void After(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + '#undef ROCKSDB_NAMESPACE', + 'void Undone(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-macro-order-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('not before its `#define`, nor after its `#undef`', () => { + expect(callees('Before')).toEqual([]); + expect(callees('Undone')).toEqual([]); + expect(callees('After')).toEqual(['rocksdb::DB::Get@db.h:1']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_namespace.rs b/codegraph-kernel/src/resolve/cpp_namespace.rs index e031e08988..b16eb7d413 100644 --- a/codegraph-kernel/src/resolve/cpp_namespace.rs +++ b/codegraph-kernel/src/resolve/cpp_namespace.rs @@ -1,6 +1,9 @@ //! Namespace ownership supplied by a project's namespace-opening macros. use super::*; +/// A file's `#define`s and `#undef`s of name macros: (line, name, whether it defines). +type Directives = Rc>; + #[derive(Default)] pub(super) struct NamespaceCache { ready: bool, @@ -14,6 +17,8 @@ pub(super) struct NamespaceCache { includes: HashMap>, replacements: HashMap<(String,String),String>, frames: HashMap)>>, + /// File → its `#define`s and `#undef`s of name macros. + directives: HashMap, } impl KernelResolver { fn prepare_namespace_macros(&mut self)->Res<()> { @@ -151,23 +156,51 @@ impl KernelResolver { } Ok(false) } - /// `qualified` spelled through the name macros `file` sees, when that differs: rocksdb's types - /// are `ROCKSDB_NAMESPACE::DB` in the index and `rocksdb::DB` to code that names them, under - /// `#define ROCKSDB_NAMESPACE rocksdb`; a tree declaring `namespace rocksdb` itself is named - /// `ROCKSDB_NAMESPACE::Cache` the other way round. - pub(super) fn cpp_name_macro_spelling(&mut self,file:&str,qualified:&str)->Res> { + /// `qualified` spelled through the name macros the file of `r` sees, when that differs: + /// rocksdb's types are `ROCKSDB_NAMESPACE::DB` in the index and `rocksdb::DB` to code that + /// names them, under `#define ROCKSDB_NAMESPACE rocksdb`; a tree declaring `namespace rocksdb` + /// itself is named `ROCKSDB_NAMESPACE::Cache` the other way round. A macro the reference writes + /// spells it only where it is defined: not before its `#define`, nor after an `#undef`. + pub(super) fn cpp_name_macro_spelling(&mut self,r:&ResolveRefIn,qualified:&str)->Res> { self.prepare_namespace_macros()?; if self.cpp_namespaces.replacements.is_empty() {return Ok(None);} + let file=r.file_path.as_str(); let visible=self.namespace_visible_files(file,"cpp")?; + let mut written=Vec::new(); let spelled:Vec=qualified.split("::").map(|segment| { let forward:HashSet<_>=self.cpp_namespaces.replacements.iter().filter(|((f,name),_)|visible.contains(f) && name==segment).map(|(_,body)|body.clone()).collect(); - if forward.len()==1 {return forward.into_iter().next().unwrap();} + if forward.len()==1 {written.push(segment.to_string());return forward.into_iter().next().unwrap();} let reverse:HashSet<_>=self.cpp_namespaces.replacements.iter().filter(|((f,_),body)|visible.contains(f) && *body==segment).map(|((_,name),_)|name.clone()).collect(); if reverse.len()==1 {reverse.into_iter().next().unwrap()} else {segment.to_string()} }).collect(); + for name in written { + if !self.cpp_name_macro_defined_at(r,&name)? {return Ok(None);} + } let spelled=spelled.join("::"); Ok((spelled!=qualified).then_some(spelled)) } + /// Whether the name macro `name` is defined where `r` writes it: by the last `#define` or + /// `#undef` of it before the reference in its own file, else by an include before it of a + /// file that defines it. + fn cpp_name_macro_defined_at(&mut self,r:&ResolveRefIn,name:&str)->Res { + let file=r.file_path.as_str(); + let directives=match self.cpp_namespaces.directives.get(file) { + Some(hit)=>hit.clone(), + None=>{ + let text=self.read_file(file).map(|s|s.text().to_string()).unwrap_or_default(); + let found:Vec<(i64,String,bool)>=super::cpp::directive_lines(&text).iter().enumerate() + .filter_map(|(i,line)|re!(r"^\s*#\s*(define|undef)\s+([A-Za-z_]\w*)").captures(line).map(|m|(i as i64+1,m[2].to_string(),&m[1]=="define"))).collect(); + let found=Rc::new(found); + self.cpp_namespaces.directives.insert(file.to_string(),found.clone());found + } + }; + if let Some((_,_,define))=directives.iter().rfind(|(line,n,_)|*line=self.cpp_namespaces.replacements.keys().filter(|(f,n)|n==name && f!=file).map(|(f,_)|f.clone()).collect(); + for definer in definers { + if self.cpp_included_before(file,r.line,&definer,&r.language)? {return Ok(true);} + } + Ok(false) + } pub(super) fn match_cpp_macro_namespaced(&mut self,r:&ResolveRefIn)->Res> { if !matches!(r.language.as_str(),"c"|"cpp") || !r.reference_name.trim_start_matches("::").contains("::") {return Ok(None);} self.prepare_namespace_macros()?; diff --git a/codegraph-kernel/src/resolve/cpp_types.rs b/codegraph-kernel/src/resolve/cpp_types.rs index 1fee99f285..58075f39ef 100644 --- a/codegraph-kernel/src/resolve/cpp_types.rs +++ b/codegraph-kernel/src/resolve/cpp_types.rs @@ -136,7 +136,7 @@ impl KernelResolver { } // A name written through a macro that stands for a namespace, or the other way round. if ty.contains("::") { - if let Some(spelled) = self.cpp_name_macro_spelling(&r.file_path, ty)? { + if let Some(spelled) = self.cpp_name_macro_spelling(r, ty)? { let raw = if raw.trim_start().starts_with("::") { format!("::{spelled}") } else { spelled.clone() }; if let [owner] = self.cpp_scoped_types(&raw, &spelled, r, included)?.as_slice() { return self.cpp_expand_owner(owner, r, depth, constructor, included); } } From aab4864c9ddf06bcc5cecbfcdaef96211b97c74b Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:21:50 -0600 Subject: [PATCH 07/13] fix(kernel): a C++ lvalue argument never binds an rvalue reference overload `Foo x; Take(x)` with `Take(const Foo&)` and `Take(Foo&&)` linked the `Foo&&` overload. An object of the parameter's own type that a name or dereference reads now never fits a `&&` parameter, unless the function's own template parameter deduces it. An argument that converts to the parameter's type, such as an `int` passed to `push_back(Value&&)`, is a temporary and keeps fitting it. --- __tests__/cpp-overload-constness.test.ts | 57 +++++++++++++++++++ codegraph-kernel/src/resolve/cpp_overloads.rs | 12 ++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/__tests__/cpp-overload-constness.test.ts b/__tests__/cpp-overload-constness.test.ts index d8d2b11c66..19549ea620 100644 --- a/__tests__/cpp-overload-constness.test.ts +++ b/__tests__/cpp-overload-constness.test.ts @@ -193,3 +193,60 @@ describe("a C++ call picks between const and non-const overloads by its argument expect(calls('Look', 'peek')).toEqual(['peek:64']); }); }); + +/** + * What an argument's value category decides: an object a name reads (an lvalue) + * never binds `Foo&&`. + */ +describe('a C++ argument binds a reference by its value category and constness', () => { + const FILES: Record = { + 'a.cc': lines( + '#include ', + 'struct Foo {};', + 'void Take(const Foo& f) {}', + 'void Take(Foo&& f) {}', + 'Foo Make() { return Foo(); }', + 'void Lvalue() { Foo x; Take(x); }', + 'void Temporary() { Take(Make()); }', + ), + 'b.cc': lines( + 'struct Value { Value(int v) {} };', + 'struct Box {', + ' void Push(Value&& v) {}', + ' void Push(const Value& v) {}', + '};', + 'void Convert(Box& box) { int i = 1; box.Push(i); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-overload-binding-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('an lvalue takes `const Foo&`, a temporary `Foo&&`', () => { + expect(callees('Lvalue')).toEqual(['Take@a.cc:3']); + expect(callees('Temporary')).toEqual(['Make@a.cc:5', 'Take@a.cc:4']); + // An `int` converted to `Value` is a temporary, so it may bind `Value&&`. + expect(callees('Convert')).toEqual(['Box::Push@b.cc:3']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_overloads.rs b/codegraph-kernel/src/resolve/cpp_overloads.rs index b242d75c93..62d74a4928 100644 --- a/codegraph-kernel/src/resolve/cpp_overloads.rs +++ b/codegraph-kernel/src/resolve/cpp_overloads.rs @@ -297,10 +297,13 @@ impl KernelResolver { /// `DynamicCastMessage(*msg)` takes a reference, and rocksdb's `db_->Put(opts, key, ts, /// value)` on a `char key[]` no `ColumnFamilyHandle*`. A pointer or reference to a non-const /// type never takes a const object, nor a reference a value a call returns (`lvalue` false). - /// None of that holds for an argument of a template parameter's type (`dependent`), and the - /// pointer and `const` rules not for a parameter whose type the function's own template - /// parameter is (`deduced`). (Of two overloads alike but for a `const`, which one a non-const - /// object takes is `cpp_const_twin_choice`'s.) + /// An object of the parameter's own type that a name or dereference reads (`lvalue`) never + /// binds an rvalue reference: `Foo x; Take(x)` is `Take(const Foo&)`, not `Take(Foo&&)`; an + /// `int` converted to a `Value` is a temporary, which does. None of that holds for an argument of a + /// template parameter's type (`dependent`), and the pointer, `const` and rvalue rules not for a + /// parameter whose type the function's own template parameter is (`deduced`). (Of two + /// overloads alike but for a `const`, which one a non-const object takes is + /// `cpp_const_twin_choice`'s.) fn cpp_written_type_fit(&mut self, argument: &str, param: &str, category: CppTypeCategory, lvalue: Option, dependent: bool, deduced: bool) -> Res { use CppTypeCategory::*; let fit = self.cpp_unqualified_type_fit(argument, param, category)?; @@ -309,6 +312,7 @@ impl KernelResolver { if category == Pointer && argument_category == Class { return Ok(-3); } if !deduced && category == Pointer && argument_category == CString && !re!(r"\bvoid\b").is_match(param) { return Ok(-3); } if category == CString && argument_category == Pointer { return Ok(-3); } + if !deduced && lvalue == Some(true) && fit == 3 && re!(r"&&\s*$").is_match(&strip_template_arguments(param)) { return Ok(-3); } if cpp_refers_to_const(param) != Some(false) { return Ok(fit); } if !deduced && cpp_argument_const(argument, param) == Some(true) { return Ok(-3); } Ok(if lvalue == Some(false) && !strip_template_arguments(param).contains('*') { -3 } else { fit }) From d33bf5b3b4a6ce6a6ea23f90849011839b210084 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:21:50 -0600 Subject: [PATCH 08/13] fix(kernel): a C++ constexpr pointer points at a non-const object `constexpr Widget* p` makes the pointer const, not the `Widget`, so `Point(p)` with `Point(Widget*)` and `Point(const Widget*)` now links the non-const overload. A `constexpr` object, and a `const` pointer target, still count as const. --- __tests__/cpp-overload-constness.test.ts | 16 +++++++++++-- codegraph-kernel/src/resolve/cpp_receivers.rs | 24 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/__tests__/cpp-overload-constness.test.ts b/__tests__/cpp-overload-constness.test.ts index 19549ea620..42d651979e 100644 --- a/__tests__/cpp-overload-constness.test.ts +++ b/__tests__/cpp-overload-constness.test.ts @@ -195,8 +195,8 @@ describe("a C++ call picks between const and non-const overloads by its argument }); /** - * What an argument's value category decides: an object a name reads (an lvalue) - * never binds `Foo&&`. + * More of what an argument's constness and value category decide: an lvalue never + * binds `Foo&&`, and a `constexpr` pointer is itself const, not what it points at. */ describe('a C++ argument binds a reference by its value category and constness', () => { const FILES: Record = { @@ -208,6 +208,13 @@ describe('a C++ argument binds a reference by its value category and constness', 'Foo Make() { return Foo(); }', 'void Lvalue() { Foo x; Take(x); }', 'void Temporary() { Take(Make()); }', + 'struct Widget {};', + 'void Point(Widget* w) {}', + 'void Point(const Widget* w) {}', + 'Widget global;', + 'void ConstexprPointer() { constexpr Widget* p = &global; Point(p); }', + 'void ConstPointer() { const Widget* p = &global; Point(p); }', + 'void ConstexprObject() { constexpr Widget w{}; Point(&w); }', ), 'b.cc': lines( 'struct Value { Value(int v) {} };', @@ -249,4 +256,9 @@ describe('a C++ argument binds a reference by its value category and constness', // An `int` converted to `Value` is a temporary, so it may bind `Value&&`. expect(callees('Convert')).toEqual(['Box::Push@b.cc:3']); }); + it('a `constexpr` pointer points at a non-const object', () => { + expect(callees('ConstexprPointer')).toEqual(['Point@a.cc:9']); + expect(callees('ConstPointer')).toEqual(['Point@a.cc:10']); + expect(callees('ConstexprObject')).toEqual(['Point@a.cc:10']); + }); }); diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index f7ef7e23d5..0f1c7b4eec 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -639,11 +639,14 @@ pub(super) fn cpp_binding_type(parent: tree_sitter::Node, at: tree_sitter::Node, /// Whether the declaration of the name `node` in scope writes `const` (or `constexpr`) in front of /// its type: `const Buffer* b`, `constexpr char name[] = "name"`, which the declared type -/// (`cpp_ast_declared_type`) leaves out. +/// (`cpp_ast_declared_type`) leaves out. A `constexpr` pointer (`constexpr Widget* p`) is itself +/// const, not what it points at. pub(super) fn cpp_declared_const(node: tree_sitter::Node, source: &SourceFile) -> bool { let name = &source.text()[node.byte_range()]; - let leading = |declaration: tree_sitter::Node| declaration.child_by_field_name("type") - .is_some_and(|ty| re!(r"\b(?:const|constexpr)\b").is_match(&source.text()[declaration.start_byte()..ty.start_byte()])); + let leading = |declaration: tree_sitter::Node| declaration.child_by_field_name("type").is_some_and(|ty| { + let head = &source.text()[declaration.start_byte()..ty.start_byte()]; + re!(r"\bconst\b").is_match(head) || (re!(r"\bconstexpr\b").is_match(head) && !cpp_binds_pointer(declaration, name, source)) + }); let mut at = node; while let Some(parent) = at.parent() { let block = matches!(parent.kind(), "compound_statement" | "declaration_list" | "translation_unit"); @@ -671,6 +674,21 @@ pub(super) fn cpp_declared_const(node: tree_sitter::Node, source: &SourceFile) - false } +/// Whether the declarator of `declaration` that binds `name` declares a pointer: `p` in +/// `constexpr Widget* p = &w;`. +fn cpp_binds_pointer(declaration: tree_sitter::Node, name: &str, source: &SourceFile) -> bool { + let mut cursor = declaration.walk(); + let found = declaration.children(&mut cursor).enumerate() + .find(|(i, child)| declaration.field_name_for_child(*i as u32) == Some("declarator") && cpp_declarator_binds(*child, name, source)) + .map(|(_, child)| child); + let Some(mut declarator) = found else { return false }; + if declarator.kind() == "init_declarator" { + let Some(inner) = declarator.child_by_field_name("declarator") else { return false }; + declarator = inner; + } + declarator.kind() == "pointer_declarator" +} + pub(super) fn cpp_ast_declared_type(ty: tree_sitter::Node, mut declarator: tree_sitter::Node, source: &SourceFile) -> String { let type_node = if matches!(ty.kind(), "class_specifier" | "struct_specifier" | "union_specifier" | "enum_specifier") { ty.child_by_field_name("name").unwrap_or_else(|| { From 72499164373806f94cb56d12a99f41351c8475b8 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:21:50 -0600 Subject: [PATCH 09/13] fix(kernel): an element read through a const C++ container is const `*v.begin()` on a `const std::vector&` is a `const Foo`, so `Bind(*v.begin())` with `Bind(Foo&)` and `Bind(const Foo&)` now links the const overload. A const container's `begin()` hands out its `const_iterator`, and that iterator's element is const. --- __tests__/cpp-overload-constness.test.ts | 11 +++++++++- codegraph-kernel/src/resolve/cpp_range_for.rs | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/__tests__/cpp-overload-constness.test.ts b/__tests__/cpp-overload-constness.test.ts index 42d651979e..eec2c915c0 100644 --- a/__tests__/cpp-overload-constness.test.ts +++ b/__tests__/cpp-overload-constness.test.ts @@ -196,7 +196,8 @@ describe("a C++ call picks between const and non-const overloads by its argument /** * More of what an argument's constness and value category decide: an lvalue never - * binds `Foo&&`, and a `constexpr` pointer is itself const, not what it points at. + * binds `Foo&&`; a `constexpr` pointer is itself const, not what it points at; and + * an element read through a const container's iterator is const. */ describe('a C++ argument binds a reference by its value category and constness', () => { const FILES: Record = { @@ -215,6 +216,10 @@ describe('a C++ argument binds a reference by its value category and constness', 'void ConstexprPointer() { constexpr Widget* p = &global; Point(p); }', 'void ConstPointer() { const Widget* p = &global; Point(p); }', 'void ConstexprObject() { constexpr Widget w{}; Point(&w); }', + 'void Bind(Foo& f) {}', + 'void Bind(const Foo& f) {}', + 'void ConstContainer(const std::vector& v) { Bind(*v.begin()); }', + 'void MutableContainer(std::vector& v) { Bind(*v.begin()); }', ), 'b.cc': lines( 'struct Value { Value(int v) {} };', @@ -261,4 +266,8 @@ describe('a C++ argument binds a reference by its value category and constness', expect(callees('ConstPointer')).toEqual(['Point@a.cc:10']); expect(callees('ConstexprObject')).toEqual(['Point@a.cc:10']); }); + it("a const container's element is const", () => { + expect(callees('ConstContainer')).toEqual(['Bind@a.cc:16']); + expect(callees('MutableContainer')).toEqual(['Bind@a.cc:15']); + }); }); diff --git a/codegraph-kernel/src/resolve/cpp_range_for.rs b/codegraph-kernel/src/resolve/cpp_range_for.rs index e371f3e20d..9bd197737e 100644 --- a/codegraph-kernel/src/resolve/cpp_range_for.rs +++ b/codegraph-kernel/src/resolve/cpp_range_for.rs @@ -618,8 +618,13 @@ impl KernelResolver { "field_expression" => { let Some((object, method, template_arguments)) = self.cpp_member_object_of(function, source, r, depth)? else { return Ok(None) }; if template_arguments.is_none() { - // `logs_.begin()`, `map.find(key)`: the iterator a library container hands out. - if let Some(iterator) = cpp_library_iterator_of(&object, &method) { return Ok(Some(iterator)); } + // `logs_.begin()`, `map.find(key)`: the iterator a library container hands + // out. (A container a name declares `const` keeps it: its declared type + // leaves the `const` out.) + let constant = function.child_by_field_name("argument").filter(|a| a.kind() == "identifier") + .is_some_and(|name| super::cpp_receivers::cpp_declared_const(name, source)); + let container = if constant { CppWritten { text: format!("const {}", object.text), site: object.site.clone() } } else { object.clone() }; + if let Some(iterator) = cpp_library_iterator_of(&container, &method) { return Ok(Some(iterator)); } // `ws.front()`, `m.at(key)`, `p.get()`, `s.value()`: what it holds. if let Some(element) = self.cpp_library_element_of(&object, &method, r)? { return Ok(Some(element)); } } @@ -725,11 +730,16 @@ impl KernelResolver { /// `std::deque::const_iterator`, protobuf's `SourceCodeInfo_Location` for /// `RepeatedPtrField::iterator`. None for any other type, and for an /// element a class template's own `begin()` or `operator*` writes with its template parameter - /// (googletest's `ParamGenerator::iterator`), which the specialization decides. + /// (googletest's `ParamGenerator::iterator`), which the specialization decides. A + /// `const_iterator`'s element is const (`const Slice`); a pointer element stays as it is. pub(super) fn cpp_iterator_target(&mut self, iterator: CppWritten, r: &ResolveRefIn) -> Res> { let Some(container) = iterator_container(&iterator.text) else { return Ok(None) }; let Some(element) = self.cpp_range_element_of(CppWritten { text: container, site: iterator.site.clone() }, r, 1)? else { return Ok(None) }; if element.site.from_node_id != iterator.site.from_node_id && self.cpp_written_template_parameter(&element)? { return Ok(None); } + let constant = re!(r"::\s*const_(?:reverse_)?iterator\s*$").is_match(&without_type_keywords(&iterator.text)); + if constant && !top_level_pointer(&element.text) && !re!(r"^\s*const\b").is_match(&element.text) { + return Ok(Some(CppWritten { text: format!("const {}", element.text), site: element.site })); + } Ok(Some(element)) } @@ -1008,7 +1018,7 @@ impl KernelResolver { /// The iterator the member `member` of a standard library or abseil container of the type `object` /// returns, where the container's type is written: `std::deque::iterator` for /// rocksdb's `logs_.begin()`, whose `->` reaches the container's element (`cpp_iterator_target`). -/// None for any other member or type. +/// A const container's is its `const_iterator`. None for any other member or type. fn cpp_library_iterator_of(object: &CppWritten, member: &str) -> Option { let kind = ITERATOR_MEMBERS.iter().find(|(m, _)| *m == member)?.1; if top_level_pointer(&object.text) { return None; } @@ -1016,6 +1026,8 @@ fn cpp_library_iterator_of(object: &CppWritten, member: &str) -> Option Date: Fri, 16 Oct 2026 12:21:51 -0600 Subject: [PATCH 10/13] fix(kernel): a C++ two-dimensional array argument is a pointer to a row, not to an element `int a[3][4]` decays to `int (*)[4]`, so `f(a)` with `f(int*)` and `f(int (*)[4])` now links the pointer-to-array overload, and a one-dimensional array still links `f(int*)`. A `void*` parameter takes either. --- __tests__/cpp-overload-argument-types.test.ts | 46 ++++++++++++++++++- codegraph-kernel/src/resolve/cpp_overloads.rs | 16 +++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/__tests__/cpp-overload-argument-types.test.ts b/__tests__/cpp-overload-argument-types.test.ts index 52ad3b7a1f..0487e89f11 100644 --- a/__tests__/cpp-overload-argument-types.test.ts +++ b/__tests__/cpp-overload-argument-types.test.ts @@ -10,7 +10,7 @@ * or `nullptr` literal, a name the calling function declares (or its * address), or an expression whose type is known. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -513,3 +513,47 @@ describe('C++ overloads chosen by argument types', () => { } }); }); + +/** + * A multidimensional array decays to a pointer to its first row, not to its + * element: `int a[3][4]` is an `int (*)[4]`, not an `int*`. + */ +describe('a C++ multidimensional array argument', () => { + const FILES: Record = { + 'a.cc': lines( + 'void f(int* p) {}', + 'void f(int (*p)[4]) {}', + 'void TwoDim() { int a[3][4]; f(a); }', + 'void OneDim() { int b[3]; f(b); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-multidim-array-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('a two-dimensional array takes the pointer to its row', () => { + expect(callees('TwoDim')).toEqual(['f@a.cc:2']); + expect(callees('OneDim')).toEqual(['f@a.cc:1']); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_overloads.rs b/codegraph-kernel/src/resolve/cpp_overloads.rs index 62d74a4928..84a3c532d1 100644 --- a/codegraph-kernel/src/resolve/cpp_overloads.rs +++ b/codegraph-kernel/src/resolve/cpp_overloads.rs @@ -66,15 +66,22 @@ pub(super) fn cpp_type_category(ty: &str) -> CppTypeCategory { if STRING_TYPES.contains(&last.as_str()) { String } else { Class } } -/// A C++ parameter declaration's type: without its default argument and its name. -/// An array type (`Char[]`) as the pointer to its element an argument of it decays to (`Char*`). +/// An array type (`Char[]`) as the pointer to its element an argument of it decays to (`Char*`); +/// a multidimensional one (`int[][]`) as the pointer to its first row (`int[]*`). fn cpp_array_as_pointer(text: String) -> String { match super::cpp_range_for::cpp_array_split(&text) { - Some((base, dimensions)) if dimensions > 0 => format!("{base}*"), + Some((base, dimensions)) if dimensions > 0 => format!("{base}{}*", "[]".repeat(dimensions - 1)), _ => text, } } +/// Whether the C++ type `ty` is a pointer to an array: an argument's `int[]*`, a parameter's +/// `int (*rows)[4]` or `int rows[][4]`. +fn cpp_pointer_to_array(ty: &str) -> bool { + re!(r"\[\s*\]\s*\*|\(\s*\*[^()]*\)\s*\[|\]\s*\[").is_match(ty) +} + +/// A C++ parameter declaration's type: without its default argument and its name. pub(super) fn cpp_parameter_type(param: &str) -> String { let mut text = param; let mut depth = 0i32; @@ -312,6 +319,9 @@ impl KernelResolver { if category == Pointer && argument_category == Class { return Ok(-3); } if !deduced && category == Pointer && argument_category == CString && !re!(r"\bvoid\b").is_match(param) { return Ok(-3); } if category == CString && argument_category == Pointer { return Ok(-3); } + // A pointer to an array (`int a[3][4]` decayed) is no pointer to its element, nor the other + // way round; `void*` takes either. + if !deduced && matches!(category, Pointer | CString) && cpp_pointer_to_array(argument) != cpp_pointer_to_array(param) && !re!(r"\bvoid\b").is_match(param) { return Ok(-3); } if !deduced && lvalue == Some(true) && fit == 3 && re!(r"&&\s*$").is_match(&strip_template_arguments(param)) { return Ok(-3); } if cpp_refers_to_const(param) != Some(false) { return Ok(fit); } if !deduced && cpp_argument_const(argument, param) == Some(true) { return Ok(-3); } From 41eca0eb682661c709a2c12609a45a4b994ec1e2 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:21:51 -0600 Subject: [PATCH 11/13] fix(kernel): a C++ qualified call whose best-fitting overloads tie links none of them When several overloads fit a call's argument types equally well and only some fit worse, the argument-type choice took the first of the best. It now decides only when one overload fits best alone or a const twin tells them apart. This shows on calls the qualified-name step resolves, such as a namespace a macro opens; overload selection by the name step keeps choosing by where the overloads are. --- __tests__/cpp-free-function-overloads.test.ts | 57 +++++++++++++++++++ codegraph-kernel/src/resolve/cpp_overloads.rs | 21 +++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/__tests__/cpp-free-function-overloads.test.ts b/__tests__/cpp-free-function-overloads.test.ts index c48af450b2..ecf2509a12 100644 --- a/__tests__/cpp-free-function-overloads.test.ts +++ b/__tests__/cpp-free-function-overloads.test.ts @@ -255,3 +255,60 @@ describe('a C++ call picks the free-function overload its arguments fit', () => expect(calls('WarnCode')).toEqual(['lg::Warn@log.cc:4']); }); }); + +/** + * Overloads the argument's type fits equally well are a tie even when another + * overload fits it worse: `f(short)` beside `f(long)`, `f(int)` and `f(char)` + * is not `f(long)` because it is declared first. The overloads sit in a + * namespace a macro opens, where only the qualified-name step can place the + * call; in a namespace written out, the name step picks among overloads the + * arguments fit alike by where they are, as above. + */ +describe('a C++ call whose best-fitting overloads tie', () => { + const FILES: Record = { + 'real.h': lines( + '#define REAL_BEGIN namespace real {', + '#define REAL_END }', + 'REAL_BEGIN', + 'void f(long v) {}', + 'void f(int v) {}', + 'void f(char v) {}', + 'REAL_END', + ), + 'use.cc': lines( + '#include "real.h"', + 'namespace r = real;', + 'void Direct() { short s = 1; real::f(s); }', + 'void ThroughAlias() { short s = 1; r::f(s); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-overload-top-tie-')); + for (const [file, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** What a function or method calls, as `qualifiedName@file:line`. */ + const callees = (caller: string): string[] => { + const fn = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')].find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('links none of the tied overloads', () => { + expect(callees('Direct')).toEqual([]); + expect(callees('ThroughAlias')).toEqual([]); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_overloads.rs b/codegraph-kernel/src/resolve/cpp_overloads.rs index 84a3c532d1..b4fe684172 100644 --- a/codegraph-kernel/src/resolve/cpp_overloads.rs +++ b/codegraph-kernel/src/resolve/cpp_overloads.rs @@ -147,13 +147,21 @@ impl KernelResolver { let tied: Vec<_> = overloads.iter().zip(&fits).filter(|(_, f)| **f == top).map(|(n, _)| n.clone()).collect(); if tied.len() == 1 || args.is_empty() { return Ok(tied[0].clone()); } let first = tied[0].clone(); - Ok(self.cpp_argument_type_choice(tied, name, r)?.unwrap_or(first)) + Ok(self.cpp_argument_type_ranking(tied, name, r)?.map_or(first, |(choice, _)| choice)) } /// Of overloads a call's arguments fit alike by count (in preference order), the one the types - /// the arguments have where the call reads them fit best; the first of those that fit best - /// alike. None when no argument's type can be read, or every overload fits the types alike. + /// the arguments have where the call reads them fit best. None when no argument's type can be + /// read, or several fit the types best alike (however many fit them worse) and no const twin + /// tells them apart: `f(short)` beside `f(long)`, `f(int)` and `f(char)` is none of them. pub(super) fn cpp_argument_type_choice(&mut self, tied: Vec>, name: &str, r: &ResolveRefIn) -> Res>> { + Ok(self.cpp_argument_type_ranking(tied, name, r)?.and_then(|(choice, decided)| decided.then_some(choice))) + } + + /// `cpp_argument_type_choice`'s ranking: the first of the overloads the argument types fit + /// best, or its const twin, and whether it alone fits them best. None when no argument's type + /// can be read. + fn cpp_argument_type_ranking(&mut self, tied: Vec>, name: &str, r: &ResolveRefIn) -> Res, bool)>> { let Some(types) = self.cpp_call_argument_types(name, r)? else { return Ok(None) }; if types.iter().all(Option::is_none) { return Ok(None); } let mut fits = Vec::with_capacity(tied.len()); @@ -162,10 +170,11 @@ impl KernelResolver { let at = fits.iter().position(|f| *f == top).unwrap_or(0); let best = tied[at].clone(); let others: Vec<_> = tied.iter().zip(&fits).enumerate().filter(|(i, (_, f))| **f == top && *i != at).map(|(_, (n, _))| n.clone()).collect(); - // Fit alike by type: decided only when a const twin is. - let alike = others.len() + 1 == tied.len(); + // Several fit the types best: decided only when a const twin is. + let alone = others.is_empty(); let choice = self.cpp_const_twin_choice(best.clone(), others, &types, r); - Ok((!alike || choice.id != best.id || at != 0).then_some(choice)) + let decided = alone || choice.id != best.id; + Ok(Some((choice, decided))) } /// The C++ function a call of `chosen`'s name reaches among `candidates` (cppFunctionOverload, From e397e414b32bf32ea60cc338e20c89cd83369586 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:22:55 -0600 Subject: [PATCH 12/13] docs(changelog): note the C and C++ resolver fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe35074fe..835f159a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Indexing a mid-size project is faster.** Below 150,000 pending references the resolver used to stay on one thread for the whole run. It now boots its worker pool part-way through once at least 20,000 references are still unresolved and they would take the main thread at least 0.4 s (an explicit `CODEGRAPH_PARALLEL_RESOLVE_MIN` turns this off). Full index of JetBrains Exposed (110,854 references) 18.5 s to 9.6 s, koel 4.9 s to 4.1 s, Alamofire 1.65 s to 1.41 s (median of 3 interleaved runs each, identical graph), at 56–132% more peak memory; a 13,900-reference project never starts the pool and is unchanged. - **More C++ links land on the right target.** `Ptr p(0);` for `using Ptr = Widget*;` declares a pointer, so it no longer counts as constructing a `Widget`. A class nested in a function-local class is as local to that function as the class around it. A class an included `.cc` file declares is seen by the file that includes it, and of two headers declaring one class an object creation takes the one it includes. A `using Base::foo;` in a nested class no longer counts as the outer class's. `NULL` is an integer zero, not a `nullptr`, when an overload is chosen. A header's unnamed namespace belongs to the files that include the header, not to every file. Two annotation-macro cases read as extraction reads them: `const int FOO_BAR = 1;` keeps its name, and `n * FOO_BAR::VALUE` stays a product. +- **Fewer wrong C++ links from overloads, aliases and specializations.** `Foo x; Take(x);` links `Take(const Foo&)`, not `Take(Foo&&)`; an `int` converted to the parameter's class still takes the `&&` one. A `constexpr Widget* p` points at a non-const `Widget`, and an element read through a const container's iterator is const, so each picks the matching `const` or non-const overload. `f(a)` on an `int a[3][4]` takes `f(int (*)[4])`, not `f(int*)`. A qualified call into a namespace a macro opens links none of the overloads that fit its arguments best when several tie. A namespace alias applies from its own line on and only inside the block that declares it, and `namespace a = ::lib;` names the global `lib`. `ROCKSDB_NAMESPACE::DB` written before `#define ROCKSDB_NAMESPACE rocksdb`, or after its `#undef`, no longer resolves through the macro. A specialization of `a::f` no longer removes `b::f` from a call's choices, `foo.foo()` reads the template arguments after the method name, and `Put()` in a class that only specializes `Put` links nothing instead of a base class's `Put`. Re-index C++ projects after upgrading. +- **Two C cases no longer link a call to the wrong definition.** A `static` function in another source file is reached only through an `#include` written before the call and not inside `#if 0`. A flag counts as set for a header's flag-only definition only by a real `#define` in the call's own component: not one inside a comment or raw string, not one in a nested component, and an empty build file still marks a component boundary. Re-index C projects after upgrading. - **Syncing a changed file in a large Python project is faster.** When a Python class has no method of the name being linked (a test class that passes `Handler.__init__` as a value, say), the resolver checks every class with that method for one that derives from it. For each class whose base is a package import (`import unittest`, then `unittest.TestCase`) it parsed the whole file again, and its cache holds only a few files, so two such references took about a second each. A plain one-line `import a.b` is now read from its line; other forms still use the parse. In CPython a one-file sync takes a median 5.5 s instead of 7.7 s on Node and 5.2 s instead of 8.1 s on Bun (4 alternating runs per build, ranges not overlapping; the phase that retries unresolved references drops from 4.6 s to 2.3 s). A full index is within the run-to-run variation, and the graph is the same on CPython and Django (no edge differs). - **The same sync resolves each Python class's bases once.** The scan above resolved the base classes of every candidate again for each reference that needed it. The bases of a class depend only on its own header and file, so they are now kept for the rest of the run. In CPython the phase that retries unresolved references drops from a median 2.29 s to 2.08 s on Node (-9%, 4 alternating runs per build, ranges not overlapping) and a one-file sync by about 3% (5.33 s to 5.15 s on the second sync); on Bun the sync time shows no clear change. A full index is within the run-to-run variation, and no edge, node or unresolved reference differs on CPython or Django. - **Questions to the MCP server are faster in a project with no `codegraph.json`.** While ranking results, CodeGraph checked for the project's `codegraph.json` once per result path, and with no such file each check raised and caught an error. It now asks the file system without raising, so `codegraph_explore` and the other query tools spend less CPU on most projects. Editing, adding or deleting `codegraph.json` is still picked up without a restart (#491). From ab786c673af698aef2030c4a444100594ae44dfe Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 12:43:22 -0600 Subject: [PATCH 13/13] fix(kernel): a C++ name macro ignores directives in disabled branches and an include after its #undef defines it again --- CHANGELOG.md | 2 +- __tests__/cpp-macro-namespace.test.ts | 62 +++++++++++++++++++ codegraph-kernel/src/resolve/cpp_namespace.rs | 22 +++++-- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 835f159a88..2065e530e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Indexing a mid-size project is faster.** Below 150,000 pending references the resolver used to stay on one thread for the whole run. It now boots its worker pool part-way through once at least 20,000 references are still unresolved and they would take the main thread at least 0.4 s (an explicit `CODEGRAPH_PARALLEL_RESOLVE_MIN` turns this off). Full index of JetBrains Exposed (110,854 references) 18.5 s to 9.6 s, koel 4.9 s to 4.1 s, Alamofire 1.65 s to 1.41 s (median of 3 interleaved runs each, identical graph), at 56–132% more peak memory; a 13,900-reference project never starts the pool and is unchanged. - **More C++ links land on the right target.** `Ptr p(0);` for `using Ptr = Widget*;` declares a pointer, so it no longer counts as constructing a `Widget`. A class nested in a function-local class is as local to that function as the class around it. A class an included `.cc` file declares is seen by the file that includes it, and of two headers declaring one class an object creation takes the one it includes. A `using Base::foo;` in a nested class no longer counts as the outer class's. `NULL` is an integer zero, not a `nullptr`, when an overload is chosen. A header's unnamed namespace belongs to the files that include the header, not to every file. Two annotation-macro cases read as extraction reads them: `const int FOO_BAR = 1;` keeps its name, and `n * FOO_BAR::VALUE` stays a product. -- **Fewer wrong C++ links from overloads, aliases and specializations.** `Foo x; Take(x);` links `Take(const Foo&)`, not `Take(Foo&&)`; an `int` converted to the parameter's class still takes the `&&` one. A `constexpr Widget* p` points at a non-const `Widget`, and an element read through a const container's iterator is const, so each picks the matching `const` or non-const overload. `f(a)` on an `int a[3][4]` takes `f(int (*)[4])`, not `f(int*)`. A qualified call into a namespace a macro opens links none of the overloads that fit its arguments best when several tie. A namespace alias applies from its own line on and only inside the block that declares it, and `namespace a = ::lib;` names the global `lib`. `ROCKSDB_NAMESPACE::DB` written before `#define ROCKSDB_NAMESPACE rocksdb`, or after its `#undef`, no longer resolves through the macro. A specialization of `a::f` no longer removes `b::f` from a call's choices, `foo.foo()` reads the template arguments after the method name, and `Put()` in a class that only specializes `Put` links nothing instead of a base class's `Put`. Re-index C++ projects after upgrading. +- **Fewer wrong C++ links from overloads, aliases and specializations.** `Foo x; Take(x);` links `Take(const Foo&)`, not `Take(Foo&&)`; an `int` converted to the parameter's class still takes the `&&` one. A `constexpr Widget* p` points at a non-const `Widget`, and an element read through a const container's iterator is const, so each picks the matching `const` or non-const overload. `f(a)` on an `int a[3][4]` takes `f(int (*)[4])`, not `f(int*)`. A qualified call into a namespace a macro opens links none of the overloads that fit its arguments best when several tie. A namespace alias applies from its own line on and only inside the block that declares it, and `namespace a = ::lib;` names the global `lib`. `ROCKSDB_NAMESPACE::DB` written before `#define ROCKSDB_NAMESPACE rocksdb`, or after its `#undef`, no longer resolves through the macro; an `#undef` in an `#if 0` branch changes nothing, and an `#include` after an `#undef` that defines the macro defines it again. A specialization of `a::f` no longer removes `b::f` from a call's choices, `foo.foo()` reads the template arguments after the method name, and `Put()` in a class that only specializes `Put` links nothing instead of a base class's `Put`. Re-index C++ projects after upgrading. - **Two C cases no longer link a call to the wrong definition.** A `static` function in another source file is reached only through an `#include` written before the call and not inside `#if 0`. A flag counts as set for a header's flag-only definition only by a real `#define` in the call's own component: not one inside a comment or raw string, not one in a nested component, and an empty build file still marks a component boundary. Re-index C projects after upgrading. - **Syncing a changed file in a large Python project is faster.** When a Python class has no method of the name being linked (a test class that passes `Handler.__init__` as a value, say), the resolver checks every class with that method for one that derives from it. For each class whose base is a package import (`import unittest`, then `unittest.TestCase`) it parsed the whole file again, and its cache holds only a few files, so two such references took about a second each. A plain one-line `import a.b` is now read from its line; other forms still use the parse. In CPython a one-file sync takes a median 5.5 s instead of 7.7 s on Node and 5.2 s instead of 8.1 s on Bun (4 alternating runs per build, ranges not overlapping; the phase that retries unresolved references drops from 4.6 s to 2.3 s). A full index is within the run-to-run variation, and the graph is the same on CPython and Django (no edge differs). - **The same sync resolves each Python class's bases once.** The scan above resolved the base classes of every candidate again for each reference that needed it. The bases of a class depend only on its own header and file, so they are now kept for the rest of the run. In CPython the phase that retries unresolved references drops from a median 2.29 s to 2.08 s on Node (-9%, 4 alternating runs per build, ranges not overlapping) and a one-file sync by about 3% (5.33 s to 5.15 s on the second sync); on Bun the sync time shows no clear change. A full index is within the run-to-run variation, and no edge, node or unresolved reference differs on CPython or Django. diff --git a/__tests__/cpp-macro-namespace.test.ts b/__tests__/cpp-macro-namespace.test.ts index 23b692a535..73e0615d8d 100644 --- a/__tests__/cpp-macro-namespace.test.ts +++ b/__tests__/cpp-macro-namespace.test.ts @@ -189,3 +189,65 @@ describe('a C++ name macro applies from its definition on', () => { expect(callees('After')).toEqual(['rocksdb::DB::Get@db.h:1']); }); }); + +/** + * Which `#define` or `#undef` of a name macro is the last one in effect: one in a branch no + * build compiles (`#if 0`) is not, and an `#undef` does not hide a definition an `#include` + * after it brings in. + */ +describe('a C++ name macro follows the directives that are in effect', () => { + const FILES: Record = { + 'db.h': lines('namespace rocksdb { struct DB { void Get() {} }; }'), + 'config.h': lines('#define ROCKSDB_NAMESPACE rocksdb'), + 'inactive.cc': lines( + '#include "db.h"', + '#define ROCKSDB_NAMESPACE rocksdb', + '#if 0', + '#undef ROCKSDB_NAMESPACE', + '#endif', + 'void Active(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + ), + 'redefined.cc': lines( + '#include "db.h"', + '#undef ROCKSDB_NAMESPACE', + '#include "config.h"', + 'void Redefined(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + ), + 'hidden.cc': lines( + '#include "db.h"', + '#include "config.h"', + '#undef ROCKSDB_NAMESPACE', + 'void Hidden(ROCKSDB_NAMESPACE::DB* db) { db->Get(); }', + ), + }; + + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-macro-effect-')); + for (const [file, text] of Object.entries(FILES)) { + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); + }, 120_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + const callees = (caller: string): string[] => { + const fn = cg.getNodesByKind('function').find((n) => n.qualifiedName === caller); + expect(fn, caller).toBeDefined(); + return cg.getCallees(fn!.id).filter((c) => c.edge.kind === 'calls').map((c) => `${c.node.qualifiedName}@${c.node.filePath}:${c.node.startLine}`).sort(); + }; + + it('ignores an `#undef` in a branch no build compiles', () => { + expect(callees('Active')).toEqual(['rocksdb::DB::Get@db.h:1']); + }); + it('lets an include after an `#undef` define the macro again, and an `#undef` after it hide it', () => { + expect(callees('Redefined')).toEqual(['rocksdb::DB::Get@db.h:1']); + expect(callees('Hidden')).toEqual([]); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_namespace.rs b/codegraph-kernel/src/resolve/cpp_namespace.rs index b16eb7d413..cd14e7b8bf 100644 --- a/codegraph-kernel/src/resolve/cpp_namespace.rs +++ b/codegraph-kernel/src/resolve/cpp_namespace.rs @@ -144,8 +144,12 @@ impl KernelResolver { /// through what those files include. An include in a branch only a build with a flag nothing /// sets compiles (`#if 0`) counts only for a line in such a branch too. pub(super) fn cpp_included_before(&mut self,file:&str,line:i64,target:&str,language:&str)->Res { + self.cpp_included_between(file,0,line,target,language) + } + /// `cpp_included_before`, counting only the includes written after line `from`. + fn cpp_included_between(&mut self,file:&str,from:i64,line:i64,target:&str,language:&str)->Res { let imports:Vec<(i64,char,String)>=self.nodes_in_file(file)?.iter() - .filter(|n|n.kind=="import" && matches!(n.language.as_str(),"c"|"cpp") && n.start_linefrom && n.start_lineRes { let file=r.file_path.as_str(); let directives=match self.cpp_namespaces.directives.get(file) { @@ -194,10 +200,16 @@ impl KernelResolver { self.cpp_namespaces.directives.insert(file.to_string(),found.clone());found } }; - if let Some((_,_,define))=directives.iter().rfind(|(line,n,_)|*line=self.cpp_namespaces.replacements.keys().filter(|(f,n)|n==name && f!=file).map(|(f,_)|f.clone()).collect(); for definer in definers { - if self.cpp_included_before(file,r.line,&definer,&r.language)? {return Ok(true);} + if self.cpp_included_between(file,from,r.line,&definer,&r.language)? {return Ok(true);} } Ok(false) }