diff --git a/crates/codegraph-extract/src/lang/c.rs b/crates/codegraph-extract/src/lang/c.rs index 0b20b6c9..3a50f124 100644 --- a/crates/codegraph-extract/src/lang/c.rs +++ b/crates/codegraph-extract/src/lang/c.rs @@ -57,6 +57,9 @@ impl LanguageSpec for CSpec { fn variable_types(&self) -> &'static [&'static str] { &["declaration"] } + fn resolve_name(&self, node: Node<'_>, source: &str) -> Option { + crate::lang::cpp::recover_single_arg_macro_defined_name(node, source) + } fn name_field(&self) -> &'static str { "declarator" } diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index a837d759..c7e5d460 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -70,7 +70,9 @@ impl LanguageSpec for CppSpec { "type" } fn resolve_name(&self, node: Node<'_>, source: &str) -> Option { - if let Some(name) = recover_cpp_macro_defined_name(node, source) { + if let Some(name) = recover_single_arg_macro_defined_name(node, source) + .or_else(|| recover_cpp_macro_defined_name(node, source)) + { return Some(name); } let qid = declarator_qualified_id(child_by_field(node, "declarator")?)?; @@ -921,6 +923,108 @@ fn mask_quoted_literal(bytes: &[u8], mask: &mut [bool], start: usize, quote: u8) i } +/// Recover the function a single-argument macro defines — +/// `#define NATIVE_FN(name) int name(void)` then `NATIVE_FN(get_version) { … }` +/// (upstream #1373). Without its definition the shape is ambiguous, so a +/// preceding local `#define` whose replacement uses its sole parameter as the +/// function declarator is required; registration, token-pasting and `typedef` +/// replacements, an `#undef` or redefinition in between, and any conditional +/// block leave the name alone. C parses the macro as the return type with a +/// `(name)` declarator; C++ as an implicit-return-type function. +pub(crate) fn recover_single_arg_macro_defined_name( + node: Node<'_>, + source: &str, +) -> Option { + if node.kind() != "function_definition" { + return None; + } + let declarator = child_by_field(node, "declarator")?; + let (macro_node, argument) = match declarator.kind() { + "parenthesized_declarator" if declarator.named_child_count() == 1 => { + let macro_node = child_by_field(node, "type")?; + let argument = declarator.named_child(0)?; + if macro_node.kind() != "type_identifier" || argument.kind() != "identifier" { + return None; + } + (macro_node, argument) + } + "function_declarator" if child_by_field(node, "type").is_none() => { + let macro_node = child_by_field(declarator, "declarator")?; + let params = child_by_field(declarator, "parameters")?; + let param = params.named_child(0)?; + if macro_node.kind() != "identifier" + || params.named_child_count() != 1 + || param.kind() != "parameter_declaration" + || param.named_child_count() != 1 + { + return None; + } + let argument = param.named_child(0)?; + if argument.kind() != "type_identifier" { + return None; + } + (macro_node, argument) + } + _ => return None, + }; + let macro_name = node_text(macro_node, source); + static REPLACEMENT: OnceLock = OnceLock::new(); + let replacement_pattern = REPLACEMENT.get_or_init(|| { + Regex::new( + r"^(?:[A-Za-z_][A-Za-z0-9_:]*\s+)+[*&\s]*([A-Za-z_][A-Za-z0-9_]*)\s*\([^(){};#]*\)\s*$", + ) + .expect("single-argument macro replacement pattern") + }); + static TYPEDEF: OnceLock = OnceLock::new(); + let typedef = TYPEDEF.get_or_init(|| Regex::new(r"(?-u:\b)typedef(?-u:\b)").expect("typedef")); + let mut scope = Some(node); + while let Some(current) = scope { + if current.kind() == "preproc_else" || current.kind().starts_with("preproc_elif") { + return None; + } + let mut previous = current.prev_named_sibling(); + while let Some(prev) = previous { + previous = prev.prev_named_sibling(); + if prev.kind().starts_with("preproc_if") { + return None; + } + if prev.kind() == "preproc_call" + && child_by_field(prev, "directive") + .is_some_and(|directive| node_text(directive, source) == "#undef") + && child_by_field(prev, "argument") + .is_some_and(|argument| node_text(argument, source).trim() == macro_name) + { + return None; + } + if !matches!(prev.kind(), "preproc_function_def" | "preproc_def") + || child_by_field(prev, "name").map(|name| node_text(name, source)) + != Some(macro_name.clone()) + { + continue; + } + let params = child_by_field(prev, "parameters")?; + let param = params.named_child(0)?; + let value = child_by_field(prev, "value")?; + if params.named_child_count() != 1 || param.kind() != "identifier" { + return None; + } + let replacement = node_text(value, source) + .replace("\\\r\n", " ") + .replace("\\\n", " "); + let replacement = replacement.trim(); + let declared = replacement_pattern + .captures(replacement) + .and_then(|captures| captures.get(1))?; + if typedef.is_match(replacement) || declared.as_str() != node_text(param, source) { + return None; + } + return Some(node_text(argument, source)); + } + scope = current.parent(); + } + None +} + /// Recover the real function name from the macro-definition idiom /// `MACRO_NAME(real_name, typed args…) { body }` (flash-attention's /// `DEFINE_FLASH_FORWARD_KERNEL(flash_fwd_kernel, bool Is_dropout, …) { … }`): diff --git a/crates/codegraph-extract/src/lang/javascript.rs b/crates/codegraph-extract/src/lang/javascript.rs index 97baaf26..747a7a7f 100644 --- a/crates/codegraph-extract/src/lang/javascript.rs +++ b/crates/codegraph-extract/src/lang/javascript.rs @@ -43,6 +43,16 @@ impl LanguageSpec for JavaScriptSpec { crate::lang::typescript::class_field_is_callable(node, "field_definition") } + fn resolve_name(&self, node: Node<'_>, source: &str) -> Option { + // JS `field_definition` names its key the `property` field (TS's + // `public_field_definition` uses `name`); without this every JS class + // field, handler fields included, is `` (upstream #808). + if node.kind() == "field_definition" { + return child_by_field(node, "property").map(|property| node_text(property, source)); + } + None + } + fn interface_types(&self) -> &'static [&'static str] { &[] } diff --git a/crates/codegraph-extract/src/lang/jsx.rs b/crates/codegraph-extract/src/lang/jsx.rs index 370ef9e2..9bf233e6 100644 --- a/crates/codegraph-extract/src/lang/jsx.rs +++ b/crates/codegraph-extract/src/lang/jsx.rs @@ -33,6 +33,9 @@ impl LanguageSpec for JsxSpec { fn method_types(&self) -> &'static [&'static str] { JAVASCRIPT_SPEC.method_types() } + fn class_member_is_method(&self, node: Node<'_>, source: &str) -> bool { + JAVASCRIPT_SPEC.class_member_is_method(node, source) + } fn interface_types(&self) -> &'static [&'static str] { &[] } @@ -60,6 +63,9 @@ impl LanguageSpec for JsxSpec { fn name_field(&self) -> &'static str { JAVASCRIPT_SPEC.name_field() } + fn resolve_name(&self, node: Node<'_>, source: &str) -> Option { + JAVASCRIPT_SPEC.resolve_name(node, source) + } fn body_field(&self) -> &'static str { JAVASCRIPT_SPEC.body_field() } diff --git a/crates/codegraph-extract/src/lang/python.rs b/crates/codegraph-extract/src/lang/python.rs index e8c48176..abfe8b0b 100644 --- a/crates/codegraph-extract/src/lang/python.rs +++ b/crates/codegraph-extract/src/lang/python.rs @@ -73,6 +73,10 @@ impl LanguageSpec for PythonSpec { Some(signature) } + fn body_docstring(&self, node: Node<'_>, source: &str) -> Option { + python_body_docstring(node, source) + } + fn is_async(&self, node: Node<'_>) -> bool { node.prev_sibling() .is_some_and(|prev| prev.kind() == "async") @@ -96,3 +100,135 @@ impl LanguageSpec for PythonSpec { }) } } + +/// Python states intent in a docstring — a bare string literal as the first +/// statement of a module, class or function body (upstream #1905). Reads the +/// grammar's `string_content`, so `r`/`u` prefixes and both triple-quote forms +/// work; bytes and f-strings are not docstrings, and a tuple is not a string. +fn python_body_docstring(node: Node<'_>, source: &str) -> Option { + let body = if node.kind() == "module" { + node + } else { + child_by_field(node, "body")? + }; + let first = body + .named_children(&mut body.walk()) + .find(|child| child.kind() != "comment")?; + if first.kind() != "expression_statement" + || first.named_child_count() != 1 + || first + .children(&mut first.walk()) + .any(|child| child.kind() == ",") + { + return None; + } + let mut literal = first.named_child(0)?; + while literal.kind() == "parenthesized_expression" { + literal = literal + .named_children(&mut literal.walk()) + .find(|child| child.kind() != "comment")?; + } + let strings = if literal.kind() == "concatenated_string" { + literal + .named_children(&mut literal.walk()) + .filter(|child| child.kind() != "comment") + .collect::>() + } else { + vec![literal] + }; + let mut raw = String::new(); + for string in strings { + if string.kind() != "string" { + return None; + } + let parts = string + .named_children(&mut string.walk()) + .collect::>(); + let start = parts.iter().find(|part| part.kind() == "string_start")?; + if node_text(*start, source) + .chars() + .any(|ch| matches!(ch, 'b' | 'B' | 'f' | 'F')) + || !parts.iter().any(|part| part.kind() == "string_end") + { + return None; + } + if let Some(content) = parts.iter().find(|part| part.kind() == "string_content") { + raw.push_str(&node_text(*content, source)); + } + } + let docstring = dedent_docstring(&raw); + (!docstring.is_empty()).then_some(docstring) +} + +/// PEP 257 cleaning: tabs expand to 8-column stops, every line after the first +/// loses the common indentation of the non-blank ones (the first line starts +/// right after the quotes), and blank edges are dropped. +fn dedent_docstring(raw: &str) -> String { + let lines = raw + .replace("\r\n", "\n") + .replace('\r', "\n") + .split('\n') + .map(|line| { + let mut column = 0usize; + let mut expanded = String::with_capacity(line.len()); + for ch in line.chars() { + if ch == '\t' { + let width = 8 - column % 8; + expanded.extend(std::iter::repeat_n(' ', width)); + column += width; + } else { + expanded.push(ch); + column += 1; + } + } + expanded + }) + .collect::>(); + let leading = |line: &str| line.chars().take_while(|ch| ch.is_whitespace()).count(); + let indent = lines + .iter() + .skip(1) + .filter(|line| !line.trim().is_empty()) + .map(|line| leading(line)) + .min() + .unwrap_or(0); + let mut out = Vec::with_capacity(lines.len()); + out.push( + lines + .first() + .map(|line| line.trim().to_string()) + .unwrap_or_default(), + ); + for line in lines.iter().skip(1) { + out.push( + line.chars() + .skip(indent) + .collect::() + .trim_end() + .to_string(), + ); + } + while out.first().is_some_and(|line| line.trim().is_empty()) { + out.remove(0); + } + while out.last().is_some_and(|line| line.trim().is_empty()) { + out.pop(); + } + out.join("\n") +} + +#[cfg(test)] +mod docstring_tests { + use super::dedent_docstring; + + #[test] + fn dedent_follows_pep_257() { + assert_eq!( + dedent_docstring("Summary line.\n\n Details here.\n indented\n "), + "Summary line.\n\nDetails here.\n indented" + ); + assert_eq!(dedent_docstring("\n Only body.\n "), "Only body."); + assert_eq!(dedent_docstring("a\r\n\tb"), "a\nb"); + assert_eq!(dedent_docstring(" "), ""); + } +} diff --git a/crates/codegraph-extract/src/lang/tsx.rs b/crates/codegraph-extract/src/lang/tsx.rs index 11496b6f..757b9010 100644 --- a/crates/codegraph-extract/src/lang/tsx.rs +++ b/crates/codegraph-extract/src/lang/tsx.rs @@ -31,6 +31,12 @@ impl LanguageSpec for TsxSpec { fn method_types(&self) -> &'static [&'static str] { TYPESCRIPT_SPEC.method_types() } + fn property_types(&self) -> &'static [&'static str] { + TYPESCRIPT_SPEC.property_types() + } + fn class_member_is_method(&self, node: Node<'_>, source: &str) -> bool { + TYPESCRIPT_SPEC.class_member_is_method(node, source) + } fn interface_types(&self) -> &'static [&'static str] { TYPESCRIPT_SPEC.interface_types() } diff --git a/crates/codegraph-extract/src/spec.rs b/crates/codegraph-extract/src/spec.rs index 4c5fd79b..77659854 100644 --- a/crates/codegraph-extract/src/spec.rs +++ b/crates/codegraph-extract/src/spec.rs @@ -114,6 +114,13 @@ pub trait LanguageSpec: Sync { None } + /// Prose that lives INSIDE the node's body rather than in a preceding + /// comment — Python's bare first-statement string (upstream + /// `getBodyDocstring`, #1905). Cleaned text, or `None`. + fn body_docstring(&self, _node: Node<'_>, _source: &str) -> Option { + None + } + fn get_return_type(&self, _node: Node<'_>, _source: &str) -> Option { None } diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 815b8839..623baa15 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -154,6 +154,111 @@ fn is_js_identifier(segment: &str) -> bool { && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) } +/// Languages whose member calls go through the TS/JS grammars +/// (`TS_JS_CHAIN_LANGUAGES`, upstream v1.6.1). +fn is_ts_js_chain_language(language: Language) -> bool { + matches!( + language, + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) +} + +/// Receiver kinds (TS/JS grammars) that continue a member chain downward. +fn is_ts_js_chain_receiver(node: SyntaxNode<'_>) -> bool { + matches!(node.kind(), "member_expression" | "subscript_expression") +} + +/// TS/JS wrappers that leave a member call's receiver the same object: +/// `(x).m()`, `x!.m()`, `(x as T).m()`, `(x satisfies T).m()`, `(x).m()` +/// and `(await x).m()` all call `m` on what `x` holds. +const TS_JS_TRANSPARENT_RECEIVER_KINDS: [&str; 6] = [ + "parenthesized_expression", + "non_null_expression", + "as_expression", + "satisfies_expression", + "type_assertion", + "await_expression", +]; + +/// Strip [`TS_JS_TRANSPARENT_RECEIVER_KINDS`] wrappers off a receiver. +/// tree-sitter-typescript parses `a && b!.c()` as `(a && b)!.c()`; the `!` +/// belongs to the right operand, so a non-null over a binary expression peels +/// to that operand. +fn peel_ts_js_receiver(node: SyntaxNode<'_>) -> SyntaxNode<'_> { + let mut current = node; + while TS_JS_TRANSPARENT_RECEIVER_KINDS.contains(¤t.kind()) { + let mut inner = if current.kind() == "type_assertion" { + u32::try_from(current.named_child_count()) + .ok() + .and_then(|count| count.checked_sub(1)) + .and_then(|last| current.named_child(last)) + } else { + current.named_child(0) + }; + if current.kind() == "non_null_expression" { + while let Some(binary) = inner.filter(|inner| inner.kind() == "binary_expression") { + inner = child_by_field(binary, "right"); + } + } + let Some(inner) = inner else { + break; + }; + current = inner; + } + current +} + +/// Identifier-rooted TS/JS member chains have no inferred property type +/// (upstream #1566), host API chains included (#1707). The `window.MyNs` +/// project-global escape stays outside; call-result and `this` receivers have +/// their own paths. +fn is_unresolved_ts_js_chain(node: SyntaxNode<'_>, source: &str) -> bool { + let mut current = Some(node); + while let Some(link) = current.filter(|link| is_ts_js_chain_receiver(*link)) { + current = child_by_field(link, "object"); + } + current.is_some_and(|root| root.kind() == "identifier" && node_text(root, source) != "window") +} + +/// The root of a TS/JS receiver chain, looking through +/// [`peel_ts_js_receiver`] wrappers at every hop. +fn ts_js_chain_root(node: SyntaxNode<'_>) -> Option> { + let mut current = Some(node); + while let Some(link) = current.filter(|link| is_ts_js_chain_receiver(*link)) { + current = child_by_field(link, "object").map(peel_ts_js_receiver); + } + current +} + +/// Whether a TS/JS receiver still collapses to the bare method name: `this` / +/// `super` (resolution reads the owner off the enclosing class), a member +/// chain rooted at either or at `window` (the project-global escape of +/// [`is_unresolved_ts_js_chain`]), and `new C()` (its class is written at the +/// call). +fn keeps_bare_ts_js_receiver(node: SyntaxNode<'_>, source: &str) -> bool { + let Some(root) = ts_js_chain_root(node) else { + return false; + }; + match root.kind() { + "this" | "super" => true, + "identifier" => node_text(root, source) == "window", + kind => root.id() == node.id() && kind == "new_expression", + } +} + +/// A retained untyped TS/JS chain (`holder.values.get`): at least three plain +/// identifier segments, whitespace removed and `?.` read as `.`. +fn plain_ts_js_chain(text: &str) -> Option { + let chain = text + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .replace("?.", "."); + let segments = chain.split('.').collect::>(); + (segments.len() >= 3 && segments.iter().all(|segment| is_js_identifier(segment))) + .then_some(chain) +} + /// Deterministic logical recursion bound for the native AST walker. /// /// Tree-sitter parses deeply nested input iteratively, but this walker descends @@ -802,7 +907,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { ), None => crate::lang::erlang_clause_header(first, self.source), }; - let docstring = self.preceding_docstring(spec.unwrap_or(node)); + let docstring = self.docstring_for(spec.unwrap_or(node)); let exports = crate::lang::erlang_module_exports(self.root, self.source); let is_exported = exports.contains(&name, arity); let qualified_name = format!("{}/{}", self.build_qualified_name(&name), arity); @@ -848,7 +953,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { .chars() .take(300) .collect(); - let docstring = self.preceding_docstring(node); + let docstring = self.docstring_for(node); let Some(rec) = self.create_node( NodeKind::Struct, &name, @@ -2152,7 +2257,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { end_line: self.source.split('\n').count() as i64, start_column: 0, end_column: 0, - docstring: None, + docstring: self.spec.body_docstring(self.root, self.source), signature: None, visibility: None, is_exported: false, @@ -2346,7 +2451,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), signature: self.spec.get_signature(node, self.source), visibility: self.spec.get_visibility(node), is_exported: common_js_export || self.spec.is_exported(node, self.source), @@ -2467,9 +2572,11 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } /// The declarator name for a function passed to the second application of - /// a curried wrapper (`const NAME = factory(...)(function () { ... })`). - /// Requiring the callee itself to be a call keeps single-call computations - /// such as `useMemo` and `array.map` anonymous. + /// a curried wrapper (`const NAME = factory(...)(function () { ... })`), or + /// the property key when the call is an object member (`{ NAME: + /// factory(...)(fn) }`, #1747). Requiring the callee itself to be a call + /// keeps single-call computations such as `useMemo` and `array.map` + /// anonymous. fn curried_wrapper_bound_name(&self, node: SyntaxNode<'tree>) -> Option { if !matches!( self.spec.language(), @@ -2494,11 +2601,22 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { { return None; } - let declarator = call.parent()?; - if declarator.kind() != "variable_declarator" { + let binder = call.parent()?; + if binder.kind() == "pair" { + // A service is often an object a factory returns, so the wrapper's + // result lands in a `pair`; its key names the member, as it does + // for `key: () => {}`. + let value = child_by_field(binder, "value")?; + if value.start_byte() != call.start_byte() || value.end_byte() != call.end_byte() { + return None; + } + let key = child_by_field(binder, "key")?; + return js_object_member_name(key, self.source); + } + if binder.kind() != "variable_declarator" { return None; } - let name = child_by_field(declarator, "name")?; + let name = child_by_field(binder, "name")?; (name.kind() == "identifier").then(|| node_text(name, self.source)) } @@ -2530,7 +2648,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), visibility: self.spec.get_visibility(node), is_exported: self.spec.is_exported(node, self.source), ..NodeExtra::default() @@ -2574,7 +2692,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), visibility: self.spec.get_visibility(node), is_exported: self.spec.is_exported(node, self.source), ..NodeExtra::default() @@ -2622,7 +2740,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), signature: self.spec.get_signature(node, self.source), visibility: self.spec.get_visibility(node), is_async: self.spec.is_async(node), @@ -2685,7 +2803,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), is_exported: self.spec.is_exported(node, self.source), ..NodeExtra::default() }, @@ -2710,7 +2828,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), visibility: self.spec.get_visibility(node), is_exported: self.spec.is_exported(node, self.source), ..NodeExtra::default() @@ -2804,7 +2922,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &name, node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), is_exported: self.spec.is_exported(node, self.source), ..NodeExtra::default() }, @@ -2896,7 +3014,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { &node_text(name_node, self.source), node, NodeExtra { - docstring: self.preceding_docstring(node), + docstring: self.docstring_for(node), ..NodeExtra::default() }, ); @@ -2914,7 +3032,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else { NodeKind::Variable }; - let docstring = self.preceding_docstring(node); + let docstring = self.docstring_for(node); let is_exported = self.spec.is_exported(node, self.source); for i in 0..node.named_child_count() { @@ -2969,6 +3087,21 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if let Some(owner_id) = declared_id.as_deref() { self.extract_js_object_literal_members(value, owner_id); } + } else if let (Some(owner_id), Some(actions)) = ( + declared_id.as_deref(), + (value.kind() == "call_expression") + .then(|| initializer_returned_object(value, 0)) + .flatten() + .filter(|actions| object_has_inline_functions(*actions)) + .filter(|_| is_exported || self.is_exported_later(&name)), + ) { + // An exported store — `create((set, get) => ({ reset: () => + // set({}) }))`, middleware wrappers included — owns the + // inline actions of the object its initializer returns + // (upstream #647/#1862). The initializer is not walked as a + // whole: that would re-attribute every action's calls to + // the store itself. + self.extract_js_object_literal_members(actions, owner_id); } else { match declared_id { Some(id) => { @@ -2983,6 +3116,20 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } + /// A top-level binding exported by a LATER statement rather than at its + /// declaration: `export default NAME` or `export { NAME }` (upstream + /// `isExportedLater`). + fn is_exported_later(&self, name: &str) -> bool { + if !is_js_identifier(name) { + return false; + } + let name = regex::escape(name); + Regex::new(&format!( + r"(?m)^[ \t]*export\s+(?:default\s+{name}\s*;?[ \t\r]*$|\{{[^}}]*\b{name}\b[^}}]*\}})" + )) + .is_ok_and(|pattern| pattern.is_match(self.source)) + } + /// Emit callable members directly declared in one JS-family object literal. /// /// The holder constant/variable remains the namespace anchor; each direct @@ -3009,6 +3156,17 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { let Some(value) = child_by_field(member, "value") else { continue; }; + if value.kind() == "call_expression" { + // `key: Effect.fn("…")(function* () {…})` — see + // `curried_wrapper_bound_name` (#1747). + if let Some(function) = child_by_field(value, "arguments") + .and_then(|arguments| arguments.named_child(0)) + && let Some(bound) = self.curried_wrapper_bound_name(function) + { + self.extract_function(function, Some(bound)); + } + continue; + } if !matches!( value.kind(), "arrow_function" | "function_expression" | "generator_function" @@ -3100,7 +3258,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { let values: Vec<_> = expression_list .map(|list| list.named_children(&mut list.walk()).collect()) .unwrap_or_default(); - let docstring = self.preceding_docstring(node); + let docstring = self.docstring_for(node); for (index, target_node) in targets.into_iter().enumerate() { let Some((name, receiver, full_name)) = self.lua_assignment_target(target_node) else { @@ -3636,10 +3794,22 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { if is_member_shaped_callee(func) { if let Some(property) = member_name_of(func) { let method_name = node_text(property, self.source); + let ts_js = is_ts_js_chain_language(self.spec.language()); + // TS/JS: look through wrappers that keep the receiver the + // same object (`(x).m()`, `x!.m()`, `(x as T).m()`, + // `(await f()).m()`), so the branches below see the + // identifier / call / chain the call is really made on. let receiver = child_by_field(func, "object") .or_else(|| child_by_field(func, "operand")) .or_else(|| child_by_field(func, "argument")) - .or_else(|| func.named_child(0)); + .or_else(|| func.named_child(0)) + .map(|receiver| { + if ts_js { + peel_ts_js_receiver(receiver) + } else { + receiver + } + }); if let Some(receiver) = receiver { if is_literal_receiver(receiver) { return; @@ -3695,18 +3865,16 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else { method_name }; - } else if receiver.kind() == "call_expression" - && matches!( - self.spec.language(), - Language::TypeScript - | Language::Tsx - | Language::JavaScript - | Language::Jsx - ) + } else if (ts_js && receiver.kind() == "call_expression") + || (self.spec.language() == Language::Python + && receiver.kind() == "call") { // Keep a simple inner callee so resolution can // reject an untyped call-result receiver instead of - // degrading it to a bare same-named callable. + // degrading it to a bare same-named callable — + // `make().run()`, `d.setdefault(k, []).append(v)` + // (#1683). An inner callee that is not a plain name + // or member chain has no static receiver at all. let Some(inner) = self.plain_js_inner_callee(receiver) else { return; }; @@ -3741,25 +3909,64 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } _ => method_name, }; - } else if matches!( - self.spec.language(), - Language::TypeScript - | Language::Tsx - | Language::JavaScript - | Language::Jsx - ) && let Some(field) = self.js_this_field_of(receiver) - { + } else if ts_js && let Some(field) = self.js_this_field_of(receiver) { // `this.field.method()` is an exclusive typed-field // path; keeping `this` prevents name heuristics from - // guessing an unrelated method. + // guessing an unrelated method. An ES private field + // (`this.#items.add()`) is the same shape and keeps + // its `#` (#1987). callee_name = format!("this.{field}.{method_name}"); + } else if ts_js + && is_ts_js_chain_receiver(receiver) + && is_unresolved_ts_js_chain(receiver, self.source) + { + // Keep the source call for effect reporting, but never + // collapse it to a guessed method: resolution lets + // only frameworks with receiver evidence handle these + // qualified chains (upstream #1862). + let Some(chain) = plain_ts_js_chain(&node_text(func, self.source)) + else { + return; + }; + callee_name = chain; + } else if ts_js && !keeps_bare_ts_js_receiver(receiver, self.source) { + // Any other TS/JS receiver is an expression with no + // static type here — `(a ?? b).map()`, + // `f().list.map()`, `arr[0].run()`, + // `(() => {}).call()`. The bare method name would + // exact-match whichever project method shares it. + // Emit nothing: a silent miss, never a wrong edge + // (upstream #1986). + return; + } else if ts_js { + // `super.m()`, `window.Ns.m()` and `new C().m()`: + // the owner is read off the enclosing class, the + // project global, or the call itself. KEEP-RUST: a + // member chain rooted at `this`/`super` through more + // than one field (`this.a.b.m()`) keeps its last + // segment (#1496) instead of upstream v1.6.1's bare + // name, which would let the name matcher bind the + // enclosing class's same-named method — the very + // self-edge #1496 removed. + let owner_segment = (receiver.kind() == "member_expression" + && ts_js_chain_root(receiver) + .is_some_and(|root| matches!(root.kind(), "this" | "super"))) + .then(|| member_name_of(receiver)) + .flatten() + .map(|segment| node_text(segment, self.source)) + .filter(|segment| is_plain_receiver_segment(segment)); + callee_name = match owner_segment { + Some(segment) => format!("{segment}.{method_name}"), + None => method_name, + }; } else if is_member_shaped_callee(receiver) { - // #1496 — a receiver that is itself member-shaped - // (`this.mailer`, `holder.values`, `a.b.c`) keeps its - // LAST segment, so the ref arrives as `obj.method`. + // #1496 — outside TS/JS (handled above), a receiver + // that is itself member-shaped (`self.mailer`, + // `holder.values`, `a.b.c`) keeps its LAST segment, + // so the ref arrives as `obj.method`. // Dropping it would leave resolution unable to - // separate `this.run()` (self-target correct) from - // `this.mailer.send()` (self-target wrong), and a bare + // separate `self.run()` (self-target correct) from + // `self.mailer.send()` (self-target wrong), and a bare // `send` then binds to whichever same-named method is // nearest the call line. One segment, not the full // chain: that is the receiver grammar the name matcher @@ -3812,7 +4019,12 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } let object = child_by_field(receiver, "object")?; let property = child_by_field(receiver, "property")?; - if object.kind() != "this" || property.kind() != "property_identifier" { + if object.kind() != "this" + || !matches!( + property.kind(), + "property_identifier" | "private_property_identifier" + ) + { return None; } Some(node_text(property, self.source)) @@ -4689,6 +4901,21 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { }); } + /// The node's prose from both places it can live — a preceding comment and + /// a docstring inside the body — joined when both are present: they are + /// two things the author wrote about the same symbol (upstream + /// `docstringFor`, #1905). + fn docstring_for(&self, node: SyntaxNode<'tree>) -> Option { + let preceding = self + .preceding_docstring(node) + .filter(|text| !text.is_empty()); + let body = self.spec.body_docstring(node, self.source); + match (preceding, body) { + (Some(preceding), Some(body)) => Some(format!("{preceding}\n\n{body}")), + (preceding, body) => body.or(preceding), + } + } + fn preceding_docstring(&self, node: SyntaxNode<'tree>) -> Option { // Climb out of any wrapper(s) so a comment preceding the WHOLE construct // (export-, decorator-, or const-arrow-wrapped) is reachable as a @@ -4850,6 +5077,76 @@ fn js_object_member_name(node: SyntaxNode<'_>, source: &str) -> Option { .then(|| name.to_string()) } +/// The object literal a store-initializer call returns through one of its +/// function arguments — `create((set, get) => ({ … }))` — descending through +/// nested call arguments so middleware wrappers are unwrapped +/// (`create(persist((set, get) => ({ … }), { … }))`). Keyed purely on AST +/// shape, never on library names (upstream `findInitializerReturnedObject`). +fn initializer_returned_object(call: SyntaxNode<'_>, depth: usize) -> Option> { + if depth > 4 { + return None; + } + let arguments = child_by_field(call, "arguments")?; + let arguments = arguments + .named_children(&mut arguments.walk()) + .collect::>(); + arguments + .into_iter() + .find_map(|argument| match argument.kind() { + "arrow_function" | "function_expression" => function_returned_object(argument), + "call_expression" => initializer_returned_object(argument, depth + 1), + _ => None, + }) +} + +/// The object literal a function returns: the `=> ({ … })` arrow form or a +/// top-level `return { … }` of a block body. +fn function_returned_object(function: SyntaxNode<'_>) -> Option> { + fn as_object(node: SyntaxNode<'_>) -> Option> { + match node.kind() { + "object" | "object_expression" => Some(node), + "parenthesized_expression" => node + .named_children(&mut node.walk()) + .collect::>() + .into_iter() + .find_map(as_object), + _ => None, + } + } + let body = child_by_field(function, "body")?; + if let Some(object) = as_object(body) { + return Some(object); + } + if body.kind() != "statement_block" { + return None; + } + let statements = body.named_children(&mut body.walk()).collect::>(); + statements + .into_iter() + .filter(|statement| statement.kind() == "return_statement") + .find_map(|statement| { + statement + .named_children(&mut statement.walk()) + .collect::>() + .into_iter() + .find_map(as_object) + }) +} + +/// Whether an object literal declares an inline function member — an action +/// map, unlike a setup store's all-shorthand `return { foo, bar }`. +fn object_has_inline_functions(object: SyntaxNode<'_>) -> bool { + object + .named_children(&mut object.walk()) + .any(|member| match member.kind() { + "method_definition" => true, + "pair" => child_by_field(member, "value").is_some_and(|value| { + matches!(value.kind(), "arrow_function" | "function_expression") + }), + _ => false, + }) +} + /// The call's callee name when it is a bare identifier or `pkg::fn` (yields /// `fn`). Ports `calleeName` (r.ts:44-54). fn r_callee_name(call: SyntaxNode<'_>, source: &str) -> Option { diff --git a/crates/codegraph-extract/tests/batch_coverage_languages.rs b/crates/codegraph-extract/tests/batch_coverage_languages.rs index d7759762..c3616d49 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages.rs @@ -641,14 +641,13 @@ class Panel { assert_node(&result.nodes, NodeKind::Class, "Panel"); let load = assert_node(&result.nodes, NodeKind::Function, "load"); assert!(load.is_async, "load should be async"); - // An arrow assigned to a class field is a method whose name is anonymous - // in this grammar (the identifier lives outside the arrow_function node). + // An arrow assigned to a class field is a method named by the field's + // `property` key; a plain field is a property (upstream #808). + assert_node(&result.nodes, NodeKind::Method, "handler"); + assert_node(&result.nodes, NodeKind::Property, "value"); assert!( - result - .nodes - .iter() - .any(|n| n.kind == NodeKind::Method && n.name == ""), - "arrow field should yield an anonymous method; nodes={:#?}", + !result.nodes.iter().any(|n| n.name == ""), + "no class field may stay anonymous; nodes={:#?}", result.nodes ); assert_ref(&result.unresolved_references, EdgeKind::Calls, "fetchData"); diff --git a/crates/codegraph-extract/tests/c_family_correctness.rs b/crates/codegraph-extract/tests/c_family_correctness.rs index 03477b81..f3f67472 100644 --- a/crates/codegraph-extract/tests/c_family_correctness.rs +++ b/crates/codegraph-extract/tests/c_family_correctness.rs @@ -152,3 +152,104 @@ fn cpp_pure_virtual_declarations_become_abstract_methods_only() { && matches!(reference.reference_name.as_str(), "read" | "store.read") })); } + +// ---- single-argument function macros (#1373) -------------------------------- + +fn functions(file: &str, source: &str, language: Language) -> Vec { + let mut functions = extract_source(file, source, Some(language)) + .nodes + .into_iter() + .filter(|node| node.kind == NodeKind::Function) + .collect::>(); + functions.sort_by_key(|node| (node.start_line, node.start_column)); + functions +} + +#[test] +fn single_argument_function_macros_recover_their_function_name() { + let source = "#define NATIVE_FN(name) int name(void)\n\ + NATIVE_FN(get_version) { return helper(); }\n\ + int use_it(void) { return get_version(); }\n"; + for (file, language) in [("main.c", Language::C), ("main.cpp", Language::Cpp)] { + let result = extract_source(file, source, Some(language)); + let found = functions(file, source, language); + assert_eq!( + found + .iter() + .map(|node| node.name.as_str()) + .collect::>(), + vec!["get_version", "use_it"], + "{file}" + ); + let recovered = &found[0]; + assert_eq!( + ( + recovered.qualified_name.as_str(), + recovered.start_line, + recovered.end_line, + recovered.start_column + ), + ("get_version", 2, 2, 0), + "{file}" + ); + let calls = |from: &str, name: &str| { + result.unresolved_references.iter().any(|reference| { + reference.from_node_id == from + && reference.reference_name == name + && reference.reference_kind == EdgeKind::Calls + }) + }; + assert!(calls(&found[0].id, "helper"), "{file}"); + assert!(calls(&found[1].id, "get_version"), "{file}"); + } +} + +#[test] +fn single_argument_function_macros_are_never_guessed() { + let prefixes = [ + "", + "// #define NATIVE_FN(name) int name(void)\n", + "#define NATIVE_FN(name) int fixed(name)\n", + "#define NATIVE_FN(name) int test_ ## name(void)\n", + "#define NATIVE_FN(name) register_test(name)\n", + "#define NATIVE_FN(name) typedef int name(void)\n", + "#define NATIVE_FN(name) int name(void)\n#define NATIVE_FN int\n", + "#define NATIVE_FN(name) int name(void)\n#ifdef OTHER\n#undef NATIVE_FN\n#endif\n", + "#define NATIVE_FN(name) int name(void)\n#undef NATIVE_FN\n", + "#define NATIVE_FN(name) int name(void)\n#define NATIVE_FN(name) int fixed(name)\n", + ]; + for (file, language) in [("main.c", Language::C), ("main.cpp", Language::Cpp)] { + for prefix in prefixes { + let source = format!("{prefix}NATIVE_FN(candidate) {{ return 1; }}\n"); + assert!( + !functions(file, &source, language) + .iter() + .any(|node| node.name == "candidate"), + "{file}: {prefix:?}" + ); + } + let alternate = "#ifdef OTHER\n#define NATIVE_FN(name) int name(void)\n#else\n\ + NATIVE_FN(candidate) { return 1; }\n#endif\n"; + assert!( + !functions(file, alternate, language) + .iter() + .any(|node| node.name == "candidate"), + "{file}: #else branch" + ); + let ordinary = functions(file, "int (parenthesized)(void) { return 1; }\n", language); + assert_eq!( + ordinary.first().map(|node| node.name.as_str()), + Some("(parenthesized)"), + "{file}" + ); + } + let knr = functions( + "knr.c", + "int old_style(arg) int arg; { return arg; }\n", + Language::C, + ); + assert_eq!( + knr.first().map(|node| node.name.as_str()), + Some("old_style") + ); +} diff --git a/crates/codegraph-extract/tests/fixtures/python_docstrings/docstrings.py b/crates/codegraph-extract/tests/fixtures/python_docstrings/docstrings.py new file mode 100644 index 00000000..127fb922 --- /dev/null +++ b/crates/codegraph-extract/tests/fixtures/python_docstrings/docstrings.py @@ -0,0 +1,102 @@ +# Module header is not the module docstring. +(u"Ledger module " r"documentation.") + +# Legacy reconciliation path. +@decorator +async def reconcile_ledger(): + # A comment before the docstring is allowed. + """Settle the nightly discrepancy with the bank.""" + return 1 + +# Settle the nightly discrepancy with the bank. +def audit_ledger(): + return 1 + +# Ledger class comment. +@decorator +class Ledger: + # Class body comment. + '''Post entries. + + Preserve relative indentation: + details here. + ''' + + # Method comment. + @staticmethod + async def settle(): + # Method body comment. + (U"Settle " # Between concatenated strings. + R"the ledger.") + return 1 + +def raw_doc(): + R'''Raw \d+ expression.''' + return 1 + +def empty_concat(): + "" "Kept prose." "" + return 1 + +def empty_doc(): + "" + return 1 + +def bytes_doc(): + b"Not documentation." + return 1 + +def raw_bytes_doc(): + BR"Not documentation." + return 1 + +def formatted_doc(): + F"Not documentation." + return 1 + +def interpolated_doc(): + rf"Not {documentation}." + return 1 + +def concatenated_bytes(): + b"Not " b"documentation." + return 1 + +def concatenated_format(): + "Not " f"documentation." + return 1 + +def late_string(): + value = 1 + "Not documentation." + return value + +def computed_string(): + "Not " + "documentation." + return 1 + +def tuple_string(): + ("Not documentation.",) + return 1 + +def bare_tuple(): + "Not documentation.", "Also not." + return 1 + +def singleton_tuple(): + "Not documentation.", + return 1 + +# +def blank_comment(): + (("Kept despite blank comment.")) + return 1 + +def nested_string(): + if True: + "Not documentation." + return 1 + +def ranked_decoy(): + """reconcile_ledger reconcile_ledger reconcile_ledger""" + return 1 diff --git a/crates/codegraph-extract/tests/js_callable_semantics.rs b/crates/codegraph-extract/tests/js_callable_semantics.rs index f6e99cf1..e3015c00 100644 --- a/crates/codegraph-extract/tests/js_callable_semantics.rs +++ b/crates/codegraph-extract/tests/js_callable_semantics.rs @@ -190,3 +190,100 @@ handlers.onSave = () => { findItems(); }; assert!(calls_from(&result, &delete.id).contains(&"removeItem")); assert!(!function_names(&result).contains(&"onSave")); } + +#[test] +fn tsx_and_jsx_class_fields_are_modelled_like_their_base_language() { + // TSX and JSX run the TypeScript/JavaScript extractors (upstream + // `languages/index.ts`); a plain field is a property there, only a + // function-valued field is a method. + let source = r#" +class Cart {} +export class Vault { + items = new Cart(); + #secret = 1; + handle = () => { run(); }; + run() {} +} +"#; + let shape = |file: &str, language: Language| { + let mut members = extract(file, source, language) + .nodes + .into_iter() + .filter(|node| node.qualified_name.starts_with("Vault::")) + .map(|node| (node.name, node.kind)) + .collect::>(); + members.sort_by(|a, b| a.0.cmp(&b.0)); + members + }; + // JS names a field by its `property` child (upstream #808). + let expected = vec![ + ("#secret".to_string(), NodeKind::Property), + ("handle".to_string(), NodeKind::Method), + ("items".to_string(), NodeKind::Property), + ("run".to_string(), NodeKind::Method), + ]; + for (file, language) in [ + ("vault.ts", Language::TypeScript), + ("vault.tsx", Language::Tsx), + ("vault.js", Language::JavaScript), + ("vault.jsx", Language::Jsx), + ] { + assert_eq!(shape(file, language), expected, "{file}"); + } +} + +#[test] +fn curried_wrapper_object_members_are_named_by_their_key() { + // Upstream #1747 follow-up: an Effect service is usually an object a + // factory returns, so the wrapper's result lands in a `pair`. + let result = extract( + "src/m.ts", + r#" +declare const Effect: { fn: (n: string) => (b: unknown) => unknown }; +declare function wrap(n: string): (c: unknown) => unknown; +declare function useMemo(f: () => T, d: unknown[]): T; + +function helper() { return 1; } + +function make() { + return { + getMode: Effect.fn("ACP.Session.getMode")(function* (id: string) { return helper(); }), + 'quotedKey': wrap("n")(() => { return helper(); }), + computed: useMemo(() => 1 + 1, []), + mapped: [1, 2].map((x) => x + 1), + }; +} + +const service = { + run: Effect.fn("Service.run")(function* () { return helper(); }), + plain: () => helper(), +}; + +export const mixed = { + direct: () => helper(), + wrapped: Effect.fn("Mixed.wrapped")(function* () { return helper(); }), +}; +"#, + Language::TypeScript, + ); + let names = function_names(&result); + for expected in ["getMode", "quotedKey", "run", "wrapped"] { + assert!(names.contains(&expected), "missing {expected}; {names:?}"); + } + assert!( + !names.contains(&"computed") && !names.contains(&"mapped"), + "{names:?}" + ); + // Neither path may mint a member twice. + for once in ["run", "wrapped", "plain"] { + assert_eq!( + names.iter().filter(|name| **name == once).count(), + 1, + "{once}: {names:?}" + ); + } + let get_mode = node(&result, NodeKind::Function, "getMode"); + assert!(calls_from(&result, &get_mode.id).contains(&"helper")); + let make = node(&result, NodeKind::Function, "make"); + assert!(!calls_from(&result, &make.id).contains(&"helper")); +} diff --git a/crates/codegraph-extract/tests/js_receiver_shapes.rs b/crates/codegraph-extract/tests/js_receiver_shapes.rs new file mode 100644 index 00000000..f5882d5a --- /dev/null +++ b/crates/codegraph-extract/tests/js_receiver_shapes.rs @@ -0,0 +1,266 @@ +//! TS/JS member-call receiver shapes (upstream v1.6.1 #1566/#1683/#1862/#1986/#1987). +//! +//! Ported from upstream `kernel-tsjs-parity.test.ts`, +//! `expression-receiver-calls.test.ts` and `call-receiver-no-fabrication.test.ts`: +//! wrappers that keep the receiver are looked through, identifier-rooted +//! chains keep their qualified source text, an untyped expression receiver +//! emits nothing, and an ES private field keeps its `#`. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language}; +use codegraph_extract::extract_source; + +const JS_FAMILY: [(&str, Language); 4] = [ + ("ts", Language::TypeScript), + ("tsx", Language::Tsx), + ("js", Language::JavaScript), + ("jsx", Language::Jsx), +]; + +fn extract(file: &str, source: &str, language: Language) -> ExtractionResult { + let result = extract_source(file, source, Some(language)); + assert!( + result.errors.is_empty(), + "{file}: extraction errors: {:?}", + result.errors + ); + result +} + +fn calls(result: &ExtractionResult) -> Vec<&str> { + result + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Calls) + .map(|reference| reference.reference_name.as_str()) + .collect() +} + +fn calls_from<'a>(result: &'a ExtractionResult, function: &str) -> Vec<&'a str> { + let id = &result + .nodes + .iter() + .find(|node| node.name == function) + .unwrap_or_else(|| panic!("missing {function}; nodes={:#?}", result.nodes)) + .id; + result + .unresolved_references + .iter() + .filter(|reference| { + &reference.from_node_id == id && reference.reference_kind == EdgeKind::Calls + }) + .map(|reference| reference.reference_name.as_str()) + .collect() +} + +#[test] +fn transparent_wrappers_peel_and_untyped_expression_receivers_emit_nothing() { + for (ext, language) in JS_FAMILY { + let typed = matches!(language, Language::TypeScript | Language::Tsx); + let typed_calls = if typed { + "x!.run(); (y as X).run(); (x satisfies X).stop(); getTarget(\"a\")!.install(); \ + if (x && y!.c.has(1)) {} if (x && this.e!.c.has(1)) {}" + } else { + "" + }; + let source = format!( + " +function list() {{ return []; }} +class Runner {{ go() {{ return 1; }} }} +async function exprReceivers(x, y) {{ + (await list()).map(g); + (x).run(); + {typed_calls} + (a ?? b).map(g); + arr[0].run(); + f().list.map(g); + (() => 1).call(null); + this.a.b.run(); + super.stop(); + new Runner().go(); + window.Api.start(); +}} +" + ); + let result = extract(&format!("fixture.{ext}"), &source, language); + // KEEP-RUST: a chain rooted at `this` through more than one field keeps + // its last segment (#1496) — `this.a.b.run()` is `b.run`, and + // `(x && this.e)!.c.has()` is `c.has` — where upstream emits the bare + // method name. + let mut expected = vec!["list().map", "list", "x.run"]; + if typed { + expected.extend([ + "x.run", + "y.run", + "x.stop", + "getTarget().install", + "getTarget", + "c.has", + ]); + } + expected.extend(["f", "b.run", "stop", "go", "start"]); + assert_eq!(calls_from(&result, "exprReceivers"), expected, "{ext}"); + } +} + +#[test] +fn typescript_receiver_wrappers_follow_upstream_order() { + let source = r#"async function f(x: X, y: any) { + (await list()).map(g); + x!.run(); + (y as X).run(); + (x satisfies X).stop(); + getTarget("a")!.install(); + (a ?? b).map(g); + arr[0].run(); + f().list.map(g); + (() => 1).call(null); + this.a.b.run(); + new Runner().go(); + window.Api.start(); + (x).halt(); +} +"#; + let result = extract("t.ts", source, Language::TypeScript); + assert_eq!( + calls(&result), + vec![ + "list().map", + "list", + "x.run", + "y.run", + "x.stop", + "getTarget().install", + "getTarget", + "f", + "b.run", + "go", + "start", + "x.halt", + ] + ); +} + +#[test] +fn identifier_rooted_chains_keep_qualified_sites_and_argument_calls() { + for (ext, language) in JS_FAMILY { + let result = extract( + &format!("fixture.{ext}"), + " +function readKey() { return 'answer'; } +function local() { + const values = new Map(); + return values.get(readKey()); +} +function nested(holder) { + holder.values.get(readKey()); + holder.values?.get(readKey()); + holder['values'].get(readKey()); + holder.deep.values.get(readKey()); +} +", + language, + ); + // Qualified sites are retained for effects; computed keys still make no + // receiver claim. All four calls inside arguments must also survive. + assert_eq!( + calls_from(&result, "nested"), + vec![ + "holder.values.get", + "readKey", + "holder.values.get", + "readKey", + "readKey", + "holder.deep.values.get", + "readKey", + ], + "{ext}" + ); + assert!(calls(&result).contains(&"values.get"), "{ext}"); + } +} + +#[test] +fn optional_chains_keep_the_same_qualified_site() { + let result = extract( + "effects.ts", + "import { client } from './client'; +export function create() { return 1; } +export function effects() { + client.user.create({ data: {} }); + client?.user?.create({ data: {} }); +} +", + Language::TypeScript, + ); + assert_eq!( + calls_from(&result, "effects"), + vec!["client.user.create", "client.user.create"] + ); +} + +#[test] +fn private_field_receivers_keep_their_hash() { + let source = " +class Mailer { send() {} } +class Vault { + #mailer = new Mailer(); + #items = new Set(); + notify() { this.#mailer?.send(); } + optional() { this.#mailer.send?.(); } + put() { this.#items?.add('x'); } +} +"; + for (ext, language) in JS_FAMILY { + for (ending, text) in [ + ("LF", source.to_string()), + ("CRLF", source.replace('\n', "\r\n")), + ] { + let result = extract(&format!("vault.{ext}"), &text, language); + assert_eq!( + calls(&result), + vec!["this.#mailer.send", "this.#mailer.send", "this.#items.add"], + "{ext} {ending}" + ); + } + } +} + +#[test] +fn call_result_receivers_keep_a_plain_inner_callee() { + let js = extract( + "src/x.js", + "function f(d) { d.setdefault(\"k\", []).append(1); make().run(); (0, make)().run(); arr[0]().go(); }", + Language::JavaScript, + ); + let mut names = calls(&js); + names.sort_unstable(); + // `(0, make)` and `arr[0]` are the inner calls' own refs; their chains are dropped. + assert_eq!( + names, + vec![ + "(0, make)", + "arr[0]", + "d.setdefault", + "d.setdefault().append", + "make", + "make().run", + ] + ); + + let py = extract( + "x.py", + "def f(d):\n d.setdefault(\"k\", []).append(1)\n d.items().get(2)\n", + Language::Python, + ); + let mut names = calls(&py); + names.sort_unstable(); + assert_eq!( + names, + vec![ + "d.items", + "d.items().get", + "d.setdefault", + "d.setdefault().append", + ] + ); +} diff --git a/crates/codegraph-extract/tests/python_docstrings.rs b/crates/codegraph-extract/tests/python_docstrings.rs new file mode 100644 index 00000000..8dce62f6 --- /dev/null +++ b/crates/codegraph-extract/tests/python_docstrings.rs @@ -0,0 +1,198 @@ +//! Python body docstrings (upstream v1.6.1 #1905). +//! +//! A Python docstring — a bare string literal first in a module, class or +//! function body — used to be invisible: only preceding comments reached the +//! `docstring` column. Ported from upstream `python-body-docstrings.test.ts`; +//! `fixtures/python_docstrings/docstrings.py` is upstream's kernel-parity +//! fixture, unchanged. + +use std::collections::HashMap; + +use codegraph_core::types::Language; +use codegraph_extract::extract_source; + +fn docstrings(source: &str) -> HashMap> { + let result = extract_source("ledger.py", source, Some(Language::Python)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + result + .nodes + .into_iter() + .map(|node| (node.name, node.docstring)) + .collect() +} + +fn docstring(by_name: &HashMap>, name: &str) -> Option { + by_name + .get(name) + .unwrap_or_else(|| panic!("missing node {name}; {:?}", by_name.keys())) + .clone() +} + +// Git may check the fixture out as CRLF. Start from LF so the CRLF variant +// adds exactly one carriage return per newline on either platform. +fn fixture() -> String { + include_str!("fixtures/python_docstrings/docstrings.py").replace("\r\n", "\n") +} + +#[test] +fn module_decorated_async_and_literal_forms() { + for (ending, source) in [("LF", fixture()), ("CRLF", fixture().replace('\n', "\r\n"))] { + let by_name = docstrings(&source); + let expect = |name: &str, want: &str| { + assert_eq!( + docstring(&by_name, name).as_deref(), + Some(want), + "{ending}: {name}" + ); + }; + expect("ledger.py", "Ledger module documentation."); + expect( + "reconcile_ledger", + "Legacy reconciliation path.\n\nSettle the nightly discrepancy with the bank.", + ); + expect( + "Ledger", + "Ledger class comment.\n\nPost entries.\n\nPreserve relative indentation:\n details here.", + ); + expect("settle", "Method comment.\n\nSettle the ledger."); + expect("raw_doc", "Raw \\d+ expression."); + expect("empty_concat", "Kept prose."); + expect("blank_comment", "Kept despite blank comment."); + for name in [ + "empty_doc", + "bytes_doc", + "raw_bytes_doc", + "formatted_doc", + "interpolated_doc", + "concatenated_bytes", + "concatenated_format", + "late_string", + "computed_string", + "tuple_string", + "bare_tuple", + "singleton_tuple", + "nested_string", + ] { + assert_eq!(docstring(&by_name, name), None, "{ending}: {name}"); + } + } +} + +#[test] +fn tabs_expand_and_relative_indentation_survives() { + let by_name = + docstrings("def tabbed():\n \"\"\"Summary.\n\n\tDetails.\n\t Nested.\n \"\"\"\n"); + assert_eq!( + docstring(&by_name, "tabbed").as_deref(), + Some("Summary.\n\nDetails.\n Nested.") + ); +} + +#[test] +fn a_non_docstring_module_first_statement_is_rejected() { + for source in [ + "b\"bytes\"", + "f\"formatted\"", + "pass\n\"late\"", + "\"one\" + \"two\"", + ] { + assert_eq!( + docstring(&docstrings(source), "ledger.py"), + None, + "{source}" + ); + } +} + +#[test] +fn a_docstring_and_a_leading_comment_carry_the_same_sentence() { + let by_name = docstrings( + r#" +def reconcile_ledger(): + """Settle the nightly discrepancy with the bank.""" + return LEDGER + +# Settle the nightly discrepancy with the bank. +def audit_ledger(): + return LEDGER +"#, + ); + for name in ["reconcile_ledger", "audit_ledger"] { + assert_eq!( + docstring(&by_name, name).as_deref(), + Some("Settle the nightly discrepancy with the bank."), + "{name}" + ); + } +} + +#[test] +fn a_multiline_docstring_is_dedented_to_its_own_margin() { + let by_name = docstrings( + r#" +class Ledger: + """Post entries to the general ledger. + + Nightly reconciliation runs against the bank feed. + """ + pass +"#, + ); + assert_eq!( + docstring(&by_name, "Ledger").as_deref(), + Some( + "Post entries to the general ledger.\n\nNightly reconciliation runs against the bank feed." + ) + ); +} + +#[test] +fn a_comment_and_a_docstring_are_both_kept() { + let by_name = docstrings( + r#" +# Legacy path, kept for the 2019 import. +def reconcile_ledger(): + """Settle the nightly discrepancy with the bank.""" + return LEDGER +"#, + ); + assert_eq!( + docstring(&by_name, "reconcile_ledger").as_deref(), + Some( + "Legacy path, kept for the 2019 import.\n\nSettle the nightly discrepancy with the bank." + ) + ); +} + +#[test] +fn single_quoted_prefixed_f_string_and_code_first_statements() { + let by_name = docstrings( + r#" +def with_single(): + '''Single-quoted docstring.''' + return 1 + +def with_raw_prefix(): + r"""Raw \d+ docstring.""" + return 2 + +def interpolated(name): + f"""Hello {name}.""" + return name + +def no_docstring(): + total = 0 + return total +"#, + ); + assert_eq!( + docstring(&by_name, "with_single").as_deref(), + Some("Single-quoted docstring.") + ); + assert_eq!( + docstring(&by_name, "with_raw_prefix").as_deref(), + Some("Raw \\d+ docstring.") + ); + assert_eq!(docstring(&by_name, "interpolated"), None); + assert_eq!(docstring(&by_name, "no_docstring"), None); +} diff --git a/crates/codegraph-resolve/src/alias_binding.rs b/crates/codegraph-resolve/src/alias_binding.rs index 4ce71752..299774f2 100644 --- a/crates/codegraph-resolve/src/alias_binding.rs +++ b/crates/codegraph-resolve/src/alias_binding.rs @@ -1,10 +1,11 @@ //! Resolution for a binding whose initializer is only another callable. -use codegraph_core::types::{Node, NodeKind}; +use codegraph_core::types::{EdgeKind, Node, NodeKind}; use regex::Regex; -use crate::name_matcher::same_language_family; -use crate::types::ResolutionContext; +use crate::name_matcher::{is_object_literal_language, same_language_family}; +use crate::object_literal::resolve_object_literal_binding; +use crate::types::{RefView, ResolutionContext}; fn is_alias_binding_kind(kind: NodeKind) -> bool { matches!( @@ -69,6 +70,29 @@ pub(crate) fn resolve_alias_binding( if !is_alias_binding_kind(alias.kind) { return None; } + // A JS-family object member is read off the literal's own properties and + // followed lexically from where the literal is written (#1932), not + // pattern-matched in the (truncated) signature. + if let Some(member) = member_name + && is_object_literal_language(alias.language) + { + let reference = RefView { + row_id: None, + from_node_id: alias.id.clone(), + reference_name: member.to_string(), + reference_kind: EdgeKind::Calls, + line: alias.start_line, + column: alias.start_column, + file_path: alias.file_path.clone(), + language: alias.language, + is_function_ref: false, + reference_subkind: None, + }; + let resolved = resolve_object_literal_binding(alias, member, &reference, context)?; + return context + .get_node_by_id_shared(&resolved.target_node_id) + .map(|node| node.as_ref().clone()); + } let target_name = alias_target_name(alias.signature.as_deref(), member_name)?; if target_name == alias.name { return None; diff --git a/crates/codegraph-resolve/src/awaited.rs b/crates/codegraph-resolve/src/awaited.rs index a5f474cd..73c1e609 100644 --- a/crates/codegraph-resolve/src/awaited.rs +++ b/crates/codegraph-resolve/src/awaited.rs @@ -264,7 +264,7 @@ pub(crate) fn has_parameter_binding(code: &str, name: &str) -> bool { } /// `\b(?:const|let|var|function|class)\s+(?:NAME\b|\{[^}]*\bNAME\b)` starts. -fn declaration_offsets(code: &str, name: &str) -> Vec { +pub(crate) fn declaration_offsets(code: &str, name: &str) -> Vec { static KEYWORD: OnceLock = OnceLock::new(); let keyword = KEYWORD.get_or_init(|| { Regex::new(r"(?-u:\b)(?:const|let|var|function|class)\s+").expect("declaration keyword") @@ -296,7 +296,11 @@ fn is_reassigned(code: &str, name: &str) -> bool { /// `importShadowedAt`: a parameter of the enclosing function, or a declaration /// in a scope enclosing the position, rebinds the imported `name`. -fn import_shadowed_at(name: &str, reference: &RefView, context: &dyn ResolutionContext) -> bool { +pub(crate) fn import_shadowed_at( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { let enclosing_parameter = context .get_nodes_in_file_shared(&reference.file_path) .iter() diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index fc88c495..7d3ba576 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -1619,12 +1619,21 @@ pub fn resolve_via_import( .reference_name .starts_with(&format!("{}.", imp.local_name)) { - if let Some(resolved_path) = resolve_import_path( + // Named Python imports need the same absolute-module lookup as + // namespace imports, including aliases used as receiver types + // (upstream #1820). + let resolved_path = resolve_import_path( &imp.source, &reference.file_path, reference.language, context, - ) { + ) + .or_else(|| { + (reference.language == Language::Python) + .then(|| find_python_module_file(&imp.source, context, &reference.file_path)) + .flatten() + }); + if let Some(resolved_path) = resolved_path { let exported_name = if imp.is_default { "default".to_string() } else { @@ -1640,6 +1649,27 @@ pub fn resolve_via_import( None }; + let python_symbol = |name: &str| { + // Python symbols are never marked exported; a named import + // names the module's own top-level definition (#1820). + context + .get_nodes_in_file_shared(&resolved_path) + .into_iter() + .find(|node| { + node.name == name + && !node.qualified_name.contains("::") + && matches!( + node.kind, + NodeKind::Class + | NodeKind::Function + | NodeKind::Variable + | NodeKind::Constant + ) + }) + .map(|node| node.as_ref().clone()) + }; + let wanted_python_name = + member_name.clone().unwrap_or_else(|| exported_name.clone()); if let Some(target_node) = find_exported_symbol( &resolved_path, &Want { @@ -1652,7 +1682,12 @@ pub fn resolve_via_import( context, &mut BTreeSet::new(), 0, - ) { + ) + .or_else(|| { + (reference.language == Language::Python) + .then(|| python_symbol(&wanted_python_name)) + .flatten() + }) { // `Foo.bar()` on a NAMED class import resolves the receiver to the // class container; descend to the static member so the edge stays a // `calls`/etc. to `method:bar` rather than mislinking to the class @@ -1677,6 +1712,23 @@ pub fn resolve_via_import( ) { return Some(literal_member); } + // `{ getUser }` / `{ getUser: fetchUser }`: the + // member names a binding of the literal's file + // (#1932). + if let Some(bound) = + crate::object_literal::resolve_object_literal_binding( + &target_node, + member, + reference, + context, + ) + { + return Some(ResolvedRef { + confidence: 0.9, + resolved_by: ResolvedBy::Import, + ..bound + }); + } } } // An imported VALUE called through a member — @@ -1694,9 +1746,27 @@ pub fn resolve_via_import( return Some(instance_member); } } + let member_access = !imp.is_namespace + && reference + .reference_name + .starts_with(&format!("{}.", imp.local_name)); let resolved_target = if !imp.is_namespace { - resolve_static_member(&target_node, reference, &imp.local_name, context) - .unwrap_or(target_node) + match resolve_static_member( + &target_node, + reference, + &imp.local_name, + context, + ) { + Some(member) => member, + // Finding a named Python import proves the receiver + // exists, not its requested attribute: `task.delay()` + // enqueues work, it does not call the task function. + // Keep unknown members unresolved (upstream #2040). + None if member_access && reference.language == Language::Python => { + return None; + } + None => target_node, + } } else { target_node }; diff --git a/crates/codegraph-resolve/src/js_store.rs b/crates/codegraph-resolve/src/js_store.rs new file mode 100644 index 00000000..27a87380 --- /dev/null +++ b/crates/codegraph-resolve/src/js_store.rs @@ -0,0 +1,431 @@ +//! Store-action binding for JS/TS state stores (upstream v1.6.1 #1862). +//! +//! A Zustand-style store — `export const useStore = create((set, get) => ({ +//! reset: () => set({}) }))` — owns its actions as members of the store +//! constant. Four call forms reach one of them: +//! +//! * `get().reset()` inside the store factory, where `get` is a parameter of +//! the factory arrow; +//! * `useStore.getState().reset()` anywhere the store name is bound; +//! * a bare `reset()` destructured from `useStore.getState()` in scope; +//! * a bare `selected()` bound by a selector `useStore((s) => s.reset)` of a +//! store created by Zustand's `create`. +//! +//! Each form identifies exactly one store and resolves the member inside that +//! store's own object literal — never a same-named function elsewhere, and +//! never an interface signature. Anything less certain stays unresolved. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use codegraph_core::types::{EdgeKind, Node, NodeKind}; +use regex::Regex; + +use crate::awaited::{declaration_offsets, has_parameter_binding, import_shadowed_at}; +use crate::import_resolver::resolve_via_import; +use crate::name_matcher::{ + enclosing_scope_start_line, is_bare_js_call, is_js_family, is_lexically_reachable, + range_within, resolve_object_literal_member, same_language_family, +}; +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// `get().m` / `getState().m` / `store.getState().m` for a TS/JS/Python call +/// whose receiver is itself a call — the one fallback such a chain keeps +/// (#1683). TS/JS resolves the member inside the identified store; Python +/// keeps upstream's unique-callable fallback. +pub(crate) fn match_store_accessor_chain( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let (inner, method) = split_accessor_chain(&reference.reference_name)?; + if !(inner == "get" || inner == "getState" || inner.ends_with(".getState")) { + return None; + } + if is_js_family(reference.language) { + return resolve_store_action(inner, method, reference, context, false); + } + let callables = context + .get_nodes_by_name_shared(method) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && same_language_family(node.language, reference.language) + && node.id != reference.from_node_id + }) + .collect::>(); + let [only] = callables.as_slice() else { + return None; + }; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: only.id.clone(), + confidence: 0.6, + resolved_by: ResolvedBy::ExactMatch, + }) +} + +/// A bare JS/TS call bound to a store action by a destructuring of +/// `store.getState()` or by a selector — the binding need not share the +/// action's name, so the resolver's symbol-existence prefilter must let it +/// through. +pub(crate) fn match_js_store_binding_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if reference.reference_kind != EdgeKind::Calls || !is_bare_js_call(reference, context) { + return None; + } + match_destructured_store_call(reference, context) + .or_else(|| match_selected_store_call(reference, context)) +} + +/// `^([\w$.]+)\(\)\.(\w+)$`. +fn split_accessor_chain(name: &str) -> Option<(&str, &str)> { + let (inner, method) = name.split_once("().")?; + let inner_ok = !inner.is_empty() + && inner + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '.')); + let method_ok = !method.is_empty() + && method + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_'); + (inner_ok && method_ok).then_some((inner, method)) +} + +/// Resolve `member` inside the store `inner` names: the enclosing factory for +/// `get`/`getState`, else the store bound to `.getState`. +fn resolve_store_action( + inner: &str, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, + selector: bool, +) -> Option { + let holders: Vec = if inner == "get" || inner == "getState" { + let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + let facts = context.source_facts(&reference.file_path)?; + context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + && range_within(&caller, node) + && factory_takes_accessor(&facts, node, &caller, inner) + }) + .map(|node| (*node).clone()) + .collect() + } else { + let name = inner.strip_suffix(".getState")?; + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + { + return None; + } + let mut lookup = reference.clone(); + lookup.reference_name = name.to_string(); + lookup.reference_kind = EdgeKind::References; + let imported = resolve_via_import(&lookup, context) + .and_then(|resolved| context.get_node_by_id_shared(&resolved.target_node_id)); + match imported { + Some(node) => { + if import_shadowed_at(name, reference, context) { + return None; + } + vec![(*node).clone()] + } + None => context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| { + node.file_path == reference.file_path + && is_lexically_reachable(node, reference, context) + }) + .map(|node| (*node).clone()) + .collect(), + } + }; + let [holder] = holders.as_slice() else { + return None; + }; + if selector && !is_zustand_hook(holder, context) { + return None; + } + resolve_object_literal_member( + holder, + member, + reference, + context, + 0.9, + ResolvedBy::InstanceMethod, + ) +} + +/// Whether the store factory enclosing `caller` takes `accessor` as its +/// second parameter — `(set, get) =>` / `(set, get, api) =>`. +fn factory_takes_accessor( + facts: &SourceFacts, + holder: &Node, + caller: &Node, + accessor: &str, +) -> bool { + static GET: OnceLock = OnceLock::new(); + static GET_STATE: OnceLock = OnceLock::new(); + let pattern = |cell: &'static OnceLock, name: &str| { + cell.get_or_init(|| { + Regex::new(&format!( + r"\(\s*[A-Za-z0-9_$]+\s*,\s*{name}\s*(?:,\s*[A-Za-z0-9_$]+\s*)?\)\s*=>" + )) + .expect("store accessor parameter pattern") + }) + }; + let pattern = match accessor { + "get" => pattern(&GET, "get"), + _ => pattern(&GET_STATE, "getState"), + }; + let start = holder.start_line.saturating_sub(1).max(0) as usize; + let end = (caller.start_line.max(0) as usize).min(facts.line_count()); + start < end && pattern.is_match(&facts.join_lines(start, end, "\n")) +} + +/// Only a Zustand hook promises to return a selector's result: +/// `const useStore = create(...)` with `create` imported from `zustand`. +fn is_zustand_hook(holder: &Node, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(&holder.file_path) else { + return false; + }; + let start = holder.start_line.saturating_sub(1).max(0) as usize; + let end = (holder.end_line.max(holder.start_line).max(0) as usize).min(facts.line_count()); + if start >= end { + return false; + } + let text = facts.join_lines(start, end, "\n"); + let Ok(pattern) = Regex::new(&format!( + r"(?-u:\b)(?:const|let)\s+{}\s*=\s*([A-Za-z0-9_$]+)\s*[<(]", + regex::escape(&holder.name) + )) else { + return false; + }; + let Some(factory) = pattern + .captures(&text) + .and_then(|captures| captures.get(1)) + .map(|factory| factory.as_str()) + else { + return false; + }; + context + .get_import_mappings(&holder.file_path, holder.language) + .iter() + .any(|mapping| { + mapping.local_name == factory + && mapping.source == "zustand" + && (mapping.exported_name == "create" || mapping.is_default) + }) +} + +/// A bare call to a name destructured from `store.getState()` in an +/// enclosing block of the same function: `const { reset } = +/// useStore.getState(); reset();`. +fn match_destructured_store_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(&reference.file_path)?; + if !facts.js_get_state_file(|source| source.contains(".getState")) { + return None; + } + let start = enclosing_scope_start_line(reference, context).saturating_sub(1); + let code = code_before(&facts, start.max(0) as usize, reference)?; + static BINDING: OnceLock = OnceLock::new(); + let binding = BINDING.get_or_init(|| { + Regex::new(r"(?-u:\b)const\s*\{([^{}]*)\}\s*=\s*([A-Za-z0-9_$]+)\.getState\s*\(\s*\)") + .expect("destructured store binding pattern") + }); + let call_scope = brace_stack(&code, code.len()); + let name = reference.reference_name.as_str(); + let bindings = binding.captures_iter(&code).collect::>(); + for captures in bindings.iter().rev() { + let (Some(whole), Some(names), Some(store)) = + (captures.get(0), captures.get(1), captures.get(2)) + else { + continue; + }; + // Plain named bindings only; defaults, rest and computed keys need + // their own value tracing rather than a same-name guess. + if !names.as_str().split(',').any(|part| part.trim() == name) { + continue; + } + if !encloses(&brace_stack(&code, whole.start()), &call_scope) { + continue; + } + // Another declaration between the binding and the call shadows it. + if !declaration_offsets(&code[whole.end()..], name).is_empty() { + return None; + } + return resolve_store_action( + &format!("{}.getState", store.as_str()), + name, + reference, + context, + false, + ); + } + None +} + +/// A bare call to a selector binding `const selected = useStore((s) => +/// s.reset)` in an enclosing block — closures may capture it; sibling scopes +/// and shadowing parameters or declarations cannot donate it. +fn match_selected_store_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(&reference.file_path)?; + if !facts.source().contains("=>") { + return None; + } + let name = reference.reference_name.as_str(); + if !facts.js_selector_names(selector_bound_names).contains(name) { + return None; + } + let code = code_before(&facts, 0, reference)?; + let binding = Regex::new(&format!( + r"(?-u:\b)const\s+{}\s*=\s*([A-Za-z0-9_$]+)\s*\(\s*(?:\(\s*([A-Za-z0-9_$]+)\s*\)|([A-Za-z0-9_$]+))\s*=>\s*([A-Za-z0-9_$]+)\.([A-Za-z0-9_$]+)\s*\)", + regex::escape(name) + )) + .ok()?; + let call_scope = brace_stack(&code, code.len()); + let bindings = binding.captures_iter(&code).collect::>(); + for captures in bindings.iter().rev() { + let (Some(whole), Some(store), Some(object), Some(member)) = ( + captures.get(0), + captures.get(1), + captures.get(4), + captures.get(5), + ) else { + continue; + }; + let parameter = captures.get(2).or_else(|| captures.get(3)); + if parameter.map(|parameter| parameter.as_str()) != Some(object.as_str()) { + continue; + } + if !encloses(&brace_stack(&code, whole.start()), &call_scope) { + continue; + } + let rest = &code[whole.end()..]; + if !declaration_offsets(rest, name).is_empty() || has_parameter_binding(rest, name) { + return None; + } + return resolve_store_action( + &format!("{}.getState", store.as_str()), + member.as_str(), + reference, + context, + true, + ); + } + None +} + +/// Names a file binds with `const name = fn((s) => …` or `fn(s => …`. +fn selector_bound_names(source: &str) -> HashSet { + static SELECTOR: OnceLock = OnceLock::new(); + SELECTOR + .get_or_init(|| { + Regex::new( + r"(?-u:\b)const\s+([A-Za-z0-9_$]+)\s*=\s*[A-Za-z0-9_$]+\s*\(\s*(?:\(\s*[A-Za-z0-9_$]+\s*\)|[A-Za-z0-9_$]+)\s*=>", + ) + .expect("selector binding pattern") + }) + .captures_iter(source) + .filter_map(|captures| captures.get(1).map(|name| name.as_str().to_string())) + .collect() +} + +/// The comment-free, string-blanked source from 0-based line `start` up to +/// the reference's column. +fn code_before(facts: &SourceFacts, start: usize, reference: &RefView) -> Option { + let line_index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let lines = facts.lines(); + let from = lines.raw_line_start(start.min(line_index))?; + let line_start = lines.raw_line_start(line_index)?; + let column = + (reference.column.max(0) as usize).min(facts.raw_line(line_index).map_or(0, str::len)); + let end = line_start + column; + let source = facts.source(); + if !source.is_char_boundary(from) || !source.is_char_boundary(end) || from > end { + return None; + } + Some(blank_string_contents(&strip_comments_for_regex( + &source[from..end], + CommentLang::TypeScript, + ))) +} + +/// Offsets of the `{` open at `end` in `code`, outermost first. +fn brace_stack(code: &str, end: usize) -> Vec { + let mut stack = Vec::new(); + for (offset, byte) in code.bytes().enumerate().take(end) { + match byte { + b'{' => stack.push(offset), + b'}' => { + stack.pop(); + } + _ => {} + } + } + stack +} + +/// Whether the block stack of a binding is a prefix of the call's — the +/// binding's block encloses the call rather than being a sibling of it. +fn encloses(binding: &[usize], call: &[usize]) -> bool { + binding + .iter() + .enumerate() + .all(|(depth, open)| call.get(depth) == Some(open)) +} + +#[cfg(test)] +mod tests { + use super::*; + use codegraph_core::types::Language; + + #[test] + fn accessor_chains_split_only_the_encoded_shape() { + assert_eq!( + split_accessor_chain("useStore.getState().reset"), + Some(("useStore.getState", "reset")) + ); + assert_eq!(split_accessor_chain("get().reset"), Some(("get", "reset"))); + assert_eq!(split_accessor_chain("get().a.b"), None); + assert_eq!(split_accessor_chain("().reset"), None); + assert_eq!(split_accessor_chain("make(x).reset"), None); + } + + #[test] + fn selector_names_need_a_callback_argument() { + let names = selector_bound_names( + "const a = useStore((s) => s.reset);\nconst b = other(s => s.x);\nconst c = plain(1);\n", + ); + assert_eq!(names, HashSet::from(["a".to_string(), "b".to_string()])); + } + + #[test] + fn brace_stacks_distinguish_enclosing_from_sibling_blocks() { + let code = "{ a { b } c { d"; + let call = brace_stack(code, code.len()); + assert!(encloses(&brace_stack(code, 2), &call)); + assert!(!encloses(&brace_stack(code, 6), &call)); + assert!(encloses(&brace_stack(code, 14), &call)); + } + + #[test] + fn python_chains_are_not_js_family() { + assert!(!is_js_family(Language::Python)); + } +} diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index 3c9feb3a..e75c63bb 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -22,8 +22,10 @@ pub mod context; pub mod framework; // the FrameworkResolver extension point pub mod frameworks; // concrete react/vue/nestjs FrameworkResolvers pub mod import_resolver; +mod js_store; pub mod lru_cache; pub mod name_matcher; +mod object_literal; pub mod path_aliases; pub mod pathutil; pub mod resolver; diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index c83617ae..71895502 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -5,7 +5,10 @@ //! cross-language family gate — mirror the upstream exactly. Every strategy cites its //! upstream source range. -use crate::source_facts::SourceFacts; +use crate::object_literal::{ + LiteralLookup, object_literal_property, property_contains, resolve_object_literal_binding, +}; +use crate::source_facts::{SourceFacts, TsFieldDeclaration}; use crate::strip_comments::{CommentLang, strip_comments_for_regex}; use crate::types::{ RefView, ResolutionContext, ResolvedBy, ResolvedRef, declares_type_name, is_esm_language, @@ -162,19 +165,27 @@ fn dir_of(path: &str) -> &str { } /// Language families that share a type system / runtime (`LANGUAGE_FAMILY`, -/// `name-matcher.ts:113-121`). +/// upstream v1.6.1): C, C++, Objective-C and Swift interoperate natively; ArkTS +/// and the single-file-component languages are web. fn language_family(lang: Language) -> Option<&'static str> { match lang { Language::Java | Language::Kotlin | Language::Scala => Some("jvm"), - Language::Swift | Language::ObjC => Some("apple"), - Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx => Some("web"), - Language::C | Language::Cpp => Some("c"), + Language::Swift | Language::ObjC | Language::C | Language::Cpp => Some("native"), + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + | Language::ArkTs + | Language::Svelte + | Language::Vue + | Language::Astro => Some("web"), Language::CSharp | Language::Razor => Some("dotnet"), + Language::Cfml => Some("cfml"), _ => None, } } -/// `sameLanguageFamily` (`name-matcher.ts:122-126`). +/// `sameLanguageFamily`. pub fn same_language_family(a: Language, b: Language) -> bool { if a == b { return true; @@ -185,28 +196,149 @@ pub fn same_language_family(a: Language, b: Language) -> bool { } } -/// `isKnownLanguageFamily` (`name-matcher.ts:134-136`). -pub fn is_known_language_family(lang: Language) -> bool { - language_family(lang).is_some() +/// The code family of `lang` (`CODE_FAMILY`): a multi-language family, or a +/// singleton for every other programming language. Config and markup formats +/// have none, so their transitions stay open. +fn code_family(lang: Language) -> Option<&'static str> { + language_family(lang).or(match lang { + Language::Python => Some("python"), + Language::Go => Some("go"), + Language::Rust => Some("rust"), + Language::Php => Some("php"), + Language::Ruby => Some("ruby"), + Language::Dart => Some("dart"), + Language::Lua | Language::Luau => Some("lua"), + Language::R => Some("r"), + Language::Erlang => Some("erlang"), + Language::Pascal => Some("pascal"), + Language::Solidity => Some("solidity"), + Language::Nix => Some("nix"), + _ => None, + }) +} + +/// Whether a reference from `a` to `b` crosses between two code families +/// (`crossesCodeBoundary`). +pub fn crosses_code_boundary(a: Language, b: Language) -> bool { + matches!((code_family(a), code_family(b)), (Some(fa), Some(fb)) if fa != fb) +} + +/// Cross-family name matches need an actual ABI boundary (`hasBridgeEvidence`): +/// a native caller reaching a cgo `//export` or a Rust `pub extern "C" fn`, or +/// a Go `C.name` / Rust `extern "C" { fn name(…) }` caller reaching a C or C++ +/// function. The evidence is scoped to the named free function. +fn has_bridge_evidence( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.reference_kind != EdgeKind::Calls || candidate.kind != NodeKind::Function { + return false; + } + let name = regex::escape(&candidate.name); + static CGO_IMPORT: OnceLock = OnceLock::new(); + let cgo_import = || { + CGO_IMPORT + .get_or_init(|| Regex::new(r#"(?-u:\b)import\s+(?:\(\s*)?"C""#).expect("cgo import")) + }; + if code_family(reference.language) == Some("native") { + let Some(facts) = context.source_facts(&candidate.file_path) else { + return false; + }; + if candidate.language == Language::Go { + let start = (candidate.start_line.max(2) - 2) as usize; + let end = + (candidate.end_line.max(candidate.start_line) as usize).min(facts.line_count()); + let declaration = if start < end { + facts.join_lines(start, end, "\n") + } else { + String::new() + }; + return cgo_import() + .is_match(&strip_comments_for_regex(facts.source(), CommentLang::Go)) + && Regex::new(&format!(r"(?m)^//export {name}\r?\nfunc {name}\s*\(")) + .is_ok_and(|export| export.is_match(&declaration)); + } + if candidate.language == Language::Rust { + let start = (candidate.start_line.max(1) - 1) as usize; + let end = + (candidate.end_line.max(candidate.start_line) as usize).min(facts.line_count()); + let declaration = if start < end { + facts.join_lines(start, end, "\n") + } else { + String::new() + }; + return Regex::new(&format!( + r#"(?-u:\b)pub\s+extern\s+"C"\s+fn\s+{name}(?-u:\b)"# + )) + .is_ok_and(|export| { + export.is_match(&strip_comments_for_regex(&declaration, CommentLang::Rust)) + }); + } + } + if matches!(candidate.language, Language::C | Language::Cpp) { + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + if reference.language == Language::Go { + return cgo_import() + .is_match(&strip_comments_for_regex(facts.source(), CommentLang::Go)) + && reference.reference_name == format!("C.{}", candidate.name); + } + if reference.language == Language::Rust { + return Regex::new(&format!( + r#"extern\s+"C"\s*\{{[^}}]*(?-u:\b)fn\s+{name}\s*\("# + )) + .is_ok_and(|block| { + block.is_match(&strip_comments_for_regex(facts.source(), CommentLang::Rust)) + }); + } + } + false } -/// `crossesKnownFamily` (`name-matcher.ts:147-149`). -pub fn crosses_known_family(a: Language, b: Language) -> bool { - is_known_language_family(a) && is_known_language_family(b) && !same_language_family(a, b) +/// Reject a chosen result that crosses a code-family boundary without bridge +/// evidence (`gateLanguageMatch`). It never shrinks a candidate pool or tries +/// a replacement, so no lone survivor is manufactured. +pub(crate) fn gate_language_match( + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let result = result?; + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + if crosses_code_boundary(reference.language, target.language) + && !has_bridge_evidence(&target, reference, context) + { + return None; + } + // Rust-only (upstream has no Godot support): a scene, resource, or + // project file's path references into scripts stay unresolved rows, which + // impact/affected/audit consume path-keyed (F3). Name matching must not + // turn them into edges through the open config→code transition. + if reference.reference_kind == EdgeKind::References + && matches!( + reference.language, + Language::GodotScene | Language::GodotResource | Language::GodotProject + ) + && target.language != reference.language + { + return None; + } + Some(result) } -/// Drop cross-language candidates from a name lookup (`applyLanguageGate`, -/// `name-matcher.ts:160-168`). +/// Type/value references keep same-family eligibility in the candidate pool: +/// a native namesake must not hide the actual web type. Calls, imports and +/// inheritance are gated on the chosen result instead. fn apply_language_gate>(candidates: Vec, reference: &RefView) -> Vec { match reference.reference_kind { EdgeKind::References => candidates .into_iter() .filter(|c| same_language_family(c.borrow().language, reference.language)) .collect(), - EdgeKind::Imports => candidates - .into_iter() - .filter(|c| !crosses_known_family(c.borrow().language, reference.language)) - .collect(), _ => candidates, } } @@ -264,13 +396,127 @@ pub(crate) fn is_lexically_reachable( .any(|p| reference.line >= p.start_line && reference.line <= p.end_line) } -fn is_js_family(language: Language) -> bool { +pub(crate) fn is_js_family(language: Language) -> bool { matches!( language, Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx ) } +/// Method names that require receiver evidence before a JS/TS call links to +/// project code (`JS_BUILTIN_METHODS`, upstream v1.6.1 #1987): Array / typed +/// array and collection, String, Promise, Function, EventTarget / +/// EventEmitter and iterator methods. Sorted for binary search. +const JS_BUILTIN_METHODS: [&str; 103] = [ + "add", + "addEventListener", + "addListener", + "apply", + "at", + "bind", + "call", + "catch", + "charAt", + "charCodeAt", + "clear", + "codePointAt", + "concat", + "copyWithin", + "delete", + "dispatchEvent", + "drop", + "emit", + "endsWith", + "entries", + "eventNames", + "every", + "fill", + "filter", + "finally", + "find", + "findIndex", + "findLast", + "findLastIndex", + "flat", + "flatMap", + "forEach", + "get", + "getMaxListeners", + "has", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "listenerCount", + "listeners", + "localeCompare", + "map", + "match", + "matchAll", + "next", + "normalize", + "off", + "on", + "once", + "padEnd", + "padStart", + "pop", + "prependListener", + "prependOnceListener", + "push", + "rawListeners", + "reduce", + "reduceRight", + "removeAllListeners", + "removeEventListener", + "removeListener", + "repeat", + "replace", + "replaceAll", + "return", + "reverse", + "search", + "set", + "setMaxListeners", + "shift", + "slice", + "some", + "sort", + "splice", + "split", + "startsWith", + "subarray", + "substr", + "substring", + "take", + "then", + "throw", + "toArray", + "toLocaleLowerCase", + "toLocaleString", + "toLocaleUpperCase", + "toLowerCase", + "toReversed", + "toSorted", + "toSpliced", + "toString", + "toUpperCase", + "trim", + "trimEnd", + "trimLeft", + "trimRight", + "trimStart", + "unshift", + "valueOf", + "values", + "with", +]; + +fn is_js_builtin_method(name: &str) -> bool { + JS_BUILTIN_METHODS.binary_search(&name).is_ok() +} + /// Languages whose file boundary follows ESM-style import/export semantics. /// ArkTS participates in module visibility even though the narrower JS call /// inference gates deliberately exclude it (upstream #1719). @@ -444,7 +690,7 @@ fn is_receiver_less_call(reference: &RefView, context: &dyn ResolutionContext) - } /// A receiver-less JS/TS call can never bind to a method (#1714). -fn is_bare_js_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { +pub(crate) fn is_bare_js_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { is_js_family(reference.language) && is_receiver_less_call(reference, context) } @@ -685,6 +931,14 @@ pub fn match_by_exact_name( reference: &RefView, context: &dyn ResolutionContext, ) -> Option { + let bare_js = is_bare_js_call(reference, context); + // A bare call bound to a store action (#1862) resolves inside that store, + // ahead of any same-named function. + if bare_js + && let Some(action) = crate::js_store::match_js_store_binding_call(reference, context) + { + return Some(action); + } let reachable: Vec> = apply_language_gate( context.get_nodes_by_name_shared(&reference.reference_name), reference, @@ -698,7 +952,6 @@ pub fn match_by_exact_name( // importable (#1537/#1536). Filtering BEFORE ranking — not just refusing the // winner afterwards — is what lets a legitimate supertype OUTRANK a // same-named enum member instead of the whole reference being dropped. - let bare_js = is_bare_js_call(reference, context); let bare_go = is_bare_go_call(reference, context); let candidates: Vec> = reachable .iter() @@ -881,7 +1134,7 @@ fn prefer_call_site_file>(nodes: Vec, call_site_file: &str) - } } -fn is_object_literal_language(language: Language) -> bool { +pub(crate) fn is_object_literal_language(language: Language) -> bool { matches!( language, Language::TypeScript @@ -892,7 +1145,7 @@ fn is_object_literal_language(language: Language) -> bool { ) } -fn range_within(inner: &Node, outer: &Node) -> bool { +pub(crate) fn range_within(inner: &Node, outer: &Node) -> bool { if inner.start_line < outer.start_line || inner.end_line > outer.end_line { return false; } @@ -941,6 +1194,15 @@ pub(crate) fn resolve_object_literal_member( NodeKind::Property | NodeKind::Variable | NodeKind::Constant )) }; + // Only the literal's own property that defines the member counts — the + // last one wins — and an identifier-valued member is followed by + // `resolve_object_literal_binding` instead (#1932). + let property = match object_literal_property(container, member, context) { + LiteralLookup::Absent => return None, + LiteralLookup::Property(property) if property.binding.is_some() => return None, + LiteralLookup::Property(property) => Some(property), + LiteralLookup::Unknown => None, + }; let inside = context .get_nodes_in_file_shared(&container.file_path) .into_iter() @@ -954,6 +1216,11 @@ pub(crate) fn resolve_object_literal_member( let mut candidates = inside .into_iter() .filter(|node| node.name == member && accepts(node)) + .filter(|node| { + property + .as_ref() + .is_none_or(|property| property_contains(property, container, node, context)) + }) .filter(|candidate| { !callable_bodies.iter().any(|body| { body.id != candidate.id @@ -1522,7 +1789,10 @@ fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Rege /// 1-based start line of the tightest function/method enclosing the call /// (`enclosingScopeStartLine`, name-matcher.ts:#1108). Bounds the backward scan /// so a same-named variable in another function can't leak in. -fn enclosing_scope_start_line(reference: &RefView, context: &dyn ResolutionContext) -> i64 { +pub(crate) fn enclosing_scope_start_line( + reference: &RefView, + context: &dyn ResolutionContext, +) -> i64 { let mut start = 1i64; for n in context.get_nodes_in_file_shared(&reference.file_path) { if !matches!(n.kind, NodeKind::Function | NodeKind::Method) @@ -1683,14 +1953,16 @@ fn infer_class_field_receiver_type( /// Resolve a JS/TS `this..()` exclusively through the field's /// declaration on the enclosing class. Unproven/builtin/ambiguous types remain -/// unresolved rather than falling through to receiver-name heuristics. +/// unresolved rather than falling through to receiver-name heuristics. An ES +/// private field (`#items`) is a name of its own: it never reads a public +/// `items` declaration, nor the reverse (#1987). fn match_ts_this_field_call( field: &str, method_name: &str, reference: &RefView, context: &dyn ResolutionContext, ) -> Option { - if field.is_empty() || field.contains('.') || !is_word(field) { + if !is_ts_field_name(field) { return None; } let caller = context.get_node_by_id_shared(&reference.from_node_id)?; @@ -1710,111 +1982,205 @@ fn match_ts_this_field_call( return None; } let owner = &owners[0]; - let source = context.read_file(&owner.file_path)?; - let code = strip_comments_for_regex(&source, CommentLang::TypeScript); - let lines: Vec<&str> = code.lines().collect(); - let start = owner.start_line.saturating_sub(1) as usize; - let end = (owner.end_line.max(owner.start_line) as usize).min(lines.len()); - if start >= end { + let facts = context.source_facts(&owner.file_path)?; + let declaration = facts.ts_field_declaration(&owner.id, field, |facts| { + scan_ts_field_declaration(facts, owner, field, context) + })?; + + if declaration.value_type { + let value_name = declaration.type_name.rsplit('.').next().unwrap_or_default(); + let holders = prefer_call_site_file( + context.get_nodes_by_name_shared(value_name), + &reference.file_path, + ); + for holder in holders.into_iter().filter(|node| { + matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + && same_language_family(node.language, reference.language) + }) { + if let Some(resolved) = resolve_object_literal_member( + &holder, + method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ) { + return Some(resolved); + } + } return None; } - let escaped = regex_escape(field); - let typeof_pattern = Regex::new(&format!( - r"\b{escaped}\b\s*[?!]?\s*:\s*(?:readonly\s+)?typeof\s+([A-Za-z_$][\w.$]*)" - )) - .ok()?; - let declared_pattern = Regex::new(&format!( - r"\b{escaped}\b\s*[?!]?\s*:\s*(?:readonly\s+)?([A-Za-z_$][\w.$]*)" - )) - .ok()?; - let initialized_pattern = - Regex::new(&format!(r"\b{escaped}\b\s*=\s*new\s+([A-Za-z_$][\w.$]*)")).ok()?; - let parameter_property = Regex::new(&format!( - r"\b(?:public|protected|private|readonly)(?:\s+(?:public|protected|private|readonly))*\s+{escaped}\b" - )) - .ok()?; + + let type_name = declaration.type_name.rsplit('.').next().unwrap_or_default(); + if !type_name + .chars() + .next() + .is_some_and(|first| first.is_ascii_uppercase()) + { + return None; + } + resolve_method_on_type( + type_name, + method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + None, + 0, + ) +} + +/// A public (`mailer`) or ES private (`#mailer`) class field name. +fn is_ts_field_name(field: &str) -> bool { + match field.strip_prefix('#') { + Some(private) => { + !private.is_empty() + && private + .chars() + .all(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '$')) + } + None => is_word(field), + } +} + +/// `this.#field.method` → (`#field`, `method`): upstream's +/// `/^this\.(#[\w$]+)\.(\w+)$/` (#1987). +fn split_private_this_field_call(name: &str) -> Option<(&str, &str)> { + let (field, method) = name.strip_prefix("this.")?.split_once('.')?; + (field.starts_with('#') && is_ts_field_name(field) && is_word(method)) + .then_some((field, method)) +} + +/// The first declaration of `field` on the enclosing class's own lines of the +/// comment-free source. Inside a method body only a `this.` assignment +/// counts (`this.mailer = new Mailer()` in a constructor) — a method-local +/// parameter or variable of the same name is not the field — while a +/// constructor's parameter properties (`constructor(private mailer: Mailer)`) +/// read like class-level declarations. +fn scan_ts_field_declaration( + facts: &SourceFacts, + owner: &Node, + field: &str, + context: &dyn ResolutionContext, +) -> Option { + let member_prefix = format!("{}::", owner.qualified_name); let owner_members = context .get_nodes_in_file_shared(&owner.file_path) .into_iter() .filter(|node| { matches!(node.kind, NodeKind::Method | NodeKind::Function) - && node - .qualified_name - .starts_with(&format!("{owner_qualified}::")) + && node.qualified_name.starts_with(&member_prefix) }) .collect::>(); - - for (offset, line) in lines[start..end].iter().enumerate() { - if line.len() > 10_000 { - continue; - } - let source_line = (start + offset + 1) as i64; - if let Some(member) = owner_members.iter().find(|member| { - source_line >= member.start_line - && source_line <= member.end_line.max(member.start_line) - }) && !(member.name == "constructor" && parameter_property.is_match(line)) - { - // A method-local parameter or variable is not a `this` field. + let (code, lines) = facts.ts_comment_free(); + let start = owner.start_line.saturating_sub(1) as usize; + let end = (owner.end_line.max(owner.start_line) as usize).min(lines.line_count()); + for index in start..end { + let line = lines.line(code, index); + // Every declaration pattern spells the field literally. + if line.len() > 10_000 || !line.contains(field) { continue; } - if let Some(value_name) = typeof_pattern - .captures(line) - .and_then(|captures| captures.get(1)) - .map(|capture| capture.as_str().rsplit('.').next().unwrap_or_default()) - { - let holders = prefer_call_site_file( - context.get_nodes_by_name_shared(value_name), - &reference.file_path, - ); - for holder in holders.into_iter().filter(|node| { - matches!(node.kind, NodeKind::Constant | NodeKind::Variable) - && same_language_family(node.language, reference.language) - }) { - if let Some(resolved) = resolve_object_literal_member( - &holder, - method_name, - reference, - context, - 0.85, - ResolvedBy::InstanceMethod, - ) { - return Some(resolved); - } - } - return None; - } - - let type_name = declared_pattern - .captures(line) - .and_then(|captures| captures.get(1)) - .or_else(|| { - initialized_pattern - .captures(line) - .and_then(|captures| captures.get(1)) + let source_line = (index + 1) as i64; + let this_only = owner_members + .iter() + .find(|member| { + source_line >= member.start_line + && source_line <= member.end_line.max(member.start_line) }) - .map(|capture| capture.as_str().rsplit('.').next().unwrap_or_default()); - let Some(type_name) = type_name else { continue }; - if !type_name - .chars() - .next() - .is_some_and(|first| first.is_ascii_uppercase()) - { - return None; + .is_some_and(|member| { + !(member.name == "constructor" && declares_parameter_property(line, field)) + }); + if let Some(declaration) = ts_field_on_line(line, field, this_only) { + return Some(declaration); } - return resolve_method_on_type( - type_name, - method_name, - reference, - context, - 0.85, - ResolvedBy::InstanceMethod, - None, - 0, - ); } None } +/// The declaration patterns, tried in order after a whole-token occurrence of +/// the field: `field: typeof Value` (the type OF a value — tried first, or the +/// declared-type pattern would capture the word `typeof`), a declared type +/// `field?: Mailer` (the capture stops at `<`, `[` or `|`, so a generic or +/// union yields its head), and an initializer `field = new Mailer()`. +fn ts_field_patterns() -> &'static [(Regex, bool); 3] { + static PATTERNS: OnceLock<[(Regex, bool); 3]> = OnceLock::new(); + PATTERNS.get_or_init(|| { + [ + ( + Regex::new(r"^\s*[?!]?\s*:\s*(?:readonly\s+)?typeof\s+([A-Za-z_$][\w.$]*)") + .expect("typeof field pattern"), + true, + ), + ( + Regex::new(r"^\s*[?!]?\s*:\s*(?:readonly\s+)?([A-Za-z_$][\w.$]*)") + .expect("declared field pattern"), + false, + ), + ( + Regex::new(r"^\s*=\s*new\s+([A-Za-z_$][\w.$]*)") + .expect("initialized field pattern"), + false, + ), + ] + }) +} + +/// The first declaration of `field` on one line, pattern by pattern as the +/// upstream field regexes would find it. The field must be a whole `[\w$#]` +/// token — the `(?`. KEEP-RUST: a field ending in `$` (`users$`) is a +/// whole token too, where upstream's trailing `\b` rejects it. +fn ts_field_on_line(line: &str, field: &str, this_only: bool) -> Option { + let is_token = |ch: char| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#'); + let ends = line + .match_indices(field) + .filter(|(at, _)| { + let before = &line[..*at]; + !before.chars().next_back().is_some_and(is_token) + && !line[at + field.len()..] + .chars() + .next() + .is_some_and(is_token) + && (!this_only + || before + .strip_suffix("this.") + .is_some_and(|rest| !rest.chars().next_back().is_some_and(is_token))) + }) + .map(|(at, _)| at + field.len()) + .collect::>(); + ts_field_patterns() + .iter() + .find_map(|(pattern, value_type)| { + ends.iter().find_map(|&end| { + pattern + .captures(&line[end..]) + .and_then(|captures| captures.get(1)) + .map(|capture| TsFieldDeclaration { + value_type: *value_type, + type_name: capture.as_str().to_string(), + }) + }) + }) +} + +/// Whether a constructor line declares `field` as a parameter property +/// (`private readonly mailer: Mailer`). A private name is never one. +fn declares_parameter_property(line: &str, field: &str) -> bool { + static PARAMETER_PROPERTY: OnceLock = OnceLock::new(); + PARAMETER_PROPERTY + .get_or_init(|| { + Regex::new( + r"\b(?:public|protected|private|readonly)(?:\s+(?:public|protected|private|readonly))*\s+([\w$]+)", + ) + .expect("parameter property pattern") + }) + .captures_iter(line) + .any(|captures| captures.get(1).is_some_and(|name| name.as_str() == field)) +} + /// Is the inferred receiver type `type_name` BOUND at the call site (#1566)? /// /// A built-in receiver (`new Map()`) normalizes to a bare type @@ -2083,6 +2449,16 @@ pub fn match_method_call( } } + // A TS/JS call through an ES private field of the enclosing class — + // `this.#items.add()`, emitted as `this.#items.add` (#1987) — resolves + // exactly like `this.` below (#1496). `#` is outside the dotted + // receiver grammar, so the shape is matched here. + if is_js_family(reference.language) + && let Some((field, method)) = split_private_this_field_call(&reference.reference_name) + { + return match_ts_this_field_call(field, method, reference, context); + } + let parsed = parse_method_call(&reference.reference_name, reference.language)?; let (object_or_class, method_name) = parsed; @@ -2265,7 +2641,9 @@ pub fn match_method_call( context, 0.85, ResolvedBy::InstanceMethod, - ) { + ) + .or_else(|| resolve_object_literal_binding(&holder, &method_name, reference, context)) + { return Some(member); } } @@ -2298,6 +2676,18 @@ pub fn match_method_call( } } + // Built-in method names need a validated receiver (#1987). Typed, imported, + // object-literal and direct class receivers have had their chance above; + // capitalization, word overlap or a unique method name are not evidence + // that `list.map()` / `cache.get()` calls a project class. + if reference.reference_kind == EdgeKind::Calls + && is_js_family(reference.language) + && !matches!(object_or_class.as_str(), "this" | "super") + && is_js_builtin_method(&method_name) + { + return None; + } + // Strategy 2: instance-variable receiver → capitalized class // (name-matcher.ts:852-880), call-site file first (#1079). let capitalized = capitalize(&object_or_class); @@ -3395,13 +3785,16 @@ pub fn match_reference( } } - // A JS/TS call-result receiver carries no proven result type. It stays - // unresolved instead of degrading to a global same-named callable (#1683). + // A JS/TS/Python call-result receiver carries no proven result type. It + // stays unresolved instead of degrading to a global same-named callable: + // the fuzzy strategy would split `make().run` on `.` and hand it to any + // `run` (#1683). The one exception is a store accessor, whose store is + // identified (`useStore.getState().reset`, #1862). if reference.reference_kind == EdgeKind::Calls - && is_js_family(reference.language) + && (is_js_family(reference.language) || reference.language == Language::Python) && reference.reference_name.contains("().") { - return None; + return crate::js_store::match_store_accessor_chain(reference, context); } // 2. Method call pattern. @@ -3923,6 +4316,10 @@ mod tests { assert!(same_language_family(Language::TypeScript, Language::Jsx)); assert!(same_language_family(Language::JavaScript, Language::Tsx)); assert!(same_language_family(Language::C, Language::Cpp)); + // C, C++, Objective-C and Swift interoperate natively (v1.6.1). + assert!(same_language_family(Language::Swift, Language::Cpp)); + assert!(same_language_family(Language::ArkTs, Language::TypeScript)); + assert!(same_language_family(Language::Vue, Language::TypeScript)); assert!(same_language_family(Language::CSharp, Language::Razor)); // Identity always matches even for a language with no family. assert!(same_language_family(Language::Rust, Language::Rust)); @@ -3933,19 +4330,75 @@ mod tests { } #[test] - fn known_family_and_cross_family_predicates() { - assert!(is_known_language_family(Language::Java)); - assert!(is_known_language_family(Language::Cpp)); - assert!(!is_known_language_family(Language::Rust)); - assert!(!is_known_language_family(Language::Go)); + fn code_boundary_covers_singleton_families_and_leaves_config_open() { + // Two different code families cross. + assert!(crosses_code_boundary(Language::Java, Language::Swift)); + assert!(crosses_code_boundary(Language::Rust, Language::TypeScript)); + assert!(crosses_code_boundary(Language::Python, Language::Go)); + // One family (or one language) does not. + assert!(!crosses_code_boundary(Language::Java, Language::Kotlin)); + assert!(!crosses_code_boundary(Language::Lua, Language::Luau)); + assert!(!crosses_code_boundary(Language::Rust, Language::Rust)); + // Config and markup transitions stay open. + assert!(!crosses_code_boundary(Language::Yaml, Language::Php)); + assert!(!crosses_code_boundary( + Language::TypeScript, + Language::Liquid + )); + } - // crosses_known_family: both known + different family. - assert!(crosses_known_family(Language::Java, Language::Swift)); - // same family => does not cross. - assert!(!crosses_known_family(Language::Java, Language::Kotlin)); - // one unknown => does not cross. - assert!(!crosses_known_family(Language::Rust, Language::Java)); - assert!(!crosses_known_family(Language::Java, Language::Rust)); + #[test] + fn cross_family_results_need_bridge_evidence() { + let ts_method = mk( + "method:Adapter::map", + NodeKind::Method, + "map", + "Adapter::map", + "ui/adapter.ts", + Language::TypeScript, + ); + let rust_call = refv("map", EdgeKind::Calls, "src/lib.rs", Language::Rust, 1); + let ctx = Ctx::default().node_by_id(ts_method.clone()); + let chosen = || { + Some(ResolvedRef { + original: rust_call.clone(), + target_node_id: ts_method.id.clone(), + confidence: 0.5, + resolved_by: ResolvedBy::ExactMatch, + }) + }; + assert!( + gate_language_match(chosen(), &rust_call, &ctx).is_none(), + "`v.iter().map()` never reaches TS" + ); + + let exported = { + let mut node = mk( + "function:add", + NodeKind::Function, + "add", + "add", + "go/lib.go", + Language::Go, + ); + node.start_line = 4; + node.end_line = 4; + node + }; + let c_call = refv("add", EdgeKind::Calls, "native/main.c", Language::C, 1); + let cgo = Ctx::default() + .node_by_id(exported.clone()) + .file("go/lib.go", "package main\nimport \"C\"\n//export add\nfunc add(a, b C.int) C.int { return a + b }\n"); + let via_cgo = Some(ResolvedRef { + original: c_call.clone(), + target_node_id: exported.id.clone(), + confidence: 0.5, + resolved_by: ResolvedBy::ExactMatch, + }); + assert!( + gate_language_match(via_cgo, &c_call, &cgo).is_some(), + "a cgo //export is a C ABI bridge" + ); } #[test] @@ -3979,43 +4432,6 @@ mod tests { assert_eq!(out[0].id, "function:a"); } - #[test] - fn language_gate_imports_drops_only_cross_known_family() { - // Imports edge: only candidates that CROSS a known family are dropped; - // an unknown-family candidate (Rust vs TS) is KEPT. - let same = mk( - "function:a", - NodeKind::Function, - "f", - "f", - "src/a.ts", - Language::TypeScript, - ); - let cross = mk( - "function:b", - NodeKind::Function, - "f", - "f", - "src/b.java", - Language::Java, - ); - let unknown = mk( - "function:c", - NodeKind::Function, - "f", - "f", - "src/c.rs", - Language::Rust, - ); - let r = refv("f", EdgeKind::Imports, "src/x.ts", Language::TypeScript, 1); - let out = apply_language_gate(vec![same, cross, unknown], &r); - // TS kept (same family), Java dropped (crosses web↔jvm), Rust kept (unknown family). - let ids: Vec<&str> = out.iter().map(|n| n.id.as_str()).collect(); - assert!(ids.contains(&"function:a")); - assert!(ids.contains(&"function:c")); - assert!(!ids.contains(&"function:b")); - } - #[test] fn language_gate_other_kind_passes_through() { let a = mk( @@ -4536,6 +4952,93 @@ mod tests { assert!(!is_word("a b")); } + #[test] + fn js_builtin_methods_are_sorted_for_binary_search() { + assert!(JS_BUILTIN_METHODS.windows(2).all(|pair| pair[0] < pair[1])); + for name in ["map", "get", "then", "addEventListener", "return", "with"] { + assert!(is_js_builtin_method(name), "{name}"); + } + for name in ["send", "run", "Map", "mapAll"] { + assert!(!is_js_builtin_method(name), "{name}"); + } + } + + #[test] + fn private_this_field_calls_split_only_the_exact_shape() { + assert_eq!( + split_private_this_field_call("this.#items.add"), + Some(("#items", "add")) + ); + assert_eq!( + split_private_this_field_call("this.#$store.get"), + Some(("#$store", "get")) + ); + for name in [ + "this.items.add", + "this.#.add", + "this.#items", + "this.#a.b.c", + "that.#items.add", + "this.#items.a-b", + ] { + assert_eq!(split_private_this_field_call(name), None, "{name}"); + } + } + + #[test] + fn ts_field_declarations_keep_public_and_private_names_apart() { + let declared = |line: &str, field: &str, this_only: bool| { + ts_field_on_line(line, field, this_only) + .map(|found| (found.value_type, found.type_name)) + }; + assert_eq!( + declared(" #items = new Set();", "#items", false), + Some((false, "Set".to_string())) + ); + assert_eq!(declared(" #items = new Set();", "items", false), None); + assert_eq!(declared(" items = new Cart();", "#items", false), None); + assert_eq!( + declared(" private readonly mailer?: ns.Mailer;", "mailer", false), + Some((false, "ns.Mailer".to_string())) + ); + assert_eq!( + declared(" storage: typeof DraftHubStorage;", "storage", false), + Some((true, "DraftHubStorage".to_string())) + ); + assert_eq!(declared(" mailerX: Decoy;", "mailer", false), None); + assert_eq!( + declared(" users$: Observable;", "users$", false), + Some((false, "Observable".to_string())) + ); + // Inside a method body only a `this.` assignment counts. + assert_eq!( + declared( + " constructor() { this.mailer = new Mailer(); }", + "mailer", + true + ), + Some((false, "Mailer".to_string())) + ); + assert_eq!( + declared(" run() { const mailer = new Decoy(); }", "mailer", true), + None + ); + assert_eq!( + declared(" run() { xthis.mailer = new Decoy(); }", "mailer", true), + None + ); + } + + #[test] + fn parameter_properties_need_a_modifier_before_the_exact_name() { + let line = " constructor(private readonly mailer: Mailer, other: X, public items: Y) {}"; + assert!(declares_parameter_property(line, "mailer")); + assert!(declares_parameter_property(line, "items")); + assert!(!declares_parameter_property(line, "other")); + assert!(!declares_parameter_property(line, "mail")); + assert!(!declares_parameter_property(line, "#mailer")); + } + #[test] fn parse_method_call_prefers_dot_then_colon() { assert_eq!( diff --git a/crates/codegraph-resolve/src/object_literal.rs b/crates/codegraph-resolve/src/object_literal.rs new file mode 100644 index 00000000..bbedcbf6 --- /dev/null +++ b/crates/codegraph-resolve/src/object_literal.rs @@ -0,0 +1,373 @@ +//! Own properties of a JS-family object literal (upstream v1.6.1 #1932). +//! +//! `export const api = { getUser, rename: fetchUser, inline() {} }` is the +//! usual way an API module assembles a namespace. Resolution needs to know, +//! for one member name, which own property of the literal defines it — only +//! the literal's top-level members count, the last one wins, and an unknown +//! spread or computed key invalidates earlier evidence — and, when that +//! property is a shorthand or an identifier-valued pair, which binding it +//! names. The binding is then followed lexically from where the literal is +//! written: a visible declaration of the file first, else one of its imports, +//! and never through a shadowing parameter or nearer value. + +use std::sync::OnceLock; + +use codegraph_core::types::{EdgeKind, Node, NodeKind}; +use regex::Regex; + +use crate::awaited::has_parameter_binding; +use crate::import_resolver::resolve_via_import; +use crate::name_matcher::range_within; +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// What a literal says about one member name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LiteralLookup { + /// The holder's source is not a readable object literal. + Unknown, + /// No own property defines the member (or a later spread may). + Absent, + /// The own property that defines the member. + Property(LiteralProperty), +} + +/// One own property: its character span within the holder's extent, and the +/// identifier it binds when it is `{ name }` or `{ key: name }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiteralProperty { + pub binding: Option, + start: usize, + end: usize, +} + +/// The own property of `container`'s object literal that defines `member`. +pub(crate) fn object_literal_property( + container: &Node, + member: &str, + context: &dyn ResolutionContext, +) -> LiteralLookup { + let Some(facts) = context.source_facts(&container.file_path) else { + return LiteralLookup::Unknown; + }; + facts.literal_property(&container.id, member, |facts| { + read_literal_property(facts, container, member) + }) +} + +/// Whether `node` starts inside `property` of `container`'s literal. +pub(crate) fn property_contains( + property: &LiteralProperty, + container: &Node, + node: &Node, + context: &dyn ResolutionContext, +) -> bool { + let Some(facts) = context.source_facts(&container.file_path) else { + return false; + }; + let (Some(from), Some(at)) = ( + byte_position(&facts, container.start_line, container.start_column), + byte_position(&facts, node.start_line, node.start_column), + ) else { + return false; + }; + let source = facts.source(); + if at < from || !source.is_char_boundary(from) || !source.is_char_boundary(at) { + return false; + } + let offset = source[from..at].chars().count(); + offset >= property.start && offset < property.end +} + +/// Follow the binding `member` names in `container`'s literal (#1932): a +/// lexically visible declaration of the literal's file, else one of its +/// imports. A shadowing parameter or nearer non-callable value leaves it +/// unresolved. +pub(crate) fn resolve_object_literal_binding( + container: &Node, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let LiteralLookup::Property(property) = object_literal_property(container, member, context) + else { + return None; + }; + let binding = property.binding?; + let calls = reference.reference_kind == EdgeKind::Calls; + let facts = context.source_facts(&container.file_path)?; + let key = format!("{member}\0{}", u8::from(calls)); + let target = facts.literal_binding_target(&container.id, &key, |facts| { + binding_target(facts, container, &binding, calls, reference, context) + })?; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: target, + confidence: 0.85, + resolved_by: ResolvedBy::InstanceMethod, + }) +} + +fn accepts(node: &Node, calls: bool) -> bool { + matches!( + node.kind, + NodeKind::Function | NodeKind::Method | NodeKind::Class + ) || (!calls + && matches!( + node.kind, + NodeKind::Constant | NodeKind::Variable | NodeKind::Component + )) +} + +fn binding_target( + facts: &SourceFacts, + container: &Node, + binding: &str, + calls: bool, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let in_file = context.get_nodes_in_file_shared(&container.file_path); + // A parameter of a function enclosing the literal rebinds the name. + if in_file.iter().any(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && range_within(container, node) + && node + .signature + .as_deref() + .is_some_and(|signature| has_parameter_binding(&format!("{signature} {{"), binding)) + }) { + return None; + } + let scope = brace_scope(facts, container)?; + // Select the lexical binding BEFORE checking callability: a nearer value + // shadows an outer function even if that value cannot be called. + let nearest = in_file + .iter() + .filter(|node| { + node.name == binding + && node.id != container.id + && matches!( + node.kind, + NodeKind::Function + | NodeKind::Class + | NodeKind::Constant + | NodeKind::Variable + | NodeKind::Component + ) + }) + .filter_map(|node| { + let declared = brace_scope(facts, node)?; + declared + .iter() + .enumerate() + .all(|(depth, open)| scope.get(depth) == Some(open)) + .then_some((declared.len(), node)) + }) + .max_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| right.1.start_line.cmp(&left.1.start_line)) + .then_with(|| right.1.start_column.cmp(&left.1.start_column)) + }) + .map(|(_, node)| node); + if let Some(local) = nearest { + return accepts(local, calls).then(|| local.id.clone()); + } + let mut at = reference.clone(); + at.reference_name = binding.to_string(); + at.file_path = container.file_path.clone(); + at.language = container.language; + at.from_node_id = container.id.clone(); + at.line = container.start_line; + at.column = container.start_column; + let imported = resolve_via_import(&at, context)?; + let target = context.get_node_by_id_shared(&imported.target_node_id)?; + accepts(&target, calls).then(|| target.id.clone()) +} + +/// Byte offset of a 1-based line and byte column in the file. +fn byte_position(facts: &SourceFacts, line: i64, column: i64) -> Option { + let index = usize::try_from(line).ok()?.checked_sub(1)?; + let start = facts.lines().raw_line_start(index)?; + let length = facts.raw_line(index).map_or(0, str::len); + Some(start + usize::try_from(column).ok()?.min(length)) +} + +/// Offsets of the `{` open where `node` starts, in the comment-free and +/// string-blanked whole file. +fn brace_scope(facts: &SourceFacts, node: &Node) -> Option> { + let end = byte_position(facts, node.start_line, node.start_column)?; + let mut stack = Vec::new(); + for ((offset, _), blanked) in facts + .source() + .char_indices() + .zip(facts.ts_blanked().chars()) + { + if offset >= end { + break; + } + match blanked { + '{' => stack.push(offset), + '}' => { + stack.pop(); + } + _ => {} + } + } + Some(stack) +} + +fn read_literal_property(facts: &SourceFacts, container: &Node, member: &str) -> LiteralLookup { + let (Some(from), Some(to)) = ( + byte_position(facts, container.start_line, container.start_column), + byte_position(facts, container.end_line, container.end_column), + ) else { + return LiteralLookup::Unknown; + }; + let source = facts.source(); + if from >= to || !source.is_char_boundary(from) || !source.is_char_boundary(to) { + return LiteralLookup::Unknown; + } + let extent = strip_comments_for_regex(&source[from..to], CommentLang::TypeScript); + let code = blank_string_contents(&extent); + // Start at THIS declarator, never a sibling on the same line; a literal + // handed to `Object.freeze`/`Object.seal` or parenthesized still counts. + static OPEN: OnceLock = OnceLock::new(); + let open = OPEN.get_or_init(|| { + Regex::new(r"^[^=]*=\s*(?:(?:Object\.(?:freeze|seal)\s*)?\(\s*)*\{") + .expect("object literal opening pattern") + }); + let Some(opening) = open.find(&code) else { + return LiteralLookup::Unknown; + }; + let code = code.chars().collect::>(); + let extent = extent.chars().collect::>(); + let mut members = Vec::new(); + let mut depth = 0usize; + let mut start = opening.as_str().chars().count(); + let mut index = start; + while index < code.len() { + match code[index] { + '{' | '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + '}' if depth == 0 => { + members.push((start, index)); + break; + } + '}' => depth -= 1, + ',' if depth == 0 => { + members.push((start, index)); + start = index + 1; + } + _ => {} + } + index += 1; + } + + let mut selected = None; + for (start, end) in members { + let raw = extent[start..end].iter().collect::(); + let text = raw.trim(); + if text.starts_with("...") || text.starts_with('[') { + selected = None; + continue; + } + let Some((key, key_len)) = property_key(text) else { + continue; + }; + if key != member { + continue; + } + let value = text[key_len..].trim(); + let binding = if value.is_empty() { + Some(member.to_string()) + } else { + value + .strip_prefix(':') + .map(str::trim_start) + .filter(|name| is_identifier(name)) + .map(str::to_string) + }; + selected = Some(LiteralProperty { + binding, + start, + end, + }); + } + selected.map_or(LiteralLookup::Absent, LiteralLookup::Property) +} + +/// A property's key and the byte length of the text that spells it: +/// `^(?:(?:async|get|set)\s+)?\*?\s*(?:ident|'key'|"key")(?=\s*(?:[:(<,=]|$))`. +fn property_key(text: &str) -> Option<(String, usize)> { + fn key_at(text: &str, from: usize) -> Option<(String, usize)> { + let rest = text[from..].trim_start(); + let star = rest.strip_prefix('*').unwrap_or(rest); + let spaced = star.trim_start(); + let offset = text.len() - spaced.len(); + let (key, len) = if let Some(quoted) = spaced.strip_prefix(['\'', '"']) { + let close = quoted.find(['\'', '"', '\\'])?; + if quoted[close..].starts_with('\\') { + return None; + } + (quoted[..close].to_string(), close + 2) + } else { + let len = spaced + .char_indices() + .take_while(|(at, ch)| { + if *at == 0 { + ch.is_ascii_alphabetic() || matches!(ch, '_' | '$') + } else { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$') + } + }) + .count(); + if len == 0 { + return None; + } + (spaced[..len].to_string(), len) + }; + let after = spaced[len..].trim_start(); + (after.is_empty() || after.starts_with([':', '(', '<', ',', '='])) + .then_some((key, offset + len)) + } + for prefix in ["async", "get", "set"] { + if let Some(rest) = text.strip_prefix(prefix) + && rest.starts_with(char::is_whitespace) + && let Some(found) = key_at(text, prefix.len()) + { + return Some(found); + } + } + key_at(text, 0) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '_' | '$')) + && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn property_keys_follow_the_upstream_key_grammar() { + assert_eq!(property_key("run: right"), Some(("run".into(), 3))); + assert_eq!(property_key("run"), Some(("run".into(), 3))); + assert_eq!(property_key("async run() {}"), Some(("run".into(), 9))); + assert_eq!(property_key("get value() {}"), Some(("value".into(), 9))); + assert_eq!(property_key("get: fn"), Some(("get".into(), 3))); + assert_eq!(property_key("get () {}"), Some(("get".into(), 3))); + assert_eq!(property_key("*gen() {}"), Some(("gen".into(), 4))); + assert_eq!(property_key("async *gen() {}"), Some(("gen".into(), 10))); + assert_eq!(property_key("'wrong': 0"), Some(("wrong".into(), 7))); + assert_eq!(property_key("foo bar"), None); + assert_eq!(property_key("1: x"), None); + } +} diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 9e1d9956..e32987b5 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -15,9 +15,10 @@ use crate::import_resolver::{ resolve_php_imported_static_call, resolve_via_import, }; use crate::name_matcher::{ - crosses_known_family, is_js_name_target_visible, is_php_property_receiver_shape, - is_python_class_function_ref_target, match_dotted_call_chain, match_function_ref, - match_method_call, match_reference, match_scoped_call_chain, same_language_family, + crosses_code_boundary, gate_language_match, is_js_name_target_visible, + is_php_property_receiver_shape, is_python_class_function_ref_target, match_dotted_call_chain, + match_function_ref, match_method_call, match_reference, match_scoped_call_chain, + same_language_family, }; use crate::snapshot_context::{SnapshotResolutionContext, build_edge_adjacency}; use crate::source_facts::SourceFacts; @@ -70,15 +71,45 @@ fn is_scoped_chain_language(language: Language) -> bool { language == Language::Rust } -fn is_js_call_result_chain(reference: &RefView) -> bool { +/// A TS/JS/Python call whose receiver is itself a call, encoded +/// `().` (#1683): nothing proves what the inner call returns. +fn is_call_result_chain(reference: &RefView) -> bool { reference.reference_kind == EdgeKind::Calls && matches!( reference.language, - Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + | Language::Python ) && reference.reference_name.contains("().") } +/// A retained untyped TS/JS member chain — `holder.values.get`, three or more +/// plain segments not rooted at `this`/`window` (upstream v1.6.1 +/// `isUnresolvedJsMemberCall`, #1862). It is call-site evidence, not +/// permission to infer a property type: only a framework resolver may bind it. +fn is_unresolved_js_member_call(reference: &RefView) -> bool { + fn is_segment(segment: &str) -> bool { + let mut chars = segment.chars(); + chars + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '_' | '$')) + && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) + } + let name = reference.reference_name.as_str(); + reference.reference_kind == EdgeKind::Calls + && matches!( + reference.language, + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) + && !name.starts_with("this.") + && !name.starts_with("window.") + && name.split('.').count() >= 3 + && name.split('.').all(is_segment) +} + /// The extractor's chained-receiver encoding `().` /// (`CHAIN_SHAPE`, `index.ts:44`). fn has_chain_shape(name: &str) -> bool { @@ -1097,6 +1128,28 @@ fn c_family_static_function_is_file_local( }) } +/// The one inheritance-eligible node a TypeScript value shares its name and +/// file with (`sameNamedTypeOfValue`, upstream #2055). +fn same_named_type_of_value(value: &Node, context: &dyn ResolutionContext) -> Option> { + if !matches!(value.kind, NodeKind::Constant | NodeKind::Variable) + || !matches!(value.language, Language::TypeScript | Language::Tsx) + { + return None; + } + let types: Vec> = context + .get_nodes_in_file_shared(&value.file_path) + .into_iter() + .filter(|node| { + node.name == value.name + && crate::types::node_is_eligible_target(EdgeKind::Implements, node) + }) + .collect(); + let [type_node] = types.as_slice() else { + return None; + }; + Some(Arc::clone(type_node)) +} + fn rust_module_dir(file_path: &str) -> String { let normalized = file_path.replace('\\', "/"); let dir = crate::pathutil::dirname(&normalized); @@ -1523,6 +1576,15 @@ impl ReferenceResolver { let resolved = self.gate_import_locality(resolved, reference, context); let resolved = self.gate_c_macro_calls(resolved, reference); 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 + // bridges keep their calls. + let resolved = match &resolved { + Some(result) if result.resolved_by == ResolvedBy::Framework => { + self.gate_framework_resolver_extension_language(resolved, reference, context) + } + _ => self.gate_language(resolved, reference, context), + }; let resolved = self.gate_language_visibility(resolved, reference, context); let resolved = self.gate_js_visibility(resolved, reference, context); (resolved, deferred) @@ -1670,10 +1732,21 @@ impl ReferenceResolver { return Some(result); }; if crate::types::node_is_eligible_target(reference.reference_kind, &target) { - Some(result) - } else { - None + return Some(result); } + // One exception: a TypeScript VALUE sharing its name with a type in the + // same file (`export const IFoo = createDecorator('foo')` beside + // `export interface IFoo`, VS Code's service idiom). The strategy found + // the right file and name; the type declared there is the supertype, so + // the edge moves to it instead of being dropped (upstream #2055). + if crate::types::is_inheritance_ref(reference.reference_kind) + && let Some(type_node) = same_named_type_of_value(&target, context) + { + let mut moved = result; + moved.target_node_id = type_node.id.clone(); + return Some(moved); + } + None } fn resolve_one_pure_inner( @@ -1700,16 +1773,25 @@ impl ReferenceResolver { } else { &reference.reference_name }; - let js_call_result_chain = is_js_call_result_chain(reference); + let call_result_chain = is_call_result_chain(reference); if !self.has_any_possible_match_for(existence_name, reference.language) && !self.matches_any_import(reference, context) && !self .framework_resolver_extensions .iter() .any(|f| f.claims_reference(&reference.reference_name)) - && !js_call_result_chain + && !call_result_chain { - return (None, None); + // A store-bound bare call need not share its action's name + // (#1862), so it gets one chance past the existence check. + return ( + self.gate_language( + crate::js_store::match_js_store_binding_call(reference, context), + reference, + context, + ), + None, + ); } // Function-as-value refs (#756) get a dedicated, strictly-gated path, @@ -1774,8 +1856,22 @@ impl ReferenceResolver { // An imported root in `make().run()` does not prove what `make` // returns. Preserve any framework result, but never let import or - // unique-name heuristics guess the result type (#1683). - if js_call_result_chain { + // unique-name heuristics guess the result type (#1683). A retained + // untyped member chain is the same kind of evidence: importing its + // root does not make the root its call target (#1862). + if call_result_chain { + // The one call-result receiver with an identified target is a store + // accessor (`useStore.getState().reset`, #1862). + if let Some(action) = self.gate_language( + crate::js_store::match_store_accessor_chain(reference, context), + reference, + context, + ) { + candidates.push(action); + } + return (candidates.into_iter().reduce(highest_confidence), None); + } + if is_unresolved_js_member_call(reference) { return (candidates.into_iter().reduce(highest_confidence), None); } @@ -2732,26 +2828,13 @@ impl ReferenceResolver { reference: &RefView, context: &dyn ResolutionContext, ) -> Option { - let result = result?; - let Some(target_language) = self.language_of_target(&result.target_node_id, context) else { - return Some(result); - }; - if reference.reference_kind == EdgeKind::References - && !same_language_family(target_language, reference.language) - { - return None; - } - if reference.reference_kind == EdgeKind::Imports - && crosses_known_family(target_language, reference.language) - { - return None; - } - Some(result) + gate_language_match(result, reference, context) } - /// Drop a `FrameworkResolver`-strategy result that crosses two known families - /// (`gateFrameworkResolverLanguage`, `index.ts:1182-1188`). Never fires in v1 (the - /// `FrameworkResolver` extension-point list is empty). + /// Framework calls carry their own bridge evidence (a Tauri IPC command, a + /// Godot autoload); every other framework result obeys the code-family + /// boundary, while config/markup transitions stay open + /// (`gateFrameworkLanguage`, upstream v1.6.1). fn gate_framework_resolver_extension_language( &self, result: Option, @@ -2759,16 +2842,13 @@ impl ReferenceResolver { context: &dyn ResolutionContext, ) -> Option { let result = result?; - if !matches!( - reference.reference_kind, - EdgeKind::References | EdgeKind::Imports - ) { + if reference.reference_kind == EdgeKind::Calls { return Some(result); } - if let Some(target_language) = self.language_of_target(&result.target_node_id, context) { - if crosses_known_family(target_language, reference.language) { - return None; - } + if let Some(target_language) = self.language_of_target(&result.target_node_id, context) + && crosses_code_boundary(target_language, reference.language) + { + return None; } Some(result) } diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index d61adbfb..d41ae642 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -12,6 +12,7 @@ //! snapshot of the pass — so a cached answer is identical to recomputing it. use crate::awaited::AwaitedIndex; +use crate::object_literal::LiteralLookup; use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; @@ -106,6 +107,15 @@ pub struct LocalBindingSites { pub arrows: Vec, } +/// The declared type of a TS/JS class field, read off the class's own lines. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TsFieldDeclaration { + /// `field: typeof Value` — the type OF a value (an object-literal namespace). + pub value_type: bool, + /// The declared or constructed type as spelled (`ns.Mailer`, `Mailer`). + pub type_name: String, +} + /// Lazily derived, memoised facts about one file's source text. #[derive(Debug)] pub struct SourceFacts { @@ -117,6 +127,11 @@ pub struct SourceFacts { js_binding_sites: OnceLock, js_local_bindings: Mutex>, node_decisions: Mutex>, + ts_field_declarations: Mutex>>, + js_get_state_file: OnceLock, + js_selector_names: OnceLock>, + literal_properties: Mutex>, + literal_binding_targets: Mutex>>, awaited_raw_names: OnceLock>, awaited_index: OnceLock>, } @@ -133,6 +148,11 @@ impl SourceFacts { js_binding_sites: OnceLock::new(), js_local_bindings: Mutex::new(HashMap::new()), node_decisions: Mutex::new(HashMap::new()), + ts_field_declarations: Mutex::new(HashMap::new()), + js_get_state_file: OnceLock::new(), + js_selector_names: OnceLock::new(), + literal_properties: Mutex::new(HashMap::new()), + literal_binding_targets: Mutex::new(HashMap::new()), awaited_raw_names: OnceLock::new(), awaited_index: OnceLock::new(), } @@ -224,6 +244,70 @@ impl SourceFacts { bound } + /// Whether the raw source can destructure a store's `getState()`; + /// `scan` runs once. + pub(crate) fn js_get_state_file(&self, scan: impl FnOnce(&str) -> bool) -> bool { + *self.js_get_state_file.get_or_init(|| scan(&self.source)) + } + + /// Names the raw source binds to a selector call; `scan` runs once. + pub(crate) fn js_selector_names( + &self, + scan: impl FnOnce(&str) -> HashSet, + ) -> &HashSet { + self.js_selector_names.get_or_init(|| scan(&self.source)) + } + + /// Memoised own property of the object literal `container_id` (defined in + /// this file) for `member`; `compute` runs at most once per key. + pub(crate) fn literal_property( + &self, + container_id: &str, + member: &str, + compute: impl FnOnce(&Self) -> LiteralLookup, + ) -> LiteralLookup { + let key = (container_id.to_string(), member.to_string()); + if let Some(known) = self + .literal_properties + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return known.clone(); + } + let lookup = compute(self); + self.literal_properties + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, lookup.clone()); + lookup + } + + /// Memoised target node id of an object-literal member's binding; + /// `compute` runs at most once per `(container, key)`. + pub(crate) fn literal_binding_target( + &self, + container_id: &str, + key: &str, + compute: impl FnOnce(&Self) -> Option, + ) -> Option { + let key = (container_id.to_string(), key.to_string()); + if let Some(known) = self + .literal_binding_targets + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return known.clone(); + } + let target = compute(self); + self.literal_binding_targets + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, target.clone()); + target + } + /// Names the raw source binds as `const x = await f(`; `scan` runs once. pub(crate) fn awaited_raw_names( &self, @@ -264,6 +348,31 @@ impl SourceFacts { .insert(key, decision); decision } + + /// Memoised declaration of `field` on the class `owner_id` defined in this + /// file; `compute` runs at most once per `(owner, field)`. + pub fn ts_field_declaration( + &self, + owner_id: &str, + field: &str, + compute: impl FnOnce(&Self) -> Option, + ) -> Option { + let key = (owner_id.to_string(), field.to_string()); + if let Some(known) = self + .ts_field_declarations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return known.clone(); + } + let declaration = compute(self); + self.ts_field_declarations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, declaration.clone()); + declaration + } } /// Offsets of `\b(?:kw1|kw2|…)\s` matches: a keyword preceded by a non-word @@ -393,6 +502,34 @@ mod tests { assert!(!facts.node_decision("rust_trait", "function:1", |_| false)); } + #[test] + fn ts_field_declarations_are_computed_once_per_owner_and_field() { + let facts = SourceFacts::new(Arc::from("class A { #m = new M(); }\n")); + let declared = TsFieldDeclaration { + value_type: false, + type_name: "M".to_string(), + }; + let mut runs = 0; + let mut compute = |_: &SourceFacts| { + runs += 1; + Some(declared.clone()) + }; + assert_eq!( + facts.ts_field_declaration("class:1", "#m", &mut compute), + Some(declared.clone()) + ); + assert_eq!( + facts.ts_field_declaration("class:1", "#m", |_| unreachable!("memoised")), + Some(declared) + ); + assert_eq!(facts.ts_field_declaration("class:1", "m", |_| None), None); + assert_eq!( + facts.ts_field_declaration("class:1", "m", |_| unreachable!("memoised")), + None + ); + assert_eq!(runs, 1); + } + #[test] fn ts_blanked_hides_template_imports() { let facts = SourceFacts::new(Arc::from( diff --git a/crates/codegraph-resolve/tests/c_go_correctness.rs b/crates/codegraph-resolve/tests/c_go_correctness.rs index 88e8ca13..9d197020 100644 --- a/crates/codegraph-resolve/tests/c_go_correctness.rs +++ b/crates/codegraph-resolve/tests/c_go_correctness.rs @@ -478,3 +478,40 @@ fn kotlin_private_and_rust_module_visibility_are_enforced() { BTreeSet::from([("src/net.rs", "exported"), ("src/net.rs", "shared")]) ); } + +#[test] +fn single_argument_function_macros_connect_callers_and_callees() { + // Upstream #1373: `NATIVE_FN(get_version) { ... }` defines `get_version` + // once the local `#define` establishes the name. + for file in ["main.c", "main.cpp"] { + let graph = resolve_project( + "single-arg-macro", + &[( + file, + "#define NATIVE_FN(name) int name(void)\n\ + int helper(void) { return 1; }\n\ + NATIVE_FN(get_version) { return helper(); }\n\ + int use_it(void) { return get_version(); }\n\ + int plain_func(void) { return 42; }\n", + )], + ); + let recovered = graph.node(NodeKind::Function, "get_version", file); + let helper = graph.node(NodeKind::Function, "helper", file); + let caller = graph.node(NodeKind::Function, "use_it", file); + graph.node(NodeKind::Function, "plain_func", file); + assert!( + graph + .outgoing(caller, EdgeKind::Calls) + .iter() + .any(|target| target.id == recovered.id), + "{file}" + ); + assert!( + graph + .outgoing(recovered, EdgeKind::Calls) + .iter() + .any(|target| target.id == helper.id), + "{file}" + ); + } +} diff --git a/crates/codegraph-resolve/tests/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs new file mode 100644 index 00000000..cb84c8da --- /dev/null +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -0,0 +1,1361 @@ +//! A member call binds only with receiver evidence (upstream v1.6.1 +//! #1683/#1707/#1862/#1986/#1987). +//! +//! Ported from upstream `js-builtin-method-calls.test.ts`, +//! `ts-this-field-call.test.ts`, `ts-chained-receiver.test.ts`, +//! `expression-receiver-calls.test.ts`, `call-receiver-no-fabrication.test.ts` +//! and `release-main-regressions.test.ts`. Each case drives real extraction and +//! the full resolution pipeline over files written to a temporary project. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{EdgeKind, FileRecord, Node, NodeKind}; +use codegraph_extract::{detect_language, extract_file}; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +const JS_EXTENSIONS: [&str; 4] = ["ts", "tsx", "js", "jsx"]; + +struct Project { + root: PathBuf, + store: Option, +} + +impl Drop for Project { + fn drop(&mut self) { + drop(self.store.take()); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl Project { + fn store(&self) -> &Store { + self.store.as_ref().expect("store") + } + + /// The function or method whose qualified name is `qualified` (in `file` + /// when given; its path must end with it). + fn callable(&self, qualified: &str, file: Option<&str>) -> Node { + let mut found = [NodeKind::Function, NodeKind::Method] + .into_iter() + .flat_map(|kind| self.store().nodes_by_kind(kind).expect("nodes by kind")) + .filter(|node| { + node.qualified_name == qualified + && file.is_none_or(|file| node.file_path.ends_with(file)) + }) + .collect::>(); + assert_eq!(found.len(), 1, "{qualified} in {file:?}: {found:#?}"); + found.remove(0) + } + + /// Sorted qualified names of the distinct `calls` targets of `qualified` + /// (upstream `getCallees` reports nodes, not call sites). + fn callees(&self, qualified: &str, file: Option<&str>) -> Vec { + let source = self.callable(qualified, file); + let mut names = self + .store() + .edges_by_source_kind(&source.id, Some(EdgeKind::Calls)) + .expect("calls") + .into_iter() + .map(|edge| { + self.store() + .node_by_id(&edge.target) + .expect("target query") + .expect("target node") + .qualified_name + }) + .collect::>(); + names.sort(); + names.dedup(); + names + } + + /// Sorted qualified names of the distinct callables with a `calls` edge + /// into `qualified`. + fn callers(&self, qualified: &str, file: Option<&str>) -> Vec { + let target = self.callable(qualified, file); + let mut names = self + .store() + .edges_by_target_kind(&target.id, Some(EdgeKind::Calls)) + .expect("callers") + .into_iter() + .map(|edge| { + self.store() + .node_by_id(&edge.source) + .expect("source query") + .expect("source node") + .qualified_name + }) + .collect::>(); + names.sort(); + names.dedup(); + names + } + + /// Reference names of the `calls` refs still unresolved from `qualified`. + fn unresolved_calls(&self, qualified: &str, file: Option<&str>) -> Vec { + let source = self.callable(qualified, file); + self.store() + .all_unresolved_refs() + .expect("unresolved refs") + .into_iter() + .filter(|reference| { + reference.from_node_id == source.id && reference.reference_kind == EdgeKind::Calls + }) + .map(|reference| reference.reference_name) + .collect() + } +} + +fn resolve_project(files: &[(String, String)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-receiver-evidence-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )); + 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"); + } + + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + let relative = files + .iter() + .map(|(relative, _)| relative.clone()) + .collect::>(); + for path in &relative { + let result = extract_file(&root, path).expect("extract fixture"); + assert!(result.errors.is_empty(), "{path}: {:?}", result.errors); + store + .upsert_file(&FileRecord { + path: path.clone(), + content_hash: "fixture".to_string(), + language: detect_language(path), + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + generated: false, + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); + } + + let root_str = root.to_string_lossy().to_string(); + let mut resolver = ReferenceResolver::new(root_str.clone()); + resolver.initialize(&codegraph_resolve::StoreResolutionContext::new( + &store, &root_str, + )); + resolver + .extract_and_persist_frameworks(&mut store, &relative) + .expect("framework extract"); + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + resolver.run_post_extract(&mut store).expect("post extract"); + Project { + root, + store: Some(store), + } +} + +fn files(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(path, source)| (path.to_string(), source.to_string())) + .collect() +} + +// ---- built-in method names need receiver evidence (#1987) ------------------ + +const BUILTIN_FAMILIES: [(&str, &[&str]); 7] = [ + ( + "array", + &[ + "map", + "filter", + "reduce", + "forEach", + "find", + "findIndex", + "some", + "every", + "push", + "pop", + "slice", + "splice", + "sort", + "flatMap", + "includes", + "join", + ], + ), + ( + "collection", + &[ + "get", "set", "has", "add", "delete", "clear", "keys", "values", "entries", + ], + ), + ( + "string", + &[ + "trim", + "split", + "replace", + "replaceAll", + "match", + "matchAll", + "search", + "startsWith", + "endsWith", + "substring", + "toLowerCase", + "toUpperCase", + ], + ), + ("promise", &["then", "catch", "finally"]), + ("function", &["call", "apply", "bind"]), + ( + "event", + &[ + "on", + "once", + "off", + "emit", + "addListener", + "removeListener", + "removeAllListeners", + "addEventListener", + "removeEventListener", + "dispatchEvent", + ], + ), + ("iterator", &["next", "return", "throw"]), +]; + +#[test] +fn builtin_method_names_on_unknown_receivers_do_not_guess_project_methods() { + for ext in JS_EXTENSIONS { + let mut declarations = String::new(); + let mut calls = String::new(); + let mut expected_callers = 0; + for (family, methods) in BUILTIN_FAMILIES { + // Receiver and class share a word; that is still no evidence of its type. + declarations.push_str(&format!("export class {family}Wrapper {{\n")); + for method in methods { + declarations.push_str(&format!(" {method}() {{}}\n")); + calls.push_str(&format!( + "export function {family}_{method}({family}) {{ {family}.{method}(); }}\n" + )); + expected_callers += 1; + } + declarations.push_str("}\n"); + } + let project = resolve_project(&files(&[ + (&format!("decoys.{ext}"), &declarations), + (&format!("calls.{ext}"), &calls), + ])); + let callers = project + .store() + .nodes_by_kind(NodeKind::Function) + .expect("functions") + .into_iter() + .filter(|node| node.file_path == format!("calls.{ext}")) + .collect::>(); + assert_eq!(callers.len(), expected_callers, "{ext}"); + for caller in callers { + assert_eq!( + project.callees(&caller.qualified_name, Some(&format!("calls.{ext}"))), + Vec::::new(), + "{ext}: {}", + caller.name + ); + } + } +} + +#[test] +fn every_builtin_method_family_resolves_on_a_constructed_or_typed_receiver() { + let mut methods = BUILTIN_FAMILIES + .iter() + .flat_map(|(_, methods)| methods.iter().copied()) + .collect::>(); + methods.sort_unstable(); + methods.dedup(); + for ext in JS_EXTENSIONS { + let typed = matches!(ext, "ts" | "tsx"); + let declared = methods + .iter() + .map(|method| format!(" {method}() {{}}\n")) + .collect::(); + let called = methods + .iter() + .map(|method| format!(" receiver.{method}();\n")) + .collect::(); + let mut source = format!( + "export class Project {{\n{declared}}}\nexport function useConstructed() {{\n const receiver = new Project();\n{called}}}\n" + ); + if typed { + source.push_str(&format!( + "export function useTyped(receiver: Project) {{\n{called}}}\n" + )); + } + let project = resolve_project(&files(&[(&format!("project.{ext}"), &source)])); + let expected = methods + .iter() + .map(|method| format!("Project::{method}")) + .collect::>(); + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + let callers: &[&str] = if typed { + &["useConstructed", "useTyped"] + } else { + &["useConstructed"] + }; + for caller in callers { + assert_eq!( + project.callees(caller, None), + expected_sorted, + "{ext}: {caller}" + ); + } + } +} + +#[test] +fn builtin_values_stay_unresolved_while_real_receivers_resolve() { + for ext in JS_EXTENSIONS { + let typed = matches!(ext, "ts" | "tsx"); + let list_annotation = if typed { ": string[]" } else { "" }; + let typed_call = if typed { + "export function typed(cart: Cart) { cart.map(); }\n" + } else { + "" + }; + let project = resolve_project(&files(&[ + ( + &format!("cart.{ext}"), + " +export class Cart { + map() {} + add() {} + get() {} + static bind() {} + self() { this.add(); } +} +export const api = { map() {} }; +export class LRUCache { get() {} } +", + ), + ( + &format!("calls.{ext}"), + &format!( + " +import {{ Cart, api }} from './cart'; +export function tidy(list{list_annotation}) {{ list.map(); }} +export function cached() {{ + const cache = new Map(); + cache.get('x'); +}} +export function unknownCache(cache) {{ cache.get('x'); }} +export function literalArray() {{ const cart = []; cart.map(); }} +export function literalString() {{ const cart = 'x'; cart.trim(); }} +export function constructed() {{ + const cart = new Cart(); + cart.add(); +}} +export function imported() {{ Cart.bind(); }} +export function objectLiteral() {{ api.map(); }} +export const local = {{ map() {{}} }}; +export function localLiteral() {{ local.map(); }} +{typed_call}" + ), + ), + ])); + let calls = format!("calls.{ext}"); + for name in [ + "tidy", + "cached", + "unknownCache", + "literalArray", + "literalString", + ] { + assert_eq!( + project.callees(name, Some(&calls)), + Vec::::new(), + "{ext}: {name}" + ); + } + assert!( + project + .callees("constructed", Some(&calls)) + .contains(&"Cart::add".to_string()), + "{ext}" + ); + assert!( + project + .callees("imported", Some(&calls)) + .contains(&"Cart::bind".to_string()), + "{ext}" + ); + // Object-literal members are qualified by their holder here + // (upstream keeps the bare member name). + assert_eq!( + project.callees("objectLiteral", Some(&calls)), + vec!["api::map"], + "{ext}" + ); + assert_eq!( + project.callees("localLiteral", Some(&calls)), + vec!["local::map"], + "{ext}" + ); + if typed { + assert_eq!( + project.callees("typed", Some(&calls)), + vec!["Cart::map"], + "{ext}" + ); + } + assert_eq!( + project.callees("Cart::self", None), + vec!["Cart::add"], + "{ext}" + ); + } +} + +// ---- this. and this.#field (#1496, #1987) --------------------------- + +fn this_field_project() -> Project { + resolve_project(&files(&[ + ( + "src/mailer.ts", + "export class Mailer {\n send(msg: string): string { return msg; }\n}\n", + ), + ( + "src/notifier.ts", + "import { Mailer } from './mailer';\n\ + export class Notifier {\n \ + constructor(private readonly mailer: Mailer, private items: string[]) {}\n \ + send(msg: string): string { return this.mailer.send(msg); }\n \ + other(msg: string): string { return this.mailer.send(msg); }\n \ + push(msg: string): void { this.items.push(msg); }\n\ + }\n", + ), + ( + "src/legacy-mailer.js", + "class LegacyMailer {\n send(msg) { return msg; }\n}\nmodule.exports = { LegacyMailer };\n", + ), + ( + "src/legacy.js", + "const { LegacyMailer } = require('./legacy-mailer');\n\ + class LegacyNotifier {\n \ + constructor() { this.mailer = new LegacyMailer(); }\n \ + send(msg) { return this.mailer.send(msg); }\n\ + }\n\ + module.exports = { LegacyNotifier };\n", + ), + ( + "src/storage.ts", + "export const DraftHubStorage = {\n \ + async get(key: string): Promise { return key; },\n \ + async getSettings(): Promise { return {}; },\n\ + };\n", + ), + ( + "src/keeper.ts", + "import { DraftHubStorage } from './storage';\n\ + export class Keeper {\n \ + constructor(private readonly storage: typeof DraftHubStorage) {}\n \ + async get(key: string): Promise { return this.storage.get(key); }\n \ + async settings(): Promise { return this.storage.getSettings(); }\n\ + }\n", + ), + // `Outbox::send` and `Cart::add` sit in the same file, so a bare-name + // guess would pick them over the field's real type. + ( + "src/vault.ts", + "import { Mailer } from './mailer';\n\ + export class Outbox {\n send(msg: string): string { return msg; }\n}\n\ + export class Cart {\n add(item: string): void {}\n}\n\ + export class Vault {\n \ + #mailer: Mailer;\n \ + #backup = new Mailer();\n \ + #items = new Set();\n \ + constructor(m: Mailer) { this.#mailer = m; }\n \ + notify(msg: string): string { return this.#mailer.send(msg); }\n \ + fallback(msg: string): string { return this.#backup.send(msg); }\n \ + put(x: string): void { this.#items.add(x); }\n\ + }\n", + ), + ])) +} + +#[test] +fn this_fields_resolve_on_their_declared_or_constructed_type() { + let project = this_field_project(); + assert_eq!( + project.callees("Notifier::send", None), + vec!["Mailer::send"] + ); + assert_eq!( + project.callees("Notifier::other", None), + vec!["Mailer::send"] + ); + assert!( + !project + .callers("Notifier::send", None) + .contains(&"Notifier::send".to_string()) + ); + assert_eq!( + project.callees("LegacyNotifier::send", None), + vec!["LegacyMailer::send"] + ); + assert_eq!( + project.callees("Notifier::push", None), + Vec::::new() + ); + assert_eq!( + project.callees("Keeper::settings", None), + vec!["DraftHubStorage::getSettings"] + ); + assert_eq!( + project.callees("Keeper::get", None), + vec!["DraftHubStorage::get"] + ); + assert!( + !project + .callers("Keeper::get", None) + .contains(&"Keeper::get".to_string()) + ); +} + +#[test] +fn private_fields_resolve_on_their_type_and_builtin_fields_stay_unresolved() { + let project = this_field_project(); + assert_eq!(project.callees("Vault::notify", None), vec!["Mailer::send"]); + assert_eq!( + project.callees("Vault::fallback", None), + vec!["Mailer::send"] + ); + // `this.#items.add()` on a Set must not bind to the project's `Cart::add`. + assert_eq!(project.callees("Vault::put", None), Vec::::new()); +} + +#[test] +fn private_and_public_fields_stay_distinct_in_every_variant() { + let source = " +export class Mailer { send() {} } +export class Cart { add() {} } +export class Vault { + #mailer = new Mailer(); + #items = new Set(); + items = new Cart(); + notify() { this.#mailer?.send(); } + optional() { this.#mailer.send?.(); } + put() { this.#items?.add('x'); } + publicPut() { this.items.add('x'); } +} +"; + for ext in JS_EXTENSIONS { + for (ending, text) in [ + ("LF", source.to_string()), + ("CRLF", source.replace('\n', "\r\n")), + ] { + let project = resolve_project(&files(&[(&format!("vault.{ext}"), &text)])); + let label = format!("{ext} {ending}"); + assert_eq!( + project.callees("Vault::notify", None), + vec!["Mailer::send"], + "{label}" + ); + assert_eq!( + project.callees("Vault::optional", None), + vec!["Mailer::send"], + "{label}" + ); + assert_eq!( + project.callees("Vault::put", None), + Vec::::new(), + "{label}" + ); + assert_eq!( + project.callees("Vault::publicPut", None), + vec!["Cart::add"], + "{label}" + ); + } + } +} + +// ---- host-global and untyped chains (#1707, #1566, #1862) ------------------ + +#[test] +fn host_chains_stay_unresolved_while_project_rooted_chains_resolve() { + let project = resolve_project(&files(&[ + ( + "storage.ts", + "declare const chrome: any;\n\ + export const DraftHubStorage = {\n \ + async get(key: string): Promise {\n \ + const result = await chrome.storage.local.get([key]);\n \ + return result[key];\n \ + },\n\ + };\n", + ), + ( + "dom.ts", + "export function querySelector(sel: string): string { return sel; }\n\ + export function findRow(): unknown {\n \ + return document.body.querySelector(\"tr\");\n\ + }\n", + ), + ( + "service.ts", + "declare const window: any;\n\ + export function ping(): string { return \"pong\"; }\n\ + export function viaGlobal(): string {\n \ + return window.MyNs.ping();\n\ + }\n\ + export class PingService { ping(): string { return \"service\"; } }\n\ + export class Runner {\n \ + constructor(private svc: PingService) {}\n \ + run(): string { return this.svc.ping(); }\n\ + }\n\ + export class AnonymousRunner {\n \ + constructor(private svc: { ping(): string }) {}\n \ + run(): string { return this.svc.ping(); }\n\ + }\n", + ), + ])); + assert!( + !project + .callees("DraftHubStorage::get", Some("storage.ts")) + .contains(&"DraftHubStorage::get".to_string()) + ); + assert!( + !project + .callees("findRow", Some("dom.ts")) + .contains(&"querySelector".to_string()) + ); + assert!( + project + .callees("viaGlobal", Some("service.ts")) + .contains(&"ping".to_string()) + ); + assert_eq!( + project.callees("Runner::run", None), + vec!["PingService::ping"] + ); + assert_eq!( + project.callees("AnonymousRunner::run", None), + Vec::::new() + ); +} + +#[test] +fn qualified_call_sites_stay_as_unresolved_evidence() { + let project = resolve_project(&files(&[ + ( + "effects.ts", + "import { client } from './client';\n\ + export function create() { return 1; }\n\ + export function effects() {\n \ + client.user.create({ data: {} });\n \ + client?.user?.create({ data: {} });\n\ + }\n", + ), + ("client.ts", "export const client = {};\n"), + ])); + assert_eq!(project.callees("effects", None), Vec::::new()); + assert_eq!( + project.unresolved_calls("effects", None), + vec!["client.user.create", "client.user.create"] + ); +} + +#[test] +fn this_fields_resolve_across_sibling_js_and_ts_extensions() { + for (name, target_ext, caller_ext) in [ + ("Reverse", "tsx", "ts"), + ("Legacy", "js", "jsx"), + ("LegacyReverse", "jsx", "js"), + ] { + let project = resolve_project(&files(&[ + ( + &format!("{name}.{target_ext}"), + &format!("export class {name} {{ send() {{ return 1; }} }}\n"), + ), + ( + &format!("{name}Caller.{caller_ext}"), + &format!( + "import {{ {name} }} from './{name}';\n\ + export class {name}Caller {{\n \ + service = new {name}();\n \ + send() {{ return this.service.send(); }}\n\ + }}\n" + ), + ), + ])); + assert_eq!( + project.callees(&format!("{name}Caller::send"), None), + vec![format!("{name}::send")], + "{name}" + ); + } +} + +// ---- expression receivers (#1986) ------------------------------------------ + +#[test] +fn expression_receivers_never_bind_by_bare_name() { + let project = resolve_project(&files(&[ + ( + "src/adapter.ts", + "export class GraphAdapter { async map(req?: string) { return req ?? ''; } }\n\ + export class Runner { run() { return 1; } }\n\ + export class Base { hello() { return 1; } }\n", + ), + ( + "src/use.ts", + "import { GraphAdapter, Runner, Base } from './adapter';\n\ + import { helper } from './helper.js';\n\ + async function list(): Promise { return []; }\n\ + export async function names() {\n \ + const a = (await list()).map((d) => d.length);\n \ + const b = [1, 2].map((x) => x + 1);\n \ + const c = (a ?? []).map((x) => x);\n \ + return [a, b, c];\n\ + }\n\ + export function typed(g: GraphAdapter | undefined) {\n \ + return g!.map();\n\ + }\n\ + export function fresh() { return new Runner().run() + helper(); }\n\ + export class Child extends Base {\n \ + greet() { return this.hello() + super.hello(); }\n\ + }\n", + ), + ("src/helper.js", "export function helper() { return 2; }\n"), + ( + "k/src/lib.rs", + "pub fn lens(v: Vec) -> Vec { v.iter().map(|s| s.len()).collect() }\n\ + pub fn opt(o: Option) -> Option { o.map(|x| x as u16) }\n", + ), + ( + "export_ios.go", + "package main\n\nimport \"C\"\n\n//export OpenFluxStop\nfunc OpenFluxStop() {}\n", + ), + ( + "ios/Tunnel.swift", + "func stopTunnel() {\n OpenFluxStop()\n}\n", + ), + ("web/min.js", "function i(a) { return a; }\n"), + ( + "android/Diag.kt", + "fun report() {\n android.util.Log.i(\"tag\", \"msg\")\n}\n", + ), + ])); + assert_eq!(project.callees("names", Some("use.ts")), vec!["list"]); + assert_eq!( + project.callers("GraphAdapter::map", None), + vec!["typed".to_string()] + ); + assert_eq!( + project.callees("lens", Some("lib.rs")), + Vec::::new() + ); + assert_eq!(project.callees("opt", Some("lib.rs")), Vec::::new()); + assert_eq!( + project.callees("typed", Some("use.ts")), + vec!["GraphAdapter::map"] + ); + assert_eq!( + project.callees("fresh", Some("use.ts")), + vec!["Runner::run", "helper"] + ); + assert_eq!(project.callees("Child::greet", None), vec!["Base::hello"]); + assert_eq!( + project.callees("report", Some("Diag.kt")), + Vec::::new() + ); + assert_eq!( + project.callees("stopTunnel", Some("Tunnel.swift")), + vec!["OpenFluxStop"] + ); +} + +// ---- call-result receivers (#1683) ----------------------------------------- + +#[test] +fn call_result_receivers_never_fabricate_an_edge() { + let project = resolve_project(&files(&[ + ("py/__init__.py", ""), + ( + "py/collect.py", + "def append(item):\n return item\n\n\ + def get(key):\n return key\n\n\ + def make():\n return {}\n\n\ + def bucket(d, k, v):\n d.setdefault(k, []).append(v)\n return d.items().get(k)\n\n\ + def fresh():\n return make().get(\"x\")\n", + ), + ( + "js/collect.js", + "function append(item) { return item; }\n\ + function run() { return 1; }\n\ + function make() { return {}; }\n\ + function bucket(d, k, v) { d.setdefault(k, []).append(v); make().run(); (0, make)().run(); }\n\ + module.exports = { append, run, make, bucket };\n", + ), + ])); + let py = Some("collect.py"); + assert_eq!(project.callees("bucket", py), Vec::::new()); + assert_eq!(project.callers("append", py), Vec::::new()); + assert_eq!(project.callers("get", py), Vec::::new()); + // The inner call still resolves on its own; `.get` on its unknown product does not. + assert_eq!(project.callees("fresh", py), vec!["make"]); + + let js = Some("collect.js"); + assert_eq!(project.callers("append", js), Vec::::new()); + // `make().run()` — what `make` returns is unknown, so `run` is not guessed. + assert_eq!(project.callers("run", js), Vec::::new()); + assert_eq!(project.callees("bucket", js), vec!["make"]); +} + +// ---- store actions (#647, #1862) -------------------------------------------- + +fn store_project() -> Project { + resolve_project(&files(&[ + ( + "store.ts", + "import { create } from 'zustand'; +interface S { fetchUser(): Promise; reset(): void } +export const useStore = create((set, get, api) => ({ + fetchUser: async () => { get().reset(); }, + reset: () => set({}), +})); +export const anotherStore = create((set, get) => ({ + reset: () => set({}), +})); +", + ), + ( + "consumer.ts", + "import { useStore as current, anotherStore } from './store'; +function fetchUser() { return 'local'; } +export async function loginFlow() { + const { fetchUser } = current.getState(); + await fetchUser(); +} +export function hardReset() { current.getState().reset(); } +export function multipleBindings() { + const { fetchUser, reset } = current.getState(); + fetchUser(); reset(); +} +export function otherReset() { anotherStore.getState().reset(); } +export function shadowed() { + const { fetchUser } = current.getState(); + { const fetchUser = () => 'shadow'; fetchUser(); } +} +export function siblingScope(flag: boolean) { + if (flag) { const { fetchUser } = current.getState(); } + return fetchUser(); +} +export function unknownStore(unknown: any) { unknown.getState().reset(); } +export function unknownFactory(db: any) { db.prepare().reset(); } +", + ), + ( + "not-a-store.ts", + "export const fake = otherFactory(() => ({ reset() { return 1; } }));\n", + ), + ( + "barrel.ts", + "export { useStore as routedStore } from './store';\n", + ), + ( + "barrel-consumer.ts", + "import { routedStore as current } from './barrel'; +export function barrelReset() { current.getState().reset(); } +export function barrelSelected() { const selected = current(s => s.reset); selected(); } +", + ), + ( + "selectors.ts", + "import { useStore as current, anotherStore } from './store'; +import { fake } from './not-a-store'; +export function rootShadow(current: any) { const selected = current(s => s.reset); selected(); } +export function rootBlockShadow() { const current = fake; const selected = current(s => s.reset); selected(); } +export function fakeSelector() { const selected = fake(s => s.reset); selected(); } +export function Screen() { + const selected = current((s) => s.reset); + const otherSelected = anotherStore(s => s.reset); + function captured() { selected(); } + function otherCaptured() { otherSelected(); } + function parameterShadow(selected: () => void) { selected(); } + const arrowShadow = (selected: () => void) => { selected(); }; + function localShadow() { const selected = () => 1; selected(); } + return { captured, otherCaptured, parameterShadow, arrowShadow, localShadow }; +} +export function sibling() { const selected = current(s => s.reset); } +export function outside() { selected(); } +export function wrongSelector(other: any) { + const selected = current(s => other.reset); + selected(); +} +export function unknownSelector(unknown: any) { + const selected = unknown(s => s.reset); + selected(); +} +", + ), + ])) +} + +const USE_STORE_RESET: &str = "useStore::reset"; +const OTHER_STORE_RESET: &str = "anotherStore::reset"; + +#[test] +fn store_initializers_own_their_inline_actions() { + let project = store_project(); + let store_actions = |file: &str| { + let mut names = project + .store() + .nodes_by_kind(NodeKind::Function) + .expect("functions") + .into_iter() + .filter(|node| node.file_path == file) + .map(|node| (node.qualified_name, node.start_line)) + .collect::>(); + names.sort(); + names + }; + assert_eq!( + store_actions("store.ts"), + vec![ + ("anotherStore::reset".to_string(), 8), + ("useStore::fetchUser".to_string(), 4), + ("useStore::reset".to_string(), 5), + ] + ); + assert_eq!( + store_actions("not-a-store.ts"), + vec![("fake::reset".to_string(), 1)] + ); +} + +#[test] +fn store_accessors_resolve_inside_the_identified_store() { + let project = store_project(); + assert!( + project + .callees("useStore::fetchUser", None) + .contains(&USE_STORE_RESET.to_string()) + ); + let hard = project.callees("hardReset", None); + assert!(hard.contains(&USE_STORE_RESET.to_string()), "{hard:?}"); + assert!(!hard.contains(&OTHER_STORE_RESET.to_string()), "{hard:?}"); + let other = project.callees("otherReset", None); + assert!(other.contains(&OTHER_STORE_RESET.to_string()), "{other:?}"); + assert!(!other.contains(&USE_STORE_RESET.to_string()), "{other:?}"); + for name in ["unknownStore", "unknownFactory"] { + let callees = project.callees(name, None); + assert!( + !callees + .iter() + .any(|callee| callee.starts_with("useStore::") + || callee.starts_with("anotherStore::")), + "{name}: {callees:?}" + ); + } +} + +#[test] +fn destructured_store_actions_resolve_ahead_of_same_named_locals() { + let project = store_project(); + let login = project.callees("loginFlow", None); + assert!( + login.contains(&"useStore::fetchUser".to_string()), + "{login:?}" + ); + assert!(!login.contains(&"fetchUser".to_string()), "{login:?}"); + let multiple = project.callees("multipleBindings", None); + assert!( + multiple.contains(&"useStore::fetchUser".to_string()), + "{multiple:?}" + ); + assert!( + multiple.contains(&USE_STORE_RESET.to_string()), + "{multiple:?}" + ); + for name in ["shadowed", "siblingScope"] { + let callees = project.callees(name, None); + assert!( + !callees.contains(&"useStore::fetchUser".to_string()), + "{name}: {callees:?}" + ); + } +} + +#[test] +fn selectors_follow_their_own_store_through_closures_and_barrels() { + let project = store_project(); + assert_eq!( + project.callees("Screen::captured", None), + vec![USE_STORE_RESET] + ); + assert_eq!( + project.callees("Screen::otherCaptured", None), + vec![OTHER_STORE_RESET] + ); + for name in ["barrelReset", "barrelSelected"] { + let callees = project.callees(name, Some("barrel-consumer.ts")); + assert!( + callees.contains(&USE_STORE_RESET.to_string()), + "{name}: {callees:?}" + ); + // The imported store itself may also be referenced by the accessor or + // hook call; the other store's same-named action must not leak in. + assert!( + callees + .iter() + .all(|callee| callee == USE_STORE_RESET || callee == "useStore"), + "{name}: {callees:?}" + ); + } +} + +#[test] +fn selectors_are_not_guessed_through_shadows_or_foreign_factories() { + let project = store_project(); + for name in [ + "Screen::parameterShadow", + "Screen::arrowShadow", + "Screen::localShadow", + "outside", + "wrongSelector", + "unknownSelector", + "rootShadow", + "rootBlockShadow", + ] { + let callees = project.callees(name, Some("selectors.ts")); + assert!( + !callees.contains(&USE_STORE_RESET.to_string()) + && !callees.contains(&OTHER_STORE_RESET.to_string()), + "{name}: {callees:?}" + ); + } + let fake = project.callees("fakeSelector", Some("selectors.ts")); + assert!(!fake.contains(&"fake::reset".to_string()), "{fake:?}"); +} + +// ---- object-literal members that alias a function (#1932) ------------------ + +#[test] +fn literal_members_follow_shorthand_and_identifier_bindings() { + let project = resolve_project(&files(&[ + ( + "a.ts", + "import { imported } from './d'; +const viaArrow = async () => 1; +function viaDecl() { return 2; } +const longForm = async () => 3; +function renamed() { return 4; } + +export const api = { + inline() { return 0; }, + viaArrow, + viaDecl, + longForm: longForm, + alias: renamed, + imported, +}; + +export function sameFileCaller() { + return [api.inline(), api.viaArrow(), api.viaDecl(), api.longForm(), api.alias(), api.imported()]; +} +", + ), + ( + "b.ts", + "import { api } from './a'; +export function crossFileCaller() { + return [api.viaArrow(), api.viaDecl(), api.longForm(), api.alias()]; +} +", + ), + ("d.ts", "export function imported() { return 5; }\n"), + ( + "e.ts", + "function frozenFn() { return 6; }\nexport const frozen = Object.freeze({ frozenFn });\n", + ), + ( + "f.ts", + "import { frozen } from './e';\nexport function frozenCaller() { return frozen.frozenFn(); }\n", + ), + ])); + // A literal handed straight to a wrapper still names its members. + assert_eq!( + project.callers("frozenFn", Some("e.ts")), + vec!["frozenCaller"] + ); + assert_eq!( + project.callers("api::inline", Some("a.ts")), + vec!["sameFileCaller"] + ); + for function in ["viaArrow", "viaDecl", "longForm", "renamed"] { + assert_eq!( + project.callers(function, Some("a.ts")), + vec!["crossFileCaller", "sameFileCaller"], + "{function}" + ); + } + assert_eq!( + project.callers("imported", Some("d.ts")), + vec!["sameFileCaller"] + ); +} + +#[test] +fn literal_members_never_follow_keys_nested_objects_or_shadows() { + let project = resolve_project(&files(&[( + "c.ts", + "function keyOnly() { return 1; } +function nested() { return 2; } +function shadowed() { return 3; } + +export const other = { keyOnly: 1, box: { nested } }; + +export function useOther() { + return [other.keyOnly(), other.nested()]; +} + +export function makeApi(shadowed: () => number) { + const local = { shadowed }; + return local.shadowed(); +} +", + )])); + for function in ["keyOnly", "nested", "shadowed"] { + assert_eq!( + project.callers(function, Some("c.ts")), + Vec::::new(), + "{function}" + ); + } +} + +#[test] +fn literal_member_boundaries_pick_the_last_own_property() { + let cases: [(&str, &str, &str, bool); 14] = [ + ( + "sibling literals", + "const first = { wrong }; export const api = { run: right };", + "run", + true, + ), + ( + "unicode before literal", + "const label = 'é🙂'; export const api = { run: right };", + "run", + true, + ), + ( + "absent sibling member", + "const first = { wrong }; export const api = { right };", + "wrong", + false, + ), + ( + "duplicate key", + "export const api = { run: wrong, run: right };", + "run", + true, + ), + ( + "non-callable overwrite", + "export const api = { wrong, wrong: 0 };", + "wrong", + false, + ), + ( + "nested member", + "export const api = { box: { wrong }, method() { return { wrong }; } };", + "wrong", + false, + ), + ( + "unknown spread", + "export const api = { wrong, ...unknown };", + "wrong", + false, + ), + ( + "explicit after spread", + "export const api = { ...unknown, run: right };", + "run", + true, + ), + ( + "quoted overwrite", + "export const api = { wrong, 'wrong': 0 };", + "wrong", + false, + ), + ( + "computed overwrite", + "export const api = { wrong, [unknown]: 0 };", + "wrong", + false, + ), + ( + "frozen literal", + "export const api = Object.freeze({ run: right });", + "run", + true, + ), + ( + "parenthesized literal", + "export const api = ({ run: right });", + "run", + true, + ), + ( + "inline overwrite", + "export const api = { run() { return 0; }, run: right };", + "run", + true, + ), + ( + "nested inline", + "export const api = { box: { wrong() { return 0; } } };", + "wrong", + false, + ), + ]; + for ext in ["ts", "js"] { + for (name, declaration, member, resolves) in cases { + let project = resolve_project(&files(&[ + ( + &format!("impl.{ext}"), + &format!( + "function wrong() {{ return 1; }} +function right() {{ return 2; }} +{declaration} +export function sameCaller() {{ return api.{member}(); }} +" + ), + ), + ( + &format!("consumer.{ext}"), + &format!( + "import {{ api }} from './impl'; +export function crossCaller() {{ return api.{member}(); }} +" + ), + ), + ])); + let label = format!("{ext}: {name}"); + let impl_file = format!("impl.{ext}"); + let wrong = project.callers("wrong", Some(&impl_file)); + let right = project.callers("right", Some(&impl_file)); + for caller in ["sameCaller", "crossCaller"] { + assert!(!wrong.contains(&caller.to_string()), "{label}: {wrong:?}"); + assert_eq!( + right.contains(&caller.to_string()), + resolves, + "{label}: {caller} in {right:?}" + ); + } + for inline in project + .store() + .nodes_by_kind(NodeKind::Function) + .expect("functions") + .into_iter() + .filter(|node| { + node.name == member + && !matches!(node.qualified_name.as_str(), "right" | "wrong") + }) + { + let callers = project.callers(&inline.qualified_name, Some(&inline.file_path)); + assert!( + !callers + .iter() + .any(|caller| caller == "sameCaller" || caller == "crossCaller"), + "{label}: {} called by {callers:?}", + inline.qualified_name + ); + } + } + } +} + +#[test] +fn literal_bindings_do_not_cross_parameter_or_value_shadows() { + let project = resolve_project(&files(&[( + "impl.ts", + "function target() { return 0; } +export function parameter(target: () => number) { + const api = { target }; + return api.target(); +} +export function value() { + const target = 0; + const api = { target }; + return api.target(); +} +export function later() { + const api = { target }; + const target = 0; + return api.target(); +} +", + )])); + let callers = project.callers("target", Some("impl.ts")); + for name in ["parameter", "value", "later"] { + assert!(!callers.contains(&name.to_string()), "{name}: {callers:?}"); + } +} + +#[test] +fn literal_bindings_follow_renamed_imports_and_ignore_nested_namesakes() { + let project = resolve_project(&files(&[ + ("target.ts", "export function actual() { return 1; }\n"), + ( + "impl.ts", + "import { actual as renamed } from './target'; +function unrelated() { function renamed() { return 0; } return renamed(); } +export const api = { run: renamed }; +export function sameCaller() { return api.run(); } +", + ), + ( + "consumer.ts", + "import { api as facade } from './impl'; +export function crossCaller() { return facade.run(); } +", + ), + ])); + let actual = project.callers("actual", Some("target.ts")); + assert!(actual.contains(&"sameCaller".to_string()), "{actual:?}"); + assert!(actual.contains(&"crossCaller".to_string()), "{actual:?}"); + let nested = project.callers("unrelated::renamed", Some("impl.ts")); + assert!( + !nested + .iter() + .any(|caller| caller == "sameCaller" || caller == "crossCaller"), + "{nested:?}" + ); +} diff --git a/crates/codegraph-resolve/tests/js_resolution_safety.rs b/crates/codegraph-resolve/tests/js_resolution_safety.rs index da94f505..ebb94c96 100644 --- a/crates/codegraph-resolve/tests/js_resolution_safety.rs +++ b/crates/codegraph-resolve/tests/js_resolution_safety.rs @@ -555,3 +555,68 @@ export class Consumer { "an undeclared receiver must remain unresolved" ); } + +/// `(target kind, target name)` of every `implements` edge out of class `name`. +fn implements_targets(project: &Project, name: &str) -> Vec<(NodeKind, String)> { + let store = project.store.as_ref().expect("store"); + let source = node_id(project, NodeKind::Class, name); + store + .edges_by_source_kind(&source, Some(EdgeKind::Implements)) + .expect("implements") + .into_iter() + .filter_map(|edge| store.node_by_id(&edge.target).expect("target query")) + .map(|node| (node.kind, node.name)) + .collect() +} + +#[test] +fn implements_binds_the_interface_of_a_value_and_interface_pair() { + // VS Code declares every service twice under one name: the DI identifier + // value and the interface (upstream #2055). + let value = "export const IFooService = createDecorator('fooService');\n"; + let interface = "export interface IFooService {\n run(): void;\n}\n"; + for first_is_value in [true, false] { + let foo = format!( + "import {{ createDecorator }} from './instantiation';\n\n{}", + if first_is_value { + format!("{value}{interface}") + } else { + format!("{interface}{value}") + } + ); + let project = resolve_project( + "value-interface-pair", + &[ + ( + "src/instantiation.ts", + "export function createDecorator(id: string): { id: string } { return { id }; }\n", + ), + ("src/foo.ts", &foo), + ( + "src/fooService.ts", + "import { IFooService } from './foo';\n\nexport class FooService implements IFooService {\n run(): void {}\n}\n", + ), + ], + ); + assert_eq!( + implements_targets(&project, "FooService"), + vec![(NodeKind::Interface, "IFooService".to_string())], + "value first: {first_is_value}" + ); + } +} + +#[test] +fn implements_whose_only_same_named_target_is_a_value_is_dropped() { + let project = resolve_project( + "value-only-implements", + &[ + ("src/foo.ts", "export const IBarService = { id: 'bar' };\n"), + ( + "src/barService.ts", + "import { IBarService } from './foo';\n\nexport class BarService implements IBarService {\n id = 'bar';\n}\n", + ), + ], + ); + assert!(implements_targets(&project, "BarService").is_empty()); +} diff --git a/crates/codegraph-resolve/tests/python_import_members.rs b/crates/codegraph-resolve/tests/python_import_members.rs new file mode 100644 index 00000000..de9261db --- /dev/null +++ b/crates/codegraph-resolve/tests/python_import_members.rs @@ -0,0 +1,192 @@ +//! A named Python import proves its receiver exists, not the requested +//! attribute (upstream v1.6.1 #2040): `task.delay()` enqueues work and must not +//! become a call to the task function. Ported from upstream +//! `__tests__/function-ref.test.ts`. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{EdgeKind, FileRecord, Language, NodeKind}; +use codegraph_extract::extract_file; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +struct Project { + root: PathBuf, + store: Option, +} + +impl Drop for Project { + fn drop(&mut self) { + drop(self.store.take()); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn resolve_project(files: &[(&str, &str)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-python-members-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).expect("create fixture root"); + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + for (relative, source) in files { + std::fs::write(root.join(relative), source).expect("write fixture"); + } + for (relative, _) in files { + let result = extract_file(&root, relative).expect("extract fixture"); + store + .upsert_file(&FileRecord { + path: (*relative).to_string(), + content_hash: "fixture".to_string(), + language: Language::Python, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + generated: false, + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); + } + let mut resolver = ReferenceResolver::new(root.to_string_lossy().to_string()); + resolver.initialize(&codegraph_resolve::StoreResolutionContext::new( + &store, + root.to_string_lossy().as_ref(), + )); + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + Project { + root, + store: Some(store), + } +} + +fn node(project: &Project, kind: NodeKind, name: &str) -> String { + project + .store + .as_ref() + .expect("store") + .nodes_by_kind(kind) + .expect("nodes by kind") + .into_iter() + .find(|node| node.name == name) + .unwrap_or_else(|| panic!("missing {kind:?} {name}")) + .id +} + +/// Names of the sources of `kind` edges into `target`; `fn_ref` keeps only +/// callable-value references (`References` carrying `fnRef: true`). +fn sources(project: &Project, target: &str, kind: EdgeKind, fn_ref: bool) -> Vec { + let store = project.store.as_ref().expect("store"); + let mut names: Vec = store + .edges_by_target_kind(target, Some(kind)) + .expect("incoming edges") + .into_iter() + .filter(|edge| { + !fn_ref + || edge + .metadata + .as_ref() + .and_then(|metadata| metadata.get("fnRef")) + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + .filter_map(|edge| store.node_by_id(&edge.source).expect("source query")) + .map(|node| node.name) + .collect(); + names.sort(); + names.dedup(); + names +} + +const TASKS: &str = "from celery import shared_task +@shared_task +def send_welcome(item_id): + return item_id +class Store: + @staticmethod + def fetch(): + return 1 +settings = None +"; + +fn main_module(module: &str) -> String { + format!( + "from {module} import send_welcome as welcome, Store as Actual, settings +def direct(): + welcome(1) +def callback(pool): + pool.submit(welcome, 1) +def enqueue(): + welcome.delay(1) + welcome.apply_async(args=[1]) +def member_values(pool): + pool.submit(welcome.delay, 1) + cb = welcome.custom_attribute + return [welcome.apply_async] +def missing_members(): + Actual.missing() + settings.missing() +def known_member(): + Actual.fetch() +def known_callback(pool): + pool.submit(Actual.fetch) +def construct(): + return Actual() +" + ) +} + +#[test] +fn imported_members_do_not_fall_back_to_their_receiver() { + for module in ["tasks", ".tasks"] { + let project = resolve_project(&[("tasks.py", TASKS), ("main.py", &main_module(module))]); + let task = node(&project, NodeKind::Function, "send_welcome"); + assert_eq!( + sources(&project, &task, EdgeKind::Calls, false), + vec!["direct"], + "{module}" + ); + assert_eq!( + sources(&project, &task, EdgeKind::References, true), + vec!["callback"], + "{module}" + ); + let missing = node(&project, NodeKind::Function, "missing_members"); + let store = project.store.as_ref().expect("store"); + let outgoing: Vec<_> = store + .edges_by_source_kind(&missing, None) + .expect("outgoing") + .into_iter() + .filter(|edge| matches!(edge.kind, EdgeKind::Calls | EdgeKind::Instantiates)) + .collect(); + assert!(outgoing.is_empty(), "{module}: {outgoing:#?}"); + let fetch = node(&project, NodeKind::Method, "fetch"); + assert_eq!( + sources(&project, &fetch, EdgeKind::Calls, false), + vec!["known_member"], + "{module}" + ); + assert_eq!( + sources(&project, &fetch, EdgeKind::References, true), + vec!["known_callback"], + "{module}" + ); + let class = node(&project, NodeKind::Class, "Store"); + assert_eq!( + sources(&project, &class, EdgeKind::Instantiates, false), + vec!["construct"], + "{module}" + ); + } +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index ec331148..9a183410 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 = 13; +pub const CURRENT_EXTRACTION_VERSION: u64 = 14; /// 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 bbc79542..5b6cf64f 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 = 12; + const PREVIOUS_EXTRACTION_VERSION: u64 = 13; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/docs/equivalence.md b/docs/equivalence.md index db75fe48..fbb4e3c2 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -293,12 +293,19 @@ The added alias files pin three import-resolution contracts: - `from pkg import module as mod_alias; mod_alias.func()` prefers the existing module file `pkg/module.py`; - `from imported_types import ImportedClass as ImportedAlias; return -ImportedAlias` preserves member-import semantics and resolves the aliased - class-as-value reference by import. +ImportedAlias` preserves member-import semantics: both the import statement and + the aliased class-as-value reference resolve to the class through the import + (named Python imports use the absolute-module and top-level-definition + fallback of upstream #1820, because Python symbols are never marked + exported). Missing/duplicate/ambiguous module aliases remain unresolved, and a claimed module alias never falls through to global bare-name matching. +Since extraction version 14 the `pkg/__init__.py` file node carries its module +docstring (upstream #1905): a bare string literal first in a module, class or +function body is that node's docstring, joined after any preceding comment. + Regenerate the committed database and canonical artifacts from a clean corpus: ```bash @@ -1231,6 +1238,8 @@ The expanded corpus also pins: direct callable members of one exported object literal; - source-range containment prevents the unrelated top-level `run` function from becoming the target, while nested `api.nested.run()` remains unresolved; + since extraction version 14 it is retained as the qualified call site + `api.nested.run` (upstream #1862), which only a framework resolver may bind; - `tsconfig.json` extends the JSONC/trailing-comma `config/tsconfig.base.json`; the declaring config's `baseUrl` resolves `@fixture/aliased` to `src/aliased.ts`; 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 new file mode 100644 index 00000000..65240abd --- /dev/null +++ b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md @@ -0,0 +1,127 @@ +# Upstream v1.6.1 audit — 2026-09-30 + +- **Range:** `3ed73bc127323e63153bf6ec8354afa82ce36aaf..f4ddf508516332419ea3c95702810765936cf679` + (colby `v1.6.1`, tagged 2026-09-29): 113 commits, 111 of them non-merge. +- **Baseline:** the 2026-09-15 integration snapshot audited in + [`POST_V1_6_MAIN_2026-09-15.md`](POST_V1_6_MAIN_2026-09-15.md). Every commit + in the range was classified against that tree by two read-only passes + (resolution/extraction; MCP/CLI/installer/UI family), and each PORT was + re-verified against the code before it was implemented. +- **Tracked release:** [`UPSTREAM.md`](UPSTREAM.md) keeps `v1.6.0` until the + ported rows ship in a release and the downloaded binaries are verified. +- **Kernel commits:** upstream's `codegraph-kernel` (MIT, `publish = false`) + mirrors each TypeScript change for its native backend. They were read as + reference only; none of them is a separate port. + +Dispositions use the ledger rubric: **PORT**, **CORRECT** (the Rust port has the +feature in an older or divergent form), **ALREADY-HAVE**, **N/A** (TypeScript, +Node or upstream-only surface), **DEFER**, and **KEEP-RUST** (a deliberate, +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 this PR | +| #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 this PR | +| #1987 | `b65e05b` | `this.#field.m()` resolves on the private field's type; JS built-in method names need receiver evidence | PORT | landed in this PR | +| #1986 | `9649ce1` | code references crossing code families need bridge evidence; TS/JS receiver wrappers peel and untyped expression receivers emit nothing | PORT | landed in this PR | +| #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 this PR | +| #2055 | `4960720` | an inheritance ref that lands on a TS value moves to the same-file same-named type | PORT | landed in this PR | +| #1932 | `17d09bf` | object-literal members that alias a function follow the binding lexically (last own property, spread/computed invalidation, shadowing) | CORRECT | landed in this PR | +| #1747 | `9fb2c25` | a curried-wrapper function that is an object member is named by its key | CORRECT | landed in this PR | +| #1905 | `bd993b1` | Python module/class/function body docstrings (PEP 257), joined after a preceding comment | PORT | landed in this PR | +| #1373 | `9b5c8e4` | single-argument C/C++ function macros recover the defined function name with local `#define` evidence | PORT | landed in this PR | +| #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 and the Python named-import module fallback in this PR; Go selector values and Python scoped lookup planned | +| #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 remainder planned | +| #1974 | `52df4d2` | depth-limited traversal re-expands a node reached again at a shallower depth | PORT | planned | +| #1349 | `86708bf` | a later same-line, same-name declaration gets a column-suffixed node id | PORT, touches the node-id invariant | approved by the owner on 2026-10-01; planned | +| #2072 | `fef3776` | performance: memoised gates and caches, graph output unchanged | partial PORT | `this.` declaration memo landed in this PR; per-file source facts in #275; the `file_exists` miss cache is planned | +| #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 | planned | +| #1553 | `276df3a` | resolver file reads stat first and skip non-regular or oversized files | PORT | planned | +| #1910 | `04ec5f5` | bounded extraction reads and MPEG-TS sniffing for `.ts` | PORT | planned | +| #1209 / #1222 | `9e4b8e7` | MyBatis closing tags tolerate whitespace | PORT (refid half ALREADY-HAVE) | planned | +| #1906 | `c505d0d` | Liquid `{% liquid %}` blocks | PORT | planned | +| #1360 | `6184610` | sync reports recovered orphan references | PORT | planned | +| #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 | — | + +Two corrections found while porting, outside the upstream range but required +for the rows above: the Rust TSX and JSX specs did not delegate the +class-member hooks (a plain TSX field became a method), and JavaScript class +fields were named `` instead of by their `property` key (upstream +#808). Both are fixed in this PR. + +KEEP-RUST divergences taken in this PR: + +- a TS/JS member chain rooted at `this`/`super` through more than one field + (`this.a.b.m()`) keeps its last segment (`b.m`, the #1496 port) instead of + upstream's bare method name, which would let the name matcher bind the + enclosing class's same-named method; +- a TS field whose name ends in `$` (`users$`) is a whole token for + declaration lookup, where upstream's trailing `\b` rejects it; +- a `this.` inside a method body counts only as `this. = new T()`, + so a same-named local or parameter never supplies the field type. + +## MCP, CLI, installer, lifecycle and explore + +| Issue / PR | Upstream | Behavior | Disposition | State | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------- | +| #1832 | `cba44c9` | only a prompt that is entirely a task-notification envelope is skipped | CORRECT | landed in #275 | +| #1967 | `94ae04d` | dynamic-import boundaries: template or concatenated specifiers are runtime imports | PORT | planned | +| #1964 | `a9c8808` | a failed sync keeps the pending paths and owed full scan and retries | PORT | planned | +| #1959 | `2268a09` | restart a degraded watcher, RECOVERING/DISABLED banners, refuse drifted answers while degraded | PORT | planned | +| #1654 | `4fd3e6d` | `como`/`wie` leave the unconditional structural keyword list | PORT | planned | +| #1870 | `98718cf` | installer `detect()` is read-only; backups only when a config is rewritten | PORT | planned | +| #1839 (CLI) | `fa25883` | callers/callees JSON carries each node's `relationships` | PORT | planned | +| #1895 | `ee97a57` | MCP root discovery ignores a `codegraph.db` without a schema | PORT (CLI ALREADY-HAVE) | planned | +| #995 | `c13bd08` | WSL `/mnt/` defaults to `.codegraph-wsl` and maps SQLITE_IOERR to guidance | PORT | planned | +| #2053 / #1773 | `0a9f3de`, `c405ac5` | bounded retry when Windows sharing violations block `daemon.pid` replacement | PORT | planned | +| #1294 | `d3671fa` | `install.sh` under MINGW/MSYS/CYGWIN points at the PowerShell installer | PORT | planned | +| #1904, PR #2062, PR #2063, PR #2068, PR #2071, PR #2077 | `9ad6ee9`, `adad840`, `47840a6`, `724b5de`, `290e03f`, `003305f` | explore: empty-result lexical diagnostics, cluster budget order, line anchors as exact targets, single-file budget, same-basename pins, honest completion notes | PORT | planned | +| #1834, #1831, #1830, #1968, #1372, #1361, #1835, #1963 / #1356, #1325, #1902 | various | already implemented by the port | ALREADY-HAVE | — | +| #770 | `c897daf` | watch symlinked directories | DEFER until the scan's symlink policy is settled (the scan does not follow symlinked directories) | — | +| telemetry, npm/fnm, Node timers, `FILE_NOTIFY_CHANGE_LAST_ACCESS`, test-only commits | various | no Rust analogue | N/A | — | + +A Rust-only defect found by this pass: the watcher treats only `locked`/`busy` +errors as lock contention, but an index-lease timeout reports `timed out +acquiring permanent index lock`, so a long foreground `index` makes the watcher +drop the batch and count a failure. It is planned with #1964. + +## UI family (re-triage) + +The browser viewer (`ui/**`, `src/ui-server/**`, `codegraph ui`) is an explicit +exception for this round. The graph-semantic families that shipped beside it +were re-triaged: + +| Family | Upstream | Summary | Disposition | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------- | +| F1 framework-edge infrastructure | `navigates` edge kind, fan-out refs, multi-manifest package detection | new `EdgeKind`, no DDL | DEFER to the next round (owner decision, 2026-10-01) | +| F2 Expo Router, F3 Next.js split, F4 React Router, F5 TanStack Router, F6 Vue Router / Nuxt | route nodes and `navigates` edges | graph semantics, extraction version bump | DEFER to the next round (owner decision, 2026-10-01) | +| F6 SvelteKit | `goto`/`redirect`, page/server pairing | no Svelte resolver in the port | DEFER to the next round (owner decision, 2026-10-01) | +| F7 synthesis layer | callback synthesizer, incremental refresh, `synthesis_inputs` table | **schema DDL** | DEFER to the next round (owner decision, 2026-10-01) | +| F8 cross-tier channels | HTTP clients → routes, queues, event buses | needs F7 | DEFER to the next round (owner decision, 2026-10-01) | +| F9 server route naming | Express mount prefixes, FastAPI, ASP.NET groups | resolvers the port does not have | DEFER to the next round (owner decision, 2026-10-01) | +| F10 / F11 explore Flow and branch guards | named-symbol flow section and `when` conditions | explore output | DEFER to the next round (owner decision, 2026-10-01) | +| F12 React Native bridge | `NativeModules` → native methods | new resolver | DEFER to the next round (owner decision, 2026-10-01) | +| F13 JS handler value references | JSX attribute and shorthand fnRefs, later-exported store actions, default namespace objects | extraction | PORT this round | + +## Owner decisions + +These rows touch a hard invariant or would add a deferral, so they were put to +the owner rather than decided by the porter. Decided on 2026-10-01: + +1. #1349 is ported this round: a declaration that collides with no other + keeps its id, and only a later colliding declaration gains the column + suffix (today it silently overwrites the first); the formula in `AGENTS.md` + and the goldens are updated with it. +2. The UI family F1–F12 — including the F7 schema change and the F6 SvelteKit + and F9 deferrals — moves to the next round, together with the viewer design. + F13 is pure extraction and is ported this round. diff --git a/reference/golden/python/colby.db b/reference/golden/python/colby.db index 65891c7d..c65a817a 100644 Binary files a/reference/golden/python/colby.db and b/reference/golden/python/colby.db differ diff --git a/reference/golden/python/edges.json b/reference/golden/python/edges.json index 87639e17..7dfa917b 100644 --- a/reference/golden/python/edges.json +++ b/reference/golden/python/edges.json @@ -90,9 +90,9 @@ "kind": "references", "line": 5, "metadata": { - "confidence": 0.8, + "confidence": 0.9, "fnRef": true, - "resolvedBy": "function-ref" + "resolvedBy": "import" }, "provenance": null, "source": "function:230b5585efc925330d3cbf214e8be03b", @@ -143,7 +143,7 @@ "line": 1, "metadata": { "confidence": 0.9, - "resolvedBy": "exact-match" + "resolvedBy": "import" }, "provenance": null, "source": "file:consumers.py", @@ -162,6 +162,18 @@ "source": "function:1198b6fc92ac818287e950fa1a067eda", "target": "class:f6b91632a5d8b5435b08ac99b12f0570" }, + { + "col": 44, + "kind": "imports", + "line": 3, + "metadata": { + "confidence": 0.9, + "resolvedBy": "import" + }, + "provenance": null, + "source": "file:aliases.py", + "target": "class:099ebd3f5c7b1a6ac5522e9fbfb00912" + }, { "col": 7, "kind": "imports", diff --git a/reference/golden/python/nodes.json b/reference/golden/python/nodes.json index 0a8f0211..97f2ecac 100644 --- a/reference/golden/python/nodes.json +++ b/reference/golden/python/nodes.json @@ -265,7 +265,7 @@ }, { "decorators": null, - "docstring": null, + "docstring": "Package marker for module-alias resolution fixtures.", "end_column": 0, "end_line": 2, "file_path": "pkg/__init__.py", diff --git a/reference/golden/python/refs.json b/reference/golden/python/refs.json index d73015d0..fea6b83a 100644 --- a/reference/golden/python/refs.json +++ b/reference/golden/python/refs.json @@ -38,15 +38,5 @@ "line": 59, "reference_kind": "calls", "reference_name": "register" - }, - { - "candidates": null, - "col": 44, - "file_path": "aliases.py", - "from_node_id": "file:aliases.py", - "language": "python", - "line": 3, - "reference_kind": "imports", - "reference_name": "ImportedAlias" } ] diff --git a/reference/golden/typescript/colby.db b/reference/golden/typescript/colby.db index 88a1101c..98a1e1d0 100644 Binary files a/reference/golden/typescript/colby.db and b/reference/golden/typescript/colby.db differ diff --git a/reference/golden/typescript/refs.json b/reference/golden/typescript/refs.json index 134ef53f..ec461357 100644 --- a/reference/golden/typescript/refs.json +++ b/reference/golden/typescript/refs.json @@ -17,7 +17,7 @@ "language": "typescript", "line": 18, "reference_kind": "calls", - "reference_name": "nested.run" + "reference_name": "api.nested.run" }, { "candidates": null, @@ -27,7 +27,7 @@ "language": "typescript", "line": 6, "reference_kind": "calls", - "reference_name": "nested.run" + "reference_name": "api.nested.run" }, { "candidates": null,