diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 2df3504..041dcc0 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -5281,6 +5281,83 @@ fn initializer_returned_object(call: SyntaxNode<'_>, depth: usize) -> Option()(persist((set) => ({ … }))))` yields +/// `["createSelectors", "create", "persist"]`. +pub fn js_store_initializer_callees( + source: &str, + language: Language, + name: &str, +) -> Option> { + let grammar: tree_sitter::Language = match language { + Language::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Language::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + Language::JavaScript | Language::Jsx => tree_sitter_javascript::LANGUAGE.into(), + _ => return None, + }; + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&grammar).ok()?; + let tree = parser.parse(source, None)?; + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if node.kind() == "variable_declarator" + && child_by_field(node, "name").is_some_and(|n| node_text(n, source) == name) + { + let value = child_by_field(node, "value").filter(|v| v.kind() == "call_expression")?; + let mut path = Vec::new(); + return initializer_callee_path(value, 0, source, &mut path).then_some(path); + } + pending.extend(node.named_children(&mut node.walk())); + } + None +} + +/// [`initializer_returned_object`]'s search, recording each call's callee +/// while it descends; `true` once a function argument returns an object. +fn initializer_callee_path( + call: SyntaxNode<'_>, + depth: usize, + source: &str, + path: &mut Vec, +) -> bool { + if depth > 4 { + return false; + } + let mark = path.len(); + path.push(callee_text(call, source)); + if let Some(arguments) = child_by_field(call, "arguments") { + for argument in arguments.named_children(&mut arguments.walk()) { + let found = match argument.kind() { + "arrow_function" | "function_expression" => { + function_returned_object(argument).is_some() + } + "call_expression" => initializer_callee_path(argument, depth + 1, source, path), + _ => false, + }; + if found { + return true; + } + } + } + path.truncate(mark); + false +} + +/// A call's callee as written, `create` or `zs.create`; a curried +/// `create()(…)` is named by its innermost callee. Type arguments are a +/// separate field and never part of it. +fn callee_text(call: SyntaxNode<'_>, source: &str) -> String { + let mut callee = child_by_field(call, "function"); + while let Some(inner) = callee.filter(|c| c.kind() == "call_expression") { + callee = child_by_field(inner, "function"); + } + callee.map(|c| node_text(c, source)).unwrap_or_default() +} + /// The closest function enclosing `node`: a store's factory closure. fn nearest_function_ancestor(node: SyntaxNode<'_>) -> Option> { let mut current = node.parent(); diff --git a/crates/codegraph-extract/tests/js_store_initializers.rs b/crates/codegraph-extract/tests/js_store_initializers.rs index 3f39e12..b3c3635 100644 --- a/crates/codegraph-extract/tests/js_store_initializers.rs +++ b/crates/codegraph-extract/tests/js_store_initializers.rs @@ -92,3 +92,32 @@ export const useCurried = create()((set) => { ); assert_eq!(calls_from(&result, "useCurried::bump"), vec!["set"]); } + +#[test] +fn initializer_callees_follow_the_path_to_the_action_function() { + use codegraph_extract::walker::js_store_initializer_callees; + let callees = |source: &str| js_store_initializer_callees(source, Language::TypeScript, "s"); + let path = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect::>()); + assert_eq!( + callees("export const s = createSelectors(create()(persist((set) => ({ a() {} }))));"), + path(&["createSelectors", "create", "persist"]) + ); + assert_eq!( + callees("export const s = zs.create(() => ({ a() {} }));"), + path(&["zs.create"]) + ); + // Only the parser decides what is a call, and only the path counts. + for decoy in [ + "export const s = otherFactory(/* create( */ () => ({ a() {} }));", + "export const s = otherFactory(\"create(\", () => ({ a() {} }));", + "export const s = otherFactory(create(1), () => ({ a() {} }));", + "export const s = otherFactory>(() => ({ a() {} }));", + ] { + assert_eq!(callees(decoy), path(&["otherFactory"]), "{decoy}"); + } + assert_eq!(callees("export const s = plain(1);"), None); + assert_eq!( + callees("export const other = create(() => ({ a() {} }));"), + None + ); +} diff --git a/crates/codegraph-resolve/src/c_macro_visibility.rs b/crates/codegraph-resolve/src/c_macro_visibility.rs index 6272212..aa3c14d 100644 --- a/crates/codegraph-resolve/src/c_macro_visibility.rs +++ b/crates/codegraph-resolve/src/c_macro_visibility.rs @@ -199,6 +199,7 @@ impl MacroVisibility { } let source = context.read_file(file).unwrap_or_default(); let lines = directive_lines(&source); + let code = code_line_flags(&source); let mut events = Vec::new(); for (index, text) in lines.iter().enumerate() { if let Some(branch) = BRANCH.captures(text) { @@ -211,7 +212,7 @@ impl MacroVisibility { _ => BranchOp::Endif, }; let expression = branch[2].to_string(); - let guard = guards_itself(&lines, index, op, &expression); + let guard = guards_itself(&lines, &code, index, op, &expression); events.push(FileEvent::Branch { op, expression, @@ -469,31 +470,17 @@ impl Walk { }) } - /// What an `#if` expression says, as far as the source decides it: - /// integer literals, `defined(NAME)` / `!defined NAME`, and a bare name's - /// known value. Anything else depends on the build. + /// What an `#if` expression says, as far as the source decides it: the + /// whole expression, three-valued (see [`evaluate`]). Upstream reads a + /// literal, one `defined` test or a bare name only (KEEP-RUST). fn condition(&self, expression: &str) -> Truth { - let text = expression.trim(); - if let Some(literal) = INTEGER_LITERAL.captures(text) { - let digits = literal.get(1).map_or(&literal[2], |hex| hex.as_str()); - return Some(digits.bytes().any(|digit| digit != b'0')); - } - if let Some(defined) = DEFINED_TEST.captures(text) { - let name = defined - .get(2) - .or_else(|| defined.get(3)) - .map_or("", |m| m.as_str()); - let known = self.definitions.get(name).and_then(|d| d.defined); - return if defined.get(1).is_some() { - not(known) - } else { - known - }; - } - if is_word(text) { - return self.definitions.get(text).and_then(|d| d.value); - } - None + evaluate(expression, &|name| { + let known = self.definitions.get(name); + NameState { + defined: known.and_then(|d| d.defined), + value: known.and_then(|d| d.value), + } + }) } #[allow(clippy::too_many_arguments)] @@ -557,25 +544,377 @@ static INCLUDE: LazyLock = LazyLock::new(|| { }); static PRAGMA_ONCE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*#\s*pragma\s+once\b").expect("pragma once regex is valid")); -static INTEGER_LITERAL: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)^(?:0x([0-9a-f]+)|([0-9]+))[ul]*$").expect("integer literal regex is valid") -}); -static DEFINED_TEST: LazyLock = LazyLock::new(|| { - Regex::new(r"^(!)?\s*defined\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)|([A-Za-z0-9_]+))$") - .expect("defined test regex is valid") -}); static NOT_DEFINED_GUARD: LazyLock = LazyLock::new(|| { Regex::new(r"^\s*!\s*defined\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)|([A-Za-z0-9_]+))\s*$") .expect("guard test regex is valid") }); -/// The include-guard idiom: `#ifndef X_H` (or `#if !defined(X_H)`) whose next -/// directive is `#define X_H`. Nothing defines the guard before the test, so -/// this is the first inclusion and the guarded body is active. A fallback -/// function-like macro (`#ifndef MIN` / `#define MIN(a, b) …`) reads the same -/// way. A default VALUE (`#ifndef ENABLE_X` / `#define ENABLE_X 0`) does not: -/// that is the flag a build overrides on the command line. -fn guards_itself(lines: &[String], index: usize, op: BranchOp, expression: &str) -> bool { +/// Translation phase 2: every backslash-newline joins its line to the next, +/// before comments are removed, so a backslash inside a `/* … */` still +/// continues a directive. A joined line's text moves to the line that +/// started it and leaves an empty line behind, so line numbers stay put. +fn splice_continuations(source: &str) -> String { + let mut lines: Vec = Vec::new(); + let mut joined: Option = None; + for raw in source.split('\n') { + let line = raw.strip_suffix('\r').unwrap_or(raw); + let (text, continues) = match line.strip_suffix('\\') { + Some(head) => (head, true), + None => (line, false), + }; + match joined { + Some(at) => { + lines[at].push_str(text); + lines.push(String::new()); + } + None => lines.push(text.to_string()), + } + joined = if continues { + joined.or(Some(lines.len() - 1)) + } else { + None + }; + } + lines.join("\n") +} + +/// What the walk knows about a name an `#if` mentions. +struct NameState { + defined: Truth, + /// The truth of its value, when a definitely active definition gave one. + value: Truth, +} + +/// An `#if` operand: a known integer — a literal, or a logical result's 0 or +/// 1 — or a truth with no known number: a macro's value, recorded only as a +/// truth (`#define N 2` is true, not 1), or anything the build decides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Value { + Int(i64), + Truth(Truth), +} + +/// A decided truth as C's 0 or 1, an undecided one as unknown. +fn logical(truth: Truth) -> Value { + truth.map_or(Value::Truth(None), |known| Value::Int(i64::from(known))) +} + +impl Value { + fn truth(self) -> Truth { + match self { + Value::Int(number) => Some(number != 0), + Value::Truth(truth) => truth, + } + } + + fn int(self) -> Option { + match self { + Value::Int(number) => Some(number), + Value::Truth(_) => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Token<'a> { + /// `None` for a number the evaluator cannot read (a float, an overflow). + Number(Option), + Word(&'a str), + Punct(&'static str), + /// A character or string literal, or a byte no operator uses. + Opaque, +} + +const PUNCTUATORS: [&str; 25] = [ + "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "+", "-", "*", "/", "%", "<", ">", "&", "^", + "|", "!", "~", "?", ":", "(", ")", ",", +]; + +fn tokens(expression: &str) -> Vec> { + let bytes = expression.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let byte = bytes[i]; + if byte.is_ascii_whitespace() { + i += 1; + } else if byte.is_ascii_digit() { + let start = i; + while i < bytes.len() && (is_word_byte(bytes[i]) || matches!(bytes[i], b'\'' | b'.')) { + i += 1; + } + out.push(Token::Number(integer_literal(&expression[start..i]))); + } else if is_word_byte(byte) { + let start = i; + while i < bytes.len() && is_word_byte(bytes[i]) { + i += 1; + } + out.push(Token::Word(&expression[start..i])); + } else if byte == b'"' || byte == b'\'' { + i += 1; + while i < bytes.len() && bytes[i] != byte { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + i += 1; + out.push(Token::Opaque); + } else if let Some(punct) = PUNCTUATORS + .iter() + .find(|punct| expression[i..].starts_with(**punct)) + { + i += punct.len(); + out.push(Token::Punct(punct)); + } else { + i += expression[i..].chars().next().map_or(1, char::len_utf8); + out.push(Token::Opaque); + } + } + out +} + +/// A C integer literal — decimal, `0x`, `0b` or octal, with digit separators +/// and `u`/`l`/`z` suffixes — or `None`. +fn integer_literal(text: &str) -> Option { + let digits: String = text + .trim_end_matches(['u', 'U', 'l', 'L', 'z', 'Z']) + .chars() + .filter(|c| *c != '\'') + .collect(); + let (radix, body) = if let Some(hex) = digits + .strip_prefix("0x") + .or_else(|| digits.strip_prefix("0X")) + { + (16, hex) + } else if let Some(binary) = digits + .strip_prefix("0b") + .or_else(|| digits.strip_prefix("0B")) + { + (2, binary) + } else if digits.len() > 1 && digits.starts_with('0') { + (8, &digits[1..]) + } else { + (10, digits.as_str()) + }; + i64::from_str_radix(body, radix).ok() +} + +/// Evaluate an `#if` expression three-valued. `&&` and `||` decide whenever +/// one side does (`1 || FLAG` is true, `0 && FLAG` false); `?:` with an +/// unknown condition is known only when both branches agree; every other +/// operator needs known operands, and a macro's value is known only as a truth +/// (`#if N == 1` with `#define N 2` stays unknown). An unseen name, a +/// definitely undefined name's `0` aside, is unknown; so are a call-like `__has_include(…)`, a +/// character literal, overflow, division by zero, and anything malformed. +fn evaluate(expression: &str, name: &dyn Fn(&str) -> NameState) -> Truth { + let tokens = tokens(expression); + let mut parser = ConditionParser { + tokens: &tokens, + at: 0, + name, + }; + let value = parser.ternary()?; + (parser.at == tokens.len()).then_some(())?; + value.truth() +} + +struct ConditionParser<'t, 'n> { + tokens: &'t [Token<'t>], + at: usize, + name: &'n dyn Fn(&str) -> NameState, +} + +impl<'t> ConditionParser<'t, '_> { + fn peek(&self) -> Option> { + self.tokens.get(self.at).copied() + } + + fn next(&mut self) -> Option> { + let token = self.tokens.get(self.at).copied(); + self.at += 1; + token + } + + fn eat(&mut self, punct: &str) -> bool { + let found = matches!(self.peek(), Some(Token::Punct(p)) if p == punct); + if found { + self.at += 1; + } + found + } + + fn ternary(&mut self) -> Option { + let condition = self.binary(1)?; + if !self.eat("?") { + return Some(condition); + } + let yes = self.ternary()?; + if !self.eat(":") { + return None; + } + let no = self.ternary()?; + Some(match condition.truth() { + Some(true) => yes, + Some(false) => no, + None => match (yes.int(), no.int()) { + (Some(a), Some(b)) if a == b => Value::Int(a), + _ => Value::Truth(None), + }, + }) + } + + fn binary(&mut self, minimum: u8) -> Option { + let mut left = self.unary()?; + while let Some(Token::Punct(op)) = self.peek() { + let Some(precedence) = binary_precedence(op).filter(|p| *p >= minimum) else { + break; + }; + self.at += 1; + let right = self.binary(precedence + 1)?; + left = combine(op, left, right); + } + Some(left) + } + + fn unary(&mut self) -> Option { + let Some(Token::Punct(op @ ("!" | "~" | "-" | "+"))) = self.peek() else { + return self.primary(); + }; + self.at += 1; + let value = self.unary()?; + Some(match op { + "!" => logical(not(value.truth())), + "~" => value.int().map_or(Value::Truth(None), |n| Value::Int(!n)), + "-" => value + .int() + .and_then(i64::checked_neg) + .map_or(Value::Truth(None), Value::Int), + _ => value.int().map_or(Value::Truth(None), Value::Int), + }) + } + + fn primary(&mut self) -> Option { + match self.next()? { + Token::Number(number) => Some(number.map_or(Value::Truth(None), Value::Int)), + Token::Punct("(") => { + let value = self.ternary()?; + self.eat(")").then_some(value) + } + Token::Word("defined") => { + let parenthesized = self.eat("("); + let Some(Token::Word(word)) = self.next() else { + return None; + }; + if parenthesized && !self.eat(")") { + return None; + } + Some(logical((self.name)(word).defined)) + } + Token::Word(word) => { + if matches!(self.peek(), Some(Token::Punct("("))) { + // `__has_include()`, a function-like macro: the build decides. + self.skip_group()?; + return Some(Value::Truth(None)); + } + let state = (self.name)(word); + Some(if state.defined == Some(false) { + Value::Int(0) + } else { + Value::Truth(state.value) + }) + } + Token::Opaque => Some(Value::Truth(None)), + Token::Punct(_) => None, + } + } + + /// Skip one balanced `( … )` group, whatever it holds. + fn skip_group(&mut self) -> Option<()> { + let mut depth = 0usize; + loop { + match self.next()? { + Token::Punct("(") => depth += 1, + Token::Punct(")") => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(()); + } + } + _ => {} + } + } + } +} + +fn binary_precedence(op: &str) -> Option { + Some(match op { + "||" => 1, + "&&" => 2, + "|" => 3, + "^" => 4, + "&" => 5, + "==" | "!=" => 6, + "<" | "<=" | ">" | ">=" => 7, + "<<" | ">>" => 8, + "+" | "-" => 9, + "*" | "/" | "%" => 10, + _ => return None, + }) +} + +fn combine(op: &str, left: Value, right: Value) -> Value { + match op { + "||" => logical(or(left.truth(), right.truth())), + "&&" => logical(and(left.truth(), right.truth())), + _ => left + .int() + .zip(right.int()) + .and_then(|(a, b)| arithmetic(op, a, b)) + .map_or(Value::Truth(None), Value::Int), + } +} + +fn arithmetic(op: &str, a: i64, b: i64) -> Option { + Some(match op { + "*" => a.checked_mul(b)?, + "/" => a.checked_div(b)?, + "%" => a.checked_rem(b)?, + "+" => a.checked_add(b)?, + "-" => a.checked_sub(b)?, + "<<" => a.checked_shl(u32::try_from(b).ok()?)?, + ">>" => a.checked_shr(u32::try_from(b).ok()?)?, + "<" => i64::from(a < b), + "<=" => i64::from(a <= b), + ">" => i64::from(a > b), + ">=" => i64::from(a >= b), + "==" => i64::from(a == b), + "!=" => i64::from(a != b), + "&" => a & b, + "^" => a ^ b, + "|" => a | b, + _ => return None, + }) +} + +/// A whole-file include guard: `#ifndef X_H` (or `#if !defined(X_H)`) is +/// the file's first code line, its next directive is an empty `#define X_H`, +/// and its matching `#endif` — with no `#else`/`#elif` at its depth — is the +/// file's last code line. Nothing defines the guard before the test, so this +/// is the first inclusion and the guarded body is active. +/// +/// Upstream reads any `#ifndef X` / `#define X` pair, and a fallback +/// function-like macro (`#ifndef MIN` / `#define MIN(a, b) …`), the same way. +/// The port does not (KEEP-RUST): a feature-flag default +/// (`#ifndef FEATURE` / `#define FEATURE` among other code) is skipped by a +/// build with `-DFEATURE`, and a prior `MIN` from an unseen header or `-D` may +/// be a wrapper that calls the function. A valued default +/// (`#ifndef ENABLE_X` / `#define ENABLE_X 0`) is the flag a build overrides, +/// never a guard. +fn guards_itself( + lines: &[String], + code: &[bool], + index: usize, + op: BranchOp, + expression: &str, +) -> bool { let name = match op { BranchOp::Ifndef => expression.trim().to_string(), BranchOp::If => NOT_DEFINED_GUARD @@ -584,7 +923,7 @@ fn guards_itself(lines: &[String], index: usize, op: BranchOp, expression: &str) .map_or(String::new(), |m| m.as_str().to_string()), _ => return false, }; - if !is_word(&name) { + if !is_word(&name) || code.iter().take(index).any(|has_code| *has_code) { return false; } let Some(next) = lines[index + 1..] @@ -599,8 +938,79 @@ fn guards_itself(lines: &[String], index: usize, op: BranchOp, expression: &str) if &captures[1] != "define" || captures[2] != *name { return false; } - let rest = &next[captures.get(2).map_or(0, |m| m.end())..]; - rest.starts_with('(') || rest.trim().is_empty() + if !next[captures.get(2).map_or(0, |m| m.end())..] + .trim() + .is_empty() + { + return false; + } + let mut depth = 0usize; + for (at, text) in lines.iter().enumerate().skip(index + 1) { + let Some(branch) = BRANCH.captures(text) else { + continue; + }; + match (&branch[1], depth) { + ("if" | "ifdef" | "ifndef", _) => depth += 1, + ("else" | "elif", 0) => return false, + ("endif", 0) => return !code.iter().skip(at + 1).any(|has_code| *has_code), + ("endif", _) => depth -= 1, + _ => {} + } + } + false +} + +/// Per line: whether anything but comments and whitespace is on it, with raw +/// string bodies masked first, as [`directive_lines`] does. +fn code_line_flags(source: &str) -> Vec { + let masked = splice_continuations(&mask_cpp_raw_strings(source)); + let mut in_block = false; + masked + .split('\n') + .map(|raw| { + let line = raw.strip_suffix('\r').unwrap_or(raw); + let bytes = line.as_bytes(); + let mut has_code = false; + let mut quote: Option = None; + let mut i = 0; + while i < bytes.len() { + if in_block { + let Some(end) = line[i..].find("*/") else { + break; + }; + i += end + 2; + in_block = false; + continue; + } + let byte = bytes[i]; + if let Some(q) = quote { + if byte == b'\\' { + i += 1; + } else if byte == q { + quote = None; + } + i += 1; + continue; + } + match byte { + b'/' if bytes.get(i + 1) == Some(&b'/') => break, + b'/' if bytes.get(i + 1) == Some(&b'*') => { + in_block = true; + i += 2; + continue; + } + b'"' | b'\'' => { + quote = Some(byte); + has_code = true; + } + _ if !byte.is_ascii_whitespace() => has_code = true, + _ => {} + } + i += 1; + } + has_code + }) + .collect() } /// Does the body of the `#define NAME(` at `index` (continuation lines @@ -652,7 +1062,7 @@ fn calls_itself(lines: &[String], index: usize, name: &str) -> bool { /// string bodies are masked first, so a `#define` inside one is not a /// directive. fn directive_lines(source: &str) -> Vec { - let masked = mask_cpp_raw_strings(source); + let masked = splice_continuations(&mask_cpp_raw_strings(source)); let mut out = Vec::new(); let mut in_block = false; for raw in masked.split('\n') { @@ -847,17 +1257,181 @@ mod tests { assert!(calls_itself(&parenthesized, 0, "wrap")); } + /// Evaluate with `A` definitely defined as 1, `Z` definitely undefined, + /// and every other name unseen. + fn eval(expression: &str) -> Truth { + evaluate(expression, &|name| match name { + "A" => NameState { + defined: Some(true), + value: Some(true), + }, + "Z" => NameState { + defined: Some(false), + value: None, + }, + _ => NameState { + defined: None, + value: None, + }, + }) + } + #[test] - fn guards_are_the_ifndef_define_idiom_and_not_a_default_value() { - let guard = ["#ifndef X_H", "#define X_H"].map(str::to_string).to_vec(); - assert!(guards_itself(&guard, 0, BranchOp::Ifndef, " X_H")); - let fallback = ["#if !defined(MIN)", "#define MIN(a, b) a"] - .map(str::to_string) - .to_vec(); - assert!(guards_itself(&fallback, 0, BranchOp::If, " !defined(MIN)")); - let default = ["#ifndef ENABLE_X", "#define ENABLE_X 0"] - .map(str::to_string) - .to_vec(); - assert!(!guards_itself(&default, 0, BranchOp::Ifndef, " ENABLE_X")); + fn conditions_are_whole_expressions_decided_three_valued() { + for (expression, expected) in [ + // The forms upstream reads. + ("1", Some(true)), + ("0x0", Some(false)), + ("10UL", Some(true)), + ("defined(A)", Some(true)), + ("!defined A", Some(false)), + ("defined FLAG", None), + ("A", Some(true)), + ("FLAG", None), + // `||` and `&&` decide whenever one side does. + ("1 || FLAG", Some(true)), + ("FLAG || 1", Some(true)), + ("0 && FLAG", Some(false)), + ("FLAG && 1", None), + ("defined(A) || defined(B)", Some(true)), + ("defined A && !defined(Z)", Some(true)), + // C precedence and arithmetic on known operands. + ("1 + 2 * 3 == 7", Some(true)), + ("(1 + 2) * 3 == 9", Some(true)), + ("1 << 4 == 0x10 && 07 == 7 && 0b101 == 5", Some(true)), + ("1'000 > 999", Some(true)), + ("-1 < 0 && ~0 == -1", Some(true)), + ("!0 == 1", Some(true)), + // A definitely undefined name is 0; an unseen one is unknown. + ("Z == 0", Some(true)), + ("FLAG == 0", None), + // A name's value is known only as a truth, never as a number: `A` + // may be defined as 2. A logical result is a real 0 or 1. + ("A == 1", None), + ("A + 0", None), + ("defined(A) + defined(A) == 2", Some(true)), + ("(1 || FLAG) == 1", Some(true)), + // `?:` with an unknown condition needs agreeing branches. + ("1 ? FLAG : 0", None), + ("FLAG ? 1 : 1", Some(true)), + ("0 ? FLAG : A", Some(true)), + // The build decides: calls, characters, overflow, division by zero. + ("__has_include()", None), + ("__has_include() || 1", Some(true)), + ("'a' == 97", None), + ("9223372036854775807 + 1", None), + ("1 / 0", None), + ("1.5", None), + // Malformed text decides nothing. + ("", None), + ("1 +", None), + ("1 2", None), + ("(1", None), + ("1 ? 2", None), + ("defined", None), + ] { + assert_eq!(eval(expression), expected, "{expression:?}"); + } + } + + #[test] + fn continued_directives_are_one_logical_line() { + // Phase 2 joins lines before comments go: a backslash inside a block + // comment still continues the directive. Joined lines stay as empty + // lines, so every line keeps its number. + let source = "#if 1 || \\\n FLAG // trailing\n#if A || /* c \\\n*/ B\n#define V /* v \\\n*/ 1\n#if DONE\n"; + assert_eq!( + splice_continuations(source), + "#if 1 || FLAG // trailing\n\n#if A || /* c */ B\n\n#define V /* v */ 1\n\n#if DONE\n" + ); + let lines = directive_lines(source); + assert_eq!(lines.len(), 8); + assert_eq!(lines[0], "#if 1 || FLAG "); + assert_eq!(lines[2], "#if A || B"); + assert_eq!(lines[4], "#define V 1"); + assert_eq!(lines[6], "#if DONE"); + assert!([1, 3, 5, 7].iter().all(|at| lines[*at].is_empty())); + // A trailing backslash on the last line joins nothing. + assert_eq!(splice_continuations("#if 1 \\"), "#if 1 "); + } + + #[test] + fn guards_are_whole_file_ifndef_define_idioms_only() { + // `(file, line of the test, op, expression, guard?)`. + let cases = [ + ( + "/* License */\n#ifndef X_H\n#define X_H\nint x;\n#endif // X_H\n", + 1, + BranchOp::Ifndef, + " X_H", + true, + ), + ( + "#if !defined(X_H)\n#define X_H\n#if A\n#else\n#endif\n#endif\n", + 0, + BranchOp::If, + " !defined(X_H)", + true, + ), + // A fallback function-like macro is no guard (KEEP-RUST). + ( + "#if !defined(MIN)\n#define MIN(a, b) a\n#endif\n", + 0, + BranchOp::If, + " !defined(MIN)", + false, + ), + // A default VALUE is the flag a build overrides. + ( + "#ifndef ENABLE_X\n#define ENABLE_X 0\n#endif\n", + 0, + BranchOp::Ifndef, + " ENABLE_X", + false, + ), + // Code before the test or after the `#endif`, or an `#else` at + // the guard's depth: not the whole file. + ( + "int before;\n#ifndef X_H\n#define X_H\n#endif\n", + 1, + BranchOp::Ifndef, + " X_H", + false, + ), + ( + "#ifndef X_H\n#define X_H\n#endif\nint after;\n", + 0, + BranchOp::Ifndef, + " X_H", + false, + ), + ( + "#ifndef X_H\n#define X_H\n#else\n#endif\n", + 0, + BranchOp::Ifndef, + " X_H", + false, + ), + ]; + for (source, index, op, expression, expected) in cases { + let lines = directive_lines(source); + let code = code_line_flags(source); + assert_eq!( + guards_itself(&lines, &code, index, op, expression), + expected, + "{source:?}" + ); + } + } + + #[test] + fn code_lines_ignore_comments_and_whitespace() { + let flags = code_line_flags( + "/* a\n * b */\n// c\n \nint x; // d\n/* e */ y\nchar *s = \"/* f\";\n", + ); + assert_eq!( + flags, + vec![false, false, false, false, true, true, true, false] + ); } } diff --git a/crates/codegraph-resolve/src/js_store.rs b/crates/codegraph-resolve/src/js_store.rs index 27a8738..e25e56c 100644 --- a/crates/codegraph-resolve/src/js_store.rs +++ b/crates/codegraph-resolve/src/js_store.rs @@ -13,7 +13,10 @@ //! //! 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. +//! never an interface signature. The store must be built by a Zustand factory +//! on its initializer's call path; upstream checks that for selectors only, so +//! `otherFactory(() => ({ reset() {} })).getState().reset()` bound there +//! (KEEP-RUST). Anything less certain stays unresolved. use std::collections::HashSet; use std::sync::OnceLock; @@ -29,7 +32,7 @@ use crate::name_matcher::{ }; use crate::source_facts::SourceFacts; use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; -use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; +use crate::types::{ImportMapping, 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 @@ -152,7 +155,9 @@ fn resolve_store_action( let [holder] = holders.as_slice() else { return None; }; - if selector && !is_zustand_hook(holder, context) { + if !built_by_zustand(holder, context, false) + || (selector && !built_by_zustand(holder, context, true)) + { return None; } resolve_object_literal_member( @@ -192,39 +197,76 @@ fn factory_takes_accessor( 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 { +/// Modules whose factories build a Zustand store, and those factories. +const STORE_SOURCES: [&str; 3] = ["zustand", "zustand/vanilla", "zustand/traditional"]; +const STORE_FACTORIES: [&str; 3] = ["create", "createStore", "createWithEqualityFn"]; +/// The subset whose result is a hook, the only kind a selector can call: +/// a vanilla `createStore` store is no hook. +const HOOK_SOURCES: [&str; 2] = ["zustand", "zustand/traditional"]; +const HOOK_FACTORIES: [&str; 2] = ["create", "createWithEqualityFn"]; + +/// Whether `holder` was built by a Zustand factory (a hook factory when +/// `hook`): some callee on its initializer's path to the action function is +/// imported from Zustand. The path comes from the parser, so a comment, a +/// string, a type argument or an argument off the path never proves it. +fn built_by_zustand(holder: &Node, context: &dyn ResolutionContext, hook: bool) -> 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 gate = if hook { + "zustand-hook" + } else { + "zustand-store" }; - let Some(factory) = pattern - .captures(&text) - .and_then(|captures| captures.get(1)) - .map(|factory| factory.as_str()) - else { - return false; + facts.node_decision(gate, &holder.id, |facts| { + 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 Some(callees) = codegraph_extract::walker::js_store_initializer_callees( + &facts.join_lines(start, end, "\n"), + holder.language, + &holder.name, + ) else { + return false; + }; + let imports = context.get_import_mappings(&holder.file_path, holder.language); + callees + .iter() + .any(|callee| is_zustand_factory(callee, &imports, hook)) + }) +} + +/// Whether `callee` (`create`, `zs.create`) names a Zustand factory through +/// the file's `imports`. +fn is_zustand_factory(callee: &str, imports: &[ImportMapping], hook: bool) -> bool { + let (sources, factories): (&[&str], &[&str]) = if hook { + (&HOOK_SOURCES, &HOOK_FACTORIES) + } else { + (&STORE_SOURCES, &STORE_FACTORIES) }; - 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) - }) + match callee.split_once('.') { + None => imports.iter().any(|mapping| { + mapping.local_name == callee + && !mapping.is_namespace + && sources.contains(&mapping.source.as_str()) + && if mapping.is_default { + // Zustand v3's `import create from 'zustand'`. + mapping.source == "zustand" + } else { + factories.contains(&mapping.exported_name.as_str()) + } + }), + Some((namespace, member)) => { + factories.contains(&member) + && imports.iter().any(|mapping| { + mapping.local_name == namespace + && mapping.is_namespace + && sources.contains(&mapping.source.as_str()) + }) + } + } } /// A bare call to a name destructured from `store.getState()` in an diff --git a/crates/codegraph-resolve/src/member_value.rs b/crates/codegraph-resolve/src/member_value.rs index eb1498d..9e64ab0 100644 --- a/crates/codegraph-resolve/src/member_value.rs +++ b/crates/codegraph-resolve/src/member_value.rs @@ -175,14 +175,11 @@ pub(crate) fn match_member_function_ref( return unique_callable(descendants, reference, context, 0.8); } } - // An unknown receiver keeps the unique-or-drop discipline across ALL - // files; test doubles and abstract bases are candidates too. - unique_callable( - context.get_nodes_by_name_shared(member), - reference, - context, - 0.8, - ) + // An unknowable receiver stays unresolved, even when exactly one project + // method bears the member's name: `self.store.fetch` may be a library + // object's method. Upstream keeps "the old unique-or-drop discipline" + // across ALL files here; the port does not (KEEP-RUST). + None } /// The single same-family candidate when it is a callable other than the diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 3b93dd2..b3a087b 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -137,6 +137,147 @@ fn mentions_initializer_list(signature: &str) -> bool { }) } +/// Whether the FIRST parameter of a constructor signature (`(a, b)` or a +/// prototype's `(a, b);`) names `initializer_list`. +fn first_parameter_mentions_initializer_list(signature: &str) -> bool { + let Some(open) = signature.find('(') else { + return false; + }; + let mut depth = 0usize; + for (offset, byte) in signature[open + 1..].bytes().enumerate() { + match byte { + b'(' | b'<' | b'[' | b'{' => depth += 1, + b')' if depth == 0 => { + return mentions_initializer_list(&signature[open + 1..open + 1 + offset]); + } + b',' if depth == 0 => { + return mentions_initializer_list(&signature[open + 1..open + 1 + offset]); + } + b')' | b'>' | b']' | b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } + false +} + +/// Whether the C++ construction `reference` anchors is `T x(, …)`: +/// the anchor is the `(` of the initializer, its balanced argument text holds +/// no `{` (a braced list, or a lambda body, could be or build an +/// initializer_list), and the first argument is a literal — numeric, +/// character, string, `true`, `false` or `nullptr`. Such an argument can never +/// be, or convert to, a `std::initializer_list`. +fn paren_construction_from_a_literal(reference: &RefView, context: &dyn ResolutionContext) -> bool { + static LITERAL: OnceLock = OnceLock::new(); + let literal = LITERAL.get_or_init(|| { + Regex::new(concat!( + r"^(?:true|false|nullptr", + r"|[-+]?(?:0[xX][0-9a-fA-F']+|0[bB][01']+", + r"|[0-9][0-9']*(?:\.[0-9']*)?(?:[eE][-+]?[0-9']+)?", + r"|\.[0-9][0-9']*(?:[eE][-+]?[0-9']+)?)[uUlLfFzZ]*", + r#"|(?:u8|u|U|L)?'(?:\\.|[^'\\])+'"#, + r#"|(?:u8|u|U|L)?"(?:\\.|[^"\\])*")$"#, + )) + .expect("C++ literal pattern") + }); + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let (Some(line), Ok(column)) = ( + usize::try_from(reference.line) + .ok() + .and_then(|line| line.checked_sub(1)), + usize::try_from(reference.column), + ) else { + return false; + }; + let Some(start) = facts.lines().raw_line_start(line) else { + return false; + }; + let source = facts.source(); + let Some(text) = source.get(start + column..) else { + return false; + }; + let Some(arguments) = cpp_paren_arguments(text) else { + return false; + }; + literal.is_match(first_cpp_argument(arguments).trim()) +} + +/// The text inside the balanced `( … )` that `text` opens, or `None` when it +/// does not open one, never closes, or holds a `{` outside quotes and +/// comments. +fn cpp_paren_arguments(text: &str) -> Option<&str> { + let bytes = text.as_bytes(); + if bytes.first() != Some(&b'(') { + return None; + } + let mut depth = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' => i = skip_cpp_quoted(bytes, i, b'"'), + // A digit separator (`1'000`) follows a word character. + b'\'' if i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') => { + i = skip_cpp_quoted(bytes, i, b'\''); + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + i = text[i..].find('\n').map_or(bytes.len(), |end| i + end); + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i = text[i + 2..].find("*/").map(|end| i + 2 + end + 2)?; + } + b'{' => return None, + b'(' => { + depth += 1; + i += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + return text.get(1..i); + } + i += 1; + } + _ => i += 1, + } + } + None +} + +/// The index just past the literal quoted by `quote` that opens at `start`. +fn skip_cpp_quoted(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut i = start + 1; + while i < bytes.len() && bytes[i] != quote { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + i + 1 +} + +/// The first comma-separated argument of an argument list's inner text. +fn first_cpp_argument(arguments: &str) -> &str { + let bytes = arguments.as_bytes(); + let mut depth = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' => { + i = skip_cpp_quoted(bytes, i, b'"'); + continue; + } + b'\'' if i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') => { + i = skip_cpp_quoted(bytes, i, b'\''); + continue; + } + b'(' | b'[' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b',' if depth == 0 => return &arguments[..i], + _ => {} + } + i += 1; + } + arguments +} + fn cpp_type_spelling_matches(spelled: &str, qualified: &str) -> bool { if spelled.contains("::") { qualified == spelled @@ -1775,14 +1916,32 @@ impl ReferenceResolver { }) .collect::>(); // Brace-init prefers an initializer_list overload over arity, a choice - // that needs the argument types, so decline (upstream #1839). + // that needs the argument types, so decline (upstream #1839). Upstream + // declines every form; the port keeps `T x(, …)`: a literal is + // never an initializer_list, so a constructor whose FIRST parameter is + // the list cannot take it, and the rest compete by arity (KEEP-RUST). + let mut constructors = constructors; if constructors.iter().any(|candidate| { candidate .signature .as_deref() .is_some_and(mentions_initializer_list) }) { - return None; + let list_first_only = constructors.iter().all(|candidate| { + candidate.signature.as_deref().is_none_or(|signature| { + !mentions_initializer_list(signature) + || first_parameter_mentions_initializer_list(signature) + }) + }); + if !list_first_only || !paren_construction_from_a_literal(reference, context) { + return None; + } + constructors.retain(|candidate| { + !candidate + .signature + .as_deref() + .is_some_and(first_parameter_mentions_initializer_list) + }); } // A prototype and its out-of-line definition describe one overload: // merge their admissible counts, so a default declared on the @@ -3124,6 +3283,37 @@ mod tests { assert_eq!(meta["subkind"].as_str(), Some("script_attach")); } + #[test] + fn constructor_argument_scans_see_only_real_syntax() { + assert!(first_parameter_mentions_initializer_list( + "(std::initializer_list values)" + )); + assert!(first_parameter_mentions_initializer_list( + "(const std::initializer_list>& values);" + )); + assert!(!first_parameter_mentions_initializer_list( + "(int a, std::initializer_list rest)" + )); + assert!(!first_parameter_mentions_initializer_list("(int one)")); + + assert_eq!( + cpp_paren_arguments("(1, \"a{\", 'b') rest"), + Some("1, \"a{\", 'b'") + ); + assert_eq!(cpp_paren_arguments("(1 /* { */ ) x"), Some("1 /* { */ ")); + assert_eq!(cpp_paren_arguments("(1'000)"), Some("1'000")); + assert_eq!(cpp_paren_arguments("(\n 1); }"), Some("\n 1")); + // Any brace could be or build a list: a braced argument, a lambda. + assert_eq!(cpp_paren_arguments("({1})"), None); + assert_eq!(cpp_paren_arguments("(f([] { return 1; }))"), None); + assert_eq!(cpp_paren_arguments("(1"), None); + assert_eq!(cpp_paren_arguments("x(1)"), None); + + assert_eq!(first_cpp_argument("f(a, b), 2"), "f(a, b)"); + assert_eq!(first_cpp_argument("'a', 2"), "'a'"); + assert_eq!(first_cpp_argument("\",\", 2"), "\",\""); + } + #[test] fn applies_to_language_universal_and_scoped() { assert!(applies_to_language(&ReactLike, Language::TypeScript)); diff --git a/crates/codegraph-resolve/tests/c_macro_visibility.rs b/crates/codegraph-resolve/tests/c_macro_visibility.rs index 8825968..aa64817 100644 --- a/crates/codegraph-resolve/tests/c_macro_visibility.rs +++ b/crates/codegraph-resolve/tests/c_macro_visibility.rs @@ -597,3 +597,208 @@ fn a_cpp_type_whose_name_is_no_macro_is_still_constructed() { vec!["instantiates struct Widget (widget.hpp)"] ); } + +#[test] +fn compound_conditions_decide_macro_visibility() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "compound", + &[ + ( + format!("unit.{language}"), + [ + "#if 1 || FLAG", + "#define HOOK_A(x) ((void)(x))", + "#endif", + "#if 0 && FLAG", + "#define HOOK_B(x) ((void)(x))", + "#endif", + "#define A", + "#if defined(A) || defined(B)", + "#define HOOK_C(x) ((void)(x))", + "#endif", + "#if FLAG == 0", + "#define HOOK_D(x) ((void)(x))", + "#endif", + "#if 1 || \\", + " FLAG /* continued */", + "#define HOOK_E(x) ((void)(x))", + "#endif", + "#if 0", + "#elif 1 || \\", + " FLAG", + "#define HOOK_F(x) ((void)(x))", + "#endif", + "void use_a(void) { HOOK_A(1); }", + "void use_b(void) { HOOK_B(1); }", + "void use_c(void) { HOOK_C(1); }", + "void use_d(void) { HOOK_D(1); }", + "void use_e(void) { HOOK_E(1); }", + "void use_f(void) { HOOK_F(1); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + ["A", "B", "C", "D", "E", "F"] + .map(|hook| format!("void HOOK_{hook}(int x) {{}}\n")) + .concat(), + ), + ], + ); + let calls = ["a", "b", "c", "d", "e", "f"] + .map(|user| (user, graph.calls(&format!("use_{user}")))) + .to_vec(); + let real = |hook: &str| vec![format!("function HOOK_{hook} (decoy.{language})")]; + // Definitely true, through `||`, `defined` or a continued line: the + // macro expands. Definitely false: the function is called. Unknown: the + // call keeps its function. + assert_eq!( + calls, + vec![ + ("a", Vec::::new()), + ("b", real("B")), + ("c", Vec::new()), + ("d", real("D")), + ("e", Vec::new()), + ("f", Vec::new()), + ], + "{language}" + ); + } +} + +#[test] +fn only_a_whole_file_ifndef_is_an_include_guard() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "whole-file-guard", + &[ + ( + "licensed.h".to_string(), + "/* License\n * text */\n// more\n#ifndef LICENSED_H\n#define LICENSED_H\n#define LIC(x) ((void)(x))\n#endif // LICENSED_H\n\n" + .to_string(), + ), + ( + "code_before.h".to_string(), + "int before;\n#ifndef BEFORE_H\n#define BEFORE_H\n#define BEFORE(x) ((void)(x))\n#endif\n" + .to_string(), + ), + ( + "code_after.h".to_string(), + "#ifndef AFTER_H\n#define AFTER_H\n#define AFTER(x) ((void)(x))\n#endif\nint after;\n" + .to_string(), + ), + ( + "has_else.h".to_string(), + "#ifndef ELSE_H\n#define ELSE_H\n#define ELSE(x) ((void)(x))\n#else\n#endif\n" + .to_string(), + ), + ( + format!("unit.{language}"), + [ + "#include \"licensed.h\"", + "#include \"code_before.h\"", + "#include \"code_after.h\"", + "#include \"has_else.h\"", + // A feature-flag default, not a guard: a build with + // -DFEATURE never defines HOOK. + "#ifndef FEATURE", + "#define FEATURE", + "#define HOOK(x) ((void)(x))", + "#endif", + // A fallback macro: a prior MIN from an unseen header or + // -D may call the function, so nothing is definite. + "#ifndef MIN", + "#define MIN(a, b) ((a) < (b) ? (a) : (b))", + "#endif", + "void use_lic(void) { LIC(1); }", + "void use_before(void) { BEFORE(1); }", + "void use_after(void) { AFTER(1); }", + "void use_else(void) { ELSE(1); }", + "void use_hook(void) { HOOK(1); }", + "int use_min(void) { return MIN(1, 2); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + "void LIC(int x) {}\nvoid BEFORE(int x) {}\nvoid AFTER(int x) {}\nvoid ELSE(int x) {}\nvoid HOOK(int x) {}\nint MIN(int a, int b) { return a; }\n" + .to_string(), + ), + ], + ); + let calls = ["lic", "before", "after", "else", "hook", "min"] + .map(|user| (user, graph.calls(&format!("use_{user}")))) + .to_vec(); + let real = |name: &str| vec![format!("function {name} (decoy.{language})")]; + assert_eq!( + calls, + vec![ + ("lic", Vec::::new()), + ("before", real("BEFORE")), + ("after", real("AFTER")), + ("else", real("ELSE")), + ("hook", real("HOOK")), + ("min", real("MIN")), + ], + "{language}" + ); + } +} + +#[test] +fn continuations_splice_before_comments_and_macro_values_keep_their_number() { + for language in ["c", "cpp"] { + let graph = resolve_project( + "splice-first", + &[ + ( + format!("unit.{language}"), + [ + // `N` is 2: whether it equals 1 is not its truth. + "#define N 2", + "#if N == 1", + "#define HOOK_G(x) ((void)(x))", + "#endif", + // Lines splice before comments go: the backslash inside + // the comment still continues the directive. + "#if 1 || /* comment \\", + "*/ FLAG", + "#define HOOK_H(x) ((void)(x))", + "#endif", + "#define LEVEL /* level \\", + "*/ 1", + "#if LEVEL", + "#define HOOK_I(x) ((void)(x))", + "#endif", + "void use_g(void) { HOOK_G(1); }", + "void use_h(void) { HOOK_H(1); }", + "void use_i(void) { HOOK_I(1); }", + "", + ] + .join("\n"), + ), + ( + format!("decoy.{language}"), + "void HOOK_G(int x) {}\nvoid HOOK_H(int x) {}\nvoid HOOK_I(int x) {}\n" + .to_string(), + ), + ], + ); + let calls = ["g", "h", "i"] + .map(|user| (user, graph.calls(&format!("use_{user}")))) + .to_vec(); + assert_eq!( + calls, + vec![ + ("g", vec![format!("function HOOK_G (decoy.{language})")]), + ("h", Vec::::new()), + ("i", Vec::new()), + ], + "{language}" + ); + } +} diff --git a/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs index 6304433..3ec5180 100644 --- a/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs +++ b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs @@ -446,3 +446,71 @@ fn array_elements_reach_their_constructors_and_keep_nested_calls() { assert_eq!(graph.calls("pointers"), Vec::::new()); assert_eq!(graph.calls("prototype"), Vec::::new()); } + +#[test] +fn paren_construction_from_a_literal_skips_initializer_list_constructors() { + let graph = resolve_project( + "paren-literal", + &[( + "listed.cpp", + concat!( + "namespace std { template class initializer_list {}; }\n", + "struct Listed {\n", + " Listed(std::initializer_list values) {}\n", + " Listed(int one) {}\n", + "};\n", + "struct Second {\n", + " Second(int a, std::initializer_list rest) {}\n", + " Second(int a, int b) {}\n", + "};\n", + "void literal() { Listed l(1); }\n", + "void negative() { Listed l(-1); }\n", + "void character() { Listed l('a'); }\n", + "void multiline() { Listed l(\n", + " 1); }\n", + "void variable() { std::initializer_list xs; Listed l(xs); }\n", + "void braced_argument() { Listed l({1}); }\n", + "void braced() { Listed l{1}; }\n", + "void second() { Second s(1, 2); }\n", + ), + )], + ); + // A literal can never be, or convert to, an initializer_list, so under + // parentheses `Listed(int)` (line 4) is the only constructor that can take + // it. An identifier's type is unknown, braces prefer the list, and a list + // past the first parameter needs more than the first argument: decline. + let lines = [ + "literal", + "negative", + "character", + "multiline", + "variable", + "braced_argument", + "braced", + "second", + ] + .map(|caller| { + let targets = graph.call_targets(graph.function(caller)); + ( + caller, + targets + .iter() + .map(|node| node.start_line) + .collect::>(), + ) + }) + .to_vec(); + assert_eq!( + lines, + vec![ + ("literal", vec![4]), + ("negative", vec![4]), + ("character", vec![4]), + ("multiline", vec![4]), + ("variable", vec![]), + ("braced_argument", vec![]), + ("braced", vec![]), + ("second", vec![]), + ] + ); +} diff --git a/crates/codegraph-resolve/tests/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs index 89f7bd1..76fe7b6 100644 --- a/crates/codegraph-resolve/tests/js_receiver_evidence.rs +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -1061,6 +1061,105 @@ fn selectors_are_not_guessed_through_shadows_or_foreign_factories() { assert!(!fake.contains(&"fake::reset".to_string()), "{fake:?}"); } +#[test] +fn store_actions_bind_only_inside_zustand_built_stores() { + let project = resolve_project(&files(&[ + ( + "fake.ts", + "import { create } from 'zustand'; +export const fake = otherFactory(() => ({ reset() { return 1; } })); +export const fakeGet = otherFactory((set, get) => ({ reset() { return 1; }, again() { get().reset(); } })); +export const commented = otherFactory(/* create( */ () => ({ reset() { return 1; } })); +export const quoted = otherFactory(\"create(\", () => ({ reset() { return 1; } })); +export const offPath = otherFactory(create(1), () => ({ reset() { return 1; } })); +export const typed = otherFactory>(() => ({ reset() { return 1; } })); +", + ), + ( + "real.ts", + "import { create } from 'zustand'; +import { createStore } from 'zustand/vanilla'; +import createDefault from 'zustand'; +import * as zs from 'zustand'; +import { createSelectors } from './selectors-util'; +export const vanilla = createStore(() => ({ reset() { return 1; } })); +export const wrapped = createSelectors(create<{ reset(): number }>()(() => ({ reset() { return 1; } }))); +export const viaNamespace = zs.create(() => ({ reset() { return 1; } })); +export const viaDefault = createDefault(() => ({ reset() { return 1; } })); +", + ), + ( + "use.ts", + "import { fake, commented, quoted, offPath, typed } from './fake'; +import { vanilla, wrapped, viaNamespace, viaDefault } from './real'; +export function useFake() { fake.getState().reset(); } +export function useFakeDestructured() { const { reset } = fake.getState(); reset(); } +export function useCommented() { commented.getState().reset(); } +export function useQuoted() { quoted.getState().reset(); } +export function useOffPath() { offPath.getState().reset(); } +export function useTyped() { typed.getState().reset(); } +export function useVanilla() { vanilla.getState().reset(); } +export function useWrapped() { wrapped.getState().reset(); } +export function useWrappedSelector() { const selected = wrapped((s) => s.reset); selected(); } +export function useNamespace() { viaNamespace.getState().reset(); } +export function useDefault() { viaDefault.getState().reset(); } +", + ), + ])); + // A store action needs a Zustand factory on the initializer's call path + // to the action function: a comment, a string, an argument off that path + // or a type argument naming `create` proves nothing. + let bound = |caller: &str, file: &str| { + project + .callees(caller, Some(file)) + .into_iter() + .filter(|callee| callee.ends_with("::reset")) + .collect::>() + }; + let mut actual = Vec::new(); + for caller in [ + "useFake", + "useFakeDestructured", + "useCommented", + "useQuoted", + "useOffPath", + "useTyped", + "useVanilla", + "useWrapped", + "useWrappedSelector", + "useNamespace", + "useDefault", + ] { + actual.push((caller.to_string(), bound(caller, "use.ts"))); + } + actual.push(( + "fakeGet::again".to_string(), + bound("fakeGet::again", "fake.ts"), + )); + let expected = [ + ("useFake", vec![]), + ("useFakeDestructured", vec![]), + ("useCommented", vec![]), + ("useQuoted", vec![]), + ("useOffPath", vec![]), + ("useTyped", vec![]), + ("useVanilla", vec!["vanilla::reset"]), + ("useWrapped", vec!["wrapped::reset"]), + ("useWrappedSelector", vec!["wrapped::reset"]), + ("useNamespace", vec!["viaNamespace::reset"]), + ("useDefault", vec!["viaDefault::reset"]), + ("fakeGet::again", vec![]), + ] + .map(|(caller, callees)| { + ( + caller.to_string(), + callees.into_iter().map(str::to_string).collect::>(), + ) + }) + .to_vec(); + assert_eq!(actual, expected); +} + // ---- object-literal members that alias a function (#1932) ------------------ #[test] diff --git a/crates/codegraph-resolve/tests/method_values.rs b/crates/codegraph-resolve/tests/method_values.rs index e4c7fc6..5dff63a 100644 --- a/crates/codegraph-resolve/tests/method_values.rs +++ b/crates/codegraph-resolve/tests/method_values.rs @@ -453,3 +453,47 @@ fn go_receiver_types_disambiguate_method_values_and_reject_external_fields() { .all(|edge| project.target(edge).qualified_name == "Store::Fetch") ); } + +#[test] +fn unknown_receivers_never_bind_to_a_project_unique_method() { + // Exactly ONE project method of each name, so the old unique-name + // fallback would have bound every value below. Nothing proves what these + // receivers are: `store` is assigned from an unannotated parameter, `obj` + // is an unannotated parameter, and the Go `obj` has a type from outside + // the project. + let python = resolve_project(&[ + ( + "store.py", + "class Store:\n def fetch(self, ids):\n return ids\n", + ), + ( + "consumer.py", + "class Consumer:\n\ + \x20 def __init__(self, store):\n\ + \x20 self.store = store\n\ + \x20 def via_field(self, pool, ids):\n\ + \x20 return pool.submit(self.store.fetch, ids)\n\ + def via_param(obj, pool):\n\ + \x20 return pool.submit(obj.fetch)\n", + ), + ]); + let go = resolve_project(&[ + ( + "store.go", + "package demo\n\ntype Store struct{}\n\nfunc (s *Store) Fetch(ids []string) []string { return ids }\n", + ), + ( + "consumer.go", + "package demo\n\n\ + import \"example.com/remote\"\n\n\ + func Submit(fn func([]string) []string, ids []string) []string { return fn(ids) }\n\n\ + func ViaParam(obj remote.Client, ids []string) []string { return Submit(obj.Fetch, ids) }\n", + ), + ]); + let bound = + |project: &Project, name: &str| project.source_names(&project.fn_ref_edges_into(name)); + assert_eq!( + (bound(&python, "fetch"), bound(&go, "Fetch")), + (Vec::::new(), Vec::::new()) + ); +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index c3be547..36b075c 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 = 18; +pub const CURRENT_EXTRACTION_VERSION: u64 = 19; /// 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 cc6a465..34d8c58 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 = 17; + const PREVIOUS_EXTRACTION_VERSION: u64 = 18; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/docs/upstream-sync/UPSTREAM.md b/docs/upstream-sync/UPSTREAM.md index 3ce763f..21f0daf 100644 --- a/docs/upstream-sync/UPSTREAM.md +++ b/docs/upstream-sync/UPSTREAM.md @@ -69,7 +69,12 @@ below remain immutable historical evidence. > retroactive review, in #291: a store initializer's own calls stay with the > store, an explicit `export default NAME` binding beats an exported > component, and a trim note never claims that gap markers name every elided -> symbol. See the 2026-10-01 retroactive-review entry below. +> symbol. See the 2026-10-01 retroactive-review entry below. Five more, in +> #295, fix the findings that review had kept at upstream behavior: Zustand +> provenance for every store-action form, no unique-name guess for an +> unknowable receiver, three-valued whole `#if` expressions, whole-file +> include guards only, and literal paren constructions past +> `initializer_list` overloads. See the 2026-10-02 entry. > - The `1.5.0 → 1.6.0` and older caveats below carry forward unchanged. > **1.5.0 → 1.6.0 sync: COMPLETE** (codegraph-rs `v0.48.2` → `v0.50.1`). @@ -136,6 +141,25 @@ below remain immutable historical evidence. ## Sync log +### 2026-10-02 — five more KEEP-RUST divergences: the declined retroactive-review findings FIXED (#295) + +The 2026-10-01 retroactive-review entry below kept five review findings at +upstream behavior. On 2026-10-02 the owner asked for all five to be fixed +beyond upstream `v1.6.1` (`f4ddf50`). Their design passed the kirocodex plan +review in round 3, with blocking items 6 → 1 → 0. Each item below is a +KEEP-RUST divergence: stricter than upstream, or more exact. The extraction +version moves to 19, so an index built at 18 is rebuilt. + +| Finding | Upstream `f4ddf50` | Port | +| ------- | ------------------ | ---- | +| #277: store actions | `matchStoreAccessorChain` checks Zustand provenance for selectors only, so `otherFactory(() => ({ reset() {} })).getState().reset()` binds | Every store-action form needs a Zustand factory on the initializer's call path to the action function. That path is read by the parser, following the same search that finds the actions. The factories are `create`, `createStore` and `createWithEqualityFn` from `zustand`, `zustand/vanilla` or `zustand/traditional`, the default import, or a namespace member. A selector also needs a hook factory. A wrapped hook such as `createSelectors(create(...))` now binds its selectors. | +| #278: method values | "Unknown receivers retain the old unique-or-drop discipline" | An unknowable Python or Go receiver leaves the value unresolved, even when one project method bears the name | +| #282: `#if` | `condition` reads a literal, one `defined` test or a bare name | The whole expression is evaluated three-valued, with C precedence. `&&`/`||` decide whenever one side does, and a definitely undefined name reads 0. Unseen names, `__has_include(...)`, character literals, overflow and malformed text stay unknown. Backslash continuations are spliced first. | +| #282: guards | `guardsItself` reads any `#ifndef X` followed by `#define X` (empty) or `#define X(` as a guard and forces the branch active | Only a whole-file include guard counts: the test is the first code line, the next directive is an empty `#define X`, and the matching `#endif`, with no `#else`/`#elif` at its depth, is the last code line. A feature-flag default and a fallback `#ifndef MIN` / `#define MIN(a, b)` stay undecided and keep the call. | +| #282: constructors | any constructor mentioning `initializer_list` declines the set | `T x(, ...)`, with no `{` in the arguments, drops the constructors whose first parameter is the list; a literal never converts to one. The rest compete by arity. Every other form still declines. | + +Each fix was red first; the logs are in `/tmp/evidence-p10/red-*.log`. + ### 2026-10-01 — `v0.52.1` RELEASED: #291 and #292 shipped Release-please PR [#293](https://github.com/sunerpy/codegraph-rust/pull/293)