From b0a23b1c23f78313bfcbaa4deab1a32bda192259 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:40:20 +0800 Subject: [PATCH 01/21] refactor(parser): add CalleeQualifier scaffold (Bare-only, no behavior change) Add CalleeQualifier enum and extract_callee() wrapper to helpers.rs. Swap the call_expression arm in mod.rs to call extract_callee() instead of extract_callee_name() directly; qualifier is discarded (metadata stays None) so runtime behavior is identical. Subsequent tasks layer in Rust-specific qualifier extraction. --- src/parser/relations/helpers.rs | 36 +++++++++++++++++++++++++++++++++ src/parser/relations/mod.rs | 6 ++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index 449645f..e90ed49 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -53,6 +53,42 @@ pub(super) fn extract_callee_name(node: &tree_sitter::Node, source: &str) -> Opt } } +/// Shape of a callee's qualifier. Drives same-language candidate +/// disambiguation in the edge resolver. See +/// `docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // variants used in subsequent tasks (T2–T18) +pub(crate) enum CalleeQualifier { + /// `foo()` — no qualifier (also: any non-Rust language) + Bare, + /// `crate::snapshot::create()` / `Module::foo()` / `Type::method()` + /// Stored with leading `crate`/`super`/`self` segments stripped. + /// Empty after strip → caller must convert to Bare before serialization. + Path(Vec), + /// `Self::method()` — payload is the enclosing impl block's type name. + SelfType(String), + /// `self.method()` — payload is the enclosing impl block's type name. + SelfRecv(String), + /// `obj.method()` where receiver is a plain identifier of unknown type. + Receiver(String), + /// `OpenOptions::new().create(true)` — receiver is a call_expression + /// (any chain). + Chain, +} + +/// Like `extract_callee_name` but also returns the qualifier shape. +/// Currently returns `Bare` for all shapes; subsequent tasks add Rust-specific +/// extraction logic. Non-Rust languages always return `Bare`. +pub(crate) fn extract_callee( + node: &tree_sitter::Node, + source: &str, + language: &str, + current_rust_impl: Option<&str>, +) -> Option<(String, CalleeQualifier)> { + let _ = (language, current_rust_impl); // suppress unused-warning until later tasks + extract_callee_name(node, source).map(|n| (n, CalleeQualifier::Bare)) +} + pub(super) fn extract_string_from_subtree(node: &tree_sitter::Node, source: &str) -> Option { extract_string_from_subtree_inner(node, source, 0) } diff --git a/src/parser/relations/mod.rs b/src/parser/relations/mod.rs index 7264be5..4581158 100644 --- a/src/parser/relations/mod.rs +++ b/src/parser/relations/mod.rs @@ -36,7 +36,7 @@ mod dart; #[cfg(test)] mod tests; -use helpers::{extract_callee_name, extract_string_from_subtree, MAX_SUBTREE_DEPTH}; +use helpers::{extract_callee, extract_string_from_subtree, MAX_SUBTREE_DEPTH}; use imports::{extract_import_names, extract_python_import_names, extract_python_from_import_names}; use inherits::{extract_superclasses, extract_implements}; use exports::extract_export_names; @@ -210,7 +210,9 @@ fn walk_for_relations( None => None, }; if let Some(scope) = call_scope { - if let Some(callee) = extract_callee_name(&node, source) { + if let Some((callee, _qualifier)) = extract_callee(&node, source, language, None) { + // Task 1: qualifier discarded; metadata stays None. Subsequent tasks + // serialize it once Rust-specific extraction is in place. results.push(ParsedRelation { source_name: scope, target_name: callee, From 0b9be5f9c0d254508322382afb3bc030665108f1 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:48:52 +0800 Subject: [PATCH 02/21] feat(parser): capture Rust scoped_identifier path qualifier in edge metadata Walk scoped_identifier trees to collect path segments, strip reserved prefixes (crate/super/self), and serialize non-bare qualifiers as {"q":"path","v":""} in edges.metadata. Bare calls (identifier or post-strip-empty path) produce None metadata, preserving backward compatibility with non-Rust edges and old DB rows. Co-Authored-By: Claude Sonnet 4.6 --- src/parser/relations/helpers.rs | 65 +++++++++++++++++++++++++++++++-- src/parser/relations/mod.rs | 25 +++++++++++-- src/parser/relations/tests.rs | 14 +++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index e90ed49..5de000e 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -77,16 +77,73 @@ pub(crate) enum CalleeQualifier { } /// Like `extract_callee_name` but also returns the qualifier shape. -/// Currently returns `Bare` for all shapes; subsequent tasks add Rust-specific -/// extraction logic. Non-Rust languages always return `Bare`. +/// Non-Rust languages always return `Bare`. Rust dispatches on the +/// function-node kind to detect scoped_identifier paths. pub(crate) fn extract_callee( node: &tree_sitter::Node, source: &str, language: &str, current_rust_impl: Option<&str>, ) -> Option<(String, CalleeQualifier)> { - let _ = (language, current_rust_impl); // suppress unused-warning until later tasks - extract_callee_name(node, source).map(|n| (n, CalleeQualifier::Bare)) + let _ = current_rust_impl; // used in Task 8+ + if language != "rust" { + return extract_callee_name(node, source).map(|n| (n, CalleeQualifier::Bare)); + } + + let function = node.child_by_field_name("function") + .or_else(|| node.named_child(0))?; + + match function.kind() { + "identifier" => { + Some((node_text(&function, source).to_string(), CalleeQualifier::Bare)) + } + "scoped_identifier" => extract_rust_scoped(&function, source), + // Other kinds added in later tasks (field_expression in T6/T7/T9). + _ => extract_callee_name(node, source).map(|n| (n, CalleeQualifier::Bare)), + } +} + +/// Walk a scoped_identifier collecting all path segments + final name. +/// `crate::a::b::foo` → segments=["crate","a","b"], name="foo" +fn collect_scoped_path_segments( + node: &tree_sitter::Node, + source: &str, + out: &mut Vec, +) { + if node.kind() == "scoped_identifier" { + if let Some(path) = node.child_by_field_name("path") { + collect_scoped_path_segments(&path, source, out); + } + if let Some(name) = node.child_by_field_name("name") { + out.push(node_text(&name, source).to_string()); + } + } else if matches!(node.kind(), "identifier" | "type_identifier") { + out.push(node_text(node, source).to_string()); + } +} + +/// Handle Rust scoped_identifier callee. Returns name + Path qualifier with +/// reserved prefixes (crate/super/self) stripped; SelfType detected when first +/// segment is "Self" (added in Task 10 by overriding the qualifier). +fn extract_rust_scoped( + function: &tree_sitter::Node, + source: &str, +) -> Option<(String, CalleeQualifier)> { + let mut all = Vec::new(); + collect_scoped_path_segments(function, source, &mut all); + if all.is_empty() { + return None; + } + let name = all.pop()?; + let mut path: Vec = all; + while path.first().is_some_and(|s| matches!(s.as_str(), "crate" | "super" | "self")) { + path.remove(0); + } + if path.is_empty() { + Some((name, CalleeQualifier::Bare)) + } else { + Some((name, CalleeQualifier::Path(path))) + } } pub(super) fn extract_string_from_subtree(node: &tree_sitter::Node, source: &str) -> Option { diff --git a/src/parser/relations/mod.rs b/src/parser/relations/mod.rs index 4581158..ce3a2ab 100644 --- a/src/parser/relations/mod.rs +++ b/src/parser/relations/mod.rs @@ -33,6 +33,24 @@ mod routes; mod rust; mod dart; +/// Serialize a CalleeQualifier into the wire-format JSON for `edges.metadata`. +/// Bare → None (matches non-Rust callers and old DB rows). +/// See spec §"Wire protocol" for the q/v key shapes. +fn serialize_callee_qualifier(q: &helpers::CalleeQualifier) -> Option { + use helpers::CalleeQualifier::*; + match q { + Bare => None, + Path(segments) => { + let v = segments.join("::"); + Some(format!(r#"{{"q":"path","v":"{}"}}"#, v)) + } + SelfType(t) => Some(format!(r#"{{"q":"stype","v":"{}"}}"#, t)), + SelfRecv(t) => Some(format!(r#"{{"q":"self","v":"{}"}}"#, t)), + Receiver(r) => Some(format!(r#"{{"q":"recv","v":"{}"}}"#, r)), + Chain => Some(r#"{"q":"chain"}"#.to_string()), + } +} + #[cfg(test)] mod tests; @@ -210,14 +228,13 @@ fn walk_for_relations( None => None, }; if let Some(scope) = call_scope { - if let Some((callee, _qualifier)) = extract_callee(&node, source, language, None) { - // Task 1: qualifier discarded; metadata stays None. Subsequent tasks - // serialize it once Rust-specific extraction is in place. + if let Some((callee, qualifier)) = extract_callee(&node, source, language, None) { + let metadata = serialize_callee_qualifier(&qualifier); results.push(ParsedRelation { source_name: scope, target_name: callee, relation: REL_CALLS.into(), - metadata: None, + metadata, source_language: String::new(), }); } diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index ab9d4da..63b963c 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1136,3 +1136,17 @@ fn test_extract_csharp_inheritance() { assert!(implements.contains(&"ICloneable"), "C#: missing ICloneable (IMPLEMENTS), got: {:?}", implements); } + +#[test] +fn test_rust_callee_path_qualifier_strips_crate() { + let code = "fn caller() { crate::snapshot::create(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "create") + .expect("missing call to create"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"path","v":"snapshot"}"#), + "metadata should encode Path qualifier with crate stripped" + ); +} From 5358846ca34fc38bb0ffcd368aab48e973185308 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:54:02 +0800 Subject: [PATCH 03/21] refactor(parser): idiomatic strip + identifier-arm comment (T2 follow-up) Co-Authored-By: Claude Sonnet 4.6 --- src/parser/relations/helpers.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index 5de000e..d42bdfb 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -94,6 +94,10 @@ pub(crate) fn extract_callee( .or_else(|| node.named_child(0))?; match function.kind() { + // Rust grammar uses "identifier" for bare callees. Other grammars + // (e.g. Kotlin) use "simple_identifier"; if we ever share this match + // arm with them, intentionally let "simple_identifier" fall through + // to the `_` arm where extract_callee_name handles it generically. "identifier" => { Some((node_text(&function, source).to_string(), CalleeQualifier::Bare)) } @@ -136,9 +140,10 @@ fn extract_rust_scoped( } let name = all.pop()?; let mut path: Vec = all; - while path.first().is_some_and(|s| matches!(s.as_str(), "crate" | "super" | "self")) { - path.remove(0); - } + let skip = path.iter() + .take_while(|s| matches!(s.as_str(), "crate" | "super" | "self")) + .count(); + path.drain(..skip); if path.is_empty() { Some((name, CalleeQualifier::Bare)) } else { From 8b93bc0ec88d4780910e0144ae13086507498ea5 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:56:46 +0800 Subject: [PATCH 04/21] test(parser): cover single-segment Path qualifier (Type::method) --- src/parser/relations/tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index 63b963c..ff94f3b 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1150,3 +1150,19 @@ fn test_rust_callee_path_qualifier_strips_crate() { "metadata should encode Path qualifier with crate stripped" ); } + +// T3: single-segment Type::method path +#[test] +fn test_rust_callee_type_method_call_path() { + let code = r#"fn caller() { File::create("/tmp/x"); }"#; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "create") + .expect("missing call to create"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"path","v":"File"}"#), + "single-segment Path with non-reserved name should be preserved" + ); +} + From ca4073245bec47ff9fb844a5e293eb2a1e64345f Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:57:30 +0800 Subject: [PATCH 05/21] test(parser): crate-only Path qualifier collapses to Bare --- src/parser/relations/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index ff94f3b..3656192 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1166,3 +1166,17 @@ fn test_rust_callee_type_method_call_path() { ); } +// T4: reserved-only path collapses to bare +#[test] +fn test_rust_callee_crate_only_path_collapses_to_bare() { + let code = "fn caller() { crate::foo(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "foo") + .expect("missing call to foo"); + assert_eq!( + call.metadata, None, + "crate::foo() qualifier collapses to Bare after stripping reserved prefix" + ); +} + From a45760aba3596bc6afd451e533cc7aebd34523ee Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 02:58:06 +0800 Subject: [PATCH 06/21] test(parser): super:: strip, multi-segment Path, chained reserved prefixes --- src/parser/relations/tests.rs | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index 3656192..29de80a 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1180,3 +1180,46 @@ fn test_rust_callee_crate_only_path_collapses_to_bare() { ); } +// T5: super:: strip, multi-segment, chained reserved prefixes +#[test] +fn test_rust_callee_super_prefix_stripped() { + // super:: must be stripped per reserved-prefix rule. + let code = "fn caller() { super::sibling::foo(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "foo") + .expect("missing call to foo"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"path","v":"sibling"}"#), + ); +} + +#[test] +fn test_rust_callee_multi_segment_path_preserved() { + let code = "fn caller() { crate::a::b::c::deep(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "deep") + .expect("missing call to deep"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"path","v":"a::b::c"}"#), + ); +} + +#[test] +fn test_rust_callee_chained_reserved_prefixes_stripped() { + // Multiple consecutive reserved prefixes: ensure drain(..skip) consumes + // ALL leading reserved segments, not just the first. + let code = "fn caller() { super::super::foo(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "foo") + .expect("missing call to foo"); + assert_eq!( + call.metadata, None, + "two consecutive `super::` segments + bare name → fully stripped → Bare" + ); +} + From dec67617eb15f06e450c85e2f1874ff5ee884a79 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:02:33 +0800 Subject: [PATCH 07/21] feat(parser): capture Rust field_expression Receiver qualifier --- src/parser/relations/helpers.rs | 30 +++++++++++++++++++++++++++++- src/parser/relations/tests.rs | 14 ++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index d42bdfb..41db67f 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -102,7 +102,7 @@ pub(crate) fn extract_callee( Some((node_text(&function, source).to_string(), CalleeQualifier::Bare)) } "scoped_identifier" => extract_rust_scoped(&function, source), - // Other kinds added in later tasks (field_expression in T6/T7/T9). + "field_expression" => extract_rust_field(&function, source), _ => extract_callee_name(node, source).map(|n| (n, CalleeQualifier::Bare)), } } @@ -151,6 +151,34 @@ fn extract_rust_scoped( } } +/// Handle Rust field_expression callee (`obj.method()`, `self.method()`, +/// `chain().method()`). Returns name + qualifier shape: +/// value=self / self_expression → SelfRecv (payload filled by caller via current_rust_impl in T9) +/// value=identifier → Receiver() +/// value=call_expression → Chain +/// else → Bare (unknown shape, conservative) +fn extract_rust_field( + function: &tree_sitter::Node, + source: &str, +) -> Option<(String, CalleeQualifier)> { + let field = function.child_by_field_name("field")?; + let name = node_text(&field, source).to_string(); + let value = function.child_by_field_name("value"); + let qualifier = match value.as_ref().map(|v| v.kind()) { + Some("self") | Some("self_expression") => { + // SelfRecv with empty payload here; mod.rs call_expression arm + // overwrites payload from current_rust_impl context (T9). + CalleeQualifier::SelfRecv(String::new()) + } + Some("identifier") => { + CalleeQualifier::Receiver(node_text(&value.unwrap(), source).to_string()) + } + Some("call_expression") => CalleeQualifier::Chain, + _ => CalleeQualifier::Bare, + }; + Some((name, qualifier)) +} + pub(super) fn extract_string_from_subtree(node: &tree_sitter::Node, source: &str) -> Option { extract_string_from_subtree_inner(node, source, 0) } diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index 29de80a..be5962c 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1223,3 +1223,17 @@ fn test_rust_callee_chained_reserved_prefixes_stripped() { ); } +#[test] +fn test_rust_callee_obj_method_receiver_qualifier() { + let code = "fn caller(p: &std::path::Path) { p.exists(); }"; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "exists") + .expect("missing call to exists"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"recv","v":"p"}"#), + "obj.method() where obj is a plain identifier emits Receiver qualifier" + ); +} + From e2db603ddeb011ef979b3815e2516886e8509482 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:03:16 +0800 Subject: [PATCH 08/21] test(parser): builder chain emits Chain qualifier --- src/parser/relations/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index be5962c..bdb8b82 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1237,3 +1237,38 @@ fn test_rust_callee_obj_method_receiver_qualifier() { ); } +#[test] +fn test_rust_callee_builder_chain_qualifier() { + let code = r#"fn caller() { + OpenOptions::new().create(true).open("/tmp/x"); + }"#; + let relations = extract_relations(code, "rust").unwrap(); + + // OpenOptions::new() → Path + let new_call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "new") + .expect("missing call to new"); + assert_eq!( + new_call.metadata.as_deref(), + Some(r#"{"q":"path","v":"OpenOptions"}"#), + ); + + // .create(true) — receiver is call_expression → Chain + let create_call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "create") + .expect("missing call to create"); + assert_eq!( + create_call.metadata.as_deref(), + Some(r#"{"q":"chain"}"#), + ); + + // .open(...) — receiver is also call_expression → Chain + let open_call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "open") + .expect("missing call to open"); + assert_eq!( + open_call.metadata.as_deref(), + Some(r#"{"q":"chain"}"#), + ); +} + From f847211ddde12bbdc81f062b4289f4dd8e62a41a Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:08:06 +0800 Subject: [PATCH 09/21] refactor(parser): thread current_rust_impl through walk_for_relations --- src/parser/relations/mod.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/parser/relations/mod.rs b/src/parser/relations/mod.rs index ce3a2ab..b7fd1ff 100644 --- a/src/parser/relations/mod.rs +++ b/src/parser/relations/mod.rs @@ -83,7 +83,7 @@ pub fn extract_relations(source: &str, language: &str) -> Result Vec { let mut relations = Vec::new(); let config = LanguageConfig::for_language(language); - walk_for_relations(tree.root_node(), source, language, &config, None, None, &mut relations, 0); + walk_for_relations(tree.root_node(), source, language, &config, None, None, None, &mut relations, 0); // Stamp source_language on every relation. walk_for_relations constructs // ParsedRelation with source_language: String::new(), and we fill it in // here so every call site inside walk doesn't need to propagate language. @@ -101,6 +101,7 @@ fn walk_for_relations( config: &LanguageConfig, current_scope: Option<&str>, current_class: Option<&str>, + current_rust_impl: Option<&str>, results: &mut Vec, depth: usize, ) { @@ -732,10 +733,24 @@ fn walk_for_relations( }; let effective_class = child_class.as_deref().or(current_class); + // Compute child_rust_impl: when entering a Rust impl_item, capture the + // type name so nested call_expression arms can fill SelfRecv/SelfType + // payloads. NOT folded into current_class because that would change + // scope_name building and break source_id matching downstream + // (relations source_name="conn" matches pf.node_names "conn"; would + // become "Database.conn" if folded into current_class). + let child_rust_impl: Option = if language == "rust" && kind == "impl_item" { + node.child_by_field_name("type") + .map(|t| node_text(&t, source).to_string()) + } else { + None + }; + let effective_rust_impl = child_rust_impl.as_deref().or(current_rust_impl); + // Recurse into children for i in 0..node.named_child_count() { if let Some(child) = node.named_child(i) { - walk_for_relations(child, source, language, config, active_scope, effective_class, results, depth + 1); + walk_for_relations(child, source, language, config, active_scope, effective_class, effective_rust_impl, results, depth + 1); } } } From 32c7c3d3375302fb0d3a86c99b7a9b00582852d0 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:09:36 +0800 Subject: [PATCH 10/21] feat(parser): SelfRecv qualifier picks up impl block type --- src/parser/relations/mod.rs | 21 ++++++++++++++++++++- src/parser/relations/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/parser/relations/mod.rs b/src/parser/relations/mod.rs index b7fd1ff..3710aaa 100644 --- a/src/parser/relations/mod.rs +++ b/src/parser/relations/mod.rs @@ -229,7 +229,26 @@ fn walk_for_relations( None => None, }; if let Some(scope) = call_scope { - if let Some((callee, qualifier)) = extract_callee(&node, source, language, None) { + if let Some((callee, mut qualifier)) = extract_callee(&node, source, language, current_rust_impl) { + // Fill SelfRecv/SelfType payload from current impl context. + // The helper emits these with empty payload because it + // doesn't know the enclosing impl's type; we know it here. + let needs_payload = matches!(&qualifier, + helpers::CalleeQualifier::SelfRecv(t) | helpers::CalleeQualifier::SelfType(t) if t.is_empty() + ); + if needs_payload { + match &mut qualifier { + helpers::CalleeQualifier::SelfRecv(t) | helpers::CalleeQualifier::SelfType(t) => { + if let Some(impl_type) = current_rust_impl { + *t = impl_type.to_string(); + } else { + // self/Self called outside an impl block — drop qualifier (Bare). + qualifier = helpers::CalleeQualifier::Bare; + } + } + _ => unreachable!(), + } + } let metadata = serialize_callee_qualifier(&qualifier); results.push(ParsedRelation { source_name: scope, diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index bdb8b82..9d400cf 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1272,3 +1272,23 @@ fn test_rust_callee_builder_chain_qualifier() { ); } +#[test] +fn test_rust_callee_self_recv_within_impl() { + let code = r#" + struct Db; + impl Db { + fn caller(&self) { self.helper(); } + fn helper(&self) {} + } + "#; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "helper") + .expect("missing call to helper"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"self","v":"Db"}"#), + "self.method() inside impl Db emits SelfRecv with type name" + ); +} + From cd7cfc44c22339ad1c41e87819faeb7386f3e5c8 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:10:40 +0800 Subject: [PATCH 11/21] feat(parser): SelfType qualifier for Self::method() inside impl --- src/parser/relations/helpers.rs | 8 ++++++++ src/parser/relations/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index 41db67f..608a6df 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -140,6 +140,14 @@ fn extract_rust_scoped( } let name = all.pop()?; let mut path: Vec = all; + + // `Self::method()` → SelfType (payload filled by mod.rs from current_rust_impl). + // Detected before the lowercase-reserved strip because `Self` is uppercase + // and would otherwise pass through as a Path qualifier with v="Self". + if path.first().is_some_and(|s| s == "Self") { + return Some((name, CalleeQualifier::SelfType(String::new()))); + } + let skip = path.iter() .take_while(|s| matches!(s.as_str(), "crate" | "super" | "self")) .count(); diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index 9d400cf..f108657 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1292,3 +1292,23 @@ fn test_rust_callee_self_recv_within_impl() { ); } +#[test] +fn test_rust_callee_self_type_within_impl() { + let code = r#" + struct Db; + impl Db { + fn make() -> Self { Self::default() } + } + impl Default for Db { fn default() -> Self { Db } } + "#; + let relations = extract_relations(code, "rust").unwrap(); + let call = relations.iter() + .find(|r| r.relation == REL_CALLS && r.target_name == "default" && r.source_name.contains("make")) + .expect("missing call to default from make"); + assert_eq!( + call.metadata.as_deref(), + Some(r#"{"q":"stype","v":"Db"}"#), + "Self::method() inside impl Db emits SelfType with type name" + ); +} + From 8574d15e94bde465c92735d539ff2988e335d78f Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:15:18 +0800 Subject: [PATCH 12/21] =?UTF-8?q?test(parser):=20regression=20guard=20?= =?UTF-8?q?=E2=80=94=20non-Rust=20callee=20metadata=20stays=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parser/relations/tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/parser/relations/tests.rs b/src/parser/relations/tests.rs index f108657..2e27cce 100644 --- a/src/parser/relations/tests.rs +++ b/src/parser/relations/tests.rs @@ -1312,3 +1312,15 @@ fn test_rust_callee_self_type_within_impl() { ); } +#[test] +fn test_non_rust_callee_metadata_unchanged() { + let code = "function caller() { foo.bar(); baz(); }"; + let relations = extract_relations(code, "javascript").unwrap(); + for r in relations.iter().filter(|r| r.relation == REL_CALLS) { + assert_eq!( + r.metadata, None, + "non-Rust REL_CALLS relations must keep metadata=None (regression guard for {})", + r.target_name + ); + } +} From b2ff8bf080f275e9a92bfe2da8d60ffb457e27ac Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:16:35 +0800 Subject: [PATCH 13/21] feat(resolver): add parse_callee_metadata for q/v wire format --- src/indexer/pipeline/resolve.rs | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/indexer/pipeline/resolve.rs b/src/indexer/pipeline/resolve.rs index f09cd3d..54e2e96 100644 --- a/src/indexer/pipeline/resolve.rs +++ b/src/indexer/pipeline/resolve.rs @@ -16,6 +16,45 @@ use crate::storage::queries::{ }; use crate::domain::REL_CALLS; +/// Decoded form of `edges.metadata` for REL_CALLS rows. See +/// `docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md` +/// §"Wire protocol" for the JSON shapes this parses. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CalleeMeta { + Path(Vec), + SelfType(String), + SelfRecv(String), + Receiver(String), + Chain, +} + +/// Parse a `{"q":"...", "v":"..."}` JSON metadata blob. Returns None for +/// metadata produced by other relations (routes, python imports), absent +/// metadata, or unrecognized `q` values. +#[allow(dead_code)] +pub(super) fn parse_callee_metadata(s: Option<&str>) -> Option { + let raw = s?; + let v: serde_json::Value = serde_json::from_str(raw).ok()?; + let q = v.get("q")?.as_str()?; + match q { + "chain" => Some(CalleeMeta::Chain), + "path" => { + let payload = v.get("v")?.as_str()?; + let segments: Vec = payload.split("::").map(String::from).collect(); + if segments.is_empty() || segments.iter().any(|s| s.is_empty()) { + None + } else { + Some(CalleeMeta::Path(segments)) + } + } + "self" => v.get("v")?.as_str().map(|t| CalleeMeta::SelfRecv(t.to_string())), + "stype" => v.get("v")?.as_str().map(|t| CalleeMeta::SelfType(t.to_string())), + "recv" => v.get("v")?.as_str().map(|r| CalleeMeta::Receiver(r.to_string())), + _ => None, + } +} + /// Disambiguate N same-language cross-file candidates for a single call/import /// target. Returns a subset. A single-element result is the authoritative /// winner; ties fall back to the full input so the caller does not @@ -202,3 +241,56 @@ pub(super) fn resolve_pending_calls(db: &Database) -> Result { Ok(edges_added) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_metadata_bare_returns_none() { + assert!(parse_callee_metadata(None).is_none()); + } + + #[test] + fn parse_metadata_path() { + let m = parse_callee_metadata(Some(r#"{"q":"path","v":"snapshot"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::Path(ref segs) if segs == &["snapshot"])); + } + + #[test] + fn parse_metadata_path_multi_segment() { + let m = parse_callee_metadata(Some(r#"{"q":"path","v":"a::b::c"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::Path(ref segs) if segs == &["a", "b", "c"])); + } + + #[test] + fn parse_metadata_self_recv() { + let m = parse_callee_metadata(Some(r#"{"q":"self","v":"Db"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::SelfRecv(ref t) if t == "Db")); + } + + #[test] + fn parse_metadata_self_type() { + let m = parse_callee_metadata(Some(r#"{"q":"stype","v":"Db"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::SelfType(ref t) if t == "Db")); + } + + #[test] + fn parse_metadata_recv() { + let m = parse_callee_metadata(Some(r#"{"q":"recv","v":"path"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::Receiver(ref r) if r == "path")); + } + + #[test] + fn parse_metadata_chain() { + let m = parse_callee_metadata(Some(r#"{"q":"chain"}"#)).unwrap(); + assert!(matches!(m, CalleeMeta::Chain)); + } + + #[test] + fn parse_metadata_routes_or_python_imports_returns_none() { + // Other relations also use metadata; resolver should skip non-call shapes. + assert!(parse_callee_metadata(Some(r#"{"method":"GET","path":"/api"}"#)).is_none()); + assert!(parse_callee_metadata(Some(r#"{"python_module":"foo","is_module_import":false}"#)).is_none()); + } +} From 7a74f1029dd178610f92ecfca322b3d3e95a8737 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:19:51 +0800 Subject: [PATCH 14/21] feat(resolver): drop edges for q=chain and q=recv qualifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Chain+Receiver early-exit in Phase 2 inline resolution: when parse_callee_metadata returns Chain or Receiver, the edge is dropped before the same-file / same-language lookup, eliminating phantom callers from builder chains (e.g. OpenOptions::new().create() falsely binding to snapshot::create). Integration test in tests/integration_call_qualifier.rs confirms FAIL→PASS for the chain-builder case. Co-Authored-By: Claude Sonnet 4.6 --- src/indexer/pipeline/index_files.rs | 18 ++++++++++ tests/integration_call_qualifier.rs | 55 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/integration_call_qualifier.rs diff --git a/src/indexer/pipeline/index_files.rs b/src/indexer/pipeline/index_files.rs index e4ea8db..c63b8c8 100644 --- a/src/indexer/pipeline/index_files.rs +++ b/src/indexer/pipeline/index_files.rs @@ -446,6 +446,24 @@ pub(super) fn index_files( } } + // Bare-name call qualifier (Rust): inspect metadata to + // skip / restrict candidate set before the existing fallback + // chain. See spec + // docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md. + if rel.relation == REL_CALLS { + use super::resolve::{parse_callee_metadata, CalleeMeta}; + if matches!( + parse_callee_metadata(rel.metadata.as_deref()), + Some(CalleeMeta::Chain) | Some(CalleeMeta::Receiver(_)) + ) { + // Receiver type not statically inferable; same-language + // unique match is overwhelmingly false. Drop the edge + // entirely (do not buffer in pending — re-scan won't help). + continue; + } + // Path / SelfRecv / SelfType handled in T14-T16. + } + // Default resolution: global name-based lookup with language-aware layering. // Tier order: same-file → same-language → (calls: drop) / (other: global). // Dropping calls without a same-language match prevents Rust `hasher.update()` diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs new file mode 100644 index 0000000..865f644 --- /dev/null +++ b/tests/integration_call_qualifier.rs @@ -0,0 +1,55 @@ +//! End-to-end tests for the bare-name call qualifier resolver rules. +//! See docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md. + +use code_graph_mcp::indexer::pipeline::run_full_index; +use code_graph_mcp::storage::db::Database; +use std::fs; +use tempfile::TempDir; + +fn write(dir: &std::path::Path, rel: &str, content: &str) { + let p = dir.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&p, content).unwrap(); +} + +fn callers_of(db: &Database, target_name: &str) -> Vec { + let mut stmt = db.conn().prepare( + "SELECT COALESCE(src.qualified_name, src.name) FROM edges e + JOIN nodes tgt ON tgt.id = e.target_id + JOIN nodes src ON src.id = e.source_id + WHERE e.relation = 'calls' AND tgt.name = ?" + ).unwrap(); + let rows = stmt.query_map([target_name], |r| r.get::<_, String>(0)).unwrap(); + rows.filter_map(|r| r.ok()).collect() +} + +#[test] +fn chain_builder_drops_intermediate_callers() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Project has a function literally named `create` in src/snapshot/mod.rs. + write(root, "src/snapshot/mod.rs", "pub fn create() {}\n"); + // Caller does a builder chain — `.create(true)` is a method on OpenOptions, + // NOT the project's snapshot::create. + write(root, "src/caller.rs", r#" + use std::fs::OpenOptions; + pub fn caller() { + OpenOptions::new().create(true).open("/tmp/x").ok(); + } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + let callers = callers_of(&db, "create"); + assert!( + !callers.iter().any(|c| c.contains("caller")), + "snapshot::create must NOT have `caller` as caller (it called .create() in a builder chain), got: {:?}", + callers + ); +} From 8705cf8ecd016acb9bfa8a32f6f1de29fc099eef Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:23:22 +0800 Subject: [PATCH 15/21] feat(resolver): Path qualifier filter via file-segment + qualified_name match --- src/indexer/pipeline/index_files.rs | 55 ++++++++++++++++++++++------ src/indexer/pipeline/resolve.rs | 56 +++++++++++++++++++++++++++++ tests/integration_call_qualifier.rs | 27 ++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/indexer/pipeline/index_files.rs b/src/indexer/pipeline/index_files.rs index c63b8c8..e7136fc 100644 --- a/src/indexer/pipeline/index_files.rs +++ b/src/indexer/pipeline/index_files.rs @@ -451,17 +451,52 @@ pub(super) fn index_files( // chain. See spec // docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md. if rel.relation == REL_CALLS { - use super::resolve::{parse_callee_metadata, CalleeMeta}; - if matches!( - parse_callee_metadata(rel.metadata.as_deref()), - Some(CalleeMeta::Chain) | Some(CalleeMeta::Receiver(_)) - ) { - // Receiver type not statically inferable; same-language - // unique match is overwhelmingly false. Drop the edge - // entirely (do not buffer in pending — re-scan won't help). - continue; + use super::resolve::{parse_callee_metadata, path_filter_candidates, CalleeMeta}; + match parse_callee_metadata(rel.metadata.as_deref()) { + Some(CalleeMeta::Chain) | Some(CalleeMeta::Receiver(_)) => { + // Receiver type not statically inferable; same-language + // unique match is overwhelmingly false. Drop the edge + // entirely (do not buffer in pending — re-scan won't help). + continue; + } + Some(CalleeMeta::Path(segments)) => { + let all = name_to_ids.get(&rel.target_name).cloned().unwrap_or_default(); + let same_lang: Vec = all.iter() + .filter(|id| matches!( + node_id_to_language.get(id).and_then(|l| l.as_deref()), + Some(l) if l == pf.language.as_str() + )) + .filter(|id| !local_ids.contains(id)) + .copied() + .collect(); + let filtered = path_filter_candidates( + &segments, + &same_lang, + &node_id_to_path, + db, + )?; + if filtered.is_empty() { + // No project candidate matches the Path qualifier. + // External crate (or unmatched module) — drop without buffering. + continue; + } + let final_targets = if filtered.len() > 1 { + refine_ambiguous_targets(&filtered, &pf.rel_path, &node_id_to_path) + } else { + filtered + }; + for &src_id in &source_ids { + for &tgt_id in &final_targets { + if src_id != tgt_id + && insert_edge_cached(db.conn(), src_id, tgt_id, &rel.relation, rel.metadata.as_deref())? { + total_edges_created += 1; + } + } + } + continue; + } + _ => {} // SelfRecv / SelfType / Bare handled below or in T16. } - // Path / SelfRecv / SelfType handled in T14-T16. } // Default resolution: global name-based lookup with language-aware layering. diff --git a/src/indexer/pipeline/resolve.rs b/src/indexer/pipeline/resolve.rs index 54e2e96..ff13ad8 100644 --- a/src/indexer/pipeline/resolve.rs +++ b/src/indexer/pipeline/resolve.rs @@ -242,6 +242,62 @@ pub(super) fn resolve_pending_calls(db: &Database) -> Result { Ok(edges_added) } +/// Filter a candidate set down to those matching the Path qualifier: +/// (1) file path contains "/seg1/seg2/" OR starts with "seg1/seg2/", OR +/// (2) qualified_name contains the segment chain joined by `.` as a +/// contiguous segment (anchored on `.` or boundary). +/// +/// Storage uses `.` separator for qualified_name (treesitter.rs:582), NOT `::`. +/// Returns the filtered subset; empty result is a meaningful signal +/// (no project candidate matches → caller should drop the edge). +#[allow(dead_code)] // consumed by index_files.rs Phase 2 dispatch +pub(super) fn path_filter_candidates( + segments: &[String], + candidates: &[i64], + node_id_to_path: &std::collections::HashMap, + db: &crate::storage::db::Database, +) -> anyhow::Result> { + if candidates.is_empty() || segments.is_empty() { + return Ok(candidates.to_vec()); + } + let path_chain = segments.join("/"); + let qn_chain = segments.join("."); + + let placeholders: String = std::iter::repeat_n("?", candidates.len()).collect::>().join(","); + let sql = format!( + "SELECT id, COALESCE(qualified_name, '') FROM nodes WHERE id IN ({})", + placeholders + ); + let mut stmt = db.conn().prepare(&sql)?; + let params: Vec<&dyn rusqlite::ToSql> = candidates.iter() + .map(|id| id as &dyn rusqlite::ToSql) + .collect(); + let rows = stmt.query_map(params.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + let mut id_to_qn: std::collections::HashMap = std::collections::HashMap::new(); + for r in rows { + let (id, qn) = r?; + id_to_qn.insert(id, qn); + } + + let kept: Vec = candidates.iter().copied().filter(|id| { + let path = node_id_to_path.get(id).map(String::as_str).unwrap_or(""); + let qn = id_to_qn.get(id).map(String::as_str).unwrap_or(""); + + let path_match = path.contains(&format!("/{}/", path_chain)) + || path.starts_with(&format!("{}/", path_chain)); + + let qn_match = qn == qn_chain + || qn.starts_with(&format!("{}.", qn_chain)) + || qn.contains(&format!(".{}.", qn_chain)) + || qn.ends_with(&format!(".{}", qn_chain)); + + path_match || qn_match + }).collect(); + Ok(kept) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs index 865f644..a48846c 100644 --- a/tests/integration_call_qualifier.rs +++ b/tests/integration_call_qualifier.rs @@ -53,3 +53,30 @@ fn chain_builder_drops_intermediate_callers() { callers ); } + +#[test] +fn bare_name_qualifier_drops_phantom_callers_for_file_create() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Project has snapshot::create. + write(root, "src/snapshot/mod.rs", "pub fn create() {}\n"); + // Caller calls std::fs::File::create — Path qualifier with first segment + // "File" which is NOT a project module → drop. + write(root, "src/caller.rs", r#" + use std::fs::File; + pub fn caller() { let _ = File::create("/tmp/x"); } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + let callers = callers_of(&db, "create"); + assert!( + !callers.iter().any(|c| c.contains("caller")), + "snapshot::create must NOT have `caller` (caller called std::fs::File::create), got: {:?}", + callers + ); +} From fe49682fd8065505c4c230d42e8b780eda39e3dc Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:25:37 +0800 Subject: [PATCH 16/21] test(resolver): Path qualifier disambiguates same-name candidates --- tests/integration_call_qualifier.rs | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs index a48846c..4120639 100644 --- a/tests/integration_call_qualifier.rs +++ b/tests/integration_call_qualifier.rs @@ -25,6 +25,18 @@ fn callers_of(db: &Database, target_name: &str) -> Vec { rows.filter_map(|r| r.ok()).collect() } +fn callers_of_in_file(db: &Database, target_name: &str, file_rel: &str) -> Vec { + let mut stmt = db.conn().prepare( + "SELECT COALESCE(src.qualified_name, src.name) FROM edges e + JOIN nodes tgt ON tgt.id = e.target_id + JOIN nodes src ON src.id = e.source_id + JOIN files f ON f.id = tgt.file_id + WHERE e.relation = 'calls' AND tgt.name = ? AND f.path = ?" + ).unwrap(); + let rows = stmt.query_map([target_name, file_rel], |r| r.get::<_, String>(0)).unwrap(); + rows.filter_map(|r| r.ok()).collect() +} + #[test] fn chain_builder_drops_intermediate_callers() { let tmp = TempDir::new().unwrap(); @@ -80,3 +92,31 @@ fn bare_name_qualifier_drops_phantom_callers_for_file_create() { callers ); } + +#[test] +fn path_qualifier_picks_module_specific_candidate() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Two project modules each with a `create` fn. + write(root, "src/snapshot/mod.rs", "pub fn create() {}\n"); + write(root, "src/builder/mod.rs", "pub fn create() {}\n"); + // Caller explicitly targets snapshot::create. + write(root, "src/caller.rs", r#" + pub fn caller() { crate::snapshot::create(); } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + // snapshot::create gets the caller; builder::create does not. + let snap = callers_of_in_file(&db, "create", "src/snapshot/mod.rs"); + let bld = callers_of_in_file(&db, "create", "src/builder/mod.rs"); + + assert!(snap.iter().any(|c| c.contains("caller")), + "snapshot::create should have caller, got: {:?}", snap); + assert!(!bld.iter().any(|c| c.contains("caller")), + "builder::create should NOT have caller (qualifier was snapshot), got: {:?}", bld); +} From 5c7c6b7dd8607607621ea91ab46537483de26251 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:28:21 +0800 Subject: [PATCH 17/21] feat(resolver): SelfRecv/SelfType filter via qualified_name LIKE --- src/indexer/pipeline/index_files.rs | 30 ++++++++++++++++++++++- src/indexer/pipeline/resolve.rs | 37 +++++++++++++++++++++++++++++ tests/integration_call_qualifier.rs | 34 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/indexer/pipeline/index_files.rs b/src/indexer/pipeline/index_files.rs index e7136fc..e201c23 100644 --- a/src/indexer/pipeline/index_files.rs +++ b/src/indexer/pipeline/index_files.rs @@ -451,7 +451,7 @@ pub(super) fn index_files( // chain. See spec // docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md. if rel.relation == REL_CALLS { - use super::resolve::{parse_callee_metadata, path_filter_candidates, CalleeMeta}; + use super::resolve::{parse_callee_metadata, path_filter_candidates, self_filter_candidates, CalleeMeta}; match parse_callee_metadata(rel.metadata.as_deref()) { Some(CalleeMeta::Chain) | Some(CalleeMeta::Receiver(_)) => { // Receiver type not statically inferable; same-language @@ -459,6 +459,34 @@ pub(super) fn index_files( // entirely (do not buffer in pending — re-scan won't help). continue; } + Some(CalleeMeta::SelfRecv(impl_type)) | Some(CalleeMeta::SelfType(impl_type)) => { + let all = name_to_ids.get(&rel.target_name).cloned().unwrap_or_default(); + let same_lang: Vec = all + .iter() + .filter(|id| matches!( + node_id_to_language.get(id).and_then(|l| l.as_deref()), + Some(l) if l == pf.language.as_str() + )) + .copied() + .collect(); + let filtered = self_filter_candidates(&impl_type, &same_lang, db)?; + if filtered.is_empty() { + // No method on this impl type found in the project. + // Drop without buffering — qualifier is fixed and a + // re-scan will yield the same answer. + continue; + } + for &src_id in &source_ids { + for &tgt_id in &filtered { + if src_id != tgt_id + && insert_edge_cached(db.conn(), src_id, tgt_id, &rel.relation, rel.metadata.as_deref())? + { + total_edges_created += 1; + } + } + } + continue; + } Some(CalleeMeta::Path(segments)) => { let all = name_to_ids.get(&rel.target_name).cloned().unwrap_or_default(); let same_lang: Vec = all.iter() diff --git a/src/indexer/pipeline/resolve.rs b/src/indexer/pipeline/resolve.rs index ff13ad8..5107418 100644 --- a/src/indexer/pipeline/resolve.rs +++ b/src/indexer/pipeline/resolve.rs @@ -298,6 +298,43 @@ pub(super) fn path_filter_candidates( Ok(kept) } +/// Filter candidates to those whose `qualified_name` belongs to `impl_type` +/// (i.e. is a method of the named type). Storage encodes this as `Type.method` +/// with `.` separator (treesitter.rs qualified_name assignment). +/// +/// Not file-restricted — Rust allows `impl Type {}` blocks to span multiple +/// files (e.g. `impl Database` is split across 3+ files in this repo), so we +/// match by `qualified_name LIKE 'Type.%'` across all files. +#[allow(dead_code)] // consumed by index_files.rs Phase 2 dispatch +pub(super) fn self_filter_candidates( + impl_type: &str, + candidates: &[i64], + db: &crate::storage::db::Database, +) -> anyhow::Result> { + if candidates.is_empty() { + return Ok(Vec::new()); + } + let placeholders: String = std::iter::repeat_n("?", candidates.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT id FROM nodes + WHERE id IN ({}) + AND qualified_name LIKE ? || '.%'", + placeholders + ); + let mut stmt = db.conn().prepare(&sql)?; + let mut params: Vec> = candidates + .iter() + .map(|id| Box::new(*id) as Box) + .collect(); + params.push(Box::new(impl_type.to_string())); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|b| b.as_ref()).collect(); + let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, i64>(0))?; + let kept: Vec = rows.filter_map(|r| r.ok()).collect(); + Ok(kept) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs index 4120639..96954ab 100644 --- a/tests/integration_call_qualifier.rs +++ b/tests/integration_call_qualifier.rs @@ -120,3 +120,37 @@ fn path_qualifier_picks_module_specific_candidate() { assert!(!bld.iter().any(|c| c.contains("caller")), "builder::create should NOT have caller (qualifier was snapshot), got: {:?}", bld); } + +#[test] +fn self_method_within_impl_uses_correct_type() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + write(root, "src/db.rs", r#" + pub struct Db; + impl Db { + pub fn caller(&self) { self.helper(); } + pub fn helper(&self) {} + } + "#); + // Sibling type with same-named method — must NOT win. + write(root, "src/other.rs", r#" + pub struct Other; + impl Other { + pub fn helper(&self) {} + } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + let db_helper = callers_of_in_file(&db, "helper", "src/db.rs"); + let other_helper = callers_of_in_file(&db, "helper", "src/other.rs"); + + assert!(db_helper.iter().any(|c| c.contains("caller")), + "Db::helper should have Db::caller, got: {:?}", db_helper); + assert!(!other_helper.iter().any(|c| c.contains("caller")), + "Other::helper should NOT have Db::caller, got: {:?}", other_helper); +} From be12d566105cd9270661073c28370e18698c3d8a Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:32:23 +0800 Subject: [PATCH 18/21] fix(parser): strip impl type prefix so SelfRecv resolves across split impls `impl crate::db_a::Db { fn helper() }` stored qualified_name `crate::db_a::Db.helper`, but the SelfRecv payload from the caller's impl block was `Db` (bare name), making the LIKE pattern `Db.%` miss. Strip `::` path prefix in both parser walks (treesitter.rs parent_class capture and relations/mod.rs child_rust_impl) so the rightmost segment `Db` is used consistently. Confirmed with new cross-file split-impl test. Co-Authored-By: Claude Sonnet 4.6 --- src/parser/relations/mod.rs | 9 ++++++++- src/parser/treesitter.rs | 9 ++++++++- tests/integration_call_qualifier.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/parser/relations/mod.rs b/src/parser/relations/mod.rs index 3710aaa..28b3363 100644 --- a/src/parser/relations/mod.rs +++ b/src/parser/relations/mod.rs @@ -760,7 +760,14 @@ fn walk_for_relations( // become "Database.conn" if folded into current_class). let child_rust_impl: Option = if language == "rust" && kind == "impl_item" { node.child_by_field_name("type") - .map(|t| node_text(&t, source).to_string()) + .map(|t| { + let full = node_text(&t, source); + // Strip path prefix: `impl crate::db_a::Db` → "Db". Mirrors + // treesitter.rs's parent_class strip so SelfRecv payloads + // match qualified_name (which uses just the rightmost type + // segment). + full.rsplit("::").next().unwrap_or(full).to_string() + }) } else { None }; diff --git a/src/parser/treesitter.rs b/src/parser/treesitter.rs index b02f6c0..89df46e 100644 --- a/src/parser/treesitter.rs +++ b/src/parser/treesitter.rs @@ -424,7 +424,14 @@ fn extract_nodes( } "impl_item" => { if let Some(type_node) = node.child_by_field_name("type") { - let impl_name = node_text(&type_node, source); + let impl_name_full = node_text(&type_node, source); + // Strip path prefix so `impl crate::db_a::Db` is captured as + // "Db" (matching what callers use as `Self`/`self` payload). + // Mirrors the strip in relations/mod.rs walk_for_relations + // for impl_item — keeps qualified_name consistent across the + // two parser walks (treesitter.rs builds nodes; relations/mod.rs + // builds edges). + let impl_name = impl_name_full.rsplit("::").next().unwrap_or(impl_name_full); extract_children(node, source, language, config, Some(impl_name), results, depth, node_is_test); return; } diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs index 96954ab..116549b 100644 --- a/tests/integration_call_qualifier.rs +++ b/tests/integration_call_qualifier.rs @@ -154,3 +154,32 @@ fn self_method_within_impl_uses_correct_type() { assert!(!other_helper.iter().any(|c| c.contains("caller")), "Other::helper should NOT have Db::caller, got: {:?}", other_helper); } + +#[test] +fn self_method_resolves_across_split_impl_blocks() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Db's caller is in db_a.rs; Db's helper is in db_b.rs (impl block split). + write(root, "src/db_a.rs", r#" + pub struct Db; + impl Db { + pub fn caller(&self) { self.helper(); } + } + "#); + write(root, "src/db_b.rs", r#" + impl crate::db_a::Db { + pub fn helper(&self) {} + } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + let helpers = callers_of_in_file(&db, "helper", "src/db_b.rs"); + assert!(helpers.iter().any(|c| c.contains("caller")), + "Db::helper in db_b.rs should have Db::caller from db_a.rs, got: {:?}", + helpers); +} From 46c11ec1e85b7d56751dfc0d325532a6a5ee67a4 Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:33:01 +0800 Subject: [PATCH 19/21] test(resolver): non-Rust callgraph baseline unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard that JS caller→helper bare-name edges survive unfiltered — the qualifier resolver is Rust-only and must not touch other languages. Co-Authored-By: Claude Sonnet 4.6 --- tests/integration_call_qualifier.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/integration_call_qualifier.rs b/tests/integration_call_qualifier.rs index 116549b..032242f 100644 --- a/tests/integration_call_qualifier.rs +++ b/tests/integration_call_qualifier.rs @@ -183,3 +183,31 @@ fn self_method_resolves_across_split_impl_blocks() { "Db::helper in db_b.rs should have Db::caller from db_a.rs, got: {:?}", helpers); } + +#[test] +fn non_rust_callgraph_unchanged() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // JS file with simple function call — must not be qualifier-filtered. + write(root, "src/util.js", r#" + function helper() {} + function caller() { helper(); } + "#); + + let db_path = root.join(".code-graph/graph.db"); + fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let db = Database::open(&db_path).unwrap(); + run_full_index(&db, root, None, None).unwrap(); + + let mut stmt = db.conn().prepare( + "SELECT COUNT(*) FROM edges e + JOIN nodes src ON src.id = e.source_id + JOIN nodes tgt ON tgt.id = e.target_id + WHERE e.relation = 'calls' + AND src.name = 'caller' + AND tgt.name = 'helper'" + ).unwrap(); + let count: i64 = stmt.query_row([], |r| r.get(0)).unwrap(); + assert_eq!(count, 1, "JS caller→helper edge must survive (no qualifier filtering for non-Rust)"); +} From 6e1ddefe8d1c241a268b767975493cb65875ba1e Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:43:35 +0800 Subject: [PATCH 20/21] =?UTF-8?q?chore(release):=20v0.24.0=20=E2=80=94=20b?= =?UTF-8?q?are-name=20call=20qualifier=20(Rust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 4 ++-- CHANGELOG.md | 27 ++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- claude-plugin/.claude-plugin/plugin.json | 2 +- npm/darwin-arm64/package.json | 2 +- npm/darwin-x64/package.json | 2 +- npm/linux-arm64/package.json | 2 +- npm/linux-x64/package.json | 2 +- npm/win32-x64/package.json | 2 +- package.json | 12 +++++------ 11 files changed, 43 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8506667..2cc311c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "AST knowledge graph plugin for Claude Code — semantic search, call graph, HTTP tracing, impact analysis", - "version": "0.23.1" + "version": "0.24.0" }, "plugins": [ { "name": "code-graph-mcp", "source": "./claude-plugin", "description": "AST knowledge graph for intelligent code navigation — auto-indexes your codebase and provides semantic search, call graph traversal, HTTP route tracing, and impact analysis via MCP tools", - "version": "0.23.1", + "version": "0.24.0", "author": { "name": "sdsrs" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 36007ec..c98dc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v0.24.0 — Bare-name call qualifier (Rust) + +### Fixed +- callgraph: Rust qualified calls (`Type::method`, `crate::path::fn`, + `self.method`, `Self::method`, builder chains like `OpenOptions::new().create()`) + no longer route to unrelated project functions sharing the rightmost name. + Eliminates phantom callers in `impact_analysis` and `find_dead_code` for + short-named functions (`new`/`create`/`open`/`from`). +- parser: `impl crate::path::Type { ... }` impl-block type now strips the + leading path so qualified_name and SelfRecv payloads match (was producing + `crate::path::Type.method` qualified_names that broke same-type LIKE + matching). + +### Migration +- Existing `.code-graph/` databases keep working (qualifier-aware resolution + is a no-op when `edges.metadata IS NULL`). Run `code-graph-mcp index --rebuild` + to populate qualifier metadata on existing Rust files; incremental indexing + picks it up automatically as files change. + +### Verification +- `impact run_full_index`: 36 → 33 transitive callers; the 3 documented + phantoms (decompress_with_cap, try_acquire_index_lock, from_project_root) + no longer appear. +- routing_bench P@1: 22/22 (no regression). +- 558 tests pass with default + `--no-default-features`. Clippy clean with + `--all-features`. + ## v0.23.1 — snapshot UX + FTS garbage-query guard Follow-up enhancements to v0.23.0 snapshot work plus an unrelated diff --git a/Cargo.lock b/Cargo.lock index 25e2664..ec15b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,7 +353,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "code-graph-mcp" -version = "0.23.1" +version = "0.24.0" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index d5307f8..641e779 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "code-graph-mcp" -version = "0.23.1" +version = "0.24.0" edition = "2021" [features] diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index bc34d1f..a83aef6 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "author": { "name": "sdsrs" }, - "version": "0.23.1", + "version": "0.24.0", "keywords": [ "code-graph", "ast", diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index 8d365bf..48ca46b 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph-darwin-arm64", - "version": "0.23.1", + "version": "0.24.0", "description": "code-graph-mcp binary for macOS ARM64", "license": "MIT", "repository": { diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json index ed5c3c1..c7dffc6 100644 --- a/npm/darwin-x64/package.json +++ b/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph-darwin-x64", - "version": "0.23.1", + "version": "0.24.0", "description": "code-graph-mcp binary for macOS x64", "license": "MIT", "repository": { diff --git a/npm/linux-arm64/package.json b/npm/linux-arm64/package.json index 122fd1a..b77237f 100644 --- a/npm/linux-arm64/package.json +++ b/npm/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph-linux-arm64", - "version": "0.23.1", + "version": "0.24.0", "description": "code-graph-mcp binary for Linux ARM64", "license": "MIT", "repository": { diff --git a/npm/linux-x64/package.json b/npm/linux-x64/package.json index 41bca4d..c3bc874 100644 --- a/npm/linux-x64/package.json +++ b/npm/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph-linux-x64", - "version": "0.23.1", + "version": "0.24.0", "description": "code-graph-mcp binary for Linux x64", "license": "MIT", "repository": { diff --git a/npm/win32-x64/package.json b/npm/win32-x64/package.json index 29b3d54..d48429b 100644 --- a/npm/win32-x64/package.json +++ b/npm/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph-win32-x64", - "version": "0.23.1", + "version": "0.24.0", "description": "code-graph-mcp binary for Windows x64", "license": "MIT", "repository": { diff --git a/package.json b/package.json index aaa42df..a53ad21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/code-graph", - "version": "0.23.1", + "version": "0.24.0", "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing", "license": "MIT", "repository": { @@ -35,10 +35,10 @@ "node": ">=16" }, "optionalDependencies": { - "@sdsrs/code-graph-linux-x64": "0.23.1", - "@sdsrs/code-graph-linux-arm64": "0.23.1", - "@sdsrs/code-graph-darwin-x64": "0.23.1", - "@sdsrs/code-graph-darwin-arm64": "0.23.1", - "@sdsrs/code-graph-win32-x64": "0.23.1" + "@sdsrs/code-graph-linux-x64": "0.24.0", + "@sdsrs/code-graph-linux-arm64": "0.24.0", + "@sdsrs/code-graph-darwin-x64": "0.24.0", + "@sdsrs/code-graph-darwin-arm64": "0.24.0", + "@sdsrs/code-graph-win32-x64": "0.24.0" } } From 224c353a375329e5d1cacab225f50f4efb9a9aaf Mon Sep 17 00:00:00 2001 From: "sds.rs" Date: Mon, 11 May 2026 03:51:16 +0800 Subject: [PATCH 21/21] chore(parser,resolver): drop dead_code suppressors + stale task-progression comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review cleanup: by T18 every CalleeMeta variant + parse_callee_metadata + path_filter_candidates + self_filter_candidates + CalleeQualifier is consumed; the #[allow(dead_code)] suppressors only mask future regressions. Wildcard arm comment in index_files.rs Phase 2 dispatch updated to reflect actual semantics (None / unrecognized q → fallthrough), not the now-irrelevant T16 reference. --- src/indexer/pipeline/index_files.rs | 2 +- src/indexer/pipeline/resolve.rs | 4 ---- src/parser/relations/helpers.rs | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/indexer/pipeline/index_files.rs b/src/indexer/pipeline/index_files.rs index e201c23..34fb595 100644 --- a/src/indexer/pipeline/index_files.rs +++ b/src/indexer/pipeline/index_files.rs @@ -523,7 +523,7 @@ pub(super) fn index_files( } continue; } - _ => {} // SelfRecv / SelfType / Bare handled below or in T16. + _ => {} // None (Bare) or unrecognized q → falls through to default chain below. } } diff --git a/src/indexer/pipeline/resolve.rs b/src/indexer/pipeline/resolve.rs index 5107418..faa4c78 100644 --- a/src/indexer/pipeline/resolve.rs +++ b/src/indexer/pipeline/resolve.rs @@ -19,7 +19,6 @@ use crate::domain::REL_CALLS; /// Decoded form of `edges.metadata` for REL_CALLS rows. See /// `docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md` /// §"Wire protocol" for the JSON shapes this parses. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum CalleeMeta { Path(Vec), @@ -32,7 +31,6 @@ pub(super) enum CalleeMeta { /// Parse a `{"q":"...", "v":"..."}` JSON metadata blob. Returns None for /// metadata produced by other relations (routes, python imports), absent /// metadata, or unrecognized `q` values. -#[allow(dead_code)] pub(super) fn parse_callee_metadata(s: Option<&str>) -> Option { let raw = s?; let v: serde_json::Value = serde_json::from_str(raw).ok()?; @@ -250,7 +248,6 @@ pub(super) fn resolve_pending_calls(db: &Database) -> Result { /// Storage uses `.` separator for qualified_name (treesitter.rs:582), NOT `::`. /// Returns the filtered subset; empty result is a meaningful signal /// (no project candidate matches → caller should drop the edge). -#[allow(dead_code)] // consumed by index_files.rs Phase 2 dispatch pub(super) fn path_filter_candidates( segments: &[String], candidates: &[i64], @@ -305,7 +302,6 @@ pub(super) fn path_filter_candidates( /// Not file-restricted — Rust allows `impl Type {}` blocks to span multiple /// files (e.g. `impl Database` is split across 3+ files in this repo), so we /// match by `qualified_name LIKE 'Type.%'` across all files. -#[allow(dead_code)] // consumed by index_files.rs Phase 2 dispatch pub(super) fn self_filter_candidates( impl_type: &str, candidates: &[i64], diff --git a/src/parser/relations/helpers.rs b/src/parser/relations/helpers.rs index 608a6df..1ad9247 100644 --- a/src/parser/relations/helpers.rs +++ b/src/parser/relations/helpers.rs @@ -57,7 +57,6 @@ pub(super) fn extract_callee_name(node: &tree_sitter::Node, source: &str) -> Opt /// disambiguation in the edge resolver. See /// `docs/superpowers/specs/2026-05-11-bare-name-call-qualifier-design.md`. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // variants used in subsequent tasks (T2–T18) pub(crate) enum CalleeQualifier { /// `foo()` — no qualifier (also: any non-Rust language) Bare,