From fd3740ec2fb6c0512836a05ab5591bd3a28051f0 Mon Sep 17 00:00:00 2001 From: Brandon Ferguson Date: Fri, 15 May 2026 00:29:52 +0200 Subject: [PATCH] Render unresolved ancestor names instead of leaking Debug output `get_declaration` formatted `Ancestor::Partial` entries with `{:?}`, dumping the internal `Name` struct (`Id { value: ... }`, `PhantomData`, `ref_count`) into the MCP response. This fires for any class whose superclass or mixin lives outside the indexed workspace -- e.g. every Rails base class inheriting from a gem. Reuse `Graph::build_concatenated_name_from_name` (made `pub`) to walk the `parent_scope` chain into a readable constant path like `ActiveRecord::Base`. A missing name lookup now yields `` in the chain rather than silently dropping the ancestor. --- rust/rubydex-mcp/src/server.rs | 27 ++++++++++++++++++------- rust/rubydex/src/stats/orphan_report.rs | 6 ++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/rust/rubydex-mcp/src/server.rs b/rust/rubydex-mcp/src/server.rs index f40bc1637..41d4fb77e 100644 --- a/rust/rubydex-mcp/src/server.rs +++ b/rust/rubydex-mcp/src/server.rs @@ -192,13 +192,10 @@ fn format_ancestors(graph: &Graph, ancestors: &Ancestors) -> Vec { - let name_ref = graph.names().get(name_id)?; - Some(serde_json::json!({ - "name": format!("{name_ref:?}"), - "kind": "Unresolved", - })) - } + Ancestor::Partial(name_id) => Some(serde_json::json!({ + "name": graph.build_concatenated_name_from_name(*name_id), + "kind": "Unresolved", + })), }) .collect() } @@ -845,6 +842,22 @@ mod tests { assert_includes!(get_declaration(&s, "Person"), "ancestors", "Greetable"); } + #[test] + fn get_declaration_unresolved_ancestor_renders_qualified_name() { + // A superclass defined outside the indexed workspace (e.g. a gem) stays an + // unresolved ancestor. Its name must still serialize as a readable constant + // path, not the internal `Name` debug representation. + let s = server_with_source("class Dog < ActiveRecord::Base; end"); + let res = get_declaration(&s, "Dog"); + + let unresolved = array!(res, "ancestors") + .iter() + .find(|a| a["kind"].as_str() == Some("Unresolved")) + .expect("expected an Unresolved ancestor"); + let name = unresolved["name"].as_str().expect("expected 'name' to be a string"); + assert_eq!(name, "ActiveRecord::Base"); + } + #[test] fn get_declaration_constant() { let s = server_with_source( diff --git a/rust/rubydex/src/stats/orphan_report.rs b/rust/rubydex/src/stats/orphan_report.rs index ac997957f..26d30df1c 100644 --- a/rust/rubydex/src/stats/orphan_report.rs +++ b/rust/rubydex/src/stats/orphan_report.rs @@ -56,8 +56,10 @@ impl Graph { /// Falls back to `nesting` for enclosing scope context when there is no explicit parent scope. /// /// Note: this produces a concatenated name by piecing together name parts, not a properly - /// resolved qualified name. - pub(crate) fn build_concatenated_name_from_name(&self, name_id: NameId) -> String { + /// resolved qualified name. This is the best available rendering for names that never + /// resolve to a declaration -- e.g. an ancestor defined outside the indexed workspace. + #[must_use] + pub fn build_concatenated_name_from_name(&self, name_id: NameId) -> String { let Some(name_ref) = self.names().get(&name_id) else { return "".to_string(); };