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-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 9e77d466..1ea248a0 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -4289,11 +4289,6 @@ fn cmd_callers( Related::Callers, file.as_deref(), )?; - let summaries = report - .union - .iter() - .map(NodeSummary::from) - .collect::>(); let godot = godot_honesty_for_symbol(&store, &project, &symbol)?; if json_output { let targets = report @@ -4311,14 +4306,14 @@ fn cmd_callers( "filteredOut": report.filtered_out, "note": report.note, "definitions": related_definitions_json(&report, Related::Callers), - "callers": summaries, + "callers": related_union_json(&report, Related::Callers), "total": report.total, "limit": report.limit, "truncated": report.total > report.limit, "godotDynamic": godot.as_json(), }))?; } else { - print_related_report("Callers", "callers", &symbol, &report); + print_related_report("Callers", "callers", &symbol, &report, Related::Callers); godot.print_cli(report.union.is_empty()); } if strict && report.union.is_empty() { @@ -4345,11 +4340,6 @@ fn cmd_callees( Related::Callees, file.as_deref(), )?; - let summaries = report - .union - .iter() - .map(NodeSummary::from) - .collect::>(); if json_output { let targets = report .definitions @@ -4366,13 +4356,13 @@ fn cmd_callees( "filteredOut": report.filtered_out, "note": report.note, "definitions": related_definitions_json(&report, Related::Callees), - "callees": summaries, + "callees": related_union_json(&report, Related::Callees), "total": report.total, "limit": report.limit, "truncated": report.total > report.limit, }))?; } else { - print_related_report("Callees", "callees", &symbol, &report); + print_related_report("Callees", "callees", &symbol, &report, Related::Callees); } if strict && report.union.is_empty() { bail!("codegraph callees: no callees found for \"{symbol}\""); @@ -6239,10 +6229,12 @@ fn related_definitions_json(report: &RelatedReport, related: Related) -> Vec Vec<&'static str> { + let mut kinds = Vec::new(); + for edge in edges { + let end = match related { + Related::Callers => &edge.source, + Related::Callees => &edge.target, + }; + if end == &node.id && !kinds.contains(&edge.kind.as_str()) { + kinds.push(edge.kind.as_str()); + } + } + kinds +} + +/// The legacy union list with each node's relationships across every +/// definition. +fn related_union_json(report: &RelatedReport, related: Related) -> Vec { + let edges = report + .definitions + .iter() + .flat_map(|definition| definition.edges.iter().cloned()) + .collect::>(); + report + .union + .iter() + .map(|node| { + let mut value = + serde_json::to_value(NodeSummary::from(node)).expect("node summary serializes"); + value + .as_object_mut() + .expect("node summary is object") + .insert( + "relationships".to_string(), + json!(relationships(node, &edges, related)), + ); + value + }) + .collect() +} + /// Collected Godot honesty signals for the matched symbols of one query: the /// runtime-reachability reasons (so "no static callers" is never reported as /// dead) and the symbols' own `godot:dynamic:` computed call-sites. Empty for @@ -7018,7 +7053,13 @@ fn print_related(label: &str, symbol: &str, nodes: &[NodeSummary]) { } } -fn print_related_report(title: &str, label: &str, symbol: &str, report: &RelatedReport) { +fn print_related_report( + title: &str, + label: &str, + symbol: &str, + report: &RelatedReport, + related: Related, +) { if let Some(note) = &report.note { println!("Note: {note}"); } @@ -7057,7 +7098,17 @@ fn print_related_report(title: &str, label: &str, symbol: &str, report: &Related continue; } for node in definition.nodes.iter().take(report.limit) { - println!("{:<12}{}", node.kind, node.name); + // Kinds other than a plain call — `[instantiates]`, `[references]`. + let kinds = relationships(node, &definition.edges, related) + .into_iter() + .filter(|kind| *kind != "calls") + .collect::>(); + let relation = if kinds.is_empty() { + String::new() + } else { + format!(" [{}]", kinds.join(", ")) + }; + println!("{:<12}{}{relation}", node.kind, node.name); println!(" {}:{}\n", node.file_path, node.start_line); } if definition.nodes.len() > report.limit { diff --git a/crates/codegraph-cli/tests/definition_grouping.rs b/crates/codegraph-cli/tests/definition_grouping.rs index 1de58e07..01e42bc8 100644 --- a/crates/codegraph-cli/tests/definition_grouping.rs +++ b/crates/codegraph-cli/tests/definition_grouping.rs @@ -189,3 +189,44 @@ fn limits_and_impact_metadata_tell_the_truth() { assert!(definition["affected"][0]["id"].is_string()); } } + +#[test] +fn callers_report_each_callers_relationship_kinds() { + // Upstream #1839 (CLI): a caller that constructs the symbol is marked, in + // JSON and in text, so it is not read as a plain call. + let dir = TestDir::new(); + fs::write( + dir.0.join("widget.ts"), + "export class Widget {}\n\ + export function make() { return new Widget(); }\n\ + export function use() { return make(); }\n", + ) + .unwrap(); + let path = dir.0.to_str().unwrap(); + let (stdout, stderr, ok) = run(&dir.0, &["init", path]); + assert!(ok, "init failed: stdout={stdout} stderr={stderr}"); + + let widget = json(&dir.0, &["callers", "Widget", "--path", path, "--json"]); + assert_eq!(widget["callers"][0]["name"], "make"); + assert_eq!( + widget["callers"][0]["relationships"], + serde_json::json!(["instantiates"]) + ); + assert_eq!( + widget["definitions"][0]["callers"][0]["relationships"], + serde_json::json!(["instantiates"]) + ); + let make = json(&dir.0, &["callers", "make", "--path", path, "--json"]); + assert_eq!( + make["callers"][0]["relationships"], + serde_json::json!(["calls"]) + ); + + let (text, stderr, ok) = run(&dir.0, &["callers", "Widget", "--path", path]); + assert!(ok, "callers failed: {stderr}"); + assert!(text.contains("make [instantiates]"), "{text}"); + let (text, stderr, ok) = run(&dir.0, &["callers", "make", "--path", path]); + assert!(ok, "callers failed: {stderr}"); + assert!(text.contains("use\n"), "{text}"); + assert!(!text.contains("[calls]"), "{text}"); +} 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/function_ref.rs b/crates/codegraph-extract/src/function_ref.rs index 205bbdf2..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: &[], @@ -183,7 +193,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 +583,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 +803,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 +892,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 +1366,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 +1401,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 +1415,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 +2293,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/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/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-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