From daed6fa4d9dea12730434220b389f35efe48d108 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 08:13:43 +0800 Subject: [PATCH 1/6] fix(extract): keep same-line declarations that share a node id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-kind, same-name declaration on one line — a getter/setter pair, two {% render "x" %} tags — hashed to the node id of the first and silently replaced it in the store. Within one file's extraction the first declaration now keeps its id and every later one at a different column appends :{column}, its zero-based UTF-16 column, as upstream v1.6.1 does (#1349); revisiting a declaration yields the same id, so a declaration that collides with nothing keeps exactly the id it had. Applied to the tree-sitter walker (CFML included) and the Liquid extractor; the formula in AGENTS.md and docs/equivalence.md records the suffix. Extraction version 15. --- AGENTS.md | 7 +- crates/codegraph-core/src/node_id.rs | 79 ++++++++++ .../codegraph-extract/src/embedded/liquid.rs | 145 +++++++++++------- crates/codegraph-extract/src/walker.rs | 13 +- .../codegraph-extract/tests/node_identity.rs | 129 ++++++++++++++++ .../tests/js_resolution_safety.rs | 57 +++++++ crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- docs/equivalence.md | 8 + 9 files changed, 381 insertions(+), 61 deletions(-) create mode 100644 crates/codegraph-extract/tests/node_identity.rs diff --git a/AGENTS.md b/AGENTS.md index eedd0dc3..2ab410bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,11 @@ or required by that guidance. in [`docs/equivalence.md`](docs/equivalence.md). 3. **Stable node IDs.** Symbol IDs are `{kind}:{sha256("{filePath}:{kind}:{name}:{line}").hex[..32]}`; file nodes are - `file:{relative/path}`. Paths use `/`; lines are 1-based. Never change this - formula incidentally. + `file:{relative/path}`. Paths use `/`; lines are 1-based. Within one file's + extraction, a later declaration that collides with an earlier one at a + different column appends `:{column}` (zero-based UTF-16 code units, upstream + #1349), so it no longer overwrites the first. Never change this formula + incidentally. 4. **No AI/vector runtime.** Do not add AI, LLM, embedding, vector-database, or inference dependencies. `scripts/guardrail.sh` enforces the dependency boundary. 5. **Project containment.** Managed state stays under the selected project index diff --git a/crates/codegraph-core/src/node_id.rs b/crates/codegraph-core/src/node_id.rs index af806c9e..67e54d24 100644 --- a/crates/codegraph-core/src/node_id.rs +++ b/crates/codegraph-core/src/node_id.rs @@ -1,5 +1,7 @@ //! Upstream-compatible node and content identifiers. +use std::collections::HashMap; + use sha2::{Digest, Sha256}; use crate::types::NodeKind; @@ -19,6 +21,50 @@ pub fn generate_node_id(file_path: &str, kind: NodeKind, name: &str, line: u32) format!("{}:{}", kind, &hex[..32]) } +/// Per-extraction node identities (upstream `NodeIdAllocator`, #1349). +/// +/// A declaration keeps its [`generate_node_id`] identity unless an EARLIER +/// declaration of the same extraction already holds that identity at a +/// different source position — a same-kind, same-name declaration on the same +/// line, such as a getter/setter pair. The later one then appends +/// `:{column}`, its zero-based UTF-16 column, instead of overwriting the first +/// in the store. Revisiting a declaration yields the identity it had. +#[derive(Debug, Default)] +pub struct NodeIdAllocator { + first_columns: HashMap, +} + +impl NodeIdAllocator { + /// The identity of the declaration of `kind`/`name` at `line` (1-based) + /// and `utf16_column` (0-based UTF-16 code units). + pub fn generate( + &mut self, + file_path: &str, + kind: NodeKind, + name: &str, + line: u32, + utf16_column: u32, + ) -> String { + let id = generate_node_id(file_path, kind, name, line); + let first = *self.first_columns.entry(id.clone()).or_insert(utf16_column); + if first == utf16_column { + id + } else { + format!("{id}:{utf16_column}") + } + } +} + +/// Zero-based UTF-16 column of byte offset `byte` in `source` — the column +/// unit upstream's identities use, never tree-sitter's byte column. +pub fn utf16_column(source: &str, byte: usize) -> u32 { + let byte = byte.min(source.len()); + let line_start = source[..byte].rfind('\n').map_or(0, |newline| newline + 1); + source + .get(line_start..byte) + .map_or(0, |prefix| prefix.encode_utf16().count() as u32) +} + /// Generate the literal file-node ID used by the upstream tree-sitter extractor. pub fn file_node_id(file_path: &str) -> String { format!("file:{file_path}") @@ -49,6 +95,39 @@ mod tests { use super::*; + #[test] + fn collisions_only_suffix_later_distinct_columns() { + // Upstream kernel `collision_only_identity_vectors`. + let mut ids = NodeIdAllocator::default(); + let base = generate_node_id("src/a.ts", NodeKind::Function, "foo", 3); + for (column, expected) in [ + (2, base.clone()), + (24, format!("{base}:24")), + (48, format!("{base}:48")), + (24, format!("{base}:24")), + (2, base.clone()), + ] { + assert_eq!( + ids.generate("src/a.ts", NodeKind::Function, "foo", 3, column), + expected + ); + } + assert_eq!( + ids.generate("src/a.ts", NodeKind::Function, "bar", 3, 24), + generate_node_id("src/a.ts", NodeKind::Function, "bar", 3) + ); + } + + #[test] + fn utf16_columns_count_code_units_from_the_line_start() { + let source = "x\n/* é😀 */ set"; + let byte = source.find("set").unwrap(); + // `/* ` 3, `é` 1, `😀` 2, ` */ ` 4. + assert_eq!(utf16_column(source, byte), 10); + assert_eq!(utf16_column(source, 0), 0); + assert_eq!(utf16_column("ab", 99), 2); + } + #[derive(Debug, Deserialize)] struct GoldenNode { id: String, diff --git a/crates/codegraph-extract/src/embedded/liquid.rs b/crates/codegraph-extract/src/embedded/liquid.rs index 55472674..05957051 100644 --- a/crates/codegraph-extract/src/embedded/liquid.rs +++ b/crates/codegraph-extract/src/embedded/liquid.rs @@ -1,4 +1,7 @@ -use codegraph_core::types::{EdgeKind, ExtractionResult, Language, NodeKind}; +use std::cell::RefCell; + +use codegraph_core::node_id::{NodeIdAllocator, utf16_column}; +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, Node, NodeKind}; use regex::Regex; use crate::embedded::shared::{ @@ -10,6 +13,7 @@ pub struct LiquidExtractor<'a> { file_path: &'a str, source: &'a str, line_starts: Vec, + node_ids: RefCell, } impl<'a> LiquidExtractor<'a> { @@ -18,9 +22,24 @@ impl<'a> LiquidExtractor<'a> { file_path, source, line_starts: line_starts(source), + node_ids: RefCell::new(NodeIdAllocator::default()), } } + /// Identify `node`, created at byte `offset`, through the per-file + /// allocator: a later same-kind, same-name node on the same line gains a + /// column suffix instead of overwriting the first (#1349). + fn identify(&self, mut node: Node, offset: usize) -> Node { + node.id = self.node_ids.borrow_mut().generate( + self.file_path, + node.kind, + &node.name, + node.start_line.max(1) as u32, + utf16_column(self.source, offset), + ); + node + } + pub fn extract(self) -> ExtractionResult { let start = std::time::Instant::now(); let mut result = empty_result(0); @@ -73,17 +92,20 @@ impl<'a> LiquidExtractor<'a> { let name = cap.get(2).unwrap().as_str(); let line = line_number_for_offset(&self.line_starts, full.start()); let col = full.start() as i64 - line_start_for(&self.line_starts, line) as i64; - self.push_import_node(result, file_id, name, full.as_str(), line, col); - let node = default_node( - self.file_path, - Language::Liquid, - NodeKind::Component, - name.to_string(), - format!("{}::{}:{}", self.file_path, tag_type, name), - line, - line, - col, - col + full.as_str().len() as i64, + self.push_import_node(result, file_id, name, full.as_str(), full.start()); + let node = self.identify( + default_node( + self.file_path, + Language::Liquid, + NodeKind::Component, + name.to_string(), + format!("{}::{}:{}", self.file_path, tag_type, name), + line, + line, + col, + col + full.as_str().len() as i64, + ), + full.start(), ); let node_id = node.id.clone(); result.nodes.push(node); @@ -107,17 +129,20 @@ impl<'a> LiquidExtractor<'a> { let name = cap.get(1).unwrap().as_str(); let line = line_number_for_offset(&self.line_starts, full.start()); let col = full.start() as i64 - line_start_for(&self.line_starts, line) as i64; - self.push_import_node(result, file_id, name, full.as_str(), line, col); - let node = default_node( - self.file_path, - Language::Liquid, - NodeKind::Component, - name.to_string(), - format!("{}::section:{}", self.file_path, name), - line, - line, - col, - col + full.as_str().len() as i64, + self.push_import_node(result, file_id, name, full.as_str(), full.start()); + let node = self.identify( + default_node( + self.file_path, + Language::Liquid, + NodeKind::Component, + name.to_string(), + format!("{}::section:{}", self.file_path, name), + line, + line, + col, + col + full.as_str().len() as i64, + ), + full.start(), ); let node_id = node.id.clone(); result.nodes.push(node); @@ -143,16 +168,19 @@ impl<'a> LiquidExtractor<'a> { let start_line = line_number_for_offset(&self.line_starts, full.start()); let end_line = line_number_for_offset(&self.line_starts, full.end()); let schema_name = schema_name(content); - let mut node = default_node( - self.file_path, - Language::Liquid, - NodeKind::Constant, - schema_name.clone(), - format!("{}::schema:{}", self.file_path, schema_name), - start_line, - end_line, - full.start() as i64 - line_start_for(&self.line_starts, start_line) as i64, - 0, + let mut node = self.identify( + default_node( + self.file_path, + Language::Liquid, + NodeKind::Constant, + schema_name.clone(), + format!("{}::schema:{}", self.file_path, schema_name), + start_line, + end_line, + full.start() as i64 - line_start_for(&self.line_starts, start_line) as i64, + 0, + ), + full.start(), ); node.docstring = None; let node_id = node.id.clone(); @@ -168,16 +196,19 @@ impl<'a> LiquidExtractor<'a> { let name = cap.get(1).unwrap().as_str(); let line = line_number_for_offset(&self.line_starts, full.start()); let col = full.start() as i64 - line_start_for(&self.line_starts, line) as i64; - let node = default_node( - self.file_path, - Language::Liquid, - NodeKind::Variable, - name.to_string(), - format!("{}::{}", self.file_path, name), - line, - line, - col, - col + full.as_str().len() as i64, + let node = self.identify( + default_node( + self.file_path, + Language::Liquid, + NodeKind::Variable, + name.to_string(), + format!("{}::{}", self.file_path, name), + line, + line, + col, + col + full.as_str().len() as i64, + ), + full.start(), ); let node_id = node.id.clone(); result.nodes.push(node); @@ -191,19 +222,23 @@ impl<'a> LiquidExtractor<'a> { file_id: &str, name: &str, signature: &str, - line: i64, - col: i64, + offset: usize, ) { - let mut node = default_node( - self.file_path, - Language::Liquid, - NodeKind::Import, - name.to_string(), - format!("{}::import:{}", self.file_path, name), - line, - line, - col, - col + signature.len() as i64, + let line = line_number_for_offset(&self.line_starts, offset); + let col = offset as i64 - line_start_for(&self.line_starts, line) as i64; + let mut node = self.identify( + default_node( + self.file_path, + Language::Liquid, + NodeKind::Import, + name.to_string(), + format!("{}::import:{}", self.file_path, name), + line, + line, + col, + col + signature.len() as i64, + ), + offset, ); node.signature = Some(signature.to_string()); let node_id = node.id.clone(); diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 623baa15..10f384e1 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -5,7 +5,7 @@ //! `:580-650` becomes [`TreeSitterWalker::create_node`], `:748-983` maps to //! symbol extractors, and `:1872-2632` maps to import/call reference extraction. -use codegraph_core::node_id::{file_node_id, generate_node_id}; +use codegraph_core::node_id::{NodeIdAllocator, file_node_id, utf16_column}; use codegraph_core::types::{ Edge, EdgeKind, ExtractionResult, Language, Node, NodeKind, ReferenceSubkind, UnresolvedRef, }; @@ -320,6 +320,8 @@ pub struct TreeSitterWalker<'a, 'tree> { unresolved_references: Vec, errors: Vec, node_stack: Vec, + /// Same-line, same-name declarations keep distinct identities (#1349). + node_ids: NodeIdAllocator, fn_ref_candidates: Vec<(crate::function_ref::FnRefCandidate, String)>, /// C++ enclosing `namespace ns { … }` names, prefixed onto contained /// symbols' `qualified_name`. Prefix-only (no namespace node) to avoid the @@ -361,6 +363,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { unresolved_references: Vec::new(), errors: Vec::new(), node_stack: Vec::new(), + node_ids: NodeIdAllocator::default(), fn_ref_candidates: Vec::new(), namespace_prefix: Vec::new(), erlang_last_fn_name: None, @@ -2305,7 +2308,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } let start_line = node.start_position().row as u32 + 1; - let id = generate_node_id(self.file_path, kind, name, start_line); + let id = self.node_ids.generate( + self.file_path, + kind, + name, + start_line, + utf16_column(self.source, node.start_byte()), + ); let mut new_node = Node { id: id.clone(), kind, diff --git a/crates/codegraph-extract/tests/node_identity.rs b/crates/codegraph-extract/tests/node_identity.rs new file mode 100644 index 00000000..b8ac6606 --- /dev/null +++ b/crates/codegraph-extract/tests/node_identity.rs @@ -0,0 +1,129 @@ +//! Same-line node identity (upstream v1.6.1 #1349). +//! +//! Same-kind, same-name declarations on one line used to share one node id, so +//! the later silently replaced the earlier in the store. The first keeps its +//! legacy id; a later distinct declaration appends `:{utf16Column}`. + +use codegraph_core::node_id::{generate_node_id, utf16_column}; +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, Node, NodeKind}; +use codegraph_extract::extract_source; + +fn extract(file: &str, source: &str, language: Language) -> ExtractionResult { + let result = extract_source(file, source, Some(language)); + assert!(result.errors.is_empty(), "{file}: {:?}", result.errors); + result +} + +fn named<'a>(result: &'a ExtractionResult, name: &str, kind: NodeKind) -> Vec<&'a Node> { + let mut nodes = result + .nodes + .iter() + .filter(|node| node.name == name && node.kind == kind) + .collect::>(); + nodes.sort_by_key(|node| (node.start_line, node.start_column)); + nodes +} + +#[test] +fn same_line_accessors_keep_distinct_identities_after_unicode() { + let source = "class Point { /* é😀 */ get x() { return read(); } set x(v) { write(v); } }"; + let setter = utf16_column(source, source.find("set x").expect("setter")); + for (file, language) in [ + ("point.ts", Language::TypeScript), + ("point.tsx", Language::Tsx), + ("point.js", Language::JavaScript), + ("point.jsx", Language::Jsx), + ] { + let result = extract(file, source, language); + let x = named(&result, "x", NodeKind::Method); + assert_eq!(x.len(), 2, "{file}: {:#?}", result.nodes); + assert_eq!( + x[0].id, + generate_node_id(file, NodeKind::Method, "x", 1), + "{file}" + ); + assert_eq!(x[1].id, format!("{}:{setter}", x[0].id), "{file}"); + let calls = result + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Calls) + .map(|reference| { + ( + reference.from_node_id.as_str(), + reference.reference_name.as_str(), + ) + }) + .collect::>(); + assert_eq!( + calls, + vec![(x[0].id.as_str(), "read"), (x[1].id.as_str(), "write")], + "{file}" + ); + // No state leaks between files or repeated extractions. + assert_eq!( + extract(file, source, language).nodes, + result.nodes, + "{file}" + ); + } +} + +#[test] +fn distinct_lines_and_names_keep_their_legacy_ids() { + let source = "export class Point {\n get y() { return 1; }\n set y(v: number) {}\n}\n"; + let result = extract("point.ts", source, Language::TypeScript); + for node in result + .nodes + .iter() + .filter(|node| node.kind != NodeKind::File) + { + assert_eq!( + node.id, + generate_node_id( + &node.file_path, + node.kind, + &node.name, + node.start_line as u32 + ), + "{}", + node.name + ); + } + assert_eq!(named(&result, "y", NodeKind::Method).len(), 2); +} + +#[test] +fn repeated_same_line_liquid_and_cfml_declarations_survive() { + let liquid = extract( + "template.liquid", + "é😀 {% render \"x\" %}{% render \"x\" %}{% assign v = 1 %}{% assign v = 2 %}", + Language::Liquid, + ); + for (name, kind) in [ + ("x", NodeKind::Component), + ("x", NodeKind::Import), + ("v", NodeKind::Variable), + ] { + let nodes = named(&liquid, name, kind); + assert_eq!(nodes.len(), 2, "{kind:?} {name}: {:#?}", liquid.nodes); + assert_ne!(nodes[0].id, nodes[1].id, "{kind:?} {name}"); + for node in nodes { + assert!( + liquid + .edges + .iter() + .any(|edge| edge.kind == EdgeKind::Contains && edge.target == node.id), + "{kind:?} {name} has no contains edge" + ); + } + } + + let cfml = extract( + "Service.cfc", + "", + Language::Cfml, + ); + let methods = named(&cfml, "x", NodeKind::Method); + assert_eq!(methods.len(), 2, "{:#?}", cfml.nodes); + assert_ne!(methods[0].id, methods[1].id); +} diff --git a/crates/codegraph-resolve/tests/js_resolution_safety.rs b/crates/codegraph-resolve/tests/js_resolution_safety.rs index ebb94c96..ceaa5669 100644 --- a/crates/codegraph-resolve/tests/js_resolution_safety.rs +++ b/crates/codegraph-resolve/tests/js_resolution_safety.rs @@ -620,3 +620,60 @@ fn implements_whose_only_same_named_target_is_a_value_is_dropped() { ); assert!(implements_targets(&project, "BarService").is_empty()); } + +#[test] +fn same_line_accessors_persist_with_their_own_call_edges() { + // Upstream #1349: the getter and setter share kind, name and line; before + // the column suffix the setter overwrote the getter in the store. + let project = resolve_project( + "same-line-accessors", + &[( + "point.ts", + "function read() { return 1; } function write(v: number) {}\n\ + export class Point { /* é😀 */ get x() { return read(); } set x(v: number) { write(v); }\n\ + \x20 get y() { return read(); }\n\ + \x20 set y(v: number) { write(v); }\n\ + }\n", + )], + ); + let store = project.store.as_ref().expect("store"); + let nodes = store.nodes_by_file_path("point.ts").expect("nodes"); + let mut x = nodes + .iter() + .filter(|node| node.name == "x") + .collect::>(); + x.sort_by_key(|node| node.start_column); + assert_eq!(x.len(), 2, "{nodes:#?}"); + let ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + assert_eq!(ids.len(), nodes.len()); + assert_eq!(nodes.iter().filter(|node| node.name == "y").count(), 2); + let class = nodes + .iter() + .find(|node| node.name == "Point") + .expect("class"); + for accessor in &x { + assert!( + store + .edges_by_target_kind(&accessor.id, Some(EdgeKind::Contains)) + .expect("contains") + .iter() + .any(|edge| edge.source == class.id), + "{}", + accessor.id + ); + } + let callee_names = |id: &str| { + store + .edges_by_source_kind(id, Some(EdgeKind::Calls)) + .expect("calls") + .into_iter() + .filter_map(|edge| store.node_by_id(&edge.target).expect("target")) + .map(|node| node.name) + .collect::>() + }; + assert_eq!(callee_names(&x[0].id), vec!["read"]); + assert_eq!(callee_names(&x[1].id), vec!["write"]); +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index 9a183410..08aa794a 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 = 14; +pub const CURRENT_EXTRACTION_VERSION: u64 = 15; /// 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 5b6cf64f..df760d91 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 = 13; + const PREVIOUS_EXTRACTION_VERSION: u64 = 14; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/docs/equivalence.md b/docs/equivalence.md index fbb4e3c2..4b796238 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -41,6 +41,14 @@ Inputs are part of the compatibility contract: - `line`: 1-based start line. The tree-sitter call site passes `node.startPosition.row + 1`. +Same-kind, same-name declarations on one line (a getter/setter pair) hash to +the same id. Since extraction version 15 the first one, in extraction order, +keeps it, and every later declaration at a different column appends +`:{column}`, its zero-based UTF-16 column — the column unit upstream uses, not +tree-sitter's byte column (`NodeIdAllocator`, upstream #1349). Revisiting the +same declaration yields the same id, so a declaration that collides with +nothing keeps exactly the id above. + ## File Node Special Case Tree-sitter file nodes do not call `generateNodeId()`. The tree-sitter file-node special case uses the literal ID: From c1c4d8a5d90698690411a6fca66256d7aa0790a7 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 08:20:19 +0800 Subject: [PATCH 2/6] fix(graph): re-expand a node reached nearer than before in depth-limited walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callers, callees and impact are depth-limited DFS walks. A node first expanded through a longer path was never expanded again when a nearer path reached it, so its own dependents within the limit were lost to edge order — impact at depth 2 already, callers and callees from depth 3. The walks now record the shallowest depth each node was expanded at and expand it again from a nearer depth (upstream #1974). Each (node, edge kind) is reported once per walk, keeping multi-kind rows while no longer repeating a node once per parent, and impact records a node's edges on its first expansion only. --- crates/codegraph-graph/src/graph/mod.rs | 172 +++++++++++++++------- crates/codegraph-graph/tests/traversal.rs | 136 +++++++++++++++++ 2 files changed, 252 insertions(+), 56 deletions(-) diff --git a/crates/codegraph-graph/src/graph/mod.rs b/crates/codegraph-graph/src/graph/mod.rs index 4e856e73..58dd29e5 100644 --- a/crates/codegraph-graph/src/graph/mod.rs +++ b/crates/codegraph-graph/src/graph/mod.rs @@ -375,8 +375,16 @@ impl<'store> GraphTraverser<'store> { /// cycle-safe via `visited`. pub fn get_callers(&self, node_id: &str, max_depth: usize) -> rusqlite::Result> { let mut result = Vec::new(); - let mut visited = HashSet::new(); - self.callers_recursive(node_id, max_depth, 0, &mut result, &mut visited)?; + let mut visited = HashMap::new(); + let mut reported = HashSet::new(); + self.callers_recursive( + node_id, + max_depth, + 0, + &mut result, + &mut visited, + &mut reported, + )?; Ok(result) } @@ -386,12 +394,12 @@ impl<'store> GraphTraverser<'store> { max_depth: usize, current_depth: usize, result: &mut Vec, - visited: &mut HashSet, + visited: &mut HashMap, + reported: &mut HashSet<(String, EdgeKind)>, ) -> rusqlite::Result<()> { - if current_depth >= max_depth || visited.contains(node_id) { + if current_depth >= max_depth || !enter_at_depth(visited, node_id, current_depth) { return Ok(()); } - visited.insert(node_id.to_string()); let incoming = self.incoming_edges_kinds( node_id, @@ -404,26 +412,27 @@ impl<'store> GraphTraverser<'store> { let source_ids: Vec = incoming.iter().map(|e| e.source.clone()).collect(); let caller_nodes = self.store.nodes_by_ids(&source_ids)?; - // #1087 vs #1088: emit one NodeEdge per DISTINCT (caller, edge-kind) so a - // caller linked by several kinds (Calls AND References) surfaces BOTH + // #1087 vs #1088: report one NodeEdge per DISTINCT (caller, edge-kind) so + // a caller linked by several kinds (Calls AND References) surfaces BOTH // (#1087), while repeated SAME-kind sites of one caller still collapse to - // a single row (#1088). `visited` gates only the RECURSION (cycle safety), - // never the emission — so the node is still walked at most once. - let mut emitted: HashSet<(String, EdgeKind)> = HashSet::new(); + // a single row (#1088) — across the whole walk, not per parent. `visited` + // gates only the RECURSION: a caller is expanded again only when a + // nearer path reaches it (#1974). for edge in incoming { if let Some(caller) = caller_nodes.get(&edge.source) { - if !emitted.insert((caller.id.clone(), edge.kind)) { - continue; - } - let recurse = !visited.contains(&caller.id); let caller_id = caller.id.clone(); let next_depth = current_depth + 1; - result.push(NodeEdge { - node: caller.clone(), - edge, - }); + let recurse = nearer_than_before(visited, &caller_id, next_depth); + if reported.insert((caller_id.clone(), edge.kind)) { + result.push(NodeEdge { + node: caller.clone(), + edge, + }); + } if recurse { - self.callers_recursive(&caller_id, max_depth, next_depth, result, visited)?; + self.callers_recursive( + &caller_id, max_depth, next_depth, result, visited, reported, + )?; } } } @@ -496,8 +505,16 @@ impl<'store> GraphTraverser<'store> { /// cycle-safe via `visited`. pub fn get_callees(&self, node_id: &str, max_depth: usize) -> rusqlite::Result> { let mut result = Vec::new(); - let mut visited = HashSet::new(); - self.callees_recursive(node_id, max_depth, 0, &mut result, &mut visited)?; + let mut visited = HashMap::new(); + let mut reported = HashSet::new(); + self.callees_recursive( + node_id, + max_depth, + 0, + &mut result, + &mut visited, + &mut reported, + )?; Ok(result) } @@ -507,12 +524,12 @@ impl<'store> GraphTraverser<'store> { max_depth: usize, current_depth: usize, result: &mut Vec, - visited: &mut HashSet, + visited: &mut HashMap, + reported: &mut HashSet<(String, EdgeKind)>, ) -> rusqlite::Result<()> { - if current_depth >= max_depth || visited.contains(node_id) { + if current_depth >= max_depth || !enter_at_depth(visited, node_id, current_depth) { return Ok(()); } - visited.insert(node_id.to_string()); let outgoing = self.outgoing_edges_kinds( node_id, @@ -526,23 +543,24 @@ impl<'store> GraphTraverser<'store> { let callee_nodes = self.store.nodes_by_ids(&target_ids)?; // #1087 vs #1088: mirror of `callers_recursive` — one NodeEdge per - // DISTINCT (callee, edge-kind) keeps multi-kind pairs while collapsing - // repeated same-kind sites; `visited` gates only recursion. - let mut emitted: HashSet<(String, EdgeKind)> = HashSet::new(); + // DISTINCT (callee, edge-kind) across the walk keeps multi-kind pairs + // while collapsing repeated same-kind sites; `visited` gates only + // recursion, by shallowest expansion depth (#1974). for edge in outgoing { if let Some(callee) = callee_nodes.get(&edge.target) { - if !emitted.insert((callee.id.clone(), edge.kind)) { - continue; - } - let recurse = !visited.contains(&callee.id); let callee_id = callee.id.clone(); let next_depth = current_depth + 1; - result.push(NodeEdge { - node: callee.clone(), - edge, - }); + let recurse = nearer_than_before(visited, &callee_id, next_depth); + if reported.insert((callee_id.clone(), edge.kind)) { + result.push(NodeEdge { + node: callee.clone(), + edge, + }); + } if recurse { - self.callees_recursive(&callee_id, max_depth, next_depth, result, visited)?; + self.callees_recursive( + &callee_id, max_depth, next_depth, result, visited, reported, + )?; } } } @@ -696,10 +714,18 @@ impl<'store> GraphTraverser<'store> { }; let mut graph = Subgraph::empty(); - let mut visited = HashSet::new(); + let mut visited = HashMap::new(); + let mut expanded = HashSet::new(); graph.set_node(focal); - self.impact_recursive(node_id, max_depth, 0, &mut graph, &mut visited)?; + self.impact_recursive( + node_id, + max_depth, + 0, + &mut graph, + &mut visited, + &mut expanded, + )?; graph.roots = vec![node_id.to_string()]; Ok(graph) @@ -711,12 +737,15 @@ impl<'store> GraphTraverser<'store> { max_depth: usize, current_depth: usize, graph: &mut Subgraph, - visited: &mut HashSet, + visited: &mut HashMap, + expanded: &mut HashSet, ) -> rusqlite::Result<()> { - if current_depth >= max_depth || visited.contains(node_id) { + if current_depth >= max_depth || !enter_at_depth(visited, node_id, current_depth) { return Ok(()); } - visited.insert(node_id.to_string()); + // A node re-expanded from a nearer depth re-reads the same edges; + // record them on its first expansion only (#1974). + let first_expansion = expanded.insert(node_id.to_string()); if let Some(focal) = self.store.node_by_id(node_id)? && CONTAINER_KINDS.contains(&focal.kind) @@ -731,13 +760,22 @@ impl<'store> GraphTraverser<'store> { )?; for edge in contains { if let Some(child) = children.get(&edge.target) - && !visited.contains(&child.id) + && nearer_than_before(visited, &child.id, current_depth) { - let child = child.clone(); let child_id = child.id.clone(); - graph.set_node(child); - graph.edges.push(edge); - self.impact_recursive(&child_id, max_depth, current_depth, graph, visited)?; + if !graph.nodes.contains_key(&child_id) { + graph.set_node(child.clone()); + graph.edges.push(edge); + } + // Children are part of the same symbol: same depth. + self.impact_recursive( + &child_id, + max_depth, + current_depth, + graph, + visited, + expanded, + )?; } } } @@ -760,20 +798,23 @@ impl<'store> GraphTraverser<'store> { for edge in incoming { if let Some(source) = sources.get(&edge.source) { - // #1086: the direct-dependency edge is recorded unconditionally — - // even when `source` is already in the subgraph via another path, - // the edge between the two endpoints is a real dependency and must - // survive. Only the NODE addition + recursion is guarded so we - // neither duplicate the node nor re-walk an already-visited source. - let already_present = graph.nodes.contains_key(&source.id); + // #1086: the direct-dependency edge is recorded even when + // `source` is already in the subgraph via another path — it is a + // real dependency — but only on this node's first expansion, so + // a re-expansion never repeats it. A source is walked again only + // when this path reaches it nearer than before (#1974). let source_id = source.id.clone(); let next_depth = current_depth + 1; - if !already_present { + if !graph.nodes.contains_key(&source_id) { graph.set_node(source.clone()); } - graph.edges.push(edge); - if !already_present { - self.impact_recursive(&source_id, max_depth, next_depth, graph, visited)?; + if first_expansion { + graph.edges.push(edge); + } + if nearer_than_before(visited, &source_id, next_depth) { + self.impact_recursive( + &source_id, max_depth, next_depth, graph, visited, expanded, + )?; } } } @@ -1378,6 +1419,25 @@ fn unvisited_neighbor_ids(edges: &[Edge], node_id: &str, visited: &HashSet, node_id: &str, depth: usize) -> bool { + visited.get(node_id).is_none_or(|&seen| depth < seen) +} + +/// Record that `node_id` is expanded at `depth`; false when it already was at +/// this depth or nearer. +fn enter_at_depth(visited: &mut HashMap, node_id: &str, depth: usize) -> bool { + if !nearer_than_before(visited, node_id, depth) { + return false; + } + visited.insert(node_id.to_string(), depth); + true +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codegraph-graph/tests/traversal.rs b/crates/codegraph-graph/tests/traversal.rs index 00a27ecb..96ab28ae 100644 --- a/crates/codegraph-graph/tests/traversal.rs +++ b/crates/codegraph-graph/tests/traversal.rs @@ -1166,3 +1166,139 @@ fn callers_dedup_same_kind_multiple_sites_counted_once() { "same-kind repeated call site must dedup to one — #1088 (callers: {callers:?})" ); } + +// ---- shallowest-depth expansion (#1974) ------------------------------------- + +/// A store over function nodes `ids` and `calls` edges `(source, target)` in +/// insertion order — the order the traversal reads them back. +fn depth_store(test_name: &str, ids: &[&str], calls: &[(&str, &str)]) -> Store { + let mut store = Store::open(&temp_db_path(test_name)).expect("open store"); + let nodes = ids + .iter() + .enumerate() + .map(|(line, id)| { + node( + &format!("function:{id}"), + NodeKind::Function, + id, + id, + "src/depth.ts", + Language::TypeScript, + line as i64 + 1, + line as i64 + 1, + ) + }) + .collect::>(); + store.upsert_nodes(&nodes).expect("insert nodes"); + let edges = calls + .iter() + .enumerate() + .map(|(line, (source, target))| { + edge( + &format!("function:{source}"), + &format!("function:{target}"), + EdgeKind::Calls, + Some(line as i64 + 1), + Some(0), + ) + }) + .collect::>(); + store.insert_edges(&edges).expect("insert edges"); + store +} + +fn names(rows: &[codegraph_graph::graph::NodeEdge]) -> Vec { + let mut names = rows + .iter() + .map(|row| row.node.name.clone()) + .collect::>(); + names.sort(); + names +} + +#[test] +fn impact_keeps_dependents_reached_first_through_a_longer_path() { + // The issue's graph: a→t, b→a, b→t, c→b. Edge order sends the walk to b + // through a first, before the direct b→t edge (upstream #1974). + let store = depth_store( + "impact-1974", + &["t", "a", "b", "c"], + &[("a", "t"), ("b", "a"), ("b", "t"), ("c", "b")], + ); + let impact = GraphTraverser::new(&store) + .get_impact_radius("function:t", 2) + .expect("impact"); + assert_eq!( + id_set(impact.nodes.keys().cloned()), + id_set(["a", "b", "c", "t"].map(|id| format!("function:{id}"))) + ); + assert!( + impact + .edges + .iter() + .any(|edge| edge.source == "function:c" && edge.target == "function:b") + ); + let keys = impact + .edges + .iter() + .map(|edge| format!("{}>{}:{:?}", edge.source, edge.target, edge.line)) + .collect::>(); + assert_eq!( + keys.iter().collect::>().len(), + keys.len(), + "re-expansion must not record an edge twice: {keys:?}" + ); +} + +#[test] +fn callers_and_callees_report_every_node_within_the_depth_once() { + let store = depth_store( + "callers-1974", + &["t", "a", "b", "c"], + &[("a", "t"), ("b", "a"), ("b", "t"), ("c", "b")], + ); + let traverser = GraphTraverser::new(&store); + assert_eq!( + names(&traverser.get_callers("function:t", 2).expect("callers")), + vec!["a", "b", "c"] + ); + // Mirror image: t→a, a→b, t→b, b→c. + let store = depth_store( + "callees-1974", + &["t", "a", "b", "c"], + &[("t", "a"), ("a", "b"), ("t", "b"), ("b", "c")], + ); + assert_eq!( + names( + &GraphTraverser::new(&store) + .get_callees("function:t", 2) + .expect("callees") + ), + vec!["a", "b", "c"] + ); +} + +#[test] +fn a_nearer_path_re_expands_a_node_first_expanded_deeper() { + // t ← a ← b ← x ← d, and t ← x directly; the a-path reaches x at depth 3 + // first. With max depth 3, x is one hop from t, so its caller d (two hops) + // must be found — the loss the shallowest-depth rule exists to prevent. + let store = depth_store( + "nearer-1974", + &["t", "a", "b", "x", "d"], + &[("a", "t"), ("b", "a"), ("x", "b"), ("x", "t"), ("d", "x")], + ); + let traverser = GraphTraverser::new(&store); + assert_eq!( + names(&traverser.get_callers("function:t", 3).expect("callers")), + vec!["a", "b", "d", "x"] + ); + let impact = traverser + .get_impact_radius("function:t", 3) + .expect("impact"); + assert!( + impact.nodes.contains_key("function:d"), + "{:?}", + impact.nodes.keys() + ); +} From 13ee6397a4a790f4f51660a322d0a1a74886a88d Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 08:31:04 +0800 Subject: [PATCH 3/6] fix(resolve): scope Python and Go method values by their receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pool.submit(self.store.fetch) and Submit(c.store.Fetch) pass a method as a value. The extractor now keeps the whole receiver path (self.store.fetch, c.store.Fetch, Actual.fetch) instead of a last segment or a this. marker, and resolution follows upstream v1.6.1 #1820: self/cls resolve on the enclosing class and its bases, a field on its annotated, constructed or parameter-assigned type, a local on its annotation or constructor, an imported class through the import, a Go receiver on its declared type or a struct field's in-module type, and a method expression on its type; a base-typed field reaches only that base's descendants. Otherwise a method name binds only when it is unique across the project — test doubles and abstract bases count. Data attributes, @property members, ambiguity and unknowable receivers stay unlinked, and the capitalization heuristic is no longer used for method values. --- crates/codegraph-extract/src/function_ref.rs | 103 ++- .../tests/batch_coverage_languages3.rs | 7 +- crates/codegraph-resolve/src/lib.rs | 1 + crates/codegraph-resolve/src/member_value.rs | 650 ++++++++++++++++++ crates/codegraph-resolve/src/name_matcher.rs | 15 +- crates/codegraph-resolve/src/resolver.rs | 10 + crates/codegraph-resolve/src/source_facts.rs | 13 + .../codegraph-resolve/tests/method_values.rs | 455 ++++++++++++ 8 files changed, 1220 insertions(+), 34 deletions(-) create mode 100644 crates/codegraph-resolve/src/member_value.rs create mode 100644 crates/codegraph-resolve/tests/method_values.rs diff --git a/crates/codegraph-extract/src/function_ref.rs b/crates/codegraph-extract/src/function_ref.rs index 205bbdf2..3f17a202 100644 --- a/crates/codegraph-extract/src/function_ref.rs +++ b/crates/codegraph-extract/src/function_ref.rs @@ -183,7 +183,7 @@ const GO_SPEC: FnRefSpec = FnRefSpec { ], layers: &[("literal_element", None), ("expression_list", None)], unwrap: &[], - special: &[], + special: &["selector_expression"], ungated_modes: &[], address_of_only: false, }; @@ -573,6 +573,33 @@ impl<'tree> NormalizedRef<'tree> { skip_gate: false, } } + + /// A Python/Go member value: it retains its receiver for scoped + /// resolution, so the same-file/import name gate does not apply. + fn ungated(name: String, node: SyntaxNode<'tree>) -> Self { + Self { + name, + node, + skip_gate: true, + } + } +} + +/// `^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$` — a statically named member path. +fn is_dotted_member_value(value: &str) -> bool { + let mut segments = value.split('.'); + let Some(first) = segments.next() else { + return false; + }; + let segment_ok = |segment: &str| { + let mut chars = segment.chars(); + chars + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') + && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + }; + let rest = segments.collect::>(); + segment_ok(first) && !rest.is_empty() && rest.into_iter().all(segment_ok) } /// Normalize one value expression to zero or more function names + source node. @@ -766,6 +793,26 @@ fn normalize_special<'tree>( // Swift `#selector(fire)` / ObjC `@selector(storeImage:)` // (function-ref.ts:685-696). "selector_expression" => { + // Go `c.store.Fetch` (a `field` child) keeps its receiver path, so + // resolution can use the receiver's type before a unique name + // (#1820); Swift `#selector(...)` / ObjC `@selector(...)` have no + // `field`. + if let Some(field) = child_by_field(node, "field") { + let value = child_by_field(node, "operand") + .map(|operand| { + format!( + "{}.{}", + node_text(operand, source), + node_text(field, source) + ) + }) + .unwrap_or_default(); + return if is_dotted_member_value(&value) { + vec![NormalizedRef::ungated(value, field)] + } else { + Vec::new() + }; + } let Some(inner) = node.named_child(0) else { return Vec::new(); }; @@ -835,28 +882,21 @@ fn normalize_special<'tree>( Vec::new() } - // Python method values. A direct `self.handle_click` is class-scoped; - // `store.fetch` and `self.store.fetch` retain `store.fetch`, matching - // the representation used by direct calls so both paths choose the - // same target. Dynamic receivers remain unlinked (#1820). + // Python method values keep their whole receiver path — + // `self.handle_click`, `self.store.fetch`, `Actual.fetch` — so + // resolution can scope the member through the class, a field's type, + // a local's type or an import (#1820). A call or subscript receiver is + // not statically named and must not collapse to a bare method. "attribute" => { - let obj = child_by_field(node, "object"); - let attr = child_by_field(node, "attribute"); - if let (Some(obj), Some(attr)) = (obj, attr) { - if obj.kind() == "identifier" && node_text(obj, source) == "self" { - return vec![NormalizedRef::new( - format!("this.{}", node_text(attr, source)), - attr, - )]; - } - if let Some(receiver) = method_value_receiver_segment(obj, source) { - return vec![NormalizedRef::new( - format!("{receiver}.{}", node_text(attr, source)), - attr, - )]; - } + let Some(attr) = child_by_field(node, "attribute") else { + return Vec::new(); + }; + let value = node_text(node, source); + if is_dotted_member_value(&value) { + vec![NormalizedRef::ungated(value, attr)] + } else { + Vec::new() } - Vec::new() } // `this.Run0` (C#) (function-ref.ts:738-746). @@ -1316,8 +1356,10 @@ class W: register("blur", handler) "#; let refs = fn_refs("src/w.py", src, Language::Python); + // Upstream v1.6.1 #1820: the receiver path is kept as written and the + // resolver scopes `self` to the enclosing class. assert!( - has_fn_ref(&refs, "this.on_click"), + has_fn_ref(&refs, "self.on_click"), "names={:?}", names(&refs) ); @@ -1349,9 +1391,11 @@ class Consumer: register(factory().fetch) "#; let refs = fn_refs("src/receiver_values.py", src, Language::Python); + // Upstream v1.6.1 #1820: every member value keeps its whole receiver + // path, so resolution can scope `self`, a field or a local. assert!( - has_fn_ref(&refs, "this.own_handler"), - "self method must retain class scope: names={:?}", + has_fn_ref(&refs, "self.own_handler"), + "self method must retain its receiver: names={:?}", names(&refs) ); assert!( @@ -1361,15 +1405,16 @@ class Consumer: ); assert_eq!( refs.iter() - .filter(|reference| reference.reference_name == "store.fetch") + .filter(|reference| reference.reference_name == "self.store.fetch") .count(), - 3, - "argument, assignment, and list forms must retain their distinct source owners" + 2, + "assignment and list forms must retain the field receiver and their owners" ); assert!( !refs .iter() - .any(|reference| reference.reference_name == "factory.fetch"), + .any(|reference| reference.reference_name.ends_with("fetch") + && reference.reference_name.contains("factory")), "dynamic call-result receivers must remain unresolved" ); } @@ -2238,7 +2283,7 @@ object M { let lang = tree_sitter_python::LANGUAGE.into(); let names = special_names(lang, "reg(self.handler)", "attribute"); assert!( - names.contains(&"this.handler".to_string()), + names.contains(&"self.handler".to_string()), "self.attr; names={names:?}" ); } diff --git a/crates/codegraph-extract/tests/batch_coverage_languages3.rs b/crates/codegraph-extract/tests/batch_coverage_languages3.rs index 8695415b..6c667336 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages3.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages3.rs @@ -128,10 +128,11 @@ class Widget: assert!(result.errors.is_empty(), "{:?}", result.errors); let refs = &result.unresolved_references; assert!(has_fn_ref(refs, "on_click"), "arg fn-ref; refs={refs:#?}"); - // `self.handle` preserves the class-scoped owner marker so resolution can - // distinguish it from unrelated same-named methods. + // `self.handle` keeps its receiver as written (upstream v1.6.1 #1820); + // resolution scopes `self` to the enclosing class, so an unrelated + // same-named method cannot win. assert!( - has_fn_ref(refs, "this.handle"), + has_fn_ref(refs, "self.handle"), "self.attr fn-ref; refs={refs:#?}" ); assert!( diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index e75c63bb..c35cc650 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -24,6 +24,7 @@ pub mod frameworks; // concrete react/vue/nestjs FrameworkResolvers pub mod import_resolver; mod js_store; pub mod lru_cache; +mod member_value; pub mod name_matcher; mod object_literal; pub mod path_aliases; diff --git a/crates/codegraph-resolve/src/member_value.rs b/crates/codegraph-resolve/src/member_value.rs new file mode 100644 index 00000000..eb1498de --- /dev/null +++ b/crates/codegraph-resolve/src/member_value.rs @@ -0,0 +1,650 @@ +//! Python and Go method values (upstream v1.6.1 #1820). +//! +//! `pool.submit(self.store.fetch)` and `Submit(c.store.Fetch)` pass a method +//! as a value. The extractor keeps the whole receiver path, and this module +//! resolves it through the receiver's own scope — the enclosing class for +//! `self`/`cls`, a field's declared or constructed type, a local's type, an +//! imported class, a Go struct field — before considering a globally unique +//! method name. Ambiguity, a non-callable member (a data attribute, a +//! `@property`) or an unknowable receiver leaves the value unresolved: a wrong +//! callback edge is worse than none. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; +use regex::Regex; + +use crate::import_resolver::resolve_via_import; +use crate::name_matcher::{ + infer_local_receiver_type, prefer_call_site_file, resolve_method_on_type, same_language_family, +}; +use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; + +/// Primitive and built-in Go field types: a field of one never names a +/// project type. +const GO_BUILTIN_FIELD_TYPES: [&str; 26] = [ + "string", + "bool", + "byte", + "rune", + "error", + "any", + "int", + "int8", + "int16", + "int32", + "int64", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + "float32", + "float64", + "complex64", + "complex128", + "chan", + "map", + "func", + "struct", + "interface", +]; + +/// Resolve a Python or Go member value `receiver.member` (#1820). +pub(crate) fn match_member_function_ref( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let (receiver, member) = reference.reference_name.rsplit_once('.')?; + let imports = context.get_import_mappings(&reference.file_path, reference.language); + let root = receiver.split('.').next().unwrap_or(receiver); + // An import is authoritative even when it points outside the project. + if imports.iter().any(|import| import.local_name == root) { + if reference.language == Language::Python + && let Some(class) = python_ref_class(receiver, reference, context) + { + return unique_callable( + python_members(&class, member, reference, context, &mut HashSet::new()), + reference, + context, + 0.9, + ); + } + let imported = resolve_via_import(reference, context)?; + let node = context.get_node_by_id_shared(&imported.target_node_id)?; + let same_name = context + .get_nodes_by_qualified_name_shared(&node.qualified_name) + .into_iter() + .filter(|candidate| candidate.file_path == node.file_path) + .collect(); + return unique_callable(same_name, reference, context, 0.9); + } + if reference.language == Language::Go { + if receiver.contains('.') { + return match_go_field_chain_value(receiver, member, reference, context); + } + if let Some(type_name) = infer_local_receiver_type(receiver, reference, context) { + return go_method_value(&type_name, member, reference, context); + } + let types = context + .get_nodes_by_name_shared(receiver) + .into_iter() + .filter(|node| { + node.language == Language::Go + && matches!(node.kind, NodeKind::Struct | NodeKind::Interface) + }) + .count(); + if types > 0 { + // A method expression `Store.Fetch` names its type directly. + return (types == 1) + .then(|| go_method_value(receiver, member, reference, context)) + .flatten(); + } + } else { + let owner = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + node.kind == NodeKind::Class + && node.start_line <= reference.line + && node.end_line >= reference.line + }) + .max_by_key(|node| node.start_line); + if receiver == "self" || receiver == "cls" { + let owner = owner?; + return unique_callable( + python_members(&owner, member, reference, context, &mut HashSet::new()), + reference, + context, + 0.9, + ); + } + let mut type_name = if let Some(field) = receiver + .strip_prefix("self.") + .or_else(|| receiver.strip_prefix("cls.")) + .filter(|field| is_word(field)) + { + python_field_type(field, owner.as_deref()?, reference, context) + } else { + python_local_type(receiver, reference, context) + }; + // A type name used directly (`Store.fetch`) is scoped like an annotation. + if type_name.is_none() + && receiver + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_uppercase()) + && is_word(receiver) + { + type_name = Some(receiver.to_string()); + } + if let Some(type_name) = type_name.filter(|name| name != "object" && name != "Any") { + let class = python_ref_class(&type_name, reference, context)?; + let members = python_members(&class, member, reference, context, &mut HashSet::new()); + if !members.is_empty() { + return unique_callable(members, reference, context, 0.9); + } + // A base-typed field can hold a subclass-only method: keep only + // descendants of THAT base, so unrelated namesakes cannot win. + let descendants = context + .get_nodes_by_name_shared(member) + .into_iter() + .filter(|node| node.kind == NodeKind::Method && node.language == Language::Python) + .filter(|method| { + context + .get_nodes_in_file_shared(&method.file_path) + .into_iter() + .find(|candidate| { + candidate.kind == NodeKind::Class + && method.qualified_name + == format!("{}::{member}", candidate.qualified_name) + }) + .is_some_and(|parent| { + python_derives_from( + &parent, + &class, + reference, + context, + &mut HashSet::new(), + ) + }) + }) + .collect(); + return unique_callable(descendants, reference, context, 0.8); + } + } + // An unknown receiver keeps the unique-or-drop discipline across ALL + // files; test doubles and abstract bases are candidates too. + unique_callable( + context.get_nodes_by_name_shared(member), + reference, + context, + 0.8, + ) +} + +/// The single same-family candidate when it is a callable other than the +/// referencing node itself and not a Python property. +fn unique_callable( + nodes: Vec>, + reference: &RefView, + context: &dyn ResolutionContext, + confidence: f64, +) -> Option { + let pool = nodes + .into_iter() + .filter(|node| same_language_family(node.language, reference.language)) + .collect::>(); + let [target] = pool.as_slice() else { + return None; + }; + (matches!(target.kind, NodeKind::Function | NodeKind::Method) + && target.id != reference.from_node_id + && !is_python_property(target, context)) + .then(|| ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence, + resolved_by: ResolvedBy::FunctionRef, + }) +} + +fn go_method_value( + type_name: &str, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + resolve_method_on_type( + type_name, + member, + reference, + context, + 0.9, + ResolvedBy::FunctionRef, + None, + 0, + ) +} + +/// `base.field.Method` — the field's declared type read off the base type's +/// own struct declaration (upstream `matchGoFieldChainCall`, #1276/#1316). A +/// package-qualified field type is followed only when the package is in the +/// module; built-in types name no project type. +fn match_go_field_chain_value( + receiver_chain: &str, + method: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let (base, field) = receiver_chain.split_once('.')?; + if base.is_empty() || field.is_empty() || field.contains('.') { + return None; + } + let base_type = infer_local_receiver_type(base, reference, context)?; + let field_type = Regex::new(&format!( + r"(?-u:\b){}\s+\*?\[?\]?([A-Za-z_][\w.]*)", + regex::escape(field) + )) + .ok()?; + let structs = prefer_call_site_file( + context.get_nodes_by_name_shared(&base_type), + &reference.file_path, + ) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Struct | NodeKind::Class) && node.language == Language::Go + }) + .collect::>(); + for declaration in structs { + let Some(facts) = context.source_facts(&declaration.file_path) else { + continue; + }; + let start = declaration.start_line.saturating_sub(1).max(0) as usize; + let end = (declaration.end_line.max(0) as usize).min(facts.line_count()); + for index in start..end { + let line = strip_go_line_comments(facts.line(index)); + let Some(raw_type) = field_type + .captures(&line) + .and_then(|captures| captures.get(1)) + .map(|capture| capture.as_str().to_string()) + else { + continue; + }; + if let Some((package, _)) = raw_type.split_once('.') { + let in_module = context.get_go_modules().iter().any(|module| { + context + .get_import_mappings(&declaration.file_path, Language::Go) + .iter() + .any(|import| { + import.local_name == package + && (import.source == module.module_path + || import + .source + .starts_with(&format!("{}/", module.module_path))) + }) + }); + if !in_module { + continue; + } + } + let Some(type_name) = raw_type.rsplit('.').next() else { + continue; + }; + if !type_name + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') + || GO_BUILTIN_FIELD_TYPES.contains(&type_name) + { + continue; + } + if let Some(resolved) = resolve_method_on_type( + type_name, + method, + reference, + context, + 0.85, + ResolvedBy::FunctionRef, + None, + 0, + ) { + return Some(resolved); + } + } + } + None +} + +/// A Go declaration line without its `//` and single-line `/* */` comments. +fn strip_go_line_comments(line: &str) -> String { + let line = line.split("//").next().unwrap_or(line); + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(open) = rest.find("/*") { + out.push_str(&rest[..open]); + match rest[open + 2..].find("*/") { + Some(close) => rest = &rest[open + 2 + close + 2..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out +} + +/// The project class a Python name refers to: an import binding when the +/// name's root is imported, else the one same-file class of that name. +fn python_ref_class( + name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let root = name.split('.').next().unwrap_or(name); + if context + .get_import_mappings(&reference.file_path, Language::Python) + .iter() + .any(|import| import.local_name == root) + { + let mut lookup = reference.clone(); + lookup.reference_name = name.to_string(); + lookup.reference_kind = EdgeKind::References; + let hit = resolve_via_import(&lookup, context)?; + let node = context.get_node_by_id_shared(&hit.target_node_id)?; + let unique = context + .get_nodes_by_qualified_name_shared(&node.qualified_name) + .into_iter() + .filter(|candidate| { + candidate.kind == NodeKind::Class && candidate.file_path == node.file_path + }) + .count() + == 1; + return (node.kind == NodeKind::Class && unique).then(|| node.as_ref().clone()); + } + let classes = context + .get_nodes_by_name_shared(name) + .into_iter() + .filter(|node| node.kind == NodeKind::Class && node.file_path == reference.file_path) + .collect::>(); + match classes.as_slice() { + [class] => Some(class.as_ref().clone()), + _ => None, + } +} + +/// The classes named in a Python class's base list, resolved from its file. +fn python_bases(class: &Node, reference: &RefView, context: &dyn ResolutionContext) -> Vec { + let Some(facts) = context.source_facts(&class.file_path) else { + return Vec::new(); + }; + let Some(index) = usize::try_from(class.start_line) + .ok() + .and_then(|line| line.checked_sub(1)) + else { + return Vec::new(); + }; + if index >= facts.line_count() { + return Vec::new(); + } + let header = facts.line(index); + let Some(bases) = Regex::new(r"^\s*class\s+\w+\s*\(([^)]*)\)") + .ok() + .and_then(|pattern| pattern.captures(header)) + .and_then(|captures| captures.get(1)) + .map(|bases| bases.as_str().to_string()) + else { + return Vec::new(); + }; + let mut at_class = reference.clone(); + at_class.file_path = class.file_path.clone(); + bases + .split(',') + .filter_map(|base| python_ref_class(base.trim(), &at_class, context)) + .collect() +} + +fn python_derives_from( + class: &Node, + base: &Node, + reference: &RefView, + context: &dyn ResolutionContext, + seen: &mut HashSet, +) -> bool { + if seen.len() >= 16 || !seen.insert(class.id.clone()) { + return false; + } + python_bases(class, reference, context) + .iter() + .any(|parent| { + parent.id == base.id || python_derives_from(parent, base, reference, context, seen) + }) +} + +/// What `class.member` names: an instance or class assignment shadows any +/// method (the class itself is returned, which is not callable), else the +/// class's own method, else what its bases provide. +fn python_members( + class: &Node, + member: &str, + reference: &RefView, + context: &dyn ResolutionContext, + seen: &mut HashSet, +) -> Vec> { + if seen.len() >= 16 || !seen.insert(class.id.clone()) { + return Vec::new(); + } + if let Some(facts) = context.source_facts(&class.file_path) { + let (code, lines) = facts.python_comment_free(); + let start = class.start_line.saturating_sub(1).max(0) as usize; + let end = (class.end_line.max(0) as usize).min(lines.line_count()); + let body = lines.join(code, start.min(end), end, "\n"); + let shadow = Regex::new(&format!( + r"(?m)^\s*(?:(?:self|cls)\.)?{}\s*(?:=|:)", + regex::escape(member) + )); + if shadow.is_ok_and(|pattern| pattern.is_match(&body)) { + return vec![Arc::new(class.clone())]; + } + } + let own = context + .get_nodes_by_qualified_name_shared(&format!("{}::{member}", class.qualified_name)) + .into_iter() + .filter(|node| node.file_path == class.file_path) + .collect::>(); + if !own.is_empty() { + return own; + } + let mut inherited = BTreeMap::new(); + for base in python_bases(class, reference, context) { + for node in python_members(&base, member, reference, context, seen) { + inherited.entry(node.id.clone()).or_insert(node); + } + } + inherited.into_values().collect() +} + +fn is_python_property(node: &Node, context: &dyn ResolutionContext) -> bool { + if node.language != Language::Python || node.kind != NodeKind::Method { + return false; + } + let Some(facts) = context.source_facts(&node.file_path) else { + return false; + }; + let decorator = Regex::new(r"^\s*@(?:property|(?:functools\.)?cached_property)\s*$") + .expect("property decorator pattern"); + let mut index = node.start_line - 2; + while index >= 0 && (index as usize) < facts.line_count() { + let line = facts.line(index as usize); + if !line.trim_start().starts_with('@') { + break; + } + if decorator.is_match(line) { + return true; + } + index -= 1; + } + false +} + +/// A local's type from its nearest declaration in the calling function — +/// an annotation or a capitalized constructor call — or a parameter +/// annotation; `` for any other assignment. +fn python_local_type( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if !is_word(receiver) { + return None; + } + let caller = context.get_node_by_id_shared(&reference.from_node_id); + let facts = context.source_facts(&reference.file_path)?; + let (code, lines) = facts.python_comment_free(); + let name = regex::escape(receiver); + let declaration = Regex::new(&format!( + r#"^\s*{name}\s*(?::\s*["']?([\w.]+)["']?)?\s*=\s*(.*)$"# + )) + .ok()?; + let annotation = Regex::new(&format!(r#"^\s*{name}\s*:\s*["']?([\w.]+)"#)).ok()?; + let constructor = Regex::new(r"^([A-Z][\w.]*)\s*\(").expect("constructor pattern"); + let lowest = caller.as_ref().map_or(1, |caller| caller.start_line).max(1) - 1; + let mut index = reference.line - 1; + while index >= lowest { + if (index as usize) < lines.line_count() { + let line = lines.line(code, index as usize); + if let Some(assigned) = declaration.captures(line) { + if let Some(declared) = assigned.get(1) { + return Some(declared.as_str().to_string()); + } + let value = assigned.get(2).map_or("", |value| value.as_str()); + return Some( + constructor + .captures(value) + .and_then(|captures| captures.get(1)) + .map_or_else( + || "".to_string(), + |class| class.as_str().to_string(), + ), + ); + } + if let Some(declared) = annotation + .captures(line) + .and_then(|captures| captures.get(1)) + { + return Some(declared.as_str().to_string()); + } + } + index -= 1; + } + let signature = caller?.signature.clone()?; + Regex::new(&format!(r#"(?-u:\b){name}\s*:\s*["']?([\w.]+)"#)) + .ok()? + .captures(&signature) + .and_then(|captures| captures.get(1)) + .map(|declared| declared.as_str().to_string()) +} + +/// A field's type from its class-level annotation, its own annotated or +/// constructed assignment, or the annotated constructor parameter assigned to +/// it. Conflicting assignments are known-but-ambiguous (``), never +/// a name-only fallback. +fn python_field_type( + field: &str, + owner: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let facts = context.source_facts(&reference.file_path)?; + let (code, lines) = facts.python_comment_free(); + let field = regex::escape(field); + let assignment = Regex::new(&format!( + r#"^\s*(?:self|cls)\.{field}\s*(?::\s*["']?([\w.]+)["']?)?\s*=\s*(.*)$"# + )) + .ok()?; + let annotation = Regex::new(&format!( + r#"^\s*(?:(?:self|cls)\.)?{field}\s*:\s*["']?([\w.]+)"# + )) + .ok()?; + let receiver_prefix = Regex::new(r"^\s*(?:self|cls)\.").expect("receiver prefix pattern"); + let constructor = Regex::new(r"^([A-Z][\w.]*)\s*\(").expect("constructor pattern"); + let member_prefix = format!("{}::", owner.qualified_name); + let methods = context + .get_nodes_in_file_shared(&reference.file_path) + .into_iter() + .filter(|node| { + node.kind == NodeKind::Method && node.qualified_name.starts_with(&member_prefix) + }) + .collect::>(); + let mut types = std::collections::BTreeSet::new(); + let start = owner.start_line.max(0) as usize; + let end = (owner.end_line.max(0) as usize).min(lines.line_count()); + for index in start..end { + let line_number = index as i64 + 1; + let method = methods + .iter() + .find(|method| method.start_line <= line_number && method.end_line >= line_number); + if let Some(method) = method { + if method.name != "__init__" && method.id != reference.from_node_id { + continue; + } + if method.id == reference.from_node_id && line_number > reference.line { + continue; + } + } + let line = lines.line(code, index); + if (method.is_none() || receiver_prefix.is_match(line)) + && let Some(declared) = annotation + .captures(line) + .and_then(|captures| captures.get(1)) + { + types.insert(declared.as_str().to_string()); + } + let Some(assigned) = assignment.captures(line) else { + continue; + }; + if let Some(declared) = assigned.get(1) { + types.insert(declared.as_str().to_string()); + continue; + } + let value = assigned.get(2).map_or("", |value| value.as_str()); + if let Some(class) = constructor + .captures(value) + .and_then(|captures| captures.get(1)) + { + types.insert(class.as_str().to_string()); + continue; + } + let parameter = value.trim(); + if let Some(method) = method + && is_word(parameter) + { + if let Some(declared) = method.signature.as_deref().and_then(|signature| { + Regex::new(&format!( + r#"(?-u:\b){}\s*:\s*["']?([\w.]+)"#, + regex::escape(parameter) + )) + .ok()? + .captures(signature) + .and_then(|captures| captures.get(1)) + .map(|declared| declared.as_str().to_string()) + }) { + types.insert(declared); + } + } else { + types.insert("".to_string()); + } + } + match types.len() { + 0 => None, + 1 => types.into_iter().next(), + _ => Some("".to_string()), + } +} + +fn is_word(text: &str) -> bool { + !text.is_empty() && text.chars().all(|ch| ch.is_alphanumeric() || ch == '_') +} diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 71895502..306ecd05 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -1113,7 +1113,10 @@ pub fn match_by_qualified_name( /// `a/svc`. No-op when there are <2 candidates or none share the call site's /// file. The partition is STABLE (preserves within-group order), so edge output /// stays deterministic. -fn prefer_call_site_file>(nodes: Vec, call_site_file: &str) -> Vec { +pub(crate) fn prefer_call_site_file>( + nodes: Vec, + call_site_file: &str, +) -> Vec { if nodes.len() < 2 { return nodes; } @@ -1818,7 +1821,7 @@ pub(crate) fn enclosing_scope_start_line( /// languages without patterns or when no declaration is found. Bounded to the /// enclosing scope. The caller validates the method via `resolve_method_on_type`, /// so a mis-inference produces no edge. -fn infer_local_receiver_type( +pub(crate) fn infer_local_receiver_type( receiver_name: &str, reference: &RefView, context: &dyn ResolutionContext, @@ -3846,6 +3849,14 @@ pub fn match_function_ref( return None; } + // Python and Go member values retain their receiver path and resolve + // through the receiver's scope before a unique name (upstream #1820). + if matches!(reference.language, Language::Python | Language::Go) + && reference.reference_name.contains('.') + { + return crate::member_value::match_member_function_ref(reference, context); + } + // Receiver-qualified first-class method value (#1820): reuse the exact // same conservative target selection as `obj.method(...)`, but preserve // the original `references` kind and label the result as function-ref. diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index e32987b5..051759ce 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -1805,6 +1805,16 @@ impl ReferenceResolver { let (resolved, deferred) = self.resolve_this_member_fn_ref_pure(reference, context); return (self.gate_language(resolved, reference, context), deferred); } + // Python/Go member values are scoped by their receiver; import + // lookups happen inside that scoping (#1820). + if matches!(reference.language, Language::Python | Language::Go) + && reference.reference_name.contains('.') + { + return ( + self.gate_language(match_function_ref(reference, context), reference, context), + None, + ); + } if let Some(via_import) = self.gate_language(resolve_via_import(reference, context), reference, context) { diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index d41ae642..529b29f1 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -122,6 +122,7 @@ pub struct SourceFacts { source: Arc, lines: OnceLock, ts_comment_free: OnceLock<(String, LineIndex)>, + python_comment_free: OnceLock<(String, LineIndex)>, ts_blanked: OnceLock, js_sealed_module: OnceLock, js_binding_sites: OnceLock, @@ -143,6 +144,7 @@ impl SourceFacts { source, lines: OnceLock::new(), ts_comment_free: OnceLock::new(), + python_comment_free: OnceLock::new(), ts_blanked: OnceLock::new(), js_sealed_module: OnceLock::new(), js_binding_sites: OnceLock::new(), @@ -200,6 +202,17 @@ impl SourceFacts { (code, lines) } + /// The whole file through [`strip_comments_for_regex`] with the Python + /// rules, plus its line index. + pub fn python_comment_free(&self) -> (&str, &LineIndex) { + let (code, lines) = self.python_comment_free.get_or_init(|| { + let code = strip_comments_for_regex(&self.source, CommentLang::Python); + let lines = LineIndex::new(&code); + (code, lines) + }); + (code, lines) + } + /// [`Self::ts_comment_free`] with every string and template literal's /// contents blanked too (`blankStringContents(stripCommentsForRegex(…))`), /// so line-anchored patterns see only code. diff --git a/crates/codegraph-resolve/tests/method_values.rs b/crates/codegraph-resolve/tests/method_values.rs new file mode 100644 index 00000000..e4c7fc66 --- /dev/null +++ b/crates/codegraph-resolve/tests/method_values.rs @@ -0,0 +1,455 @@ +//! Python and Go method values passed as callbacks (upstream v1.6.1 #1820). +//! +//! Ported from upstream `function-ref.test.ts`: a member value keeps its +//! receiver path and resolves through the receiver's scope before a unique +//! name; ambiguity, non-callable members and unknowable receivers stay +//! unlinked. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{Edge, EdgeKind, FileRecord, NodeKind}; +use codegraph_extract::{detect_language, 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); + } +} + +impl Project { + fn store(&self) -> &Store { + self.store.as_ref().expect("store") + } + + /// Function-ref `references` edges into callables named `name`. + fn fn_ref_edges_into(&self, name: &str) -> Vec { + self.store() + .nodes_by_name(name) + .expect("nodes by name") + .into_iter() + .filter(|node| matches!(node.kind, NodeKind::Function | NodeKind::Method)) + .flat_map(|node| { + self.store() + .edges_by_target_kind(&node.id, Some(EdgeKind::References)) + .expect("references") + }) + .filter(|edge| { + edge.metadata + .as_ref() + .and_then(|metadata| metadata.get("fnRef")) + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + .collect() + } + + /// Sorted names of the edges' sources. + fn source_names(&self, edges: &[Edge]) -> Vec { + let mut names = edges + .iter() + .map(|edge| { + self.store() + .node_by_id(&edge.source) + .expect("source query") + .expect("source node") + .name + }) + .collect::>(); + names.sort(); + names + } + + fn target(&self, edge: &Edge) -> codegraph_core::types::Node { + self.store() + .node_by_id(&edge.target) + .expect("target query") + .expect("target node") + } + + /// Sorted names of every `calls`/`references` caller of the one method + /// named `name`. + fn callers_of_method(&self, name: &str) -> Vec { + let method = self + .store() + .nodes_by_name(name) + .expect("nodes by name") + .into_iter() + .find(|node| node.kind == NodeKind::Method) + .unwrap_or_else(|| panic!("missing method {name}")); + let mut names = [EdgeKind::Calls, EdgeKind::References] + .into_iter() + .flat_map(|kind| { + self.store() + .edges_by_target_kind(&method.id, Some(kind)) + .expect("incoming") + }) + .map(|edge| { + self.store() + .node_by_id(&edge.source) + .expect("source query") + .expect("source node") + .name + }) + .collect::>(); + names.sort(); + names.dedup(); + names + } +} + +fn resolve_project(files: &[(&str, &str)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-method-values-{}-{}", + 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"); + for (relative, _) in files { + let result = extract_file(&root, relative).expect("extract fixture"); + assert!(result.errors.is_empty(), "{relative}: {:?}", result.errors); + store + .upsert_file(&FileRecord { + path: (*relative).to_string(), + content_hash: "fixture".to_string(), + language: detect_language(relative), + 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 + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + Project { + root, + store: Some(store), + } +} + +#[test] +fn python_member_value_through_an_annotated_base_field_is_a_caller() { + let project = resolve_project(&[ + ( + "store.py", + "class Base:\n pass\n\nclass Store(Base):\n def fetch(self, ids):\n return ids\n", + ), + ( + "consumer.py", + "from concurrent.futures import ThreadPoolExecutor\n\ + from store import Base\n\ + \n\ + class Consumer:\n\ + \x20 def __init__(self, store: Base):\n\ + \x20 self.store = store\n\ + \n\ + \x20 def direct(self, ids):\n\ + \x20 return self.store.fetch(ids)\n\ + \n\ + \x20 def via_callback(self, ids, pool: ThreadPoolExecutor):\n\ + \x20 return pool.submit(self.store.fetch, ids)\n", + ), + ]); + let edges = project.fn_ref_edges_into("fetch"); + assert_eq!(project.source_names(&edges), vec!["via_callback"]); + assert!( + project + .callers_of_method("fetch") + .contains(&"via_callback".to_string()) + ); +} + +#[test] +fn python_unknown_receivers_stay_ambiguous_with_test_doubles_bases_or_namesakes() { + let consumer = "class Consumer:\n\ + \x20 def __init__(self, store):\n\ + \x20 self.store = store\n\ + \x20 def via_callback(self, pool, ids):\n\ + \x20 return pool.submit(self.store.fetch, ids)\n"; + for files in [ + vec![ + ( + "store.py", + "class Store:\n def fetch(self, ids):\n return ids\n", + ), + ( + "tests/test_store.py", + "class FakeStore:\n def fetch(self, ids):\n return ids\n", + ), + ("consumer.py", consumer), + ], + vec![ + ( + "base.py", + "class Base:\n def fetch(self, ids):\n raise NotImplementedError(\"subclass\")\n", + ), + ( + "store.py", + "from base import Base\nclass Store(Base):\n def fetch(self, ids):\n return ids\n", + ), + ("consumer.py", consumer), + ], + vec![ + ( + "a.py", + "class A:\n def fetch(self, ids):\n return ids\n", + ), + ( + "b.py", + "class B:\n def fetch(self, ids):\n return ids\n", + ), + ("consumer.py", consumer), + ], + ] { + let project = resolve_project(&files); + assert_eq!(project.fn_ref_edges_into("fetch"), Vec::new(), "{files:?}"); + } +} + +#[test] +fn go_method_values_are_callers_and_go_statements_stay_calls() { + let project = resolve_project(&[ + ( + "store.go", + "package demo\n\ntype Store struct{}\n\nfunc (s *Store) Fetch(ids []string) []string { return ids }\n", + ), + ( + "consumer.go", + "package demo\n\n\ + func Submit(fn func([]string) []string, ids []string) []string { return fn(ids) }\n\n\ + type Consumer struct{ store *Store }\n\n\ + func (c *Consumer) Direct(ids []string) []string { return c.store.Fetch(ids) }\n\n\ + func (c *Consumer) ViaGo(ids []string) { go c.store.Fetch(ids) }\n\n\ + func (c *Consumer) ViaSubmit(ids []string) []string { return Submit(c.store.Fetch, ids) }\n", + ), + ]); + let callers = project.callers_of_method("Fetch"); + for caller in ["Direct", "ViaGo", "ViaSubmit"] { + assert!( + callers.contains(&caller.to_string()), + "{caller}: {callers:?}" + ); + } + assert_eq!( + project.source_names(&project.fn_ref_edges_into("Fetch")), + vec!["ViaSubmit"] + ); +} + +#[test] +fn python_receiver_identity_beats_same_file_and_imported_decoys() { + let project = resolve_project(&[ + ( + "store.py", + "class Store:\n def fetch(self, ids):\n return ids\n", + ), + ( + "main.py", + "from store import Store as Actual\n\ + class Store:\n\ + \x20 def fetch(self, ids):\n\ + \x20 return ids\n\ + class Consumer:\n\ + \x20 def __init__(self, store: Actual):\n\ + \x20 self.store = store\n\ + \x20 def callback(self, pool, ids):\n\ + \x20 return pool.submit(self.store.fetch, ids)\n\ + \x20 def assigned(self):\n\ + \x20 cb = self.store.fetch\n\ + \x20 def collected(self):\n\ + \x20 return [self.store.fetch]\n\ + def typed(obj: Actual, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def static(pool):\n\ + \x20 pool.submit(Actual.fetch)\n", + ), + ]); + let edges = project.fn_ref_edges_into("fetch"); + assert_eq!( + project.source_names(&edges), + vec!["assigned", "callback", "collected", "static", "typed"] + ); + assert!( + edges + .iter() + .all(|edge| project.target(edge).file_path == "store.py") + ); +} + +#[test] +fn python_same_file_ambiguity_and_noncallable_members_stay_unlinked() { + let project = resolve_project(&[( + "main.py", + "class A:\n\ + \x20 def fetch(self):\n\ + \x20 return 1\n\ + class B:\n\ + \x20 def fetch(self):\n\ + \x20 return 2\n\ + class Data:\n\ + \x20 fetch = 42\n\ + class Property:\n\ + \x20 @property\n\ + \x20 def fetch(self):\n\ + \x20 return 42\n\ + def unknown(obj, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def data(obj: Data, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def prop(obj: Property, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def bare(fetch, pool):\n\ + \x20 pool.submit(fetch)\n\ + class Own:\n\ + \x20 def fetch(self):\n\ + \x20 return 3\n\ + \x20 def bound(self, pool):\n\ + \x20 pool.submit(self.fetch)\n\ + \x20 @classmethod\n\ + \x20 def class_bound(cls, pool):\n\ + \x20 pool.submit(cls.fetch)\n", + )]); + let edges = project.fn_ref_edges_into("fetch"); + assert_eq!(project.source_names(&edges), vec!["bound", "class_bound"]); + assert!( + edges + .iter() + .all(|edge| project.target(edge).qualified_name == "Own::fetch") + ); +} + +#[test] +fn python_typed_constructed_and_inherited_receivers_exclude_noncallable_values() { + let project = resolve_project(&[( + "main.py", + "class Store:\n\ + \x20 def fetch(self):\n\ + \x20 return 1\n\ + class Child(Store):\n\ + \x20 def inherited(self, pool):\n\ + \x20 pool.submit(self.fetch)\n\ + class Data:\n\ + \x20 def __init__(self):\n\ + \x20 self.fetch = 42\n\ + class Override(Store):\n\ + \x20 def __init__(self):\n\ + \x20 self.fetch = 42\n\ + class Consumer:\n\ + \x20 def __init__(self):\n\ + \x20 self.store = Store()\n\ + \x20 def keyword(self, pool):\n\ + \x20 pool.submit(callback=self.store.fetch)\n\ + def constructor(pool):\n\ + \x20 obj = Store()\n\ + \x20 pool.submit(obj.fetch)\n\ + def partial_ref(obj: Store):\n\ + \x20 return partial(obj.fetch, 1)\n\ + def mapped(obj: Store, xs):\n\ + \x20 return map(obj.fetch, xs)\n\ + def data(obj: Data, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def override(obj: Override, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def primitive(obj: int, pool):\n\ + \x20 pool.submit(obj.fetch)\n\ + def literal(pool):\n\ + \x20 obj = 42\n\ + \x20 pool.submit(obj.fetch)\n\ + def reassigned(obj: Store, pool):\n\ + \x20 obj = 42\n\ + \x20 pool.submit(obj.fetch)\n\ + def direct(obj: Store):\n\ + \x20 obj.fetch()\n", + )]); + assert_eq!( + project.source_names(&project.fn_ref_edges_into("fetch")), + vec![ + "constructor", + "inherited", + "keyword", + "mapped", + "partial_ref" + ] + ); + let fetch = project + .store() + .nodes_by_name("fetch") + .expect("nodes") + .into_iter() + .find(|node| node.kind == NodeKind::Method) + .expect("fetch"); + let calls = project + .store() + .edges_by_target_kind(&fetch.id, Some(EdgeKind::Calls)) + .expect("calls"); + assert!(project.source_names(&calls).contains(&"direct".to_string())); +} + +#[test] +fn go_receiver_types_disambiguate_method_values_and_reject_external_fields() { + let project = resolve_project(&[( + "main.go", + "package demo\n\ + import \"database/sql\"\n\ + type Store struct{}\n\ + func (s *Store) Fetch() {}\n\ + type Decoy struct{}\n\ + func (d *Decoy) Fetch() {}\n\ + type Consumer struct { store *Store; external *sql.DB }\n\ + func (c *Consumer) Callback() { Submit(c.store.Fetch) }\n\ + func Typed(s *Store) { Submit(s.Fetch) }\n\ + func Assigned(s *Store) { cb := s.Fetch }\n\ + func Collected(s *Store) { table := []func(){s.Fetch} }\n\ + func MethodExpression() { Submit(Store.Fetch) }\n\ + func (c *Consumer) External() { Submit(c.external.Fetch) }\n\ + func Unknown(obj interface{}) { Submit(obj.Fetch) }\n", + )]); + let edges = project.fn_ref_edges_into("Fetch"); + assert_eq!( + project.source_names(&edges), + vec![ + "Assigned", + "Callback", + "Collected", + "MethodExpression", + "Typed" + ] + ); + assert!( + edges + .iter() + .all(|edge| project.target(edge).qualified_name == "Store::Fetch") + ); +} From fafeeedd107a16322a36d39945c156916585515b Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 1 Oct 2026 08:37:34 +0800 Subject: [PATCH 4/6] feat(resolve): resolve default-exported namespace objects and JSX handler values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const UploadApi = { uploadARCapture, makeFolder: createFolder }; export default UploadApi made every UploadApi.member() call from another file land on a guessed first exported function, because the constant is not exported at its declaration. A default import now finds the binding the export default statement names — a namespace object included — after a component file and before the first-exported-function guess, and the member resolves through the literal's own bindings. TS/JS function values also cover JSX attribute expressions (onPress={handleSubmit}) and object shorthand members (a hook's return { handleApprove }), so the handler a component or hook hands out has an edge (upstream F13, 873f133). --- crates/codegraph-extract/src/function_ref.rs | 12 +++- .../tests/js_callable_semantics.rs | 54 +++++++++++++++++ .../codegraph-resolve/src/import_resolver.rs | 34 ++++++++++- .../tests/js_receiver_evidence.rs | 58 +++++++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-extract/src/function_ref.rs b/crates/codegraph-extract/src/function_ref.rs index 3f17a202..ce3cc1a2 100644 --- a/crates/codegraph-extract/src/function_ref.rs +++ b/crates/codegraph-extract/src/function_ref.rs @@ -137,13 +137,23 @@ const OBJC_SPEC: FnRefSpec = c_family_spec(&["selector_expression"], false); /// Ports `TS_JS_SPEC` (function-ref.ts:177-187). const TS_JS_SPEC: FnRefSpec = FnRefSpec { - id_types: &["identifier"], + // `shorthand_property_identifier`: `{ handleSubmit }` — the object a hook + // returns its handlers in, and a namespace object's members. + id_types: &["identifier", "shorthand_property_identifier"], dispatch: &[ ("arguments", CaptureMode::Args, None), ("assignment_expression", CaptureMode::Rhs, Some("right")), ("variable_declarator", CaptureMode::VarInit, Some("value")), ("pair", CaptureMode::Value, Some("value")), ("array", CaptureMode::List, None), + // A JSX attribute value or child — `onPress={handleSubmit}`, + // `renderItem={renderRow}`: React's handler-binding idiom. A spread or + // a call normalizes to nothing. + ("jsx_expression", CaptureMode::List, None), + // An object literal's shorthand members — `return { handleApprove }` + // from a hook, `const Api = { upload }`. Only a shorthand identifier + // normalizes; a `pair` is its own container above. + ("object", CaptureMode::List, None), ], layers: &[], unwrap: &[], diff --git a/crates/codegraph-extract/tests/js_callable_semantics.rs b/crates/codegraph-extract/tests/js_callable_semantics.rs index e3015c00..21f82c0b 100644 --- a/crates/codegraph-extract/tests/js_callable_semantics.rs +++ b/crates/codegraph-extract/tests/js_callable_semantics.rs @@ -287,3 +287,57 @@ export const mixed = { let make = node(&result, NodeKind::Function, "make"); assert!(!calls_from(&result, &make.id).contains(&"helper")); } + +#[test] +fn jsx_attributes_and_returned_shorthands_are_function_values() { + // Upstream F13 (`873f133`): `onPress={handleApprove}` and a hook's + // `return { handleApprove, handleRetake, extra: helper }` hand handlers + // out as values; a shorthand naming a non-callable (`count`) does not. + let screen = extract( + "src/app/review.tsx", + r#" +import { useCallback } from 'react' +export default function ReviewScreen() { + const handleApprove = useCallback(() => { + finalize() + }, []) + return