From 9ea2d2ef2d4776a149d8d2a95ce43836c85866b7 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 12:56:05 +0800 Subject: [PATCH 1/3] fix(cpp): extract function-like macros and construct objects only where a declaration does A function-like `#define`, at file scope or inside a body, becomes a `constant` carrying its directive, so resolution can tell a macro expansion from a call (upstream #1838). An in-class constructor prototype becomes a method whose signature keeps its `;`, so a default argument declared on it can reach the definition's overload, and a union constructor carries its parameters like a class's (#1839). A local declaration now emits one constructor reference per object it constructs: `extern` declarations, pointer, reference and function declarators construct nothing; a bare array of objects is one default construction; an array's braces hold elements, each constructed by its own arity, with one default construction for the elements a literal size leaves over; comments do not count as arguments. --- crates/codegraph-extract/src/lang/cpp.rs | 40 +++- crates/codegraph-extract/src/lang/mod.rs | 3 +- crates/codegraph-extract/src/walker.rs | 173 +++++++++++++--- .../tests/cpp_macros_and_constructions.rs | 193 ++++++++++++++++++ 4 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 crates/codegraph-extract/tests/cpp_macros_and_constructions.rs diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index c7e5d46..a7c180f 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -338,12 +338,48 @@ fn cpp_constructor_signature(node: Node<'_>, source: &str) -> Option { return None; } let parameters = child_by_field(function, "parameters")?; - Some(node_text(parameters, source)) + // A prototype keeps its terminating `;`, so resolution can tell the + // declaration from the definition it merges with (#1839). + let terminator = if node.kind() == "declaration" { + ";" + } else { + "" + }; + Some(format!("{}{terminator}", node_text(parameters, source))) +} + +/// An in-class constructor prototype (upstream `isCppConstructorDeclaration`, +/// #1839): a `declaration` with no return type, directly in a class, struct or +/// union body, whose declarator is a function declarator named like the type. +/// It becomes a method node so a default argument declared on it reaches the +/// out-of-line definition's overload. +pub fn is_cpp_constructor_declaration(node: Node<'_>, source: &str) -> bool { + if node.kind() != "declaration" || child_by_field(node, "type").is_some() { + return false; + } + let Some(owner) = node.parent().and_then(|body| body.parent()) else { + return false; + }; + if !matches!( + owner.kind(), + "class_specifier" | "struct_specifier" | "union_specifier" + ) { + return false; + } + let Some(declarator) = child_by_field(node, "declarator") else { + return false; + }; + declarator.kind() == "function_declarator" + && child_by_field(declarator, "declarator").map(|name| node_text(name, source)) + == child_by_field(owner, "name").map(|name| node_text(name, source)) } fn nearest_cpp_class_name(mut node: Node<'_>, source: &str) -> Option { while let Some(parent) = node.parent() { - if matches!(parent.kind(), "class_specifier" | "struct_specifier") { + if matches!( + parent.kind(), + "class_specifier" | "struct_specifier" | "union_specifier" + ) { return child_by_field(parent, "name").map(|name| node_text(name, source)); } node = parent; diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index fb7a44b..2702cfd 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -49,7 +49,8 @@ pub use cpp::{ }; pub(crate) use cpp::{ ExplicitOperatorCall, ExportMacroClass, cpp_code_mask, detect_export_macro_class, - is_cpp_pure_virtual_method_decl, recover_explicit_operator_call, + is_cpp_constructor_declaration, is_cpp_pure_virtual_method_decl, + recover_explicit_operator_call, }; pub use csharp::CSHARP_SPEC; pub use dart::DART_SPEC; diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 10f384e..e9c71de 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -443,6 +443,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.maybe_capture_fn_refs(node, node_type); + if self.extract_c_function_macro(node) { + return; + } + if self.visit_language_specific(node) { return; } @@ -1503,7 +1507,9 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } fn visit_cpp_node(&mut self, node: SyntaxNode<'tree>) -> bool { - if crate::lang::is_cpp_pure_virtual_method_decl(node, self.source) { + if crate::lang::is_cpp_pure_virtual_method_decl(node, self.source) + || crate::lang::is_cpp_constructor_declaration(node, self.source) + { self.extract_method(node); return true; } @@ -4162,10 +4168,49 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } - // #1035 — C/C++ stack/brace construction (`Calc c(0)`, `W w{1, 2}`) records - // an Instantiates edge, matching the existing heap `new_expression` path. - // Gated to a user `type_identifier` (not a `primitive_type`) so `int y(5)` - // never fires; the init_declarator must carry a call- or brace-initializer. + /// A C/C++ function-like macro (`#define TRACE(x) ...`) becomes a `constant` + /// carrying the directive as its signature (upstream #1838). A macro is a + /// value, never an executable callee: the resolver reads these to recognize + /// a call whose name is a macro visible in the translation unit and refuses + /// to bind it to a same-named function elsewhere. An object-like `#define` + /// mints nothing. Returns whether `node` was such a directive. + fn extract_c_function_macro(&mut self, node: SyntaxNode<'tree>) -> bool { + if node.kind() != "preproc_function_def" + || !matches!(self.spec.language(), Language::C | Language::Cpp) + { + return false; + } + if let Some(name) = child_by_field(node, "name") { + let signature = node_text(node, self.source).trim().to_string(); + self.create_node( + NodeKind::Constant, + &node_text(name, self.source), + node, + NodeExtra { + signature: Some(signature), + ..NodeExtra::default() + }, + ); + } + true + } + + /// C++ stack construction (`Calc c(0)`, `W w{1, 2}`, `W w;`) carries no call + /// node, so each constructed object gets a constructor reference named for + /// its argument count (#1839), and an initialized declaration keeps the + /// #1035 `instantiates` reference to the type. + /// + /// Gated to a named class-like type (not a `primitive_type`, so `int y(5)` + /// never fires). `extern T x;` declares and constructs nothing. Per + /// declarator (upstream `cppStackConstructions`): + /// - a bare identifier is default construction, arity 0, and so is a bare + /// array of objects; + /// - an `init_declarator` whose value is `(args)` or `{args}` constructs + /// with that many arguments, comments aside — except that an array's + /// braces hold ELEMENTS, each constructed by its own arity, with one more + /// default construction when the literal size leaves elements over; + /// - pointer, reference and function declarators construct nothing: + /// `T* p{}` is a null pointer, `T& r{x}` binds, `T c();` is a prototype. fn maybe_cpp_construction(&mut self, node: SyntaxNode<'tree>) { if !matches!(self.spec.language(), Language::C | Language::Cpp) { return; @@ -4186,38 +4231,60 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if class_name.is_empty() { return; } + if node.named_children(&mut node.walk()).any(|child| { + child.kind() == "storage_class_specifier" && node_text(child, self.source) == "extern" + }) { + return; + } let caller_is_callable = self.nodes.iter().any(|candidate| { candidate.id == from_id && matches!(candidate.kind, NodeKind::Function | NodeKind::Method) }); let mut has_explicit_construction = false; + let mut constructions: Vec<(usize, SyntaxNode<'tree>)> = Vec::new(); for declarator in node.named_children(&mut node.walk()) { - let (arity, anchor, explicit) = match declarator.kind() { - // `Type object;` default-initializes an object. A pointer, - // reference, array, or most-vexing `Type object();` has a - // different direct declarator kind and is intentionally skipped. - "identifier" | "field_identifier" => (Some(0), declarator, false), + match declarator.kind() { + "identifier" | "field_identifier" => constructions.push((0, declarator)), + "array_declarator" if cpp_object_array(declarator) => { + constructions.push((0, declarator)); + } "init_declarator" => { - let Some(value) = child_by_field(declarator, "value") else { + let (Some(inner), Some(value)) = ( + child_by_field(declarator, "declarator"), + child_by_field(declarator, "value"), + ) else { continue; }; - if matches!(value.kind(), "argument_list" | "initializer_list") { - (Some(value.named_child_count()), value, true) - } else { - // Copy-initialization and nested temporary expressions - // require conversion/type analysis; their own call/new - // expression remains handled by the ordinary walker. - (None, value, false) + if !matches!(inner.kind(), "identifier" | "array_declarator") + || !matches!(value.kind(), "argument_list" | "initializer_list") + { + // Copy-initialization and nested temporaries keep their + // own call/new expression in the ordinary walk. + continue; + } + has_explicit_construction = true; + if inner.kind() == "identifier" { + constructions.push((non_comment_children(value).len(), value)); + } else if cpp_object_array(inner) { + let mut dimensions = Vec::new(); + let mut array = Some(inner); + while let Some(current) = array.filter(|a| a.kind() == "array_declarator") { + let size = child_by_field(current, "size") + .map(|size| node_text(size, self.source)) + .unwrap_or_default(); + dimensions.insert(0, size.parse::().ok()); + array = child_by_field(current, "declarator"); + } + cpp_array_element_constructions(value, 0, &dimensions, &mut constructions); } } - _ => (None, declarator, false), - }; - has_explicit_construction |= explicit; - if self.spec.language() == Language::Cpp - && caller_is_callable - && let Some(arity) = arity - { + _ => {} + } + } + + if self.spec.language() == Language::Cpp && caller_is_callable { + for (arity, anchor) in constructions { let reference_name = crate::lang::cpp_constructor_reference_name(&class_name, arity); self.push_ref(&from_id, &reference_name, EdgeKind::Calls, anchor); @@ -4255,6 +4322,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { fn visit_body_node_inner(&mut self, node: SyntaxNode<'tree>) { let node_type = node.kind(); self.maybe_capture_fn_refs(node, node_type); + // A function-like macro defined inside a body is still a macro (#1838). + if self.extract_c_function_macro(node) { + return; + } // Inside a function body, GDScript `preload(...)`/`load(...)` calls must // become Imports, not Calls. The body walker never dispatches the // language hook, so route GDScript nodes through it first; a `true` @@ -5053,6 +5124,58 @@ fn is_erlang_mfa_call(name: &str) -> bool { ) } +/// Whether an array declarator declares objects rather than pointers or +/// references: its innermost declarator is a plain identifier. +fn cpp_object_array(node: SyntaxNode<'_>) -> bool { + let mut element = child_by_field(node, "declarator"); + while let Some(current) = element.filter(|e| e.kind() == "array_declarator") { + element = child_by_field(current, "declarator"); + } + element.is_some_and(|e| e.kind() == "identifier") +} + +fn non_comment_children(node: SyntaxNode<'_>) -> Vec> { + node.named_children(&mut node.walk()) + .filter(|child| child.kind() != "comment") + .collect() +} + +/// The constructions an array initializer list implies: each innermost +/// element by its own arity (a braced element by its entry count, a scalar by +/// one), nested lists level by level, and one default construction where the +/// literal size leaves elements over. Unbraced multidimensional layouts need +/// type information and claim no remainder. +fn cpp_array_element_constructions<'tree>( + list: SyntaxNode<'tree>, + depth: usize, + dimensions: &[Option], + out: &mut Vec<(usize, SyntaxNode<'tree>)>, +) { + let entries = non_comment_children(list); + let mut elided = false; + for entry in &entries { + if depth + 1 < dimensions.len() { + if entry.kind() == "initializer_list" { + cpp_array_element_constructions(*entry, depth + 1, dimensions, out); + } else { + elided = true; + } + } else if entry.kind() == "initializer_list" { + out.push((non_comment_children(*entry).len(), *entry)); + } else { + out.push((1, *entry)); + } + } + let left_over = dimensions + .get(depth) + .copied() + .flatten() + .is_some_and(|size| size > entries.len()); + if !elided && (entries.is_empty() || left_over) { + out.push((0, list)); + } +} + #[derive(Default)] struct NodeExtra { docstring: Option, diff --git a/crates/codegraph-extract/tests/cpp_macros_and_constructions.rs b/crates/codegraph-extract/tests/cpp_macros_and_constructions.rs new file mode 100644 index 0000000..18da750 --- /dev/null +++ b/crates/codegraph-extract/tests/cpp_macros_and_constructions.rs @@ -0,0 +1,193 @@ +//! C/C++ extraction for upstream v1.6.1 #1838 / #1839: a function-like macro is +//! a `constant` carrying its directive, so the resolver can tell a macro +//! expansion from a call; a local declaration emits one constructor reference +//! per object it constructs, and none for what constructs nothing. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, NodeKind}; +use codegraph_extract::extract_source; +use codegraph_extract::lang::cpp_constructor_reference_name; + +fn extract(file: &str, source: &str, language: Language) -> ExtractionResult { + let result = extract_source(file, source, Some(language)); + assert!(result.errors.is_empty(), "{file}: {:?}", result.errors); + result +} + +/// `(name, signature)` of every constant. +fn constants(result: &ExtractionResult) -> Vec<(&str, Option<&str>)> { + result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Constant) + .map(|node| (node.name.as_str(), node.signature.as_deref())) + .collect() +} + +/// Constructor reference arities `from` emitted for `type_name`, in order. +fn constructions(result: &ExtractionResult, from: &str, type_name: &str) -> Vec { + let from = result + .nodes + .iter() + .find(|node| node.name == from && node.kind == NodeKind::Function) + .unwrap_or_else(|| panic!("no function {from}")); + result + .unresolved_references + .iter() + .filter(|reference| { + reference.from_node_id == from.id && reference.reference_kind == EdgeKind::Calls + }) + .filter_map(|reference| { + (0..8).find(|arity| { + reference.reference_name == cpp_constructor_reference_name(type_name, *arity) + }) + }) + .collect() +} + +#[test] +fn a_function_like_macro_is_a_constant_carrying_its_directive() { + for (file, language) in [("trace.h", Language::C), ("trace.hpp", Language::Cpp)] { + let result = extract( + file, + concat!( + "#define TRACE_POINT(value) ((void)(value))\n", + "#define LIMIT 10\n", + "#define WIDE(a, b) \\\n", + " ((a) + (b))\n", + "int run(void) {\n", + "#define LOCAL_MAX(a, b) ((a) > (b) ? (a) : (b))\n", + " return LOCAL_MAX(1, 2);\n", + "}\n", + ), + language, + ); + assert_eq!( + constants(&result), + vec![ + ( + "TRACE_POINT", + Some("#define TRACE_POINT(value) ((void)(value))") + ), + ("WIDE", Some("#define WIDE(a, b) \\\n ((a) + (b))")), + ( + "LOCAL_MAX", + Some("#define LOCAL_MAX(a, b) ((a) > (b) ? (a) : (b))") + ), + ], + "{file}: an object-like #define is a value, not a macro call target" + ); + } +} + +#[test] +fn one_constructor_reference_per_declarator_with_its_own_arity() { + let result = extract( + "case.cpp", + "struct Widget { Widget() {} Widget(int value) {} };\n\ + void run() { Widget a, b(1), c{}; ns::Other d{1, 2}; Widget *p{}; }\n\ + void commented() { Widget e(/* note */ 1); }\n", + Language::Cpp, + ); + assert_eq!(constructions(&result, "run", "Widget"), vec![0, 1, 0]); + assert_eq!(constructions(&result, "run", "ns::Other"), vec![2]); + assert_eq!(constructions(&result, "commented", "Widget"), vec![1]); + // The #1035 type-level relationship is kept for initialized declarations. + let instantiated: Vec<&str> = result + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Instantiates) + .map(|reference| reference.reference_name.as_str()) + .collect(); + assert!(instantiated.contains(&"Widget"), "{instantiated:?}"); +} + +#[test] +fn pointers_references_function_declarators_and_extern_construct_nothing() { + let result = extract( + "controls.cpp", + "struct Widget { Widget() {} Widget(int) {} };\n\ + void pointers() { Widget *p{}; Widget *q(nullptr); Widget *arr[2]{}; Widget (*fn)(){}; }\n\ + void reference_bind(Widget &other) { Widget &r{other}; Widget &s(other); }\n\ + void prototype() { Widget most_vexing(); extern Widget external; }\n\ + void actual() { Widget object{}; }\n", + Language::Cpp, + ); + for name in ["pointers", "reference_bind", "prototype"] { + assert_eq!( + constructions(&result, name, "Widget"), + Vec::::new(), + "{name}" + ); + } + assert_eq!(constructions(&result, "actual", "Widget"), vec![0]); +} + +#[test] +fn array_braces_hold_elements_each_constructed_by_its_own_arity() { + let result = extract( + "arrays.cpp", + "struct Widget { Widget() {} Widget(int value) {} };\n\ + int argument() { return 1; }\n\ + void plain() { Widget items[2]; }\n\ + void empty() { Widget items[2]{}; }\n\ + void elements() { Widget items[3]{{argument()}, {2}}; }\n\ + void grid() { Widget items[2][2]{{{1}, {2}}, {{3}}}; }\n\ + void scalar_elements() { Widget items[2]{1, 2}; }\n\ + void unknown_size(int n) { Widget items[n]{{1}}; }\n", + Language::Cpp, + ); + assert_eq!(constructions(&result, "plain", "Widget"), vec![0]); + assert_eq!(constructions(&result, "empty", "Widget"), vec![0]); + // Two braced elements, and the third default-constructed. + assert_eq!(constructions(&result, "elements", "Widget"), vec![1, 1, 0]); + assert_eq!(constructions(&result, "grid", "Widget"), vec![1, 1, 1, 0]); + assert_eq!( + constructions(&result, "scalar_elements", "Widget"), + vec![1, 1] + ); + // A size no literal states says nothing about the elements left over. + assert_eq!(constructions(&result, "unknown_size", "Widget"), vec![1]); + // A nested argument call keeps its own reference. + let elements = result + .nodes + .iter() + .find(|node| node.name == "elements") + .unwrap(); + assert!(result.unresolved_references.iter().any(|reference| { + reference.from_node_id == elements.id + && reference.reference_kind == EdgeKind::Calls + && reference.reference_name == "argument" + })); +} + +#[test] +fn an_in_class_constructor_prototype_is_a_method_carrying_its_parameters() { + let result = extract( + "widget.hpp", + "class Widget {\npublic:\n Widget();\n explicit Widget(int value = 7);\n int size() const;\n};\n\ + Widget::Widget() {}\n", + Language::Cpp, + ); + let mut methods: Vec<(&str, Option<&str>, i64)> = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Method) + .map(|node| { + ( + node.qualified_name.as_str(), + node.signature.as_deref(), + node.start_line, + ) + }) + .collect(); + methods.sort_by_key(|method| method.2); + assert_eq!( + methods, + vec![ + ("Widget::Widget", Some("();"), 3), + ("Widget::Widget", Some("(int value = 7);"), 4), + ("Widget::Widget", Some("()"), 7), + ], + "only constructor prototypes become nodes; `size()` stays a declaration" + ); +} From cd567589e7d15b26db9fa124c66a1a825e95b952 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 13:14:56 +0800 Subject: [PATCH 2/3] fix(c): judge macro visibility in translation-unit order and merge constructor overloads The port suppressed a call whenever any header in the include closure defined a function-like macro of that name, ignoring line order, #if and #ifdef, #undef, guards and #pragma once (upstream #1838). Directives are now evaluated in include order with three-valued conditions: a call is an expansion only where the macro is definitely visible at that line, or when only macros bear the name. A macro under an undecided flag keeps the call, a wrapper macro that calls its own name hides nothing, unguarded headers replay on every inclusion and an include stack breaks cycles. An #undef under an undecidable #if leaves a never-seen flag unknown (#2069). A call whose name is a macro anywhere in the project binds only a function or method, never the macro or a same-named type, and neither counts toward the same-name ceiling (#2070); a #define is never a callee. Constructor resolution merges a prototype with its out-of-line definition by parameter types, so a default declared on the prototype admits the definition's overload, and targets the definition. An initializer_list overload declines brace construction, and an unreadable signature declines the whole overload set; a parameter pack is unreadable here (upstream admits it unbounded), so it can no longer make a lone T(int) look unique. --- crates/codegraph-extract/src/lang/cpp.rs | 193 ++-- crates/codegraph-extract/src/lang/mod.rs | 4 +- crates/codegraph-extract/src/walker.rs | 36 +- .../src/c_macro_visibility.rs | 863 ++++++++++++++++++ crates/codegraph-resolve/src/lib.rs | 1 + crates/codegraph-resolve/src/name_matcher.rs | 26 +- crates/codegraph-resolve/src/resolver.rs | 373 ++------ .../tests/c_macro_visibility.rs | 599 ++++++++++++ .../tests/cpp_constructor_resolution.rs | 214 +++++ 9 files changed, 1926 insertions(+), 383 deletions(-) create mode 100644 crates/codegraph-resolve/src/c_macro_visibility.rs create mode 100644 crates/codegraph-resolve/tests/c_macro_visibility.rs diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index a7c180f..3cbacfc 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -211,105 +211,132 @@ pub fn parse_cpp_constructor_reference_name(name: &str) -> Option<(&str, usize)> Some((type_name, arity.parse().ok()?)) } -/// Return the accepted positional-argument range for a constructor signature. -/// -/// Constructor nodes store only their raw `parameter_list` as `signature`, so -/// resolution can distinguish overloads without reparsing source files for -/// every call. Nested delimiters and lexically-masked literals/comments do not -/// split parameters; defaults lower the minimum accepted arity. Parameter packs -/// deliberately return `None`: proving their overload semantics requires more -/// type information than the graph carries, so resolution must fail closed. -pub fn cpp_constructor_arity_range(signature: &str) -> Option<(usize, usize)> { +/// A constructor's overload shape (upstream `constructorShape`, #1839): its +/// parameter types with names and defaults stripped — the key a prototype and +/// its out-of-line definition share — and the argument counts it admits +/// (defaults lower the minimum). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CppConstructorShape { + pub key: String, + pub min: usize, + pub max: usize, +} + +/// The overload shape of a constructor signature (`(int value = 7)`, or a +/// prototype's `(int value = 7);`), or `None` when it cannot be read: brackets +/// that do not balance, or a comparison in a default argument, which would be +/// taken for a default or a template bracket. A parameter pack is unreadable +/// too: its overload semantics need more type information than the graph +/// carries. Upstream admits a pack unbounded; this port declines the overload +/// set instead, so a pack never lets a lone non-pack overload look unique. +pub fn cpp_constructor_shape(signature: &str) -> Option { let signature = signature.trim(); - let inner = signature.strip_prefix('(')?.strip_suffix(')')?.trim(); - if inner.is_empty() || inner == "void" { - return Some((0, 0)); - } - if inner.contains("...") { - return None; + let signature = signature.strip_suffix(';').unwrap_or(signature).trim_end(); + let text = signature.strip_prefix('(')?.strip_suffix(')')?.trim(); + if text.is_empty() || text == "void" { + return Some(CppConstructorShape { + key: String::new(), + min: 0, + max: 0, + }); } - - let mask = cpp_code_mask(inner); - let bytes = inner.as_bytes(); - let mut round = 0usize; - let mut square = 0usize; - let mut brace = 0usize; - let mut angle = 0usize; + // Top-level commas only: `std::map`, `int (*cb)(int, int)` and + // `T x = f(a, b)` all nest theirs. + let bytes = text.as_bytes(); + let mut parts = Vec::new(); let mut start = 0usize; - let mut spans = Vec::new(); - for (index, byte) in bytes.iter().copied().enumerate() { - if !mask[index] { + let mut depth = 0i64; + let mut quote: Option = None; + let mut i = 0usize; + while i < bytes.len() { + let byte = bytes[i]; + if let Some(open) = quote { + if byte == b'\\' { + i += 1; + } else if byte == open { + quote = None; + } + i += 1; continue; } match byte { - b'(' => round += 1, - b')' => round = round.checked_sub(1)?, - b'[' => square += 1, - b']' => square = square.checked_sub(1)?, - b'{' => brace += 1, - b'}' => brace = brace.checked_sub(1)?, - // In a parameter list, angle brackets overwhelmingly delimit a - // template type. If a default expression uses comparison operators - // and leaves this unbalanced, the final balance check rejects the - // signature rather than guessing an overload. - b'<' => angle += 1, - b'>' if angle > 0 => angle -= 1, - b',' if round == 0 && square == 0 && brace == 0 && angle == 0 => { - spans.push((start, index)); - start = index + 1; + b'"' | b'\'' => quote = Some(byte), + b'(' | b'<' | b'[' | b'{' => depth += 1, + b')' | b'>' | b']' | b'}' => depth -= 1, + b',' if depth == 0 => { + parts.push(&text[start..i]); + start = i + 1; } _ => {} } + i += 1; } - if round != 0 || square != 0 || brace != 0 || angle != 0 { + if depth != 0 || quote.is_some() { return None; } - spans.push((start, bytes.len())); - - let mut required = 0usize; - for (start, end) in &spans { - let parameter = inner.get(*start..*end)?.trim(); - if parameter.is_empty() { - return None; - } - if !has_top_level_default(parameter) { - required += 1; - } + parts.push(&text[start..]); + if parts.iter().any(|part| { + part.contains(">=") || part.contains("<=") || part.contains("==") || part.contains("!=") + }) || parts.iter().any(|part| part.contains("...")) + { + return None; } - Some((required, spans.len())) + let key = parts + .iter() + .map(|part| { + let declared = part.split('=').next().unwrap_or(part).trim(); + strip_cpp_parameter_name(declared) + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + }) + .collect::>() + .join(","); + Some(CppConstructorShape { + key, + min: parts.iter().filter(|part| !part.contains('=')).count(), + max: parts.len(), + }) } -fn has_top_level_default(parameter: &str) -> bool { - let mask = cpp_code_mask(parameter); - let bytes = parameter.as_bytes(); - let mut round = 0usize; - let mut square = 0usize; - let mut brace = 0usize; - let mut angle = 0usize; - for (index, byte) in bytes.iter().copied().enumerate() { - if !mask[index] { - continue; - } - match byte { - b'(' => round += 1, - b')' => round = round.saturating_sub(1), - b'[' => square += 1, - b']' => square = square.saturating_sub(1), - b'{' => brace += 1, - b'}' => brace = brace.saturating_sub(1), - b'<' => angle += 1, - b'>' if angle > 0 => angle -= 1, - b'=' if round == 0 && square == 0 && brace == 0 && angle == 0 => { - let previous = bytes.get(index.wrapping_sub(1)).copied(); - let next = bytes.get(index + 1).copied(); - if previous != Some(b'=') && next != Some(b'=') { - return true; - } - } - _ => {} - } +/// Strip an optional parameter name, keeping unnamed built-in types +/// (`unsigned int`, `long long`) and qualified ones (`const T`, `struct S`). +/// Complex declarators stay distinct unless their spelling matches: an +/// uncertain merge must not join two overloads. +fn strip_cpp_parameter_name(declared: &str) -> &str { + static NAMED: OnceLock = OnceLock::new(); + let named = NAMED.get_or_init(|| { + Regex::new(r"^(.*[\s*&>])([A-Za-z_][A-Za-z0-9_]*)$").expect("parameter name regex is valid") + }); + let Some(captures) = named.captures(declared) else { + return declared; + }; + let prefix = captures.get(1).map_or("", |m| m.as_str()); + let name = captures.get(2).map_or("", |m| m.as_str()); + let builtin = matches!( + name, + "void" + | "bool" + | "char" + | "short" + | "int" + | "long" + | "float" + | "double" + | "signed" + | "unsigned" + | "const" + | "volatile" + ); + let qualifier = matches!( + prefix.trim_end(), + "const" | "volatile" | "struct" | "class" | "enum" + ); + if builtin || qualifier { + declared + } else { + prefix } - false } /// Store signatures only for actual constructors. Broadly adding C++ callable diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index 2702cfd..9d4eb3e 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -44,8 +44,8 @@ pub(crate) use cfml::{ cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml, }; pub use cpp::{ - CPP_CONSTRUCTOR_REFERENCE_PREFIX, CPP_SPEC, cpp_constructor_arity_range, - cpp_constructor_reference_name, parse_cpp_constructor_reference_name, + CPP_CONSTRUCTOR_REFERENCE_PREFIX, CPP_SPEC, CppConstructorShape, + cpp_constructor_reference_name, cpp_constructor_shape, parse_cpp_constructor_reference_name, }; pub(crate) use cpp::{ ExplicitOperatorCall, ExportMacroClass, cpp_code_mask, detect_export_macro_class, diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index e9c71de..1223bed 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -7628,20 +7628,38 @@ void build() { } #[test] - fn cpp_constructor_arity_range_handles_defaults_and_nested_types() { - use crate::lang::cpp_constructor_arity_range; + fn cpp_constructor_shape_handles_defaults_nested_types_and_names() { + use crate::lang::cpp_constructor_shape; + let shape = |signature: &str| { + cpp_constructor_shape(signature).map(|shape| (shape.key, shape.min, shape.max)) + }; - assert_eq!(cpp_constructor_arity_range("()"), Some((0, 0))); - assert_eq!(cpp_constructor_arity_range("(void)"), Some((0, 0))); + assert_eq!(shape("()"), Some((String::new(), 0, 0))); + assert_eq!(shape("(void)"), Some((String::new(), 0, 0))); + assert_eq!( + shape("(std::pair value, int flags = 0)"), + Some(("std::pair,int".to_string(), 1, 2)) + ); + assert_eq!( + shape("(void (*callback)(int, int), const char *text = \"a,b\")"), + Some(("void(*callback)(int,int),constchar*".to_string(), 1, 2)) + ); + // A prototype and its definition share the key, whatever the names. + assert_eq!( + shape("(int value = 7);").map(|(key, ..)| key), + shape("(int renamed)").map(|(key, ..)| key) + ); assert_eq!( - cpp_constructor_arity_range("(std::pair value, int flags = 0)"), - Some((1, 2)) + shape("(unsigned int)"), + Some(("unsignedint".to_string(), 1, 1)) ); assert_eq!( - cpp_constructor_arity_range("(void (*callback)(int, int), const char *text = \"a,b\")"), - Some((1, 2)) + shape("(const Widget)"), + Some(("constWidget".to_string(), 1, 1)) ); - assert_eq!(cpp_constructor_arity_range("(Args&&... args)"), None); + // A parameter pack and a comparison in a default are unreadable. + assert_eq!(shape("(Args&&... args)"), None); + assert_eq!(shape("(int x = a >= b)"), None); } #[test] diff --git a/crates/codegraph-resolve/src/c_macro_visibility.rs b/crates/codegraph-resolve/src/c_macro_visibility.rs new file mode 100644 index 0000000..6272212 --- /dev/null +++ b/crates/codegraph-resolve/src/c_macro_visibility.rs @@ -0,0 +1,863 @@ +//! Is a C/C++ call's name a function-like macro visible at the call site? +//! (upstream `cpp-macro-visibility.ts`, #1838, #2069) +//! +//! `TRACE_POINT(1)` parses as a call, so extraction records a `calls` reference +//! named `TRACE_POINT`. If the translation unit defines `#define +//! TRACE_POINT(value) ...` before that line, in the file itself or through an +//! include, the "call" is a macro expansion, and binding it to a same-spelled +//! function in an unrelated file fabricates a caller and a callee that never +//! existed. +//! +//! Directive summaries are cached per file but evaluated in translation-unit +//! order on every inclusion. Only `#pragma once` and actual guard state +//! suppress reinclusion; an active include stack breaks cycles. Unknown build +//! flags stay possible, so only DEFINITE macro visibility suppresses a call: a +//! macro that exists in one build configuration only keeps the call to the +//! function the other configuration compiles, and a wrapper macro that calls +//! its own name is how that function gets called and hides nothing. +//! Object-like definitions take part in conditions but never enter the +//! per-root timelines. + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::sync::{Arc, LazyLock}; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::import_resolver::resolve_import_path; +use crate::types::{RefView, ResolutionContext}; + +/// Three-valued truth: `Some` is decided, `None` depends on a build flag. +type Truth = Option; + +fn and(a: Truth, b: Truth) -> Truth { + if a == Some(false) || b == Some(false) { + Some(false) + } else if a == Some(true) && b == Some(true) { + Some(true) + } else { + None + } +} + +fn or(a: Truth, b: Truth) -> Truth { + if a == Some(true) || b == Some(true) { + Some(true) + } else if a == Some(false) && b == Some(false) { + Some(false) + } else { + None + } +} + +fn not(a: Truth) -> Truth { + a.map(|value| !value) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BranchOp { + If, + Ifdef, + Ifndef, + Elif, + Else, + Endif, +} + +#[derive(Debug, Clone)] +enum FileEvent { + Define { + name: String, + line: usize, + function_like: bool, + value: String, + wraps_itself: bool, + }, + Undef { + name: String, + line: usize, + }, + Include { + quote: u8, + spec: String, + line: usize, + }, + Branch { + op: BranchOp, + expression: String, + guard: bool, + }, + Once, +} + +/// Per root file: macro name → `(root line, defined)` events in root order. +type Timeline = BTreeMap>; + +/// Roots kept at once; a pure cache, so eviction only costs a recomputation. +const ROOT_TIMELINE_CAP: usize = 32; + +/// Per resolution pass: cleared with the resolver's other caches. +#[derive(Default)] +pub(crate) struct MacroVisibility { + summaries: HashMap>>, + includes: HashMap<(Language, String, u8, String), Option>, + /// Indexed files by basename, for `#include "dir/name.h"` no include root + /// explains. + by_basename: Option>>, + roots: HashMap<(Language, String), Arc>, + root_order: VecDeque<(Language, String)>, +} + +/// A `constant` minted for a function-like `#define` (`CPP_DEFINE_SIGNATURE`). +pub(crate) fn is_define_constant(node: &Node) -> bool { + node.kind == NodeKind::Constant + && node.signature.as_deref().is_some_and(|signature| { + let rest = signature.trim_start(); + rest.strip_prefix('#').is_some_and(|rest| { + rest.trim_start() + .strip_prefix("define") + .is_some_and(|tail| { + !tail + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + }) + }) + }) +} + +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn is_word(text: &str) -> bool { + !text.is_empty() && text.bytes().all(is_word_byte) +} + +/// Whether this C/C++ `calls` reference is a macro expansion rather than a +/// call: the index knows the name as a function-like macro and either nothing +/// but macros bears it (there is no function to call, and a loose match such +/// as `SWAP` → `swap` must not invent one) or the macro is definitely visible +/// at the call site. The name checks run without the cache's lock; only a +/// reference that needs its translation unit's timeline takes it. +pub(crate) fn is_visible_macro( + cache: &std::sync::Mutex, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if !matches!(reference.language, Language::C | Language::Cpp) + || reference.reference_kind != EdgeKind::Calls + || !is_word(&reference.reference_name) + { + return false; + } + let same_name = context.get_nodes_by_name_shared(&reference.reference_name); + if !same_name.iter().any(|node| is_define_constant(node)) { + return false; + } + if !same_name.iter().any(|node| !is_define_constant(node)) { + return true; + } + let timeline = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .timeline_for(&reference.file_path, reference.language, context); + let line = usize::try_from(reference.line).unwrap_or(0); + timeline + .get(&reference.reference_name) + .and_then(|events| events.iter().rev().find(|(at, _)| *at <= line)) + .is_some_and(|(_, defined)| *defined == Some(true)) +} + +impl MacroVisibility { + fn timeline_for( + &mut self, + root: &str, + language: Language, + context: &dyn ResolutionContext, + ) -> Arc { + let key = (language, root.to_string()); + if let Some(timeline) = self.roots.get(&key) { + return Arc::clone(timeline); + } + let timeline = Arc::new(self.walk_translation_unit(root, language, context)); + if self.roots.len() >= ROOT_TIMELINE_CAP + && let Some(oldest) = self.root_order.pop_front() + { + self.roots.remove(&oldest); + } + self.root_order.push_back(key.clone()); + self.roots.insert(key, Arc::clone(&timeline)); + timeline + } + + /// Cache syntax, never conditional truth: an included file can change its + /// flags. + fn summarize(&mut self, file: &str, context: &dyn ResolutionContext) -> Arc> { + if let Some(cached) = self.summaries.get(file) { + return Arc::clone(cached); + } + let source = context.read_file(file).unwrap_or_default(); + let lines = directive_lines(&source); + let mut events = Vec::new(); + for (index, text) in lines.iter().enumerate() { + if let Some(branch) = BRANCH.captures(text) { + let op = match &branch[1] { + "ifdef" => BranchOp::Ifdef, + "ifndef" => BranchOp::Ifndef, + "if" => BranchOp::If, + "elif" => BranchOp::Elif, + "else" => BranchOp::Else, + _ => BranchOp::Endif, + }; + let expression = branch[2].to_string(); + let guard = guards_itself(&lines, index, op, &expression); + events.push(FileEvent::Branch { + op, + expression, + guard, + }); + continue; + } + if let Some(directive) = DEFINE_OR_UNDEF.captures(text) { + let name = directive[2].to_string(); + let line = index + 1; + if &directive[1] == "define" { + let function_like = !directive[3].is_empty(); + let matched = directive.get(0).map_or(0, |m| m.end()); + events.push(FileEvent::Define { + wraps_itself: function_like && calls_itself(&lines, index, &name), + value: text[matched..].to_string(), + name, + line, + function_like, + }); + } else { + events.push(FileEvent::Undef { name, line }); + } + continue; + } + if let Some(include) = INCLUDE.captures(text) { + events.push(FileEvent::Include { + quote: include[1].as_bytes()[0], + spec: include[2].to_string(), + line: index + 1, + }); + } + if PRAGMA_ONCE.is_match(text) { + events.push(FileEvent::Once); + } + } + let events = Arc::new(events); + self.summaries.insert(file.to_string(), Arc::clone(&events)); + events + } + + fn resolve_include( + &mut self, + file: &str, + quote: u8, + spec: &str, + language: Language, + context: &dyn ResolutionContext, + ) -> Option { + let key = (language, file.to_string(), quote, spec.to_string()); + if let Some(cached) = self.includes.get(&key) { + return cached.clone(); + } + let spec_slash = spec.replace('\\', "/"); + let dir = crate::pathutil::dirname(file); + let local = crate::pathutil::normalize(&if dir.is_empty() { + spec_slash.clone() + } else { + format!("{dir}/{spec_slash}") + }); + let mut target = if quote == b'"' + && !local.starts_with("../") + && !local.starts_with('/') + && context.file_exists(&local) + { + Some(local) + } else { + resolve_import_path(spec, file, language, context) + }; + if target.is_none() { + let by_basename = self.by_basename.get_or_insert_with(|| { + let mut map: HashMap> = HashMap::new(); + for indexed in context.get_all_files() { + let base = indexed.rsplit('/').next().unwrap_or(&indexed).to_string(); + map.entry(base).or_default().push(indexed); + } + map + }); + let base = spec_slash.rsplit('/').next().unwrap_or(&spec_slash); + let matches: Vec<&String> = by_basename + .get(base) + .map(|files| { + files + .iter() + .filter(|f| **f == spec_slash || f.ends_with(&format!("/{spec_slash}"))) + .collect() + }) + .unwrap_or_default(); + if let [only] = matches.as_slice() { + target = Some((*only).clone()); + } + } + self.includes.insert(key, target.clone()); + target + } + + fn walk_translation_unit( + &mut self, + root: &str, + language: Language, + context: &dyn ResolutionContext, + ) -> Timeline { + let mut walk = Walk::default(); + if let Some(frame) = walk.enter(root, Some(true), None, self, context) { + walk.stack.push(frame); + } + while let Some(frame) = walk.stack.last_mut() { + let Some(event) = frame.events.get(frame.next).cloned() else { + let done = walk.stack.pop().expect("a frame to pop"); + walk.scanning.remove(&done.file); + continue; + }; + frame.next += 1; + let (file, active, include_line) = + (frame.file.clone(), frame.active, frame.include_line); + let at = |line: usize| include_line.unwrap_or(line); + match event { + FileEvent::Branch { + op, + expression, + guard, + } => { + let known = walk + .definitions + .get(expression.trim()) + .and_then(|d| d.defined); + let condition = walk.condition(&expression); + let frame = walk.stack.last_mut().expect("current frame"); + match op { + BranchOp::If | BranchOp::Ifdef | BranchOp::Ifndef => { + let mut selected = match op { + BranchOp::If => condition, + BranchOp::Ifndef => not(known), + _ => known, + }; + if selected.is_none() && guard { + selected = Some(true); + } + frame.branches.push((frame.active, selected)); + frame.active = and(frame.active, selected); + } + BranchOp::Endif => { + frame.active = frame + .branches + .pop() + .map_or(frame.inherited, |(parent, _)| parent); + } + BranchOp::Elif | BranchOp::Else => { + let test = if op == BranchOp::Else { + Some(true) + } else { + condition + }; + if let Some((parent, taken)) = frame.branches.last_mut() { + frame.active = and(*parent, and(not(*taken), test)); + *taken = or(*taken, test); + } + } + } + } + _ if active == Some(false) => {} + FileEvent::Once => { + let prior = walk.once.get(&file).copied().unwrap_or(Some(false)); + walk.once.insert(file, or(prior, active)); + } + FileEvent::Include { quote, spec, line } => { + if let Some(target) = + self.resolve_include(&file, quote, &spec, language, context) + && let Some(child) = + walk.enter(&target, active, Some(at(line)), self, context) + { + walk.stack.push(child); + } + } + FileEvent::Define { + name, + line, + function_like, + value, + wraps_itself, + } => { + let macro_now = function_like && !wraps_itself; + walk.record( + &name, + at(line), + active, + true, + macro_now, + function_like, + &value, + ); + } + FileEvent::Undef { name, line } => { + walk.record(&name, at(line), active, false, false, false, ""); + } + } + } + walk.timeline + } +} + +#[derive(Default, Clone, Copy)] +struct Definition { + defined: Truth, + value: Truth, + macro_: Truth, +} + +struct Frame { + file: String, + events: Arc>, + next: usize, + inherited: Truth, + active: Truth, + /// Open `#if` frames: `(active before it, some branch taken)`. + branches: Vec<(Truth, Truth)>, + /// The root file's line of the outermost `#include`, for nested events. + include_line: Option, +} + +#[derive(Default)] +struct Walk { + stack: Vec, + definitions: HashMap, + scanning: HashSet, + macro_names: HashSet, + once: HashMap, + timeline: Timeline, +} + +impl Walk { + fn enter( + &mut self, + file: &str, + inherited: Truth, + include_line: Option, + cache: &mut MacroVisibility, + context: &dyn ResolutionContext, + ) -> Option { + if inherited == Some(false) + || self.scanning.contains(file) + || self.once.get(file).copied() == Some(Some(true)) + { + return None; + } + self.scanning.insert(file.to_string()); + Some(Frame { + file: file.to_string(), + events: cache.summarize(file, context), + next: 0, + inherited, + active: inherited, + branches: Vec::new(), + include_line, + }) + } + + /// What an `#if` expression says, as far as the source decides it: + /// integer literals, `defined(NAME)` / `!defined NAME`, and a bare name's + /// known value. Anything else depends on the build. + fn condition(&self, expression: &str) -> Truth { + let text = expression.trim(); + if let Some(literal) = INTEGER_LITERAL.captures(text) { + let digits = literal.get(1).map_or(&literal[2], |hex| hex.as_str()); + return Some(digits.bytes().any(|digit| digit != b'0')); + } + if let Some(defined) = DEFINED_TEST.captures(text) { + let name = defined + .get(2) + .or_else(|| defined.get(3)) + .map_or("", |m| m.as_str()); + let known = self.definitions.get(name).and_then(|d| d.defined); + return if defined.get(1).is_some() { + not(known) + } else { + known + }; + } + if is_word(text) { + return self.definitions.get(text).and_then(|d| d.value); + } + None + } + + #[allow(clippy::too_many_arguments)] + fn record( + &mut self, + name: &str, + line: usize, + active: Truth, + defining: bool, + macro_now: bool, + function_like: bool, + value: &str, + ) { + let prior = self.definitions.get(name).copied(); + let macro_ = (active == Some(true) || prior.and_then(|p| p.macro_) == Some(macro_now)) + .then_some(macro_now); + // A name no directive has touched is unknown, not undefined: the build + // can set it on the command line. So an `#undef` under an undecidable + // `#if` leaves it unknown (#2069); only a certain one clears it. + let prior_defined = prior.and_then(|p| p.defined); + let defined = if defining { + or(prior_defined, active) + } else { + and(prior_defined, not(active)) + }; + let value = if defining && active == Some(true) { + self.condition(value) + } else { + None + }; + self.definitions.insert( + name.to_string(), + Definition { + defined, + value, + macro_, + }, + ); + if function_like { + self.macro_names.insert(name.to_string()); + } + if self.macro_names.contains(name) { + self.timeline + .entry(name.to_string()) + .or_default() + .push((line, macro_)); + } + } +} + +static BRANCH: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*#\s*(ifdef|ifndef|if|elif|else|endif)\b(.*)$") + .expect("branch directive regex is valid") +}); +static DEFINE_OR_UNDEF: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*#\s*(define|undef)\s+([A-Za-z0-9_]+)(\(?)") + .expect("define directive regex is valid") +}); +static INCLUDE: LazyLock = LazyLock::new(|| { + Regex::new(r#"^\s*#\s*include\s*([<"])([^>"]+)[>"]"#).expect("include regex is valid") +}); +static PRAGMA_ONCE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*#\s*pragma\s+once\b").expect("pragma once regex is valid")); +static INTEGER_LITERAL: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^(?:0x([0-9a-f]+)|([0-9]+))[ul]*$").expect("integer literal regex is valid") +}); +static DEFINED_TEST: LazyLock = LazyLock::new(|| { + Regex::new(r"^(!)?\s*defined\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)|([A-Za-z0-9_]+))$") + .expect("defined test regex is valid") +}); +static NOT_DEFINED_GUARD: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*!\s*defined\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)|([A-Za-z0-9_]+))\s*$") + .expect("guard test regex is valid") +}); + +/// The include-guard idiom: `#ifndef X_H` (or `#if !defined(X_H)`) whose next +/// directive is `#define X_H`. Nothing defines the guard before the test, so +/// this is the first inclusion and the guarded body is active. A fallback +/// function-like macro (`#ifndef MIN` / `#define MIN(a, b) …`) reads the same +/// way. A default VALUE (`#ifndef ENABLE_X` / `#define ENABLE_X 0`) does not: +/// that is the flag a build overrides on the command line. +fn guards_itself(lines: &[String], index: usize, op: BranchOp, expression: &str) -> bool { + let name = match op { + BranchOp::Ifndef => expression.trim().to_string(), + BranchOp::If => NOT_DEFINED_GUARD + .captures(expression) + .and_then(|c| c.get(1).or_else(|| c.get(2))) + .map_or(String::new(), |m| m.as_str().to_string()), + _ => return false, + }; + if !is_word(&name) { + return false; + } + let Some(next) = lines[index + 1..] + .iter() + .find(|text| text.trim_start().starts_with('#')) + else { + return false; + }; + let Some(captures) = DEFINE_OR_UNDEF.captures(next) else { + return false; + }; + if &captures[1] != "define" || captures[2] != *name { + return false; + } + let rest = &next[captures.get(2).map_or(0, |m| m.end())..]; + rest.starts_with('(') || rest.trim().is_empty() +} + +/// Does the body of the `#define NAME(` at `index` (continuation lines +/// included) call `NAME`, directly or as `(NAME)(…)`? +fn calls_itself(lines: &[String], index: usize, name: &str) -> bool { + let mut text = lines[index].clone(); + let mut j = index; + while lines[j].trim_end().ends_with('\\') && j + 1 < lines.len() { + text.push(' '); + text.push_str(&lines[j + 1]); + j += 1; + } + let Some(open) = text.find('(') else { + return false; + }; + let body = &text[open + 1..]; + let bytes = body.as_bytes(); + let mut from = 0; + while let Some(found) = body[from..].find(name) { + let start = from + found; + let end = start + name.len(); + from = start + 1; + if start > 0 && is_word_byte(bytes[start - 1]) { + continue; + } + if bytes.get(end).is_some_and(|b| is_word_byte(*b)) { + continue; + } + let after = body[end..].trim_start(); + if after.starts_with('(') { + return true; + } + // `(NAME)(...)` + let before = body[..start].trim_end(); + if before.ends_with('(') + && let Some(close) = after.strip_prefix(')') + && close.trim_start().starts_with('(') + { + return true; + } + } + false +} + +/// The file's lines with comments removed only as far as the preprocessor +/// needs: a line inside a block comment is blank, a directive line loses its +/// trailing `//` / `/* … */`, and every other line is kept verbatim (its +/// content is never read, only whether a block comment opens on it). Raw +/// string bodies are masked first, so a `#define` inside one is not a +/// directive. +fn directive_lines(source: &str) -> Vec { + let masked = mask_cpp_raw_strings(source); + let mut out = Vec::new(); + let mut in_block = false; + for raw in masked.split('\n') { + let raw = raw.strip_suffix('\r').unwrap_or(raw); + let mut text = raw.to_string(); + if in_block { + let Some(end) = text.find("*/") else { + out.push(String::new()); + continue; + }; + text = text[end + 2..].to_string(); + in_block = false; + } + let directive = text.trim_start().starts_with('#'); + let mut kept: Option = None; + let mut quote: Option = None; + let mut i = 0; + while i < text.len() { + let byte = text.as_bytes()[i]; + if let Some(q) = quote { + if byte == b'\\' { + i += 1; + } else if byte == q { + quote = None; + } + i += 1; + continue; + } + if byte == b'"' || byte == b'\'' { + quote = Some(byte); + i += 1; + continue; + } + if byte == b'/' && text.as_bytes().get(i + 1) == Some(&b'/') { + kept = Some(text[..i].to_string()); + break; + } + if byte == b'/' && text.as_bytes().get(i + 1) == Some(&b'*') { + match text[i + 2..].find("*/") { + None => { + in_block = true; + kept = Some(text[..i].to_string()); + break; + } + Some(end) => { + text = format!("{} {}", &text[..i], &text[i + 2 + end + 2..]); + continue; + } + } + } + i += 1; + } + out.push(if directive { + kept.filter(|k| !k.is_empty()).unwrap_or(text) + } else { + raw.to_string() + }); + } + out +} + +/// Blank the contents of C++ raw string literals (`R"tag(...)tag"`, with an +/// optional `u8`/`L`/`u`/`U` prefix), keeping line breaks, after skipping +/// comments and ordinary literals (upstream `maskCppRawStrings`). A char +/// literal must not follow a word character, so a digit separator (`1'000`) +/// opens none. +fn mask_cpp_raw_strings(source: &str) -> String { + if !source.contains("R\"") { + return source.to_string(); + } + let bytes = source.as_bytes(); + let mut out = bytes.to_vec(); + let mut i = 0; + while i < bytes.len() { + let boundary = i == 0 || !is_word_byte(bytes[i - 1]); + if boundary && let Some(open) = raw_string_open(bytes, i) { + let (delimiter_start, paren) = open; + let mut closer = Vec::with_capacity(paren - delimiter_start + 2); + closer.push(b')'); + closer.extend_from_slice(&bytes[delimiter_start..paren]); + closer.push(b'"'); + let mut end = bytes.len(); + let mut j = paren + 1; + while j + closer.len() <= bytes.len() { + if bytes[j..j + closer.len()] == closer[..] { + end = j + closer.len(); + break; + } + j += 1; + } + for byte in &mut out[i..end] { + if *byte != b'\n' && *byte != b'\r' { + *byte = b' '; + } + } + i = end; + continue; + } + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' { + i += 1; + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(bytes.len()); + } + quote @ b'"' => i = skip_literal(bytes, i, quote), + quote @ b'\'' if boundary => i = skip_literal(bytes, i, quote), + _ => i += 1, + } + } + String::from_utf8(out).unwrap_or_else(|_| source.to_string()) +} + +/// `(delimiter start, opening paren)` of a raw string literal starting at `i`. +fn raw_string_open(bytes: &[u8], i: usize) -> Option<(usize, usize)> { + let rest = &bytes[i..]; + let prefix = [&b"u8R\""[..], b"LR\"", b"uR\"", b"UR\"", b"R\""] + .into_iter() + .find(|prefix| rest.starts_with(prefix))?; + let delimiter_start = i + prefix.len(); + let mut j = delimiter_start; + while j < bytes.len() && j - delimiter_start <= 16 { + match bytes[j] { + b'(' => return Some((delimiter_start, j)), + b' ' | b'\t' | b'\x0b' | b'\x0c' | b'\r' | b'\n' | b')' | b'\\' => return None, + _ => j += 1, + } + } + None +} + +/// Past an ordinary string or char literal, which may run over line breaks as +/// upstream's pattern does. +fn skip_literal(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut i = start + 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + continue; + } + if bytes[i] == quote { + return i + 1; + } + i += 1; + } + bytes.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directive_lines_drop_comments_but_keep_directive_text() { + let lines = directive_lines( + "#define A(x) x // trailing\n/* open\n#define HIDDEN(x) x\n*/ #define AFTER(x) x\nconst char *s = \"/* not a comment\";\n#define B(x) x /* inline */ + 1\n", + ); + assert_eq!(lines[0], "#define A(x) x "); + assert_eq!(lines[2], ""); + assert_eq!(lines[5], "#define B(x) x + 1"); + } + + #[test] + fn raw_string_bodies_are_masked_and_digit_separators_open_nothing() { + let masked = mask_cpp_raw_strings( + "int n = 1'000;\nconst char *r = R\"doc(\n#define helper(x) x\n)doc\";\n#define REAL(x) x\n", + ); + assert!(!masked.contains("helper")); + assert!(masked.contains("#define REAL(x) x")); + assert_eq!(masked.lines().count(), 5); + } + + #[test] + fn a_wrapper_macro_calling_its_own_name_is_recognized() { + let lines: Vec = [ + "#define vec_splice(v, start, count)\\", + " ( vec_splice((char **)(v), start, count),\\", + " (v)->length -= (count) )", + ] + .map(str::to_string) + .to_vec(); + assert!(calls_itself(&lines, 0, "vec_splice")); + let plain = vec!["#define helper(x) ((x) + 1)".to_string()]; + assert!(!calls_itself(&plain, 0, "helper")); + let parenthesized = vec!["#define wrap(x) (wrap)(x)".to_string()]; + assert!(calls_itself(&parenthesized, 0, "wrap")); + } + + #[test] + fn guards_are_the_ifndef_define_idiom_and_not_a_default_value() { + let guard = ["#ifndef X_H", "#define X_H"].map(str::to_string).to_vec(); + assert!(guards_itself(&guard, 0, BranchOp::Ifndef, " X_H")); + let fallback = ["#if !defined(MIN)", "#define MIN(a, b) a"] + .map(str::to_string) + .to_vec(); + assert!(guards_itself(&fallback, 0, BranchOp::If, " !defined(MIN)")); + let default = ["#ifndef ENABLE_X", "#define ENABLE_X 0"] + .map(str::to_string) + .to_vec(); + assert!(!guards_itself(&default, 0, BranchOp::Ifndef, " ENABLE_X")); + } +} diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index c35cc65..800b830 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -18,6 +18,7 @@ mod alias_binding; mod awaited; +mod c_macro_visibility; pub mod context; pub mod framework; // the FrameworkResolver extension point pub mod frameworks; // concrete react/vue/nestjs FrameworkResolvers diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 306ecd0..98aaa69 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -939,14 +939,24 @@ pub fn match_by_exact_name( { return Some(action); } - let reachable: Vec> = apply_language_gate( - context.get_nodes_by_name_shared(&reference.reference_name), - reference, - ) - .into_iter() - // Nested locals are only reachable from inside their container (#1230). - .filter(|n| is_lexically_reachable(n, reference, context)) - .collect(); + let same_name = context.get_nodes_by_name_shared(&reference.reference_name); + // `NAME(...)` where NAME is a function-like macro somewhere in the project + // is an expansion or a call to a same-named function — never the macro + // itself (#1839), and never a type that happens to share the name (#2070: + // expat's `PREFIX(scanRef)(…)` bound an unrelated `struct PREFIX`). Neither + // is a candidate at all, so neither makes the reference look ambiguous or + // counts toward the same-name ceiling. + let macro_call = reference.reference_kind == EdgeKind::Calls + && matches!(reference.language, Language::C | Language::Cpp) + && same_name + .iter() + .any(|n| crate::c_macro_visibility::is_define_constant(n)); + let reachable: Vec> = apply_language_gate(same_name, reference) + .into_iter() + .filter(|n| !macro_call || matches!(n.kind, NodeKind::Function | NodeKind::Method)) + // Nested locals are only reachable from inside their container (#1230). + .filter(|n| is_lexically_reachable(n, reference, context)) + .collect(); // A same-named non-type is not a supertype, and a type member is not // importable (#1537/#1536). Filtering BEFORE ranking — not just refusing the diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 57eed40..3b93dd2 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -11,8 +11,8 @@ use crate::alias_binding::resolve_alias_binding; use crate::framework::FrameworkResolver; use crate::import_resolver::{ PhpImportedStaticCallResolution, is_bound_to_out_of_repo_module, is_php_include_path_ref, - python_module_member_is_claimed, resolve_import_path, resolve_jvm_import, - resolve_php_imported_static_call, resolve_via_import, + python_module_member_is_claimed, resolve_jvm_import, resolve_php_imported_static_call, + resolve_via_import, }; use crate::name_matcher::{ crosses_code_boundary, gate_language_match, is_js_name_target_visible, @@ -27,12 +27,12 @@ use crate::types::{ RefView, ResolutionContext, ResolutionResult, ResolutionStats, ResolvedBy, ResolvedRef, }; use codegraph_core::types::{Edge, EdgeKind, Language, Node, NodeKind, UnresolvedRef}; -use codegraph_extract::lang::{cpp_constructor_arity_range, parse_cpp_constructor_reference_name}; +use codegraph_extract::lang::{cpp_constructor_shape, parse_cpp_constructor_reference_name}; use codegraph_store::Store; use codegraph_store::queries::ReferenceSite; use rayon::prelude::*; use regex::Regex; -use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, OnceLock}; /// Read-only deferred-pass intent returned by [`ReferenceResolver::resolve_one_pure`] @@ -123,6 +123,20 @@ fn has_chain_shape(name: &str) -> bool { && method.chars().all(|c| c.is_alphanumeric() || c == '_') } +/// Whether a constructor signature names `initializer_list` as a whole word. +fn mentions_initializer_list(signature: &str) -> bool { + let bytes = signature.as_bytes(); + signature + .match_indices("initializer_list") + .any(|(start, word)| { + let end = start + word.len(); + (start == 0 || !(bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_')) + && bytes + .get(end) + .is_none_or(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'_')) + }) +} + fn cpp_type_spelling_matches(spelled: &str, qualified: &str) -> bool { if spelled.contains("::") { qualified == spelled @@ -846,245 +860,6 @@ fn cpp_built_ins() -> &'static BTreeSet<&'static str> { }) } -fn c_family_language_for_path(path: &str) -> Option { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".c") { - Some(Language::C) - } else if [ - ".h", ".hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".cc", ".cpp", ".cxx", ".c++", ".m", - ".mm", - ] - .iter() - .any(|extension| lower.ends_with(extension)) - { - Some(Language::Cpp) - } else { - None - } -} - -/// Build `file -> function-like macro names visible from that file` through -/// project-local include edges. Macro names propagate backwards from a header -/// to every transitive includer; unrelated headers never suppress a real call. -fn build_c_family_visible_macros( - context: &dyn ResolutionContext, -) -> BTreeMap> { - let mut files = context.get_all_files(); - files.sort(); - let mut visible = BTreeMap::>::new(); - let mut reverse_includes = BTreeMap::>::new(); - - for file in files { - let Some(language) = c_family_language_for_path(&file) else { - continue; - }; - let Some(source) = context.read_file(&file) else { - continue; - }; - visible.insert(file.clone(), function_like_macro_names(&source)); - - for import in context.get_import_mappings(&file, language) { - let from_dir = crate::pathutil::dirname(&file); - let sibling = crate::pathutil::normalize(&if from_dir.is_empty() { - import.source.clone() - } else { - format!("{from_dir}/{}", import.source) - }); - let included = if context.file_exists(&sibling) { - Some(sibling) - } else { - resolve_import_path(&import.source, &file, language, context) - }; - if let Some(included) = included { - reverse_includes - .entry(included) - .or_default() - .insert(file.clone()); - } - } - } - - // Monotone work queue: when a file learns a macro, propagate the complete - // set to its includers. Cycles terminate because every set only grows. - let mut queue: VecDeque = visible - .iter() - .filter(|(_, names)| !names.is_empty()) - .map(|(file, _)| file.clone()) - .collect(); - while let Some(included) = queue.pop_front() { - let inherited = visible.get(&included).cloned().unwrap_or_default(); - let parents = reverse_includes.get(&included).cloned().unwrap_or_default(); - for parent in parents { - let names = visible.entry(parent.clone()).or_default(); - let before = names.len(); - names.extend(inherited.iter().cloned()); - if names.len() != before { - queue.push_back(parent); - } - } - } - visible -} - -/// Collect real preprocessor function macros (`#define NAME(`) while ignoring -/// comments, quoted literals, C++ raw strings, and continuation lines. The C -/// standard requires `(` to immediately follow the name for a function-like -/// macro, so `#define NAME (value)` is correctly not admitted. -fn function_like_macro_names(source: &str) -> BTreeSet { - let bytes = source.as_bytes(); - let mut names = BTreeSet::new(); - let mut i = 0usize; - let mut directive_allowed = true; - - while i < bytes.len() { - match bytes[i] { - b'\n' => { - directive_allowed = true; - i += 1; - } - b' ' | b'\t' | b'\r' if directive_allowed => i += 1, - b'/' if bytes.get(i + 1) == Some(&b'/') => { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - } - b'/' if bytes.get(i + 1) == Some(&b'*') => { - i += 2; - while i < bytes.len() { - if bytes[i] == b'\n' { - directive_allowed = true; - } - if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') { - i += 2; - break; - } - i += 1; - } - } - b'#' if directive_allowed => { - if let Some(name) = function_macro_name_on_directive(&bytes[i..]) { - names.insert(name.to_string()); - } - i = c_logical_directive_end(bytes, i); - directive_allowed = true; - } - b'"' if i > 0 && bytes[i - 1] == b'R' => { - directive_allowed = false; - i = skip_cpp_raw_string(bytes, i); - } - quote @ (b'"' | b'\'') => { - directive_allowed = false; - i = skip_c_quoted_literal(bytes, i, quote); - } - _ => { - directive_allowed = false; - i += 1; - } - } - } - names -} - -fn function_macro_name_on_directive(bytes: &[u8]) -> Option<&str> { - let line_end = bytes - .iter() - .position(|byte| *byte == b'\n') - .unwrap_or(bytes.len()); - let line = &bytes[..line_end]; - let mut i = 1usize; // leading '#' - while matches!(line.get(i), Some(b' ' | b'\t')) { - i += 1; - } - let keyword = b"define"; - if line.get(i..i + keyword.len())? != keyword { - return None; - } - i += keyword.len(); - if !matches!(line.get(i), Some(b' ' | b'\t')) { - return None; - } - while matches!(line.get(i), Some(b' ' | b'\t')) { - i += 1; - } - let start = i; - if !line - .get(i) - .is_some_and(|byte| *byte == b'_' || byte.is_ascii_alphabetic()) - { - return None; - } - i += 1; - while line - .get(i) - .is_some_and(|byte| *byte == b'_' || byte.is_ascii_alphanumeric()) - { - i += 1; - } - if line.get(i) != Some(&b'(') { - return None; - } - std::str::from_utf8(&line[start..i]).ok() -} - -fn c_logical_directive_end(bytes: &[u8], start: usize) -> usize { - let mut i = start; - loop { - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - let mut back = i; - if back > start && bytes[back - 1] == b'\r' { - back -= 1; - } - let continued = back > start && bytes[back - 1] == b'\\'; - if i < bytes.len() { - i += 1; - } - if !continued || i >= bytes.len() { - return i; - } - } -} - -fn skip_c_quoted_literal(bytes: &[u8], start: usize, quote: u8) -> usize { - let mut i = start + 1; - while i < bytes.len() { - match bytes[i] { - b'\\' => i = (i + 2).min(bytes.len()), - b'\n' => return i, - byte if byte == quote => return i + 1, - _ => i += 1, - } - } - i -} - -fn skip_cpp_raw_string(bytes: &[u8], quote: usize) -> usize { - let mut delimiter_end = quote + 1; - while delimiter_end < bytes.len() - && bytes[delimiter_end] != b'(' - && delimiter_end - quote - 1 < 16 - && !bytes[delimiter_end].is_ascii_whitespace() - { - delimiter_end += 1; - } - if delimiter_end >= bytes.len() || bytes[delimiter_end] != b'(' { - return skip_c_quoted_literal(bytes, quote, b'"'); - } - let mut closer = Vec::with_capacity(delimiter_end - quote + 1); - closer.push(b')'); - closer.extend_from_slice(&bytes[quote + 1..delimiter_end]); - closer.push(b'"'); - let mut i = delimiter_end + 1; - while i + closer.len() <= bytes.len() { - if bytes[i..i + closer.len()] == closer { - return i + closer.len(); - } - i += 1; - } - bytes.len() -} - fn c_family_static_function_is_file_local( target: &Node, reference: &RefView, @@ -1293,13 +1068,12 @@ pub struct ReferenceResolver { /// Distinct symbol names known to the graph, for the fast pre-filter /// (`knownNames`, `index.ts:224`). Populated by `warm_caches`. known_names: Option>, - /// Function-like macro names visible from each C/C++ file through its - /// project-local include closure. Built once with the other read-only - /// resolution caches, then shared by the parallel resolver. - c_family_visible_macros: BTreeMap>, - /// Constructor node id -> accepted positional-argument range. Built once - /// from the narrow C++ constructor signatures stored by extraction. - cpp_constructor_arities: BTreeMap, + /// Per-file directive summaries and per-root macro timelines for C/C++ + /// translation-unit macro visibility (#1838), filled on demand by the + /// parallel resolver and cleared with the other resolution caches. Every + /// entry is a pure function of the files, so evaluation order cannot reach + /// the output. + macro_visibility: std::sync::Mutex, /// `this.` fn-refs whose member wasn't on the enclosing class /// itself — retried in the supertype pass once implements/extends edges /// exist (`deferredThisMemberRefs`, index.ts:214 / #808). A `Mutex` (not @@ -1324,8 +1098,7 @@ impl ReferenceResolver { max_file_size: codegraph_core::config::DEFAULT_MAX_FILE_SIZE, framework_resolver_extensions: Vec::new(), known_names: None, - c_family_visible_macros: BTreeMap::new(), - cpp_constructor_arities: BTreeMap::new(), + macro_visibility: std::sync::Mutex::default(), deferred_this_member_refs: std::sync::Mutex::new(Vec::new()), deferred_chain_refs: std::sync::Mutex::new(Vec::new()), } @@ -1494,16 +1267,10 @@ impl ReferenceResolver { pub fn warm_caches(&mut self, context: &dyn ResolutionContext) { let names: BTreeSet = context.known_node_names().into_iter().collect(); self.known_names = Some(names); - self.c_family_visible_macros = build_c_family_visible_macros(context); - self.cpp_constructor_arities = context - .get_nodes_by_kind_shared(NodeKind::Method) - .into_iter() - .filter(|node| node.language == Language::Cpp) - .filter_map(|node| { - let range = cpp_constructor_arity_range(node.signature.as_deref()?)?; - Some((node.id.clone(), range)) - }) - .collect(); + *self + .macro_visibility + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Default::default(); } /// Resolve all unresolved references (`resolveAll`, `index.ts:511-572`). @@ -1600,7 +1367,7 @@ impl ReferenceResolver { let (resolved, deferred) = self.resolve_one_pure_inner(reference, context); let resolved = self.gate_target_kind(resolved, reference, context); let resolved = self.gate_import_locality(resolved, reference, context); - let resolved = self.gate_c_macro_calls(resolved, reference); + let resolved = self.gate_c_macro_calls(resolved, reference, context); let resolved = self.forward_alias_binding(resolved, reference, context); // Every chosen result — including an alias-forwarded one — obeys the // code-family boundary (upstream v1.6.1 `resolveOne`); framework @@ -1616,27 +1383,30 @@ impl ReferenceResolver { (resolved, deferred) } - /// C/C++ preprocessor binding gate: a call spelled like a function-like - /// macro visible from the calling file is an expansion, not a call. (A - /// receiver-less Go call is kept off methods inside the name matchers, - /// #1857.) + /// C/C++ preprocessor binding gate (upstream #1838). A call spelled like + /// a function-like macro definitely visible at the call site, or a name + /// only macros bear, is an expansion, not a call; and a `#define` is a + /// value, never a callee, whichever strategy produced it. (A receiver-less + /// Go call is kept off methods inside the name matchers, #1857.) fn gate_c_macro_calls( &self, result: Option, reference: &RefView, + context: &dyn ResolutionContext, ) -> Option { let result = result?; - - if matches!(reference.language, Language::C | Language::Cpp) - && reference.reference_kind == EdgeKind::Calls - && self - .c_family_visible_macros - .get(&reference.file_path) - .is_some_and(|names| names.contains(&reference.reference_name)) + if reference.reference_kind != EdgeKind::Calls { + return Some(result); + } + if context + .get_node_by_id_shared(&result.target_node_id) + .is_some_and(|target| crate::c_macro_visibility::is_define_constant(&target)) { return None; } - + if crate::c_macro_visibility::is_visible_macro(&self.macro_visibility, reference, context) { + return None; + } Some(result) } @@ -1995,24 +1765,65 @@ impl ReferenceResolver { .collect::>(); let owner = unique_cpp_type_owner(owners, reference, context)?; let constructor_name = format!("{}::{base_name}", owner.qualified_name); - let candidates = context + let constructors = context .get_nodes_by_name_shared(base_name) .into_iter() .filter(|candidate| { candidate.language == Language::Cpp && candidate.kind == NodeKind::Method && candidate.qualified_name == constructor_name - && self.cpp_constructor_arities.get(&candidate.id).is_some_and( - |(minimum, maximum)| *minimum <= call_arity && call_arity <= *maximum, - ) }) .collect::>(); - if candidates.len() != 1 { + // Brace-init prefers an initializer_list overload over arity, a choice + // that needs the argument types, so decline (upstream #1839). + if constructors.iter().any(|candidate| { + candidate + .signature + .as_deref() + .is_some_and(mentions_initializer_list) + }) { return None; } + // A prototype and its out-of-line definition describe one overload: + // merge their admissible counts, so a default declared on the + // prototype reaches the definition, then prefer the executable + // definition. A signature that cannot be read declines the whole set. + let mut overloads: BTreeMap>, usize, usize)> = BTreeMap::new(); + for candidate in constructors { + let shape = cpp_constructor_shape(candidate.signature.as_deref()?)?; + let entry = overloads + .entry(shape.key) + .or_insert_with(|| (Vec::new(), shape.min, shape.max)); + entry.1 = entry.1.min(shape.min); + entry.0.push(candidate); + } + let admitting = overloads + .into_values() + .filter(|(_, minimum, maximum)| *minimum <= call_arity && call_arity <= *maximum) + .collect::>(); + let [(nodes, _, _)] = admitting.as_slice() else { + return None; + }; + let definitions = nodes + .iter() + .filter(|node| { + !node + .signature + .as_deref() + .is_some_and(|signature| signature.trim_end().ends_with(';')) + }) + .collect::>(); + let targets = if definitions.is_empty() { + nodes.iter().collect() + } else { + definitions + }; + let [target] = targets.as_slice() else { + return None; + }; Some(ResolvedRef { original: reference.clone(), - target_node_id: candidates[0].id.clone(), + target_node_id: target.id.clone(), confidence: 0.98, resolved_by: ResolvedBy::QualifiedName, }) diff --git a/crates/codegraph-resolve/tests/c_macro_visibility.rs b/crates/codegraph-resolve/tests/c_macro_visibility.rs new file mode 100644 index 0000000..8825968 --- /dev/null +++ b/crates/codegraph-resolve/tests/c_macro_visibility.rs @@ -0,0 +1,599 @@ +//! C/C++ macro visibility in translation-unit order (upstream v1.6.1 #1838, +//! #2069, #2070): a call spelled like a function-like macro that is definitely +//! visible at the call site, or that only macros bear, is an expansion and +//! binds nothing; a macro that exists in one build configuration only, sits +//! below the call, or wraps its own name hides nothing; and a macro-named call +//! never binds to a same-named type. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{Edge, EdgeKind, FileRecord, Language, Node, NodeKind}; +use codegraph_extract::{detect_language, extract_file}; +use codegraph_resolve::{ReferenceResolver, StoreResolutionContext}; +use codegraph_store::Store; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + +struct ResolvedGraph { + nodes: Vec, + edges: Vec, +} + +impl ResolvedGraph { + fn function(&self, name: &str) -> &Node { + self.nodes + .iter() + .find(|node| node.kind == NodeKind::Function && node.name == name) + .unwrap_or_else(|| panic!("no function {name}; nodes={:#?}", self.nodes)) + } + + fn label(&self, id: &str) -> String { + let node = self + .nodes + .iter() + .find(|node| node.id == id) + .unwrap_or_else(|| panic!("missing node {id}")); + format!( + "{} {} ({})", + node.kind.as_str(), + node.qualified_name, + node.file_path + ) + } + + /// `calls` callees of a function, as `kind qualified_name (file)`. + fn calls(&self, caller: &str) -> Vec { + let caller = self.function(caller); + let mut out: Vec = self + .edges + .iter() + .filter(|edge| edge.source == caller.id && edge.kind == EdgeKind::Calls) + .map(|edge| self.label(&edge.target)) + .collect(); + out.sort(); + out + } + + /// Every outgoing edge of a function, as `edge_kind kind qualified_name (file)`. + fn edges_of(&self, caller: &str) -> Vec { + let caller = self.function(caller); + let mut out: Vec = self + .edges + .iter() + .filter(|edge| edge.source == caller.id && edge.kind != EdgeKind::Contains) + .map(|edge| format!("{} {}", edge.kind.as_str(), self.label(&edge.target))) + .collect(); + out.sort(); + out + } + + fn callers_of(&self, callee: &Node) -> usize { + self.edges + .iter() + .filter(|edge| edge.target == callee.id && edge.kind == EdgeKind::Calls) + .count() + } +} + +fn temp_path(slug: &str) -> PathBuf { + let nonce = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codegraph-c-macro-{slug}-{}-{nonce}", + std::process::id() + )) +} + +fn collect_files(root: &Path, dir: &Path, output: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read fixture directory") { + let path = entry.expect("fixture entry").path(); + if path.is_dir() { + collect_files(root, &path, output); + } else { + output.push( + path.strip_prefix(root) + .expect("fixture path under root") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } +} + +fn resolve_project(slug: &str, files: &[(String, String)]) -> ResolvedGraph { + let root = temp_path(slug); + std::fs::create_dir_all(&root).expect("create fixture root"); + for (relative, source) in files { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent"); + } + std::fs::write(path, source).expect("write fixture source"); + } + let mut relative = Vec::new(); + collect_files(&root, &root, &mut relative); + relative.sort(); + let db = temp_path(&format!("{slug}-db")).with_extension("db"); + let mut store = Store::open(&db).expect("open fixture store"); + for path in &relative { + let result = extract_file(&root, path).expect("extract fixture"); + let language = detect_language(path); + assert_ne!(language, Language::Unknown, "unknown fixture: {path}"); + store + .upsert_file(&FileRecord { + path: path.clone(), + content_hash: "fixture".to_string(), + language, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: result.errors, + generated: false, + }) + .expect("insert file"); + store.upsert_nodes(&result.nodes).expect("insert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert references"); + } + let root_string = root.to_string_lossy().to_string(); + let mut resolver = ReferenceResolver::new(&root_string); + { + let context = StoreResolutionContext::new(&store, &root_string); + resolver.initialize(&context); + } + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + let nodes = store.all_nodes().expect("read nodes"); + let edges = store.all_edges().expect("read edges"); + drop(store); + let _ = std::fs::remove_file(db); + let _ = std::fs::remove_dir_all(root); + ResolvedGraph { nodes, edges } +} + +fn files(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(path, source)| (path.to_string(), source.to_string())) + .collect() +} + +#[test] +fn a_macro_from_an_included_header_does_not_bind_to_the_decoy() { + let graph = resolve_project( + "reproduction", + &files(&[ + ("marker.hpp", "#define TRACE_POINT(value) ((void)(value))\n"), + ( + "exercise.cpp", + "#include \"marker.hpp\"\n\nvoid exercise() {\n TRACE_POINT(1);\n}\n", + ), + ("macro_decoy.cpp", "void TRACE_POINT(int value) {}\n"), + ]), + ); + assert_eq!(graph.calls("exercise"), Vec::::new()); + assert_eq!(graph.callers_of(graph.function("TRACE_POINT")), 0); +} + +#[test] +fn nested_includes_sibling_headers_local_defines_and_undef_decide_visibility() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "nested", + &[ + ( + "src/pg/pinio.h".to_string(), + "#define HEADER_TRACE(v) ((void)(v))\n".to_string(), + ), + ( + "src/drivers/pinio.h".to_string(), + "// unrelated header with the same basename\n".to_string(), + ), + ( + format!("src/pg/pinio.{language}"), + "#include \"pinio.h\"\nvoid sibling_header_use() { HEADER_TRACE(1); }\n" + .to_string(), + ), + ( + "inner.h".to_string(), + "#define TRACE_POINT(value) ((void)(value))\n".to_string(), + ), + ("outer.h".to_string(), "#include \"inner.h\"\n".to_string()), + ( + format!("exercise.{language}"), + [ + "#include \"outer.h\"", + "void macro_use() { TRACE_POINT(1); }", + "void local_macro() {", + "#define INNER_TRACE(v) ((void)(v))", + "INNER_TRACE(1);", + "}", + "#undef TRACE_POINT", + "void after_undef() { TRACE_POINT(1); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + [ + "void HEADER_TRACE(int value) {}", + "void INNER_TRACE(int value) {}", + "void TRACE_POINT(int value) {}", + "void unrelated_use() { TRACE_POINT(1); }", + "", + ] + .join("\n"), + ), + ], + ); + let real = vec![format!("function TRACE_POINT (decoy.{language})")]; + assert_eq!(graph.calls("macro_use"), Vec::::new(), "{language}"); + assert_eq!( + graph.calls("sibling_header_use"), + Vec::::new(), + "{language}" + ); + assert_eq!( + graph.calls("local_macro"), + Vec::::new(), + "{language}" + ); + // After `#undef`, and in a file that never sees the macro, the call is real. + assert_eq!(graph.calls("after_undef"), real, "{language}"); + assert_eq!(graph.calls("unrelated_use"), real, "{language}"); + } +} + +#[test] +fn unguarded_includes_replay_while_guards_once_and_changed_flags_are_respected() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "replay", + &[ + ("unguarded.h".to_string(), "#define TRACE(v) ((void)(v))\n".to_string()), + ( + "guarded.h".to_string(), + "#ifndef GUARDED_H\n#define GUARDED_H\n#define GUARDED(v) ((void)(v))\n#endif\n" + .to_string(), + ), + ("once.h".to_string(), "#pragma once\n#define ONCE(v) ((void)(v))\n".to_string()), + ( + "conditional.h".to_string(), + "#if ENABLE_TRACE\n#define CONDITIONAL(v) ((void)(v))\n#endif\n".to_string(), + ), + ( + format!("unit.{language}"), + [ + "#include \"unguarded.h\"", + "#undef TRACE", + "void between() { TRACE(1); }", + "#include \"unguarded.h\"", + "void repeated() { TRACE(1); }", + "#include \"guarded.h\"", + "#undef GUARDED", + "#include \"guarded.h\"", + "void guarded() { GUARDED(1); }", + "#undef GUARDED_H", + "#include \"guarded.h\"", + "void reset_guard() { GUARDED(1); }", + "#include \"once.h\"", + "#undef ONCE", + "#include \"once.h\"", + "void once() { ONCE(1); }", + "#define ENABLE_TRACE 0", + "#include \"conditional.h\"", + "#undef ENABLE_TRACE", + "#define ENABLE_TRACE 1", + "#include \"conditional.h\"", + "void changed_flag() { CONDITIONAL(1); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + "void TRACE(int x) {}\nvoid GUARDED(int x) {}\nvoid ONCE(int x) {}\nvoid CONDITIONAL(int x) {}\n" + .to_string(), + ), + ], + ); + let real = |name: &str| vec![format!("function {name} (decoy.{language})")]; + assert_eq!(graph.calls("between"), real("TRACE"), "{language}"); + assert_eq!(graph.calls("repeated"), Vec::::new(), "{language}"); + assert_eq!(graph.calls("guarded"), real("GUARDED"), "{language}"); + assert_eq!( + graph.calls("reset_guard"), + Vec::::new(), + "{language}" + ); + assert_eq!(graph.calls("once"), real("ONCE"), "{language}"); + assert_eq!( + graph.calls("changed_flag"), + Vec::::new(), + "{language}" + ); + } +} + +#[test] +fn a_diamond_cyclic_include_graph_finds_the_macro_on_an_unconditional_path() { + // top.h includes left.h and right.h; both include shared.h (no guard), which + // includes top.h again. The active include stack breaks the cycle while + // shared.h is replayed on the unconditional path. + let graph = resolve_project( + "diamond", + &files(&[ + ("top.h", "#include \"left.h\"\n#include \"right.h\"\n"), + ("left.h", "#ifdef USE_LEFT\n#include \"shared.h\"\n#endif\n"), + ("right.h", "#include \"shared.h\"\n"), + ( + "shared.h", + "#include \"top.h\"\n#define SHARED_TRACE(v) ((void)(v))\n", + ), + ( + "unit.cpp", + "#include \"top.h\"\nvoid unit() { SHARED_TRACE(1); }\n", + ), + ("decoy.cpp", "void SHARED_TRACE(int value) {}\n"), + ]), + ); + assert_eq!(graph.calls("unit"), Vec::::new()); +} + +#[test] +fn a_define_in_a_comment_string_or_raw_literal_is_no_macro() { + let graph = resolve_project( + "masked", + &files(&[ + ( + "doc.hpp", + "/*\n * Example:\n * #define helper(x) ((x) + 1)\n */\nconst char *usage = \"/* #define helper(x) */\";\nconst char *raw = R\"doc(\n#define helper(x) ((x) + 9)\n)doc\";\n", + ), + ( + "lib.cpp", + "#include \"doc.hpp\"\nint helper(int x) { return x + 1; }\nint run() { return helper(1); }\n", + ), + ("other.cpp", "#define helper(x) ((x) + 2)\n"), + ]), + ); + assert_eq!(graph.calls("run"), vec!["function helper (lib.cpp)"]); +} + +#[test] +fn a_wrapper_macro_calling_its_own_name_keeps_the_call() { + let graph = resolve_project( + "wrapper", + &files(&[( + "vec.c", + "static void vec_splice(char **data, int start, int count) {}\n\ + #define vec_splice(v, start, count)\\\n\ + \x20 ( vec_splice((char **)(v), start, count),\\\n\ + \x20 (v)->length -= (count) )\n\ + struct buf { char *data; int length; };\n\ + void flush(struct buf *b) { vec_splice(b, 0, 1); }\n", + )]), + ); + assert_eq!(graph.calls("flush"), vec!["function vec_splice (vec.c)"]); +} + +#[test] +fn a_macro_only_in_an_unrelated_file_neither_suppresses_nor_receives_a_call() { + let graph = resolve_project( + "unrelated", + &files(&[ + ("unrelated.hpp", "#define helper(x) ((x) + 1)\n"), + ("lib.cpp", "int helper(int x) { return x + 1; }\n"), + ( + "main.cpp", + "int helper(int x);\nint run() { return helper(1); }\n", + ), + ]), + ); + assert_eq!(graph.calls("run"), vec!["function helper (lib.cpp)"]); +} + +#[test] +fn a_call_above_the_define_or_under_a_false_branch_is_still_a_call() { + let graph = resolve_project( + "ordered", + &files(&[ + ( + "maths.h", + "#define FAST_MATH\n#if defined(FAST_MATH)\nfloat sin_approx(float x);\n#else\n#define sin_approx(x) external_sin(x)\n#endif\n", + ), + ( + "maths.cpp", + "#include \"maths.h\"\nfloat sin_approx(float x) { return x; }\nvoid real_fn(int value) {}\nfloat invoke_real(float value) { real_fn(1); return sin_approx(value); }\n#define real_fn(x) ((void)(x))\n", + ), + ]), + ); + assert_eq!( + graph.calls("invoke_real"), + vec![ + "function real_fn (maths.cpp)", + "function sin_approx (maths.cpp)" + ] + ); +} + +#[test] +fn a_name_only_macros_bear_binds_nothing_even_by_a_loose_match() { + // The macro is not visible from `run` at all, but no function bears its + // exact name: a case-insensitive match (`SWAP` -> `swap`) must not invent one. + let graph = resolve_project( + "only-macros", + &files(&[ + ( + "swap.h", + "#define SWAP(a, b) do { int t = a; a = b; b = t; } while (0)\n", + ), + ("lib.c", "void swap(int *a, int *b) {}\n"), + ("main.c", "void run(int a, int b) { SWAP(a, b); }\n"), + ]), + ); + assert_eq!(graph.calls("run"), Vec::::new()); +} + +#[test] +fn an_undef_under_an_undecidable_if_leaves_a_never_seen_flag_unknown() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "undef-unknown", + &[ + ( + "config.h".to_string(), + "#if FREE_THREADED == 0\n#undef FREE_THREADED\n#endif\n".to_string(), + ), + ( + format!("unit.{language}"), + [ + "#include \"config.h\"", + "#ifdef FREE_THREADED", + "static int world_stopped(void) { return 0; }", + "#else", + "#define world_stopped() 1", + "#endif", + "int check(void) { return world_stopped(); }", + "", + ] + .join("\n"), + ), + ], + ); + assert_eq!( + graph.calls("check"), + vec![format!("function world_stopped (unit.{language})")], + "{language}" + ); + } +} + +#[test] +fn an_undef_on_a_certain_line_still_makes_the_flag_undefined() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "undef-certain", + &[ + ( + "config.h".to_string(), + "#undef TOP_LEVEL\n#if 1\n#undef TRUE_BRANCH\n#endif\n".to_string(), + ), + ( + format!("unit.{language}"), + [ + "#include \"config.h\"", + "#ifdef TOP_LEVEL", + "#else", + "#define top_hook() 1", + "#endif", + "#ifdef TRUE_BRANCH", + "#else", + "#define branch_hook() 1", + "#endif", + "int use_top(void) { return top_hook(); }", + "int use_branch(void) { return branch_hook(); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + "int top_hook(void) { return 0; }\nint branch_hook(void) { return 0; }\n" + .to_string(), + ), + ], + ); + assert_eq!(graph.calls("use_top"), Vec::::new(), "{language}"); + assert_eq!( + graph.calls("use_branch"), + Vec::::new(), + "{language}" + ); + } +} + +#[test] +fn a_macro_named_call_never_binds_to_a_same_named_type() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "macro-type", + &[ + ( + format!("unit.{language}"), + "#ifdef TOK_IMPL\n#define PREFIX(ident) ident\n#endif\nint tok(int x) { return PREFIX(scan)(x); }\n" + .to_string(), + ), + (format!("parser.{language}"), "struct PREFIX { int x; };\n".to_string()), + ("types.h".to_string(), "struct WRAP { int x; };\n".to_string()), + ( + format!("wrapped.{language}"), + "#include \"types.h\"\n#ifdef WRAP_IMPL\n#define WRAP(ident) ident\n#endif\nint wrapped(int x) { return WRAP(scan)(x); }\n" + .to_string(), + ), + ], + ); + assert!( + !graph + .edges_of("tok") + .iter() + .any(|edge| edge.contains("struct PREFIX")), + "{language}: {:?}", + graph.edges_of("tok") + ); + assert!( + !graph + .edges_of("wrapped") + .iter() + .any(|edge| edge.contains("struct WRAP")), + "{language}: {:?}", + graph.edges_of("wrapped") + ); + } +} + +#[test] +fn a_same_named_function_is_still_the_callee_when_the_macro_may_be_off() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "macro-off", + &[ + ( + format!("unit.{language}"), + "#ifdef FAST\n#define helper(x) (x)\n#endif\nint run(int x) { return helper(x); }\n" + .to_string(), + ), + (format!("lib.{language}"), "int helper(int x) { return x; }\n".to_string()), + (format!("types.{language}"), "struct helper { int x; };\n".to_string()), + ], + ); + assert_eq!( + graph.edges_of("run"), + vec![format!("calls function helper (lib.{language})")], + "{language}" + ); + } +} + +#[test] +fn a_cpp_type_whose_name_is_no_macro_is_still_constructed() { + let graph = resolve_project( + "type-not-macro", + &files(&[ + ("widget.hpp", "struct Widget { int x; };\n"), + ( + "use.cpp", + "#include \"widget.hpp\"\nvoid make() { auto w = Widget(1); }\n", + ), + ("other.cpp", "#define OTHER(x) (x)\n"), + ]), + ); + assert_eq!( + graph.edges_of("make"), + vec!["instantiates struct Widget (widget.hpp)"] + ); +} diff --git a/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs index a175d92..6304433 100644 --- a/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs +++ b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs @@ -232,3 +232,217 @@ fn lexical_namespace_prefers_nested_or_root_owner_without_path_guessing() { assert_eq!(root_targets.len(), 1); assert_eq!(root_targets[0].qualified_name, "Widget::Widget"); } + +impl ResolvedGraph { + fn function(&self, name: &str) -> &Node { + self.nodes + .iter() + .find(|node| node.kind == NodeKind::Function && node.name == name) + .unwrap_or_else(|| panic!("missing function {name}")) + } + + /// `kind qualified_name (file)` of a function's `calls` targets, sorted. + fn calls(&self, caller: &str) -> Vec { + let mut out: Vec = self + .call_targets(self.function(caller)) + .into_iter() + .map(|node| { + format!( + "{} {} ({})", + node.kind.as_str(), + node.qualified_name, + node.file_path + ) + }) + .collect(); + out.sort(); + out + } + + /// The signatures (or names) of a function's `calls` targets, sorted. + fn callee_signatures(&self, caller: &str) -> Vec { + let mut out: Vec = self + .call_targets(self.function(caller)) + .into_iter() + .map(|node| node.signature.clone().unwrap_or_else(|| node.name.clone())) + .collect(); + out.sort(); + out + } +} + +#[test] +fn a_constructor_is_chosen_in_the_sites_namespace_then_an_enclosing_or_global_one() { + let graph = resolve_project( + "namespaces", + &[( + "ns.cpp", + concat!( + "struct Global { Global() {} };\n", + "union Value { Value() {} int x; };\n", + "void union_use() { Value v; }\n", + "namespace first { struct Widget { Widget() {} }; }\n", + "namespace second {\n", + " struct Widget { Widget() {} };\n", + " void local_use() { Widget w; }\n", + " void global_use() { Global g; }\n", + "}\n", + "void explicit_use() { first::Widget w; }\n", + ), + )], + ); + assert_eq!( + graph.calls("union_use"), + vec!["method Value::Value (ns.cpp)"] + ); + assert_eq!( + graph.calls("local_use"), + vec!["method second::Widget::Widget (ns.cpp)"] + ); + assert_eq!( + graph.calls("global_use"), + vec!["method Global::Global (ns.cpp)"] + ); + assert_eq!( + graph.calls("explicit_use"), + vec!["method first::Widget::Widget (ns.cpp)"] + ); +} + +#[test] +fn an_overload_is_chosen_by_arity_only_when_exactly_one_admits_the_count() { + let graph = resolve_project( + "overloads", + &[( + "overloads.cpp", + concat!( + "struct Widget {\n", + " Widget() {}\n", + " Widget(int value) {}\n", + " Widget(int a, int b = 2) {}\n", + "};\n", + "struct Ambiguous {\n", + " Ambiguous(int) {}\n", + " Ambiguous(double) {}\n", + "};\n", + "void default_use() { Widget w; }\n", + "void two_use() { Widget w(1, 2); }\n", + "void one_use() { Widget w(1); }\n", + "void ambiguous_use(int value) { Ambiguous w(value); }\n", + ), + )], + ); + assert_eq!(graph.callee_signatures("default_use"), vec!["()"]); + assert_eq!( + graph.callee_signatures("two_use"), + vec!["(int a, int b = 2)"] + ); + // `Widget(int)` and `Widget(int, int = 2)` both admit one argument. + assert_eq!(graph.callee_signatures("one_use"), Vec::::new()); + assert_eq!( + graph.callee_signatures("ambiguous_use"), + Vec::::new() + ); +} + +#[test] +fn a_default_declared_on_a_prototype_reaches_the_definitions_overload() { + let graph = resolve_project( + "prototype-defaults", + &[ + ( + "widget.hpp", + "namespace app {\nstruct Widget {\n Widget(int value = 7);\n Widget(double value);\n};\nstruct Pair { Pair(int first, int second = 2); };\n}\n", + ), + ( + "widget.cpp", + "#include \"widget.hpp\"\napp::Widget::Widget(int renamed) {}\napp::Widget::Widget(double renamed) {}\napp::Pair::Pair(int a, int b) {}\n", + ), + ( + "use.cpp", + "#include \"widget.hpp\"\nint argument(int value) { return value; }\nvoid defaults() { app::Widget item; }\nvoid nested() { app::Pair item(argument(1)); }\nvoid ambiguous() { app::Widget item(1); }\n", + ), + ], + ); + assert_eq!( + graph.calls("defaults"), + vec!["method app::Widget::Widget (widget.cpp)"] + ); + assert_eq!(graph.callee_signatures("defaults"), vec!["(int renamed)"]); + assert_eq!( + graph.calls("nested"), + vec![ + "function argument (use.cpp)", + "method app::Pair::Pair (widget.cpp)" + ] + ); + assert_eq!(graph.calls("ambiguous"), Vec::::new()); +} + +#[test] +fn an_initializer_list_overload_or_a_parameter_pack_declines() { + let graph = resolve_project( + "declines", + &[( + "declines.cpp", + concat!( + "namespace std { template class initializer_list {}; }\n", + "struct Listed {\n", + " Listed(std::initializer_list values) {}\n", + " Listed(int one) {}\n", + "};\n", + "struct Forwarding {\n", + " template Forwarding(A&&... args) {}\n", + " Forwarding(int one) {}\n", + "};\n", + "void braced() { Listed l{1}; }\n", + "void forwarded() { Forwarding f(1); }\n", + ), + )], + ); + // Brace-init prefers the initializer_list overload, which needs the + // argument types; a pack admits any count, so `Forwarding(int)` is not + // the only overload that could take one argument. + assert_eq!(graph.calls("braced"), Vec::::new()); + assert_eq!(graph.calls("forwarded"), Vec::::new()); +} + +#[test] +fn array_elements_reach_their_constructors_and_keep_nested_calls() { + let graph = resolve_project( + "arrays", + &[( + "arrays.cpp", + concat!( + "struct Widget {\n", + " Widget() {}\n", + " Widget(int value) {}\n", + "};\n", + "int argument() { return 1; }\n", + "void plain() { Widget items[2]; }\n", + "void empty() { Widget items[2]{}; }\n", + "void elements() { Widget items[3]{{argument()}, {2}}; }\n", + "void grid() { Widget items[2][2]{{{1}, {2}}, {{3}}}; }\n", + "void scalar_elements() { Widget items[2]{1, 2}; }\n", + "void pointers() { Widget *p{}; Widget *q(nullptr); Widget *arr[2]{}; }\n", + "void prototype() { Widget most_vexing(); extern Widget external; }\n", + ), + )], + ); + assert_eq!(graph.callee_signatures("plain"), vec!["()"]); + assert_eq!(graph.callee_signatures("empty"), vec!["()"]); + assert_eq!( + graph.callee_signatures("grid"), + vec!["()", "(int value)", "(int value)", "(int value)"] + ); + assert_eq!( + graph.callee_signatures("scalar_elements"), + vec!["(int value)", "(int value)"] + ); + assert_eq!( + graph.callee_signatures("elements"), + vec!["()", "(int value)", "(int value)", "argument"] + ); + assert_eq!(graph.calls("pointers"), Vec::::new()); + assert_eq!(graph.calls("prototype"), Vec::::new()); +} From 1472c1232458c3241b08f7472941dc3db6670cc0 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 13:14:56 +0800 Subject: [PATCH 3/3] fix(index): bump the extraction version for the C/C++ macro and constructor rows Macro constants, constructor prototypes and per-declarator constructions change what extraction writes, so existing indexes rebuild. The language notes and the v1.6.1 audit record the rows landed here. --- crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- docs/languages.md | 62 +++++++++--------- docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md | 64 +++++++++---------- 4 files changed, 65 insertions(+), 65 deletions(-) diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index 089357a..bf082c1 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -115,7 +115,7 @@ pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; /// The extraction-pipeline version this binary produces. This is the single /// source of truth: no other crate may define its own copy. -pub const CURRENT_EXTRACTION_VERSION: u64 = 16; +pub const CURRENT_EXTRACTION_VERSION: u64 = 17; /// The `project_metadata` key under which a built index records the extraction /// version it was produced with. Single source of truth for the key spelling. diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index dba33b6..65e5e7c 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -119,7 +119,7 @@ fn assert_corrupt( fn index_state_constants_and_canonical_payload_are_exact() { /// The extraction version the immediately preceding release wrote. Read from /// `index_state.rs` at implementation time; see plan decision C1 step 0. - const PREVIOUS_EXTRACTION_VERSION: u64 = 15; + const PREVIOUS_EXTRACTION_VERSION: u64 = 16; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/docs/languages.md b/docs/languages.md index ceb4867..7006116 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -25,37 +25,37 @@ parse tree has errors and collapses to only the synthetic file node, indexing st but emits and stores `parse produced no symbols (tree has errors)` so an empty graph is never presented as a clean parse. -| Language | Extensions | Extraction | Notes | -| ----------- | ------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | Generator callables, interface members, declarator handlers, supported React hooks, and curried wrappers own body calls. Pure callable aliases forward; receiver-qualified method values emit callable-use `References` edges (not immediate `Calls`); `this.field.method()` and awaited `Promise` receivers require declared, visible project types; untyped call-result/bare-call guesses are refused. Import-only modules with no export are sealed. Config aliases follow bounded inherited `tsconfig`/`jsconfig` chains. | -| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | -| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | Generator/declarator/hook/wrapper ownership and fail-closed receiver/alias rules follow TypeScript; `exports.x = function` and `module.exports.x = function` create exported callables. Classic scripts and real CommonJS/later exports remain visible; an import-only module with no export is sealed. `.xsjs` / `.xsjslib` follow ordinary JS candidates. | -| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | -| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | -| Python | `.py` `.pyw` | Full tree-sitter | Bare class names used as values resolve to `References` edges in return, assignment, pair, argument, and list positions. `import x as y` binds `y` to module `x`; `from p import x as y` prefers module `p.x` when that file exists, otherwise it remains a member import. Receiver-qualified method values emit callable-use `References` edges rather than immediate calls. Missing, duplicate, or ambiguous aliases stay unresolved and never fall through to a global bare-name guess. Tuple returns are not recursively traversed, and bare names never resolve to methods. | -| Go | `.go` | Full tree-sitter | Lowercase declarations are package-local (directory-scoped) and cannot become cross-package name targets; local callable parameters/variables also block same-named method guesses. Exported identifiers remain cross-package candidates. | -| Rust | `.rs` | Full tree-sitter | Methods in generic, lifetime, reference, and qualified `impl` blocks belong to the implementing type; only trait impls emit `Implements`. Exact `self.method()` binds only to the enclosing impl owner, including split/trait impls. Exact `self.field.method()` resolves through the owning struct field's declared project type. Private items are visible only to their module subtree (trait-impl methods follow trait visibility); absent members, containers, generics, external types, and ambiguity remain unresolved. | -| Java | `.java` | Full tree-sitter | | -| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C. C++ detection scans masked full source for ordinary `class/struct Derived : Base` forms while rejecting comments, strings, character literals, preprocessor text, bitfields, labels, and ternaries. | -| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` `.metal` `.cu` `.cuh` | Full tree-sitter | Class/struct inheritance supports access modifiers, `virtual`, qualified bases, and templated-base stripping; pure virtual declarations are abstract owned methods; local object initialization calls one constructor only with unique namespace/arity proof. A free `static` function in another translation unit is not a target, while header `static inline` remains usable. Raw-string contents are opaque to macro blankers. | -| C# | `.cs` | Full tree-sitter | | -| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | -| Ruby | `.rb` `.rake` | Full tree-sitter | | -| Swift | `.swift` | Full tree-sitter | | -| Kotlin | `.kt` `.kts` | Full tree-sitter | Top-level functions, class methods, and extension functions include raw parameter-list signatures with optional declared return types; constructors, accessors, lambdas, and anonymous functions are not synthesized as callable nodes. Private definitions never become cross-file targets. | -| Dart | `.dart` | Full tree-sitter | Dart 3 `extension type` declarations are class-like owners; getters and methods qualify under the extension type rather than leaking to file scope. | -| Scala | `.scala` `.sc` | Full tree-sitter | Scala grammar 0.26.2; multiple constructor parameter lists and every `extends`/`with` parent are retained. Companion `object` nodes are Modules, while same-named class/trait types win inheritance resolution. | -| Lua | `.lua` | Full tree-sitter | `local f = function` emits one Function (not a duplicate Variable); `M.f = function`, string-key table assignments, and nested table constructor functions emit qualified Methods. Body calls belong to the synthesized callable; `f()`, `M.f()`, and `M:f()` resolve, while computed keys stay dynamic. | -| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | -| Objective-C | `.m` `.mm` | Full tree-sitter | | -| R | `.r` | Full tree-sitter | | -| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | -| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred | -| Terraform | `.tf` `.tfvars` `.tofu` | Full tree-sitter | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver deferred | -| Erlang | `.erl` `.hrl` | Full tree-sitter | Functions keep a bare display name and qualify as `module::function/arity`; clauses, exports, specs, local/remote calls, `fun`, static `gen_server` targets, and spawn/apply-style MFA lists are arity-aware. Binary-literal commas do not inflate arity. Missing or ambiguous arities remain unresolved; dynamic behaviour/resource wiring is still deferred. | -| GDScript | `.gd` | Full tree-sitter | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`godot.md`](godot.md) | -| Pascal | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx` | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path | -| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred | +| Language | Extensions | Extraction | Notes | +| ----------- | ------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | Generator callables, interface members, declarator handlers, supported React hooks, and curried wrappers own body calls. Pure callable aliases forward; receiver-qualified method values emit callable-use `References` edges (not immediate `Calls`); `this.field.method()` and awaited `Promise` receivers require declared, visible project types; untyped call-result/bare-call guesses are refused. Import-only modules with no export are sealed. Config aliases follow bounded inherited `tsconfig`/`jsconfig` chains. | +| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | +| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | Generator/declarator/hook/wrapper ownership and fail-closed receiver/alias rules follow TypeScript; `exports.x = function` and `module.exports.x = function` create exported callables. Classic scripts and real CommonJS/later exports remain visible; an import-only module with no export is sealed. `.xsjs` / `.xsjslib` follow ordinary JS candidates. | +| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | +| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | +| Python | `.py` `.pyw` | Full tree-sitter | Bare class names used as values resolve to `References` edges in return, assignment, pair, argument, and list positions. `import x as y` binds `y` to module `x`; `from p import x as y` prefers module `p.x` when that file exists, otherwise it remains a member import. Receiver-qualified method values emit callable-use `References` edges rather than immediate calls. Missing, duplicate, or ambiguous aliases stay unresolved and never fall through to a global bare-name guess. Tuple returns are not recursively traversed, and bare names never resolve to methods. | +| Go | `.go` | Full tree-sitter | Lowercase declarations are package-local (directory-scoped) and cannot become cross-package name targets; local callable parameters/variables also block same-named method guesses. Exported identifiers remain cross-package candidates. | +| Rust | `.rs` | Full tree-sitter | Methods in generic, lifetime, reference, and qualified `impl` blocks belong to the implementing type; only trait impls emit `Implements`. Exact `self.method()` binds only to the enclosing impl owner, including split/trait impls. Exact `self.field.method()` resolves through the owning struct field's declared project type. Private items are visible only to their module subtree (trait-impl methods follow trait visibility); absent members, containers, generics, external types, and ambiguity remain unresolved. | +| Java | `.java` | Full tree-sitter | | +| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C. C++ detection scans masked full source for ordinary `class/struct Derived : Base` forms while rejecting comments, strings, character literals, preprocessor text, bitfields, labels, and ternaries. A function-like `#define` is a `constant`; a call it shadows binds nothing when the macro is definitely visible at that line, with directives, `#if`/`#ifdef` branches, guards and `#pragma once` followed in include order, or when only macros bear the name. | +| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` `.metal` `.cu` `.cuh` | Full tree-sitter | Class/struct inheritance supports access modifiers, `virtual`, qualified bases, and templated-base stripping; pure virtual declarations are abstract owned methods; local object initialization calls one constructor only with unique namespace/arity proof: an in-class prototype is a method whose defaults reach its out-of-line definition, array elements are constructed one by one, `extern`, pointer and reference declarations construct nothing, and an `initializer_list` or parameter-pack overload declines the call. A free `static` function in another translation unit is not a target, while header `static inline` remains usable. Raw-string contents are opaque to macro blankers. | +| C# | `.cs` | Full tree-sitter | | +| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | +| Ruby | `.rb` `.rake` | Full tree-sitter | | +| Swift | `.swift` | Full tree-sitter | | +| Kotlin | `.kt` `.kts` | Full tree-sitter | Top-level functions, class methods, and extension functions include raw parameter-list signatures with optional declared return types; constructors, accessors, lambdas, and anonymous functions are not synthesized as callable nodes. Private definitions never become cross-file targets. | +| Dart | `.dart` | Full tree-sitter | Dart 3 `extension type` declarations are class-like owners; getters and methods qualify under the extension type rather than leaking to file scope. | +| Scala | `.scala` `.sc` | Full tree-sitter | Scala grammar 0.26.2; multiple constructor parameter lists and every `extends`/`with` parent are retained. Companion `object` nodes are Modules, while same-named class/trait types win inheritance resolution. | +| Lua | `.lua` | Full tree-sitter | `local f = function` emits one Function (not a duplicate Variable); `M.f = function`, string-key table assignments, and nested table constructor functions emit qualified Methods. Body calls belong to the synthesized callable; `f()`, `M.f()`, and `M:f()` resolve, while computed keys stay dynamic. | +| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | +| Objective-C | `.m` `.mm` | Full tree-sitter | | +| R | `.r` | Full tree-sitter | | +| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | +| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred | +| Terraform | `.tf` `.tfvars` `.tofu` | Full tree-sitter | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver deferred | +| Erlang | `.erl` `.hrl` | Full tree-sitter | Functions keep a bare display name and qualify as `module::function/arity`; clauses, exports, specs, local/remote calls, `fun`, static `gen_server` targets, and spawn/apply-style MFA lists are arity-aware. Binary-literal commas do not inflate arity. Missing or ambiguous arities remain unresolved; dynamic behaviour/resource wiring is still deferred. | +| GDScript | `.gd` | Full tree-sitter | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`godot.md`](godot.md) | +| Pascal | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx` | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path | +| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred | --- diff --git a/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md index fb871cc..b878246 100644 --- a/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md +++ b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md @@ -20,38 +20,38 @@ documented divergence). ## Resolution and extraction -| Issue / PR | Upstream | Behavior | Disposition | State | -| --------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| #1862 (a) | `4297b8e` | an identifier-rooted TS/JS chain of three or more segments keeps its qualified call site; only a framework resolver may bind it | CORRECT | landed in #277 | -| #1862 (b) | `4297b8e` | typed method lookup matches within a language family (`.tsx` reaches `.ts`, `.js` reaches `.jsx`) | PORT | landed in #275 | -| #1862 (c), #647 | `4297b8e`, `68eaf0d` | Zustand `get()`, `useStore.getState()`, destructured and selector bindings resolve inside one identified store; requires the store-initializer action extraction the port never had | PORT | landed in #277 | -| #1987 | `b65e05b` | `this.#field.m()` resolves on the private field's type; JS built-in method names need receiver evidence | PORT | landed in #277 | -| #1986 | `9649ce1` | code references crossing code families need bridge evidence; TS/JS receiver wrappers peel and untyped expression receivers emit nothing | PORT | landed in #277 | -| #1857 | `53fe53d` | a receiver-less Go call never binds a method | CORRECT | landed in #275 | -| #1882 | `8f80819` | Rust `self.m()` with same-named owners in several modules pins the caller's file | CORRECT | landed in #275 | -| #1885 / #1840 | `d0996a2` | awaited receiver inference: block scope, reassignment, call tail, imported or reachable factory, `Promise` | CORRECT | landed in #275 | -| #1824 | `e27d6da` | a Scala object is never a supertype target but owns methods and supertypes | CORRECT | landed in #275 | -| #2040 | `9be3f1a` | an unknown member of a named Python import stays unresolved | PORT | landed in #277 | -| #2055 | `4960720` | an inheritance ref that lands on a TS value moves to the same-file same-named type | PORT | landed in #277 | -| #1932 | `17d09bf` | object-literal members that alias a function follow the binding lexically (last own property, spread/computed invalidation, shadowing) | CORRECT | landed in #277 | -| #1747 | `9fb2c25` | a curried-wrapper function that is an object member is named by its key | CORRECT | landed in #277 | -| #1905 | `bd993b1` | Python module/class/function body docstrings (PEP 257), joined after a preceding comment | PORT | landed in #277 | -| #1373 | `9b5c8e4` | single-argument C/C++ function macros recover the defined function name with local `#define` evidence | PORT | landed in #277 | -| #1820 | `5eaa6fe` | Python attribute and Go selector method values keep their receiver and resolve through type/import scope | CORRECT | receiver-qualified JS/TS/Python values landed in #275, the Python named-import module fallback in #277, Go selector values and Python receiver scoping in #278 | -| #1838 / #1839 / #2069 / #2070 | `fa25883`, `15a5d1d` | translation-unit-ordered macro visibility; `extern`, array and `initializer_list` constructor rules; macro-name calls bind only functions | CORRECT | constructor/macro basics landed in #275; translation-unit macro visibility, the newer constructor rules, #2069 and #2070 planned | -| #1974 | `52df4d2` | depth-limited traversal re-expands a node reached again at a shallower depth | PORT | landed in #278 | -| #1349 | `86708bf` | a later same-line, same-name declaration gets a column-suffixed node id | PORT, touches the node-id invariant | landed in #278 (approved by the owner on 2026-10-01) | -| #2072 | `fef3776` | performance: memoised gates and caches, graph output unchanged | partial PORT | `this.` declaration memo landed in #277; per-file source facts in #275; the `file_exists` memo in #279 | -| #1719 | (09-15 row) | sealed-module markers read after blanking | CORRECT | landed in #275 | -| #1642 | `4ebd0d6` | `src/*/{java,kotlin,scala}/**/build/` is source, not build output | PORT | landed in #279, with the root `.gitignore` read by git's rules in scan and watch (both matchers diverged from git before) | -| #1553 | `276df3a` | resolver file reads stat first and skip non-regular or oversized files | PORT | landed in #279 | -| #1910 | `04ec5f5` | bounded extraction reads and MPEG-TS sniffing for `.ts` | PORT | landed in #279, with lossy UTF-8 decoding: a non-UTF-8 source file failed the whole index before (a Rust-only defect) | -| #1209 / #1222 | `9e4b8e7` | MyBatis closing tags tolerate whitespace | PORT (refid half ALREADY-HAVE), CORRECT for #1182 | landed in #279 with the rest of #1182 (comments, quote styles, namespace-less ``, iBatis verbs, `databaseId`); KEEP-RUST: a self-closing `` is never a root | -| #1906 | `c505d0d` | Liquid `{% liquid %}` blocks | PORT | landed in #279 | -| #1360 | `6184610` | sync reports recovered orphan references | PORT | landed in #279 | -| #1829 / #1878 | `4871114` | git-stamped fast path for pending status | KEEP-RUST (full inventory); fast path DEFER pending measurement | — | -| #1833 / #1849, #1830, #1784, #1823, #1519, #1350, #1348, #1359, #1361 | various | already implemented by the port | ALREADY-HAVE | — | -| #1827, #1773, #1864, #1973 / #1543, #1355 / #1988 / #2038 | various | Spring DI heuristics, Node `indexAll` spin, dominant-file SQL, dead-code, callback synthesis storage | N/A | — | +| Issue / PR | Upstream | Behavior | Disposition | State | +| --------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #1862 (a) | `4297b8e` | an identifier-rooted TS/JS chain of three or more segments keeps its qualified call site; only a framework resolver may bind it | CORRECT | landed in #277 | +| #1862 (b) | `4297b8e` | typed method lookup matches within a language family (`.tsx` reaches `.ts`, `.js` reaches `.jsx`) | PORT | landed in #275 | +| #1862 (c), #647 | `4297b8e`, `68eaf0d` | Zustand `get()`, `useStore.getState()`, destructured and selector bindings resolve inside one identified store; requires the store-initializer action extraction the port never had | PORT | landed in #277 | +| #1987 | `b65e05b` | `this.#field.m()` resolves on the private field's type; JS built-in method names need receiver evidence | PORT | landed in #277 | +| #1986 | `9649ce1` | code references crossing code families need bridge evidence; TS/JS receiver wrappers peel and untyped expression receivers emit nothing | PORT | landed in #277 | +| #1857 | `53fe53d` | a receiver-less Go call never binds a method | CORRECT | landed in #275 | +| #1882 | `8f80819` | Rust `self.m()` with same-named owners in several modules pins the caller's file | CORRECT | landed in #275 | +| #1885 / #1840 | `d0996a2` | awaited receiver inference: block scope, reassignment, call tail, imported or reachable factory, `Promise` | CORRECT | landed in #275 | +| #1824 | `e27d6da` | a Scala object is never a supertype target but owns methods and supertypes | CORRECT | landed in #275 | +| #2040 | `9be3f1a` | an unknown member of a named Python import stays unresolved | PORT | landed in #277 | +| #2055 | `4960720` | an inheritance ref that lands on a TS value moves to the same-file same-named type | PORT | landed in #277 | +| #1932 | `17d09bf` | object-literal members that alias a function follow the binding lexically (last own property, spread/computed invalidation, shadowing) | CORRECT | landed in #277 | +| #1747 | `9fb2c25` | a curried-wrapper function that is an object member is named by its key | CORRECT | landed in #277 | +| #1905 | `bd993b1` | Python module/class/function body docstrings (PEP 257), joined after a preceding comment | PORT | landed in #277 | +| #1373 | `9b5c8e4` | single-argument C/C++ function macros recover the defined function name with local `#define` evidence | PORT | landed in #277 | +| #1820 | `5eaa6fe` | Python attribute and Go selector method values keep their receiver and resolve through type/import scope | CORRECT | receiver-qualified JS/TS/Python values landed in #275, the Python named-import module fallback in #277, Go selector values and Python receiver scoping in #278 | +| #1838 / #1839 / #2069 / #2070 | `fa25883`, `15a5d1d` | translation-unit-ordered macro visibility; `extern`, array and `initializer_list` constructor rules; macro-name calls bind only functions | CORRECT | constructor/macro basics landed in #275; the rest landed in #282: macro `constant` nodes, translation-unit macro visibility with #2069, the #2070 callee rule, constructor prototypes, per-declarator and array-element construction, prototype/definition overload merging and the `initializer_list` decline. KEEP-RUST: a parameter-pack constructor declines the overload set (upstream admits it unbounded) | +| #1974 | `52df4d2` | depth-limited traversal re-expands a node reached again at a shallower depth | PORT | landed in #278 | +| #1349 | `86708bf` | a later same-line, same-name declaration gets a column-suffixed node id | PORT, touches the node-id invariant | landed in #278 (approved by the owner on 2026-10-01) | +| #2072 | `fef3776` | performance: memoised gates and caches, graph output unchanged | partial PORT | `this.` declaration memo landed in #277; per-file source facts in #275; the `file_exists` memo in #279 | +| #1719 | (09-15 row) | sealed-module markers read after blanking | CORRECT | landed in #275 | +| #1642 | `4ebd0d6` | `src/*/{java,kotlin,scala}/**/build/` is source, not build output | PORT | landed in #279, with the root `.gitignore` read by git's rules in scan and watch (both matchers diverged from git before) | +| #1553 | `276df3a` | resolver file reads stat first and skip non-regular or oversized files | PORT | landed in #279 | +| #1910 | `04ec5f5` | bounded extraction reads and MPEG-TS sniffing for `.ts` | PORT | landed in #279, with lossy UTF-8 decoding: a non-UTF-8 source file failed the whole index before (a Rust-only defect) | +| #1209 / #1222 | `9e4b8e7` | MyBatis closing tags tolerate whitespace | PORT (refid half ALREADY-HAVE), CORRECT for #1182 | landed in #279 with the rest of #1182 (comments, quote styles, namespace-less ``, iBatis verbs, `databaseId`); KEEP-RUST: a self-closing `` is never a root | +| #1906 | `c505d0d` | Liquid `{% liquid %}` blocks | PORT | landed in #279 | +| #1360 | `6184610` | sync reports recovered orphan references | PORT | landed in #279 | +| #1829 / #1878 | `4871114` | git-stamped fast path for pending status | KEEP-RUST (full inventory); fast path DEFER pending measurement | — | +| #1833 / #1849, #1830, #1784, #1823, #1519, #1350, #1348, #1359, #1361 | various | already implemented by the port | ALREADY-HAVE | — | +| #1827, #1773, #1864, #1973 / #1543, #1355 / #1988 / #2038 | various | Spring DI heuristics, Node `indexAll` spin, dominant-file SQL, dead-code, callback synthesis storage | N/A | — | Findings outside the upstream range, recorded so the next audit starts from them: callers/callees never followed `instantiates` edges (upstream #774/#804,