From 2258abd378f6ae72a70cd7662f104c629c57908f Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 21:50:35 +0800 Subject: [PATCH 01/12] fix(resolve): keep unknown Python import members unresolved A named Python import proves the receiver exists, not the attribute: an unresolved member such as task.delay() no longer falls back to the imported task function (upstream #2040). Named imports, including aliases, also get the absolute-module lookup and the top-level-definition fallback Python needs because its symbols are never marked exported (#1820), so `Store as Actual; Actual.fetch()` now reaches Store::fetch. --- .../codegraph-resolve/src/import_resolver.rs | 63 +++++- .../tests/python_import_members.rs | 192 ++++++++++++++++++ 2 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 crates/codegraph-resolve/tests/python_import_members.rs diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index fc88c495..444d8054 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 @@ -1694,9 +1729,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/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}" + ); + } +} From 0710811ce6fd5e6d4c057b26d66c026c52799155 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 21:52:32 +0800 Subject: [PATCH 02/12] fix(resolve): bind implements to the interface of a value+interface pair A TypeScript value that shares its name with a type in the same file (the VS Code service idiom) now moves an inheritance edge to that type instead of dropping it (upstream #2055). --- crates/codegraph-resolve/src/resolver.rs | 39 ++++++++++- .../tests/js_resolution_safety.rs | 65 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 9e1d9956..bb210864 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -1097,6 +1097,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); @@ -1670,10 +1692,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( 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()); +} From e2c051c1d4787a4221863dcf31ab35eaccd3c71f Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 22:19:02 +0800 Subject: [PATCH 03/12] fix(resolve): reject unrelated cross-language matches Adopt upstream v1.6.1's language families (C, C++, Objective-C and Swift are one native family; ArkTS, Vue, Svelte and Astro join web) and its code-family boundary: every chosen result that crosses between two code families needs C ABI evidence (a cgo //export, a Rust extern "C" function, or a Go/Rust caller naming a C symbol); config and markup transitions stay open. The gate judges the chosen result, never the candidate pool, and also covers alias-forwarded and framework results (#1986). Godot scene, resource, and project path references into scripts stay unresolved rows because impact/affected/audit consume them path-keyed. The Python golden gains the aliased member import edge that #1820's named-import fallback now resolves. --- crates/codegraph-resolve/src/name_matcher.rs | 284 ++++++++++++++----- crates/codegraph-resolve/src/resolver.rs | 52 ++-- docs/equivalence.md | 7 +- reference/golden/python/colby.db | Bin 163840 -> 163840 bytes reference/golden/python/edges.json | 18 +- reference/golden/python/refs.json | 10 - 6 files changed, 261 insertions(+), 110 deletions(-) diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index c83617ae..23a3e78e 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -162,19 +162,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 +193,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, + }) } -/// `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) +/// 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) } -/// Drop cross-language candidates from a name lookup (`applyLanguageGate`, -/// `name-matcher.ts:160-168`). +/// 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 +} + +/// 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) +} + +/// 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, } } @@ -3923,6 +4052,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 +4066,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 + )); + } + + #[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" + ); - // 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)); + 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 +4168,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( diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index bb210864..5f0f38f8 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; @@ -1545,6 +1546,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) @@ -2765,26 +2775,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, @@ -2792,16 +2789,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/docs/equivalence.md b/docs/equivalence.md index db75fe48..db7eec1c 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -293,8 +293,11 @@ 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. diff --git a/reference/golden/python/colby.db b/reference/golden/python/colby.db index 65891c7dea512aa5fbccfbdcfaecc46a43b47947..ad62741312805f97b84b6ae77d9bb93fd3e55594 100644 GIT binary patch delta 2084 zcmdT_`)`v~82;Yx((nGA5p)*{uI;)@zs+@HEDVJxE{t%|jSZH}0p096*jBc>ZjJ$N zGcY0Q$nc1VguBFe1A+`zA{dz_Vip4af`mH}qge#SsZk_hp05L1{15!4@AEw8J*VeA zZ{MdqJxWiHa_EGYAKmv&H9qOhwLtQ~sJFr~(Jj0yNPLj{lUvXJ#1_MD<}Mye*oRM) zC6YC3!AbND;KhyQGuiCPxMoozo>bls8Tga(_tcbhnekOQI{+AhdZCJDO~4gP+=`%v z+%n^>vMG;U5BpwYjUk;qzRrsl7>Q@+JrLml7WiV@Et5IxBH%g5xBgIhkyZTN2%D_;CT0`75kUn9ZxWc8e)D8DGh%29f) zgEGlzgd%z^9}R-1i1#^3Xf5S^jgUgFHG&D5L}`K|A~yjd$-Uq}u?wb8;k|YOn<3d2 zcE{ng=s81OV5+fX7x}FT3It~X?@J=yZpap_${b`%5bTf|4Ux7W^mFq~aWyvP-E_PO1Ck zO8rM;lXNVyFa*EbNLvRK+RQ?}$_WJCf(&wIJt*X(4$#QY9bnqB#+O1HAfGH;4TWUE zYPw{0f+?g=<*V$(*#()}gN7CG)poq+g)Hg5kVaBF>G>|JFd!2)fhk9OW^sJxc~gnD z(DCpf+xWi7@lKd$bD8XEMyat~Hs%Fvz*9u)6yW zvwL22a07M4!P;XBc#YXxFVLxDMIKKg#sQm#m^uW?j6X zP@ySakg4$ExOrP|BvHB`Mny_z#;A?bC&_2O!hD|B8tBX126rUqHV6hc0^5nv3+CH9 zbwNcV;3J`X;H2`?QD3lJRiP}ASZ;uC(JTp#Fc)z`sYz1DK<7r7J;XZ(Gk7ZdNpK9d zaVGL^71;_Fd5_kz=c#E%rXhBBf>;p!8LS`|Q`yN%VP5VyuOD=kq9E)hr vvilA0H7E77Qg$x|9{few-oY}~=8*0(D1w?%IyTCt|3CjrBZIH7^W}d5PFbCd delta 2128 zcmdT_`EOKJ6u#%}`)#SV)G2me+nM%Fav1Xa@r(^amtCQuGI+U{G*Nj4|SpxQ2S?71H5PFq!0?@4Mf5bI&>V zyKi7X8yL_I9IXHr$RYiw14KhQc9xPy|5aVkkfZTP7C8a%0f; zkvR>os@}2*4NopL#GsB=*K+|))O$)*R2A~9B2P88knj?d_wo49tFocLKavee|ASdDHQq1g7@~R>;qBJPM#3mc4Y*ePHgvkS?drT`6% zgGJsOE)eb#b(A_iTzgP)%pZ`Iv^uD<2xjKiTeGa?R*mJe@-5l?)BGVdn)9ql&p!Yb z{rdr!InUy4=#I8UyQ7_vXrfH#Jx*UD^_FMvo(ESB(IjnbP|!dCD) znje|3;cxK?{1)DhGe>3=UU(gThvkag9i{{rgEv$XYc6Can zS_hl)#ty*b$yZ9$t9z=Tn;0*Z*9l=8*hz6{(HbbE|8_t?@=cJ_ZgcdkB53m(mJNS8yoJ7Xga?+Zn z99D~!N6kJZ-#nz;Yiuy{<+qGE#yjQ$`GOv^KC~9&O6zy+vVKml)pn>&T1Y#g?^T~P zzqht3Kc*MP;WugLfm(X^I;fSLS|MZuZP^UfP(k-IicVb*2Dx;Lm)36qP4c?}a&d*9 z>}t@!N9)%^tynfg2zjX82j%p@dXS~kn}y{bAh}C|P~s{iw>F@;TOpvj({XDZyDuPQ zYD*vKh53@#;q#EzY|Xa5uy)|X@*(V(OL3RnA+9m^n}3Tb>K|Yu`a=9il%-KA%;yPv z`E|mbY(yV$^wgse0f$y^hKN!Tk(q%3XnZ47X1qOJEx$r#=HxnhI{4{|EiA`JkFkeZ zHdkiORRPE|jdyUKQW|><<|-vu-^9jLNxy##rYm-f91gRSo|zPx*%ah;-Agw-4lPRY z9GN*96#CE;P(VhOmZw)g0Sy&%GKQ8lp4}(AGodsYij$!*8S;}Mr}CmYkv4Jwgd9aQ z=OcU;4^izHl%#{l;Ax9Lh`yyCjzMMNK{V3V9*f=@>FP}Mtc`Xjg7IDrA4Iz;oQ6E6 z+eUWY?uZJbR*>9!N0eC*K(=Iokv zd>qwh6B;A#$R<=q+~odSG}9!7!5b%`%pfLje{};r{xjSuiAFoZtEqAc%F@A0z+1vL zr~|^h{oIf#>UbOam&Px%W^F^CQ+Nj4Yf@t<<+_{v#8e7Xt;-x4T09OWTbt=xJ9AHB z!4@jZX>1%`;2m7^yv#MSmY=iIHJIC#BhPTZ1qphtm@Cq1>VjABh?f(*3g8up{VLMN zUEDrv>cGGN2x!oc5LzHrM=~SI<2dlr#ZQAbn8)#8c1KsWCz9R06N?4_2OKA8zJ*&^ zvYYK?*>mmcjOpzuEJbr0 Date: Wed, 30 Sep 2026 23:22:34 +0800 Subject: [PATCH 04/12] fix(resolve): require receiver evidence for TS/JS member calls Extraction follows upstream v1.6.1's TS/JS receiver shapes: wrappers that keep the receiver ((x), x!, x as T, x satisfies T, x, await x) are looked through, an identifier-rooted chain keeps its qualified call site (holder.values.get, with ?. read as .), an untyped expression receiver emits nothing, and an ES private field keeps its # (this.#items.add). Python call-result receivers keep (). like TS/JS (#1683). KEEP-RUST: a this/super-rooted chain through more than one field keeps its last segment instead of upstream's bare name, which would reintroduce the #1496 self-edge. Resolution reads this.#field declarations with public and private names kept apart, memoises each class field declaration per pass with static patterns instead of compiling four regexes per call, and accepts a this. assignment inside a constructor. Built-in method names need a validated receiver (#1987), retained untyped chains bind only through a framework resolver (#1862), and Python call-result chains stay unresolved. TSX and JSX now delegate their class-member hooks, so a plain field is a property as in TS/JS, and JS class fields are named by their property key (#808) instead of . Extraction version 14. --- .../codegraph-extract/src/lang/javascript.rs | 10 + crates/codegraph-extract/src/lang/jsx.rs | 6 + crates/codegraph-extract/src/lang/tsx.rs | 6 + crates/codegraph-extract/src/walker.rs | 209 ++++- .../tests/batch_coverage_languages.rs | 13 +- .../tests/js_callable_semantics.rs | 41 + .../tests/js_receiver_shapes.rs | 266 ++++++ crates/codegraph-resolve/src/name_matcher.rs | 509 +++++++++-- crates/codegraph-resolve/src/resolver.rs | 44 +- crates/codegraph-resolve/src/source_facts.rs | 64 ++ .../tests/js_receiver_evidence.rs | 835 ++++++++++++++++++ crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- 13 files changed, 1873 insertions(+), 134 deletions(-) create mode 100644 crates/codegraph-extract/tests/js_receiver_shapes.rs create mode 100644 crates/codegraph-resolve/tests/js_receiver_evidence.rs 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/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/walker.rs b/crates/codegraph-extract/src/walker.rs index 815b8839..cb701276 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 @@ -3636,10 +3741,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 +3812,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 +3856,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 +3966,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)) 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/js_callable_semantics.rs b/crates/codegraph-extract/tests/js_callable_semantics.rs index f6e99cf1..ab863a73 100644 --- a/crates/codegraph-extract/tests/js_callable_semantics.rs +++ b/crates/codegraph-extract/tests/js_callable_semantics.rs @@ -190,3 +190,44 @@ 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}"); + } +} 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-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 23a3e78e..90695b5e 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -5,7 +5,7 @@ //! cross-language family gate — mirror the upstream exactly. Every strategy cites its //! upstream source range. -use crate::source_facts::SourceFacts; +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, @@ -400,6 +400,120 @@ fn is_js_family(language: Language) -> bool { ) } +/// 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). @@ -1812,14 +1926,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)?; @@ -1839,111 +1955,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 @@ -2212,6 +2422,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; @@ -2427,6 +2647,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); @@ -3524,10 +3756,12 @@ 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). 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; @@ -4688,6 +4922,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/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 5f0f38f8..767043d5 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -71,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 { @@ -1743,14 +1773,14 @@ 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); } @@ -1817,8 +1847,10 @@ 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 || is_unresolved_js_member_call(reference) { return (candidates.into_iter().reduce(highest_confidence), None); } diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index d61adbfb..8371e90b 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -106,6 +106,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 +126,7 @@ pub struct SourceFacts { js_binding_sites: OnceLock, js_local_bindings: Mutex>, node_decisions: Mutex>, + ts_field_declarations: Mutex>>, awaited_raw_names: OnceLock>, awaited_index: OnceLock>, } @@ -133,6 +143,7 @@ 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()), awaited_raw_names: OnceLock::new(), awaited_index: OnceLock::new(), } @@ -264,6 +275,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 +429,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/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs new file mode 100644 index 00000000..dce721bd --- /dev/null +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -0,0 +1,835 @@ +//! 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"]); +} 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); From 660c6ec37f58cfaf89f294dc49aae09966240fdc Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 23:32:45 +0800 Subject: [PATCH 05/12] feat(resolve): bind Zustand store actions through accessors, destructuring and selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exported store initializer — create((set, get) => ({ reset: ... })), middleware wrappers included, or a binding exported by a later statement — now owns the inline actions of the object it returns, the extraction the store resolution depends on (upstream #647). Resolution follows upstream v1.6.1 #1862: get().reset() inside the store factory, useStore.getState().reset(), a bare call destructured from useStore.getState() in an enclosing block, and a selector binding of a Zustand create hook each resolve to the member of exactly one identified store. Import aliases and barrels are followed, and a shadowing parameter or declaration, a sibling block, an unknown store or a non-Zustand factory leaves the call unresolved. Python call-result chains keep upstream's unique-callable accessor fallback. --- crates/codegraph-extract/src/walker.rs | 99 ++++ crates/codegraph-resolve/src/awaited.rs | 8 +- crates/codegraph-resolve/src/js_store.rs | 431 ++++++++++++++++++ crates/codegraph-resolve/src/lib.rs | 1 + crates/codegraph-resolve/src/name_matcher.rs | 25 +- crates/codegraph-resolve/src/resolver.rs | 25 +- crates/codegraph-resolve/src/source_facts.rs | 18 + .../tests/js_receiver_evidence.rs | 227 +++++++++ 8 files changed, 823 insertions(+), 11 deletions(-) create mode 100644 crates/codegraph-resolve/src/js_store.rs diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index cb701276..15f64765 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -3074,6 +3074,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) => { @@ -3088,6 +3103,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 @@ -5009,6 +5038,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-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/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..a285650e 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -22,6 +22,7 @@ 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; pub mod path_aliases; diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 90695b5e..93f2d8e7 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -393,7 +393,7 @@ 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 @@ -687,7 +687,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) } @@ -928,6 +928,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, @@ -941,7 +949,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() @@ -1135,7 +1142,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; } @@ -1765,7 +1772,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) @@ -3759,12 +3769,13 @@ pub fn match_reference( // 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). + // `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) || reference.language == Language::Python) && reference.reference_name.contains("().") { - return None; + return crate::js_store::match_store_accessor_chain(reference, context); } // 2. Method call pattern. diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index 767043d5..e32987b5 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -1782,7 +1782,16 @@ impl ReferenceResolver { .any(|f| f.claims_reference(&reference.reference_name)) && !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, @@ -1850,7 +1859,19 @@ impl ReferenceResolver { // 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 || is_unresolved_js_member_call(reference) { + 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); } diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index 8371e90b..9bc5f6e4 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -127,6 +127,8 @@ pub struct SourceFacts { js_local_bindings: Mutex>, node_decisions: Mutex>, ts_field_declarations: Mutex>>, + js_get_state_file: OnceLock, + js_selector_names: OnceLock>, awaited_raw_names: OnceLock>, awaited_index: OnceLock>, } @@ -144,6 +146,8 @@ impl SourceFacts { 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(), awaited_raw_names: OnceLock::new(), awaited_index: OnceLock::new(), } @@ -235,6 +239,20 @@ 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)) + } + /// Names the raw source binds as `const x = await f(`; `scan` runs once. pub(crate) fn awaited_raw_names( &self, diff --git a/crates/codegraph-resolve/tests/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs index dce721bd..833b7909 100644 --- a/crates/codegraph-resolve/tests/js_receiver_evidence.rs +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -833,3 +833,230 @@ fn call_result_receivers_never_fabricate_an_edge() { 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:?}"); +} From 91d809b36fb46edd547ae09e673e0c0d4638eced Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 23:42:41 +0800 Subject: [PATCH 06/12] fix(resolve): follow object-literal member bindings lexically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.getUser() where api is { getUser } or { getUser: fetchUser } and the function is declared outside the literal now resolves in the literal's own file as well as through an import (upstream v1.6.1 #1932). The member is read off the literal's own top-level properties on comment- and string-blanked source: the last own property wins, an unknown spread or computed key invalidates earlier evidence, and a nested object, a member body, a comment or a string never donates a binding. The binding is then followed from where the literal is written — the nearest visible declaration of the file, else one of its imports — and a shadowing parameter or nearer value leaves it unresolved. Alias forwarding uses the same reader for JS-family members instead of a pattern over the truncated signature, and containment lookups only accept a member defined by the selected property. --- crates/codegraph-resolve/src/alias_binding.rs | 30 +- .../codegraph-resolve/src/import_resolver.rs | 17 + crates/codegraph-resolve/src/lib.rs | 1 + crates/codegraph-resolve/src/name_matcher.rs | 23 +- .../codegraph-resolve/src/object_literal.rs | 373 ++++++++++++++++++ crates/codegraph-resolve/src/source_facts.rs | 55 +++ .../tests/js_receiver_evidence.rs | 299 ++++++++++++++ 7 files changed, 793 insertions(+), 5 deletions(-) create mode 100644 crates/codegraph-resolve/src/object_literal.rs 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/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index 444d8054..7d3ba576 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -1712,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 — diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index a285650e..e75c63bb 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -25,6 +25,7 @@ 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 93f2d8e7..71895502 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -5,6 +5,9 @@ //! cross-language family gate — mirror the upstream exactly. Every strategy cites its //! upstream source range. +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::{ @@ -1131,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 @@ -1191,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() @@ -1204,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 @@ -2624,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); } } 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/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index 9bc5f6e4..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}; @@ -129,6 +130,8 @@ pub struct SourceFacts { 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>, } @@ -148,6 +151,8 @@ impl SourceFacts { 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(), } @@ -253,6 +258,56 @@ impl SourceFacts { 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, diff --git a/crates/codegraph-resolve/tests/js_receiver_evidence.rs b/crates/codegraph-resolve/tests/js_receiver_evidence.rs index 833b7909..cb84c8da 100644 --- a/crates/codegraph-resolve/tests/js_receiver_evidence.rs +++ b/crates/codegraph-resolve/tests/js_receiver_evidence.rs @@ -1060,3 +1060,302 @@ fn selectors_are_not_guessed_through_shadows_or_foreign_factories() { 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:?}" + ); +} From 932e57cda9489c448a5ea7d1f15d1395d75e7917 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 23:52:16 +0800 Subject: [PATCH 07/12] feat(extract): name curried-wrapper object members by their key An Effect-style service is usually an object a factory returns, so a wrapped function lands in a pair: { getMode: Effect.fn("x")(function* () {}) } produced no function node and its body's calls were attributed to the enclosing factory. The curried-wrapper rule (the callee is itself a call) now also names a pair value by its property key, from both the body walk and object-literal member extraction, while single-call computations such as key: useMemo(...) and key: arr.map(...) stay anonymous (upstream #1747). --- crates/codegraph-extract/src/walker.rs | 36 ++++++++++-- .../tests/js_callable_semantics.rs | 56 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 15f64765..2c65d170 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -2572,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(), @@ -2599,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)) } @@ -3143,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" diff --git a/crates/codegraph-extract/tests/js_callable_semantics.rs b/crates/codegraph-extract/tests/js_callable_semantics.rs index ab863a73..e3015c00 100644 --- a/crates/codegraph-extract/tests/js_callable_semantics.rs +++ b/crates/codegraph-extract/tests/js_callable_semantics.rs @@ -231,3 +231,59 @@ export class Vault { 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")); +} From 19e06125e3a5f28e9574cdea9eebd638f5f73b1a Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Wed, 30 Sep 2026 23:55:05 +0800 Subject: [PATCH 08/12] test(golden): keep api.nested.run as a qualified call site in the TypeScript corpus The TypeScript fixture's nested api.nested.run() now reaches refs.json as the retained chain api.nested.run instead of its last segment, the call site shape of upstream #1862. It stays unresolved and edges.json is unchanged; regenerated with the documented recipe. --- docs/equivalence.md | 2 ++ reference/golden/typescript/colby.db | Bin 208896 -> 208896 bytes reference/golden/typescript/refs.json | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/equivalence.md b/docs/equivalence.md index db7eec1c..d7a35967 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -1234,6 +1234,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/reference/golden/typescript/colby.db b/reference/golden/typescript/colby.db index 88a1101c96257f66552af72c12f3d3ccc9635988..98a1e1d00c316341b43c52d87303c8fc5fc4bcb1 100644 GIT binary patch delta 782 zcmZp8z|-)6XTu(Smd{KZOD7-D*8?&({Mh_SU(S;YC=|}P07zC&mZ~g3=1!^1K<2Vl zM3cxrNmzM!l|1N0yDL$wua0tVu)WI@YF3AY8>+!OCsg$g9Z2%F4*V zF}W~OWb&0-KVF0awtSY!3+hBCyVUt3n=-c!#Y(<<6!&%3=O8KG{J)-Az^$2qn?;g= ze-^(9-&ww7-Z#AMyed5Rxg1$Pvq%yTH+xLD$jp{u%gqq4Ir-xhlg;5%7BI5KTY&k0 zry8&%7G&x*>rUIQJB{%kC;Myu2mDw0Pfh1rz$ms^P(hQwo{O245gZ}m0thBQg2{(q z@*E!EyW<5$TW$^^W=%$r|EG7|X0(Cxwx7Dq zXdxnXl7XAKhk;`fa}UpbUU5D%-nBfjEG=v=`E8~vd|*tPZt#JTWqZ>HMlNO+8P3stk delta 802 zcmZp8z|-)6XTu(Smc@IkPEJ0cuLojOZ2qJ#=g9?REn{2&B+pKksw_Zo&rP0EnSsn@ ztI7v)mqHcSSLK4ZOTgT7lfP9Jg1C#JvW3-PqhQ?E)#=ExF*VsB?joq-i#2H=?gA*+ zu{K=-!R4%AmbK!MH*7oNw0U^2s* zE0{Q$SvkS3oV#rUBa?#ER0eMDHU_@yd{6oJ@NeKg#@z<=l{()_UY_X+7Z_EyJ6>S4 z<({~KgN2V-k#Tz4ZAKd~Yx}9&j20qNXBoJer!a6#VxGctpI4mEjCU Date: Wed, 30 Sep 2026 23:55:05 +0800 Subject: [PATCH 09/12] feat(extract): index Python body docstrings A bare string literal first in a module, class or function body is now the node's docstring, joined after any preceding comment, so Python prose reaches search and node output (upstream #1905). The grammar's string_content is read directly, so r/u prefixes, both triple-quote forms, concatenated literals and interleaved comments work, while bytes, f-strings, tuples and computed expressions are not docstrings. Cleaning follows PEP 257: tabs expand to 8-column stops, lines after the first lose their common indentation, and blank edges are dropped. The Python corpus's pkg/__init__.py file node gains its module docstring. --- crates/codegraph-extract/src/lang/python.rs | 136 ++++++++++++ crates/codegraph-extract/src/spec.rs | 7 + crates/codegraph-extract/src/walker.rs | 41 ++-- .../fixtures/python_docstrings/docstrings.py | 102 +++++++++ .../tests/python_docstrings.rs | 198 ++++++++++++++++++ docs/equivalence.md | 4 + reference/golden/python/colby.db | Bin 163840 -> 163840 bytes reference/golden/python/nodes.json | 2 +- 8 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 crates/codegraph-extract/tests/fixtures/python_docstrings/docstrings.py create mode 100644 crates/codegraph-extract/tests/python_docstrings.rs 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/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 2c65d170..623baa15 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -907,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); @@ -953,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, @@ -2257,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, @@ -2451,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), @@ -2648,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() @@ -2692,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() @@ -2740,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), @@ -2803,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() }, @@ -2828,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() @@ -2922,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() }, @@ -3014,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() }, ); @@ -3032,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() { @@ -3258,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 { @@ -4901,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 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/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/docs/equivalence.md b/docs/equivalence.md index d7a35967..fbb4e3c2 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -302,6 +302,10 @@ ImportedAlias` preserves member-import semantics: both the import statement and 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 diff --git a/reference/golden/python/colby.db b/reference/golden/python/colby.db index ad62741312805f97b84b6ae77d9bb93fd3e55594..c65a817a199a7c01baa284b3cfc302525f920225 100644 GIT binary patch delta 847 zcmZo@;A&{#+OS8T&1O$+VI0OFS8^wF-M^|IU_YUk(YsiL5ZP*fqw(P zA>T$mX5I##-#isO3f#@ytXwm>lsWr2%{b0+lyPvf&t=zU+sWq7`kJ+!Rh(rOivsfs zW-X>QOlFMx82uT(GjvQ`XtQ~*0UIZOqY$r{si?BHwj?Jb2S@4Ti;MXAO4Ii)3;`FRRynH43aK)&8)Wh)zJE}-k@ zGcEv<`zB}BrXX{#qjFvA(vW4h)}BeB8^~d0pWF0*SIukC9~Dqp85pE66V-VXr8xs;Z(Q$IB_ht1c*Fpv@!6C&bIo z%g@Ov!6(gUDa60sMvCzeDF&HU%ba1;%1vpt%p#K=WccVhR9++v%?W delta 663 zcmZo@;A&{#+OS8T<%{>X5}tXPC7Fpi3dPA8skw=~3=9lP40{>)H}D(s zZRBI-ZQ%LMQ^BLa-OSC(HIqx3^Dt*NCo{)t4mDr;*`UT7scd5xtrM+uKuq`11e{$xQ5 zvB}Fr6gDSX{d3|1I>(T40gz0XoLQTK%)O4vb*)Q7mfc#Hj?7i7Pe$fWu1`YZZvJ1- zK0)IE1OHdP$^4D{Dtzmi4&Z{!n>HU5n8YO1SAU=Z2pCxy<}ooau$w6wOx_=@uzjfz z<9jAXk?pfY87DAqcMxYZVVvF|!Klf@!^gtN$i&FR%*b3Y{kQ~U)AR^Q#@#B?{Jetv zLK60h!m6q&DsnQs>VhH$+B}kcLcII}oSecke3nAnZKN0vu`)7j|1ZzjE)S&J?HP>= z+(042&c)2i2o4@YHUyIu!DK-&nGsATPG(k4h_-DT7%%6u8-qFN({IT$DS?tg+BO9y TZ3V^yK)+6*vtPZKj+g=fWKz^$ 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", From 6e607fb423388f48d35ebf9c3f43247b3f48dcd2 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 00:00:09 +0800 Subject: [PATCH 10/12] fix(extract): recover functions defined through single-argument C/C++ macros #define NATIVE_FN(name) int name(void) followed by NATIVE_FN(get_version) { ... } was indexed as "(get_version)" in C and under the macro name in C++, so callers never reached it (upstream #1373). Both parser shapes now recover the function name, but only when a preceding local #define's replacement declares a function named by its sole parameter; registration macros, token pasting, typedefs, an #undef or redefinition in between, and any conditional block leave the name alone. The multi-argument recovery is unchanged. --- crates/codegraph-extract/src/lang/c.rs | 3 + crates/codegraph-extract/src/lang/cpp.rs | 106 +++++++++++++++++- .../tests/c_family_correctness.rs | 101 +++++++++++++++++ .../tests/c_go_correctness.rs | 37 ++++++ 4 files changed, 246 insertions(+), 1 deletion(-) 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/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-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}" + ); + } +} From 602e5c8a52f14d6dee34d0ba351a43bfa25a23a6 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 00:05:42 +0800 Subject: [PATCH 11/12] docs(upstream): record the v1.6.1 audit and its dispositions Classifies every commit in 3ed73bc..v1.6.1 against the 2026-09-15 baseline: the resolution/extraction and MCP/CLI/lifecycle/explore rows with their landing state, the KEEP-RUST divergences taken by this round, the re-triaged UI family, and the rows that wait for an owner decision because they touch a hard invariant or would add a deferral. The tracked release in UPSTREAM.md stays v1.6.0 until the ported rows ship. --- docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md 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..652f4860 --- /dev/null +++ b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md @@ -0,0 +1,124 @@ +# 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 | **owner decision required** | +| #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 | PORT, **owner decision required** (scope) | +| 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 | PORT, **owner decision required** (scope) | +| F6 SvelteKit | `goto`/`redirect`, page/server pairing | no Svelte resolver in the port | DEFER, **owner decision required** | +| F7 synthesis layer | callback synthesizer, incremental refresh, `synthesis_inputs` table | **schema DDL** | PORT, **owner decision required** (hard invariant) | +| F8 cross-tier channels | HTTP clients → routes, queues, event buses | needs F7 | PORT after F7 | +| F9 server route naming | Express mount prefixes, FastAPI, ASP.NET groups | resolvers the port does not have | DEFER, **owner decision required** | +| F10 / F11 explore Flow and branch guards | named-symbol flow section and `when` conditions | explore output | PORT, **owner decision required** (scope) | +| F12 React Native bridge | `NativeModules` → native methods | new resolver | PORT, **owner decision required** (scope) | +| F13 JS handler value references | JSX attribute and shorthand fnRefs, later-exported store actions, default namespace objects | extraction | PORT | + +## Owner decisions + +These rows touch a hard invariant or would add a deferral, so they wait for the +owner rather than being decided by the porter: + +1. #1349 changes the node-id formula for colliding declarations. +2. F7 needs new tables and indexes (schema DDL). +3. SvelteKit (F6) and F9 would be new DEFER rows. +4. The overall scope of F1–F5, F6 (Vue), F8 and F10–F12 for this round. From 7e38673688eb06c797fcb0898eb9328fe62feb1c Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 08:02:47 +0800 Subject: [PATCH 12/12] docs(upstream): record the owner decisions on #1349 and the UI family --- docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md index 652f4860..65240abd 100644 --- a/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md +++ b/docs/upstream-sync/V1_6_1_AUDIT_2026-09-30.md @@ -40,7 +40,7 @@ documented divergence). | #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 | **owner decision required** | +| #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 | @@ -101,24 +101,27 @@ 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 | PORT, **owner decision required** (scope) | -| 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 | PORT, **owner decision required** (scope) | -| F6 SvelteKit | `goto`/`redirect`, page/server pairing | no Svelte resolver in the port | DEFER, **owner decision required** | -| F7 synthesis layer | callback synthesizer, incremental refresh, `synthesis_inputs` table | **schema DDL** | PORT, **owner decision required** (hard invariant) | -| F8 cross-tier channels | HTTP clients → routes, queues, event buses | needs F7 | PORT after F7 | -| F9 server route naming | Express mount prefixes, FastAPI, ASP.NET groups | resolvers the port does not have | DEFER, **owner decision required** | -| F10 / F11 explore Flow and branch guards | named-symbol flow section and `when` conditions | explore output | PORT, **owner decision required** (scope) | -| F12 React Native bridge | `NativeModules` → native methods | new resolver | PORT, **owner decision required** (scope) | -| F13 JS handler value references | JSX attribute and shorthand fnRefs, later-exported store actions, default namespace objects | extraction | PORT | +| 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 wait for the -owner rather than being decided by the porter: +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 changes the node-id formula for colliding declarations. -2. F7 needs new tables and indexes (schema DDL). -3. SvelteKit (F6) and F9 would be new DEFER rows. -4. The overall scope of F1–F5, F6 (Vue), F8 and F10–F12 for this round. +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.