From 5e740d6787a4ac248e7164e0c7db994af2fe6586 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 17 Sep 2026 18:36:02 +0700 Subject: [PATCH 1/5] Add resume-able functions to avoid hanging --- Cargo.lock | 1 + crates/codegraph-api/src/lib.rs | 59 +++- crates/codegraph-api/tests/api.rs | 87 ++++++ crates/codegraph-bench/Cargo.toml | 5 + crates/codegraph-bench/benches/context.rs | 118 ++++++++ crates/codegraph-graph/src/lib.rs | 322 +++++++++++++++++++--- crates/codegraph-mcp/src/callers_tests.rs | 86 ++++++ crates/codegraph-mcp/src/tools.rs | 37 ++- 8 files changed, 674 insertions(+), 41 deletions(-) create mode 100644 crates/codegraph-bench/benches/context.rs create mode 100644 crates/codegraph-mcp/src/callers_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5e67703a5..1f278833e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,6 +765,7 @@ dependencies = [ "anyhow", "camino", "clap", + "codegraph-context", "codegraph-core", "codegraph-extract", "codegraph-graph", diff --git a/crates/codegraph-api/src/lib.rs b/crates/codegraph-api/src/lib.rs index 3ec3af551..d1482ff1d 100644 --- a/crates/codegraph-api/src/lib.rs +++ b/crates/codegraph-api/src/lib.rs @@ -65,6 +65,7 @@ pub struct ResumeDesc { #[derive(Debug, Clone)] pub enum ResumeCursor { Name(SearchCursor), + Callers(codegraph_graph::CallersCursor), Offset { next: usize, desc: ResumeDesc }, } @@ -135,7 +136,9 @@ impl SearchSessionStore { /// Đọc cursor theo id — `None` nếu không có / quá TTL. pub fn get(&self, id: &str) -> Option<(u64, ResumeCursor)> { let map = self.inner.lock().unwrap(); - map.get(id).map(|s| (s.index_version, s.cursor.clone())) + map.get(id) + .filter(|s| s.created.elapsed() < self.ttl) + .map(|s| (s.index_version, s.cursor.clone())) } /// Xoá session (khi search hoàn tất, không còn page nào). @@ -387,6 +390,60 @@ impl GraphApi { self.index().await.callers(id, depth as usize).await } + /// Resume caller traversal on the same query and index version. + pub async fn callers_resumable( + &self, + id: u64, + depth: u32, + resume: Option, + timeout_ms: u64, + ) -> Result { + let idx = self.index().await; + let version = idx.version(); + let cursor = match &resume { + Some(token) => { + let (stored_version, cursor) = self.sessions.get(token).ok_or_else(|| { + Error::Invalid("resume id expired or unknown — retry without resume".into()) + })?; + if stored_version != version { + return Err(Error::Invalid( + "index changed — retry without resume".into(), + )); + } + match cursor { + ResumeCursor::Callers(c) if c.id == id && c.depth == depth.max(1) as usize => { + Some(c) + } + _ => { + return Err(Error::Invalid( + "resume id was created for a different query — retry without resume" + .into(), + )); + } + } + } + None => None, + }; + let out = idx + .callers_resumable(id, depth as usize, cursor, deadline_from(timeout_ms)) + .await?; + let timed_out = out.cursor.is_some(); + if let Some(token) = &resume { + self.sessions.remove(token); + } + let token = out + .cursor + .map(|c| self.sessions.put(ResumeCursor::Callers(c), version)); + Ok(ResumeSearchOutcome { + total: out.callers.len(), + page: out.callers, + timed_out, + progress: out.progress, + resume: token, + index_version: version, + }) + } + /// Callees trực tiếp (đọc chain, skip marker/self). pub async fn callees(&self, id: u64) -> Result> { self.index().await.callees(id).await diff --git a/crates/codegraph-api/tests/api.rs b/crates/codegraph-api/tests/api.rs index 14a6d5c08..1e84478fe 100644 --- a/crates/codegraph-api/tests/api.rs +++ b/crates/codegraph-api/tests/api.rs @@ -99,6 +99,93 @@ async fn search_and_symbol_by_id() { assert!(api.symbol_by_id(9999).await.is_none()); } +#[tokio::test] +async fn callers_resume_roundtrip_and_validation() { + use codegraph_api::TIMEOUT_EXPIRE_IMMEDIATELY as EXPIRED; + let dir = tempfile::tempdir().unwrap(); + let dsn = format!("sqlite://{}", dir.path().join("resume.db").display()); + let (caller, callee, helper) = seed_index(&dsn).await; + let api = api(&dsn).await; + let first = api + .callers_resumable(helper, 2, None, EXPIRED) + .await + .unwrap(); + assert!(first.timed_out && first.page.is_empty()); + let token = first.resume.unwrap(); + for (id, depth) in [(callee, 2), (helper, 1)] { + assert!(api + .callers_resumable(id, depth, Some(token.clone()), 0) + .await + .is_err()); + } + assert!(api + .callers_resumable(helper, 2, Some("unknown".into()), 0) + .await + .is_err()); + assert!(api + .search_symbol_paged_resumable( + "helper", + None, + SymbolMatch::Contains, + Pagination { + limit: 5, + offset: 0 + }, + Some(token.clone()), + 0 + ) + .await + .is_err()); + let again = api + .callers_resumable(helper, 2, Some(token.clone()), EXPIRED) + .await + .unwrap(); + assert!(again.timed_out); + assert!(api + .callers_resumable(helper, 2, Some(token), 0) + .await + .is_err()); + let token = again.resume.unwrap(); + let done = api + .callers_resumable(helper, 2, Some(token.clone()), 0) + .await + .unwrap(); + assert!(!done.timed_out && done.resume.is_none()); + assert_eq!( + done.page.iter().map(|s| s.id).collect::>(), + vec![callee, caller] + ); + assert!(api + .callers_resumable(helper, 2, Some(token), 0) + .await + .is_err()); + let name = api + .search_symbol_paged_resumable( + "helper", + None, + SymbolMatch::Contains, + Pagination { + limit: 5, + offset: 0, + }, + None, + EXPIRED, + ) + .await + .unwrap(); + assert!(api + .callers_resumable(helper, 2, name.resume, 0) + .await + .is_err()); + let stale = api + .callers_resumable(helper, 2, None, EXPIRED) + .await + .unwrap() + .resume; + seed_index(&dsn).await; + assert!(api.callers_resumable(helper, 2, stale, 0).await.is_err()); +} + #[tokio::test] async fn callers_callees_and_flow() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codegraph-bench/Cargo.toml b/crates/codegraph-bench/Cargo.toml index 7cf0774fe..2834d3eb2 100644 --- a/crates/codegraph-bench/Cargo.toml +++ b/crates/codegraph-bench/Cargo.toml @@ -14,6 +14,7 @@ description = "Benchmark codegraph-extract + codegraph-graph trên các repo th codegraph-extract = { path = "../codegraph-extract" } codegraph-graph = { path = "../codegraph-graph", features = ["sqlite", "lmdb"] } codegraph-core = { path = "../codegraph-core" } +codegraph-context = { path = "../codegraph-context" } anyhow = { workspace = true } camino = { workspace = true } @@ -45,3 +46,7 @@ harness = false [[bench]] name = "storage" harness = false + +[[bench]] +name = "context" +harness = false diff --git a/crates/codegraph-bench/benches/context.rs b/crates/codegraph-bench/benches/context.rs new file mode 100644 index 000000000..bdf6a989e --- /dev/null +++ b/crates/codegraph-bench/benches/context.rs @@ -0,0 +1,118 @@ +//! Context queries on a deterministic SQLite graph; setup is outside timing. + +#[cfg(feature = "codspeed")] +use codspeed_criterion_compat as crit; +#[cfg(not(feature = "codspeed"))] +use criterion as crit; + +use codegraph_context::{ContextRequest, build}; +use codegraph_core::{SYMBOL_BASE, ScopeLevel, Symbol, SymbolKind}; +use codegraph_graph::{GraphIndex, ParseResult, SharedGraphIndex}; +use std::{collections::HashMap, hint::black_box, sync::Arc}; + +fn fixture(fan_in: usize) -> ParseResult { + let mut symbols = Vec::new(); + let mut chains = HashMap::new(); + for i in 0..=fan_in * 2 { + let id = SYMBOL_BASE + i as u64; + let name = if i == 0 { + "context_target".to_string() + } else { + format!("worker_{i:05}") + }; + symbols.push(Symbol { + id, + name, + kind: SymbolKind::Function, + scope: ScopeLevel::Global, + scope_id: 0, + type_ref: 0, + type_name: None, + file: "context_fixture.rs".into(), + line: 1, + end_line: 1, + signature: None, + doc: None, + annotations: Vec::new(), + language: "rust".into(), + }); + let chain = if i == 0 { + vec![id] + } else if i <= fan_in { + vec![id, SYMBOL_BASE, SYMBOL_BASE] + } else { + vec![id, SYMBOL_BASE + (i - fan_in) as u64] + }; + chains.insert(id, chain); + } + ParseResult { + path: "context_fixture.rs".into(), + language: "rust".into(), + bytes: 0, + lines: 1, + symbols, + chains, + calls: Vec::new(), + } +} + +fn benchmark_context(c: &mut crit::Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + for fan_in in [32, 256] { + let dir = tempfile::tempdir().unwrap(); + let dsn = format!("sqlite://{}", dir.path().join("context.db").display()); + let shared = rt.block_on(async { + let mut idx = GraphIndex::open(&dsn).await.unwrap(); + idx.ingest(&[fixture(fan_in)]).await.unwrap(); + drop(idx); + let shared = Arc::new(SharedGraphIndex::open(Some(dsn.clone())).await.unwrap()); + shared.ensure_fresh().await; + shared + }); + let mut group = c.benchmark_group(format!("context/sqlite/{fan_in}")); + for (case, query, depth) in [ + ("warm_depth1", "context_target", 1), + ("warm_depth2", "context_target", 2), + ("warm_broad", "worker", 1), + ("warm_no_hit", "worker_missing", 1), + ] { + let req = ContextRequest { + query: query.into(), + depth, + ..ContextRequest::default() + }; + let response = rt + .block_on(codegraph_context::build_response(&shared, &req)) + .unwrap(); + match case { + "warm_depth1" => assert_eq!(response.hits[0].callers.len(), fan_in), + "warm_depth2" => assert_eq!(response.hits[0].callers.len(), fan_in * 2), + "warm_broad" => assert_eq!(response.hits.len(), 5), + _ => assert!(response.hits.is_empty()), + } + group.bench_function(case, |b| { + b.iter(|| black_box(rt.block_on(build(&shared, black_box(&req))).unwrap())); + }); + } + let req = ContextRequest { + query: "context_target".into(), + ..ContextRequest::default() + }; + group.bench_function("cold_depth1", |b| { + b.iter_batched( + || { + Arc::new( + rt.block_on(SharedGraphIndex::open(Some(dsn.clone()))) + .unwrap(), + ) + }, + |fresh| black_box(rt.block_on(build(&fresh, black_box(&req))).unwrap()), + crit::BatchSize::PerIteration, + ); + }); + group.finish(); + } +} + +crit::criterion_group!(benches, benchmark_context); +crit::criterion_main!(benches); diff --git a/crates/codegraph-graph/src/lib.rs b/crates/codegraph-graph/src/lib.rs index 543c5b7d9..c906cd2cb 100644 --- a/crates/codegraph-graph/src/lib.rs +++ b/crates/codegraph-graph/src/lib.rs @@ -327,6 +327,34 @@ pub struct SearchCursor { pub phase: SearchCursorPhase, } +/// BFS checkpoint; valid only for the query and snapshot that created it. +#[derive(Debug, Clone)] +pub struct CallersCursor { + pub id: u64, + pub depth: usize, + pub index_version: u64, + level: usize, + frontier: Vec, + frontier_pos: usize, + next: Vec, + visited: HashSet, + out_ids: Vec, + search: Option, + pending: Vec, + pending_pos: usize, + search_complete: bool, + materialize_pos: usize, + callers: Vec, +} + +#[derive(Debug)] +pub struct CallersOutcome { + /// Complete results only; partial work stays in the cursor. + pub callers: Vec, + pub cursor: Option, + pub progress: usize, +} + /// Kết quả của [`GraphIndex::search_symbol_paged_resumable`]. #[derive(Debug)] pub struct PagedSearchOutcome { @@ -1583,52 +1611,114 @@ impl GraphIndex { /// Callers (transitive BFS) — `depth` = số hop tối đa (1 = direct). pub async fn callers(&self, id: u64, depth: usize) -> Result> { + Ok(self.callers_resumable(id, depth, None, None).await?.callers) + } + + /// Cooperative deadline across BFS, chain searches and result cloning. + pub async fn callers_resumable( + &self, + id: u64, + depth: usize, + resume: Option, + deadline: Option, + ) -> Result { + let depth = depth.max(1); if !self.symbols.contains_key(&id) { return Err(Error::Invalid(format!("symbol id {id} not found"))); } - let mut visited = HashSet::new(); - visited.insert(id); - let mut frontier = vec![id]; - let mut out_ids = Vec::new(); - for _ in 0..depth.max(1) { - let mut next = Vec::new(); - for &cur in &frontier { - for caller in self.direct_callers(cur).await? { - if visited.insert(caller) { - out_ids.push(caller); - next.push(caller); + let mut state = match resume { + Some(c) => { + if c.id != id || c.depth != depth || c.index_version != self.version() { + return Err(Error::Invalid( + "callers cursor does not match query or index version".into(), + )); + } + c + } + None => CallersCursor { + id, + depth, + index_version: self.version(), + level: 0, + frontier: vec![id], + frontier_pos: 0, + next: Vec::new(), + visited: HashSet::from([id]), + out_ids: Vec::new(), + search: None, + pending: Vec::new(), + pending_pos: 0, + search_complete: false, + materialize_pos: 0, + callers: Vec::new(), + }, + }; + loop { + if deadline.is_some_and(|dl| Instant::now() >= dl) { + return Ok(CallersOutcome { + progress: state.out_ids.len(), + callers: Vec::new(), + cursor: Some(state), + }); + } + if state.level >= depth || state.frontier.is_empty() { + if state.materialize_pos < state.out_ids.len() { + let id = state.out_ids[state.materialize_pos]; + if let Some(symbol) = self.symbols.get(&id) { + state.callers.push(symbol.clone()); } + state.materialize_pos += 1; + continue; } + return Ok(CallersOutcome { + progress: state.out_ids.len(), + callers: state.callers, + cursor: None, + }); } - frontier = next; - if frontier.is_empty() { - break; + if state.frontier_pos == state.frontier.len() { + state.frontier = std::mem::take(&mut state.next); + state.frontier_pos = 0; + state.level += 1; + continue; } - } - Ok(out_ids - .into_iter() - .filter_map(|i| self.symbols.get(&i).cloned()) - .collect()) - } - - /// Callers trực tiếp của `id` — substring search `[id]` trên chain engine. - /// - /// Mọi chain chứa id ở vị trí callee (hoặc vị trí 0 — chính chain của id, - /// bỏ qua khi `caller == id`). - async fn direct_callers(&self, id: u64) -> Result> { - let pattern = [id]; - let hits = match self.chains.search(&pattern, None).await { - Ok(h) => h, - Err(_) => return Ok(Vec::new()), - }; - let mut out = Vec::new(); - for (record, _) in hits { - let caller = record as u64; - if caller != id && self.symbols.contains_key(&caller) { - out.push(caller); + let current = state.frontier[state.frontier_pos]; + if !state.search_complete { + let page = self + .chains + .search_resumable(&[current], None, state.search.take(), deadline) + .await?; + if page.timed_out { + state.search = Some(page.resume.ok_or_else(|| { + Error::Invalid("timed out chain search has no checkpoint".into()) + })?); + return Ok(CallersOutcome { + progress: state.out_ids.len(), + callers: Vec::new(), + cursor: Some(state), + }); + } + state.pending = page.record_ids; + state.pending_pos = 0; + state.search_complete = true; + continue; + } + if state.pending_pos < state.pending.len() { + let caller = state.pending[state.pending_pos] as u64; + state.pending_pos += 1; + if caller != current + && self.symbols.contains_key(&caller) + && state.visited.insert(caller) + { + state.out_ids.push(caller); + state.next.push(caller); + } + continue; } + state.pending.clear(); + state.search_complete = false; + state.frontier_pos += 1; } - Ok(out) } /// Callees trực tiếp — đọc chain, skip marker/0/self/seen. Không có chain @@ -2624,6 +2714,164 @@ mod tests { } } + async fn check_callers_without_metadata(idx: &mut GraphIndex) { + let a = SYMBOL_BASE; + let b = a + 1; + let c = a + 2; + let isolated = a + 3; + idx.ingest(&[result( + "callers.rs", + vec![ + sym("callers.rs", "a", a), + sym("callers.rs", "b", b), + sym("callers.rs", "c", c), + sym("callers.rs", "isolated", isolated), + ], + HashMap::from([ + (a, vec![a, b, b, MARKER_IF_TRUE, c, MARKER_BRANCH_END]), + (b, vec![b, c]), + (c, vec![c, a]), + ]), + vec![], + )]) + .await + .unwrap(); + + for id in [a, b, c, isolated] { + let expected: Vec = idx + .chains + .search(&[id], None) + .await + .unwrap_or_default() + .into_iter() + .map(|(record, _)| record as u64) + .filter(|&caller| caller != id && idx.symbols.contains_key(&caller)) + .collect(); + for _ in 0..2 { + let actual: Vec<_> = idx + .callers(id, 1) + .await + .unwrap() + .into_iter() + .map(|s| s.id) + .collect(); + assert_eq!(actual, expected); + } + } + let direct = idx.callers(c, 1).await.unwrap(); + let ids = |v: &[Symbol]| v.iter().map(|s| s.id).collect::>(); + let mut sorted_ids = ids(&direct); + sorted_ids.sort_unstable(); + assert_eq!(sorted_ids, vec![a, b]); + assert_eq!(ids(&idx.callers(c, 0).await.unwrap()), ids(&direct)); + assert_eq!(ids(&idx.callers(c, 10).await.unwrap()), ids(&direct)); + assert!(idx.callers(isolated, 10).await.unwrap().is_empty()); + assert!(idx.callers(isolated + 1, 1).await.is_err()); + + let expired = Some(Instant::now()); + let paused = idx.callers_resumable(c, 10, None, expired).await.unwrap(); + assert!(paused.callers.is_empty()); + let cursor = paused.cursor.unwrap(); + assert!( + idx.callers_resumable(b, 10, Some(cursor.clone()), None) + .await + .is_err() + ); + assert!( + idx.callers_resumable(c, 1, Some(cursor.clone()), None) + .await + .is_err() + ); + let mut stale = cursor.clone(); + stale.index_version = stale.index_version.wrapping_add(1); + assert!( + idx.callers_resumable(c, 10, Some(stale), None) + .await + .is_err() + ); + let again = idx + .callers_resumable(c, 10, Some(cursor.clone()), expired) + .await + .unwrap(); + assert_eq!(again.progress, 0); + let done = idx + .callers_resumable(c, 10, again.cursor, None) + .await + .unwrap(); + assert!(done.cursor.is_none()); + assert_eq!(ids(&done.callers), ids(&direct)); + + // Resume with an unfinished inner search. + let mut searching = cursor.clone(); + searching.search = idx + .chains + .search_resumable(&[c], None, None, expired) + .await + .unwrap() + .resume; + assert!(searching.search.is_some()); + let done = idx + .callers_resumable(c, 10, Some(searching), None) + .await + .unwrap(); + assert_eq!(ids(&done.callers), ids(&direct)); + + // Resume after consuming one record of the current frontier node. + let mut pending = cursor.clone(); + pending.pending = idx + .chains + .search_resumable(&[c], None, None, None) + .await + .unwrap() + .record_ids; + pending.search_complete = true; + let first = pending.pending[0] as u64; + pending.pending_pos = 1; + if first != c && idx.symbols.contains_key(&first) && pending.visited.insert(first) { + pending.out_ids.push(first); + pending.next.push(first); + } + let done = idx + .callers_resumable(c, 10, Some(pending), None) + .await + .unwrap(); + assert_eq!(ids(&done.callers), ids(&direct)); + + // Resume in a later BFS level and while materializing the output. + let mut next_level = cursor; + next_level.level = 1; + next_level.frontier = ids(&direct); + next_level.out_ids = ids(&direct); + next_level.visited.extend(ids(&direct)); + let done = idx + .callers_resumable(c, 10, Some(next_level.clone()), None) + .await + .unwrap(); + assert_eq!(ids(&done.callers), ids(&direct)); + next_level.level = 10; + next_level.materialize_pos = 1; + next_level.callers.push(direct[0].clone()); + let done = idx + .callers_resumable(c, 10, Some(next_level), None) + .await + .unwrap(); + assert_eq!(ids(&done.callers), ids(&direct)); + } + + #[tokio::test] + async fn callers_without_metadata_matches_legacy() { + check_callers_without_metadata(&mut GraphIndex::in_memory()).await; + } + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn callers_without_metadata_matches_legacy_sqlite() { + let dir = tempfile::tempdir().unwrap(); + let dsn = format!("sqlite://{}", dir.path().join("callers.db").display()); + let mut idx = GraphIndex::open(&dsn).await.unwrap(); + check_callers_without_metadata(&mut idx).await; + } + #[tokio::test] async fn ingest_and_query_basic() { let mut idx = GraphIndex::in_memory(); diff --git a/crates/codegraph-mcp/src/callers_tests.rs b/crates/codegraph-mcp/src/callers_tests.rs new file mode 100644 index 000000000..fb9e657cb --- /dev/null +++ b/crates/codegraph-mcp/src/callers_tests.rs @@ -0,0 +1,86 @@ +use super::*; +use codegraph_core::{ScopeLevel, SYMBOL_BASE}; +use codegraph_graph::{GraphIndex, ParseResult, SharedGraphIndex}; +use std::collections::HashMap; + +#[tokio::test] +async fn callers_timeout_resume_dispatch() { + let schema = tool_defs() + .into_iter() + .find(|t| t.name == "codegraph_callers") + .unwrap() + .schema; + assert_eq!(schema["properties"]["timeout_ms"]["default"], 20000); + assert_eq!(schema["properties"]["resume"]["type"], "string"); + let dir = tempfile::tempdir().unwrap(); + let root = Utf8Path::from_path(dir.path()).unwrap(); + let dsn = format!("sqlite://{}", root.join("index.db")); + let a = SYMBOL_BASE; + let b = a + 1; + let symbol = |id, name: &str| Symbol { + id, + name: name.into(), + kind: SymbolKind::Function, + scope: ScopeLevel::Global, + scope_id: 0, + type_ref: 0, + type_name: None, + file: "a.rs".into(), + line: 1, + end_line: 1, + signature: None, + doc: None, + annotations: vec![], + language: "rust".into(), + }; + let mut idx = GraphIndex::open(&dsn).await.unwrap(); + idx.ingest(&[ParseResult { + path: "a.rs".into(), + language: "rust".into(), + bytes: 0, + lines: 1, + symbols: vec![symbol(a, "caller"), symbol(b, "callee")], + chains: HashMap::from([(a, vec![a, b])]), + calls: vec![], + }]) + .await + .unwrap(); + drop(idx); + let api = GraphApi::new_with_index(Arc::new(SharedGraphIndex::open(Some(dsn)).await.unwrap())); + let call = |args| { + dispatch_with_api( + &api, + root, + DetailLevel::Minimal, + OutputStyle::Medium, + false, + "codegraph_callers", + args, + ) + }; + let err = call( + json!({"node": b, "depth": 2, "timeout_ms": codegraph_api::TIMEOUT_EXPIRE_IMMEDIATELY}), + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("timed out")); + let token = err + .split("\"resume\": \"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap(); + let resumed = call(json!({"node": b, "depth": 2, "timeout_ms": 0, "resume": token})) + .await + .unwrap(); + let normal = call(json!({"node": b, "depth": 2})).await.unwrap(); + assert_eq!(resumed, normal); + let value: Value = serde_json::from_str(&resumed).unwrap(); + assert_eq!(value.as_array().unwrap().len(), 1); + assert_eq!(value[0]["id"], a); + assert!(call(json!({"node": b, "depth": 2, "resume": token})) + .await + .is_err()); +} diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 582ebfe45..41fbf95d9 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -19,6 +19,10 @@ use std::sync::Arc; /// `bin_base`), `codegraph_context`, `codegraph_search_flow`, /// `codegraph_references`, `codegraph_mermaid`, `codegraph_status` (stats gộp /// cả 3 dataset), `codegraph_init/deinit/index`, `codegraph_query_usage_report`. +#[cfg(test)] +#[path = "callers_tests.rs"] +mod callers_tests; + struct ToolDef { name: &'static str, desc: &'static str, @@ -56,8 +60,10 @@ fn tool_defs() -> Vec { ), tool( "codegraph_callers", - "Find functions that (transitively) call the given symbol.", + "Find functions that (transitively) call the given symbol. Code-index queries support timeout_ms (default 20000; 0 disables) and resume: on timeout retry with the returned resume id and the same node/depth. Binary queries do not support timeout/resume.", json!({ "type": "object", "properties": { + "resume": { "type": "string", "description": "Resume id returned by a timed-out code-index callers query." }, + "timeout_ms": { "type": "integer", "minimum": 0, "default": 20000 }, "node": { "type": "integer" }, "depth": { "type": "integer", "default": 1 }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, @@ -494,8 +500,26 @@ pub async fn dispatch_with_api( { return Ok(out); } - let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as u32; - let hits = api.callers(id, depth).await?; + let depth = u32::try_from(args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1)) + .map_err(|_| Error::Invalid("depth exceeds u32 range".into()))?; + let resume = args + .get("resume") + .and_then(|v| v.as_str()) + .map(str::to_owned); + let timeout_ms = args + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(20000); + let out = api.callers_resumable(id, depth, resume, timeout_ms).await?; + if out.timed_out { + return Err(Error::Other(format!( + "codegraph_callers timed out after {}ms (collected {} callers). Retry with the same node/depth plus \"resume\": \"{}\" to continue.", + timeout_ms, + out.progress, + out.resume.as_deref().unwrap_or("") + ))); + } + let hits = out.page; let detail = detail_from_args(&args, session_detail); let format = format_from_args(&args, session_format); let out: Vec = hits @@ -1062,6 +1086,13 @@ async fn dispatch_binary_graph( let Some(graph) = binary_graph_for(root, id).await else { return Ok(None); }; + if name == "codegraph_callers" + && (args.get("resume").is_some() || args.get("timeout_ms").is_some()) + { + return Err(Error::Invalid( + "binary callers do not support timeout_ms/resume".into(), + )); + } let detail = detail_from_args(args, session_detail); let format = format_from_args(args, session_format); let out = match name { From e066a32f1366d92fd5ae70ce5258b303baa38d8d Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 17 Sep 2026 20:09:03 +0700 Subject: [PATCH 2/5] Optimize cost by compressing response from MCP --- crates/codegraph-api/src/tools.rs | 14 +- crates/codegraph-mcp/src/callers_tests.rs | 36 +++ crates/codegraph-mcp/src/lib.rs | 35 ++- crates/codegraph-mcp/src/response_tests.rs | 250 ++++++++++++++++ .../codegraph-mcp/src/server-instructions.md | 38 ++- crates/codegraph-mcp/src/tools.rs | 269 +++++++++++++++--- crates/codegraph-mcp/src/usage.rs | 16 ++ 7 files changed, 594 insertions(+), 64 deletions(-) create mode 100644 crates/codegraph-mcp/src/response_tests.rs diff --git a/crates/codegraph-api/src/tools.rs b/crates/codegraph-api/src/tools.rs index bcc3e95a1..cc890450c 100644 --- a/crates/codegraph-api/src/tools.rs +++ b/crates/codegraph-api/src/tools.rs @@ -57,10 +57,7 @@ pub fn relativize_paths(v: &mut Value, root: &str) { /// Serialize payload JSON kèm relativize path theo root — mọi response tool /// đi qua đây để `file`/`path` trả về tương đối so với workspace root. pub fn emit_value(root: &str, v: Value) -> Result { - let mut v = v; - relativize_paths(&mut v, root); - omit_defaults(&mut v); - serde_json::to_string_pretty(&v).map_err(|e| Error::Invalid(e.to_string())) + emit_unpruned(root, v) } /// `emit_value` cho bất kỳ type serializable nào (chuyển qua `to_value`). @@ -69,6 +66,15 @@ pub fn emit(root: &str, v: &T) -> Result { emit_value(root, value) } +/// Serialize giữ nguyên structure (không lược default): các frontend formatter +/// (vd MCP `format_response`) cần sentinel gốc (0 / [] / "") để dựng layout +/// `minimize` chi tiết-correct; pruning ở đây sẽ làm mất data trước formatter. +pub fn emit_unpruned(root: &str, v: Value) -> Result { + let mut v = v; + relativize_paths(&mut v, root); + serde_json::to_string(&v).map_err(|e| Error::Invalid(e.to_string())) +} + /// Keys có `0` = "absent" (sentinel) — value 0 bị lược như default. Các số khác /// (counts/totals như `total`, `symbols`, `lines`, ...) giữ nguyên 0 vì ý nghĩa. const ZERO_SENTINEL_KEYS: [&str; 3] = ["scope_id", "type_ref", "end_line"]; diff --git a/crates/codegraph-mcp/src/callers_tests.rs b/crates/codegraph-mcp/src/callers_tests.rs index fb9e657cb..4bb72a154 100644 --- a/crates/codegraph-mcp/src/callers_tests.rs +++ b/crates/codegraph-mcp/src/callers_tests.rs @@ -47,6 +47,42 @@ async fn callers_timeout_resume_dispatch() { .unwrap(); drop(idx); let api = GraphApi::new_with_index(Arc::new(SharedGraphIndex::open(Some(dsn)).await.unwrap())); + let minimized = dispatch_with_api( + &api, + root, + DetailLevel::Medium, + OutputStyle::Minimize, + false, + "codegraph_symbol", + json!({"id": a, "format": "minimize"}), + ) + .await + .unwrap(); + let minimized: Value = serde_json::from_str(&minimized).unwrap(); + assert_eq!(minimized.as_array().unwrap().len(), 6); + + let context = dispatch_with_api( + &api, + root, + DetailLevel::Minimal, + OutputStyle::Minimize, + false, + "codegraph_context", + json!({"query":"caller","depth":1}), + ) + .await + .unwrap(); + let context = format_response( + root.as_str(), + &context, + DetailLevel::Minimal, + OutputStyle::Minimize, + ) + .unwrap(); + let context: Value = serde_json::from_str(&context).unwrap(); + assert_eq!(context["hits"][0]["symbol"].as_array().unwrap().len(), 5); + assert_eq!(context["hits"][0]["callees"][0][0], b); + let call = |args| { dispatch_with_api( &api, diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index d2056a50c..77eb84c0d 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -13,6 +13,8 @@ mod docgraph; #[cfg(feature = "http")] pub mod http; +#[cfg(test)] +mod response_tests; mod session; pub mod stdio; mod tools; @@ -138,6 +140,28 @@ impl CodegraphServer { /// công, [`ToolOutput::Error`] cho lỗi tool (client thấy `is_error`), /// [`Err`] cho lỗi protocol (unknown tool đã bị chặn trước ở `call_tool`). async fn run_tool(&self, name: &str, args: Value) -> Result { + let detail = tools::detail_from_args(&args, self.session.detail().await); + let format = tools::response_format_from_args(name, &args, self.session.format().await); + let root = self.session.root().await; + let output = self.run_tool_raw(name, args).await?; + let root = self.session.root().await.or(root); + match output { + ToolOutput::Text { text, source_bytes } => { + match tools::format_response( + root.as_deref().map_or("", |p| p.as_str()), + &text, + detail, + format, + ) { + Ok(text) => Ok(ToolOutput::Text { text, source_bytes }), + Err(e) => Ok(ToolOutput::Error(e.to_string())), + } + } + error => Ok(error), + } + } + + async fn run_tool_raw(&self, name: &str, args: Value) -> Result { // ── Telemetry — không cần session ── if name == "codegraph_query_usage_report" { let reset = args.get("reset").and_then(|v| v.as_bool()).unwrap_or(false); @@ -148,14 +172,13 @@ impl CodegraphServer { u.reset(); } drop(u); - let mut v = serde_json::to_value(&report).map_err(|e| { + let v = serde_json::to_value(&report).map_err(|e| { McpError::internal_error( "usage report failed", Some(json!({"reason": e.to_string()})), ) })?; - tools::omit_defaults(&mut v); - let text = serde_json::to_string_pretty(&v).map_err(|e| { + let text = serde_json::to_string(&v).map_err(|e| { McpError::internal_error( "usage report failed", Some(json!({"reason": e.to_string()})), @@ -423,7 +446,7 @@ impl CodegraphServer { // Binary tools (codegraph_graphbin_*) — dataset riêng, lazy; mở per-call (open là O(1), // search contains đi radix trie persist). if name.starts_with("codegraph_graphbin_") { - return match tools::dispatch_binary(&root, name, args).await { + return match tools::dispatch_binary(&root, name, args, format).await { Ok(text) => Ok(ToolOutput::Text { text, source_bytes: 0, @@ -524,9 +547,7 @@ enum ToolOutput { impl ToolOutput { fn json(v: &Value) -> Self { - let mut v = v.clone(); - tools::omit_defaults(&mut v); - match serde_json::to_string_pretty(&v) { + match serde_json::to_string(v) { Ok(text) => ToolOutput::Text { text, source_bytes: 0, diff --git a/crates/codegraph-mcp/src/response_tests.rs b/crates/codegraph-mcp/src/response_tests.rs new file mode 100644 index 000000000..376193dcf --- /dev/null +++ b/crates/codegraph-mcp/src/response_tests.rs @@ -0,0 +1,250 @@ +use super::*; + +fn text(output: ToolOutput) -> String { + match output { + ToolOutput::Text { text, .. } => text, + ToolOutput::Error(error) => panic!("{error}"), + } +} + +fn symbol() -> Value { + json!({"id":100,"name":"example","kind":"function","scope":"global", + "scope_id":0,"type_ref":0,"type_name":null,"file":"/repo/a.rs","line":2, + "end_line":9,"signature":"fn example()","doc":"Long documentation", + "annotations":[],"language":"rust"}) +} + +#[test] +fn nested_symbols_respect_all_detail_and_format_combinations() { + for (detail, size) in [ + (DetailLevel::Minimal, 5), + (DetailLevel::Medium, 6), + (DetailLevel::Verbose, 14), + ] { + for style in [OutputStyle::Minimize, OutputStyle::Medium] { + let input = json!({"symbol":symbol(),"matches":[symbol()],"source":"fn example() {\n false\n}"}); + let output = + tools::format_response("/repo", &input.to_string(), detail, style).unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(result["source"], input["source"]); + if style == OutputStyle::Minimize { + assert!(!output.contains('\n')); + assert_eq!(result["symbol"].as_array().unwrap().len(), size); + assert_eq!(result["matches"][0], result["symbol"]); + assert_eq!(result["symbol"][if size == 14 { 7 } else { 3 }], "a.rs"); + } else { + assert_eq!(result["symbol"]["file"], "a.rs"); + assert_eq!( + result["symbol"].get("doc").is_some(), + detail == DetailLevel::Verbose + ); + assert_eq!( + result["symbol"].get("signature").is_some(), + detail != DetailLevel::Minimal + ); + } + } + } +} + +#[test] +fn repeated_records_are_smaller_and_decodable() { + let records: Vec<_> = (0..30) + .map(|i| json!({"path":"/repo/a.rs","language":"rust","bytes":i,"lines":0})) + .collect(); + let input = json!({"files":records,"total":0,"resume":"cursor-1","chain":[0,100,101]}); + let compact = tools::format_response( + "/repo", + &input.to_string(), + DetailLevel::Minimal, + OutputStyle::Minimize, + ) + .unwrap(); + let medium = tools::format_response( + "/repo", + &input.to_string(), + DetailLevel::Minimal, + OutputStyle::Medium, + ) + .unwrap(); + let result: Value = serde_json::from_str(&compact).unwrap(); + assert_eq!( + result["files"]["columns"], + json!(["bytes", "language", "lines", "path"]) + ); + assert_eq!(result["files"]["rows"][0], json!([0, "rust", 0, "a.rs"])); + assert_eq!(result["total"], 0); + assert_eq!(result["resume"], "cursor-1"); + assert_eq!(result["chain"], input["chain"]); + assert!(compact.len() < medium.len() / 2); + println!( + "Record fixture: compact={} bytes, medium={} bytes", + compact.len(), + medium.len() + ); +} + +#[test] +fn api_emitted_payloads_keep_sentinels_through_the_formatter() { + // Path thật: codegraph-api tools (diff/sandbox) → emit_value → MCP formatter. + let full = json!({"symbols":[symbol()]}); + let raw = codegraph_api::tools::emit_value("/repo", full).unwrap(); + let out = + tools::format_response("/repo", &raw, DetailLevel::Verbose, OutputStyle::Minimize).unwrap(); + let result: Value = serde_json::from_str(&out).unwrap(); + // Minimize dựng mảng 14 cell từ object-symbol (thứ tự theo symbol_json); + // sentinel phải là số 0 / [] gốc, không phải null do prune xảy ra trước. + let cells = result["symbols"][0].as_array().unwrap(); + assert_eq!(cells.len(), 14); + assert_eq!(cells[7], "a.rs"); + assert_eq!(cells[4], json!(0)); + assert_eq!(cells[5], json!(0)); + assert_eq!(cells[9], json!(9)); + assert_eq!(cells[12], json!([])); +} + +#[test] +fn document_values_and_annotation_args_are_preserved() { + for value in [ + json!(false), + json!(null), + json!(""), + json!([]), + json!({"file":"/repo/literal","enabled":false}), + ] { + let input = + json!({"value":value,"args":{"enabled":false,"empty":""},"path":"/repo/a.json"}); + for style in [OutputStyle::Minimize, OutputStyle::Medium] { + let output = + tools::format_response("/repo", &input.to_string(), DetailLevel::Minimal, style) + .unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(result["value"], input["value"]); + assert_eq!(result["args"], input["args"]); + assert_eq!(result["path"], "a.json"); + } + } +} + +#[test] +fn every_registered_tool_advertises_output_controls() { + let tools = tools::rmcp_tools(); + assert_eq!(tools.len(), 40); + for tool in tools { + let props = &tool.input_schema["properties"]; + assert!(props.get("detail").is_some(), "{}", tool.name); + let key = if tool.name == "codegraph_graphdoc_ingest" { + "output_format" + } else { + "format" + }; + assert_eq!( + props[key]["enum"], + json!(["minimize", "medium"]), + "{}", + tool.name + ); + if tool.name == "codegraph_graphdoc_ingest" { + assert_eq!( + props["format"]["enum"], + json!(["hcl", "yaml", "json", "toml"]) + ); + } + } +} + +#[tokio::test] +async fn server_routes_share_formatting_and_overrides() { + let dir = tempfile::tempdir().unwrap(); + let server = CodegraphServer::new(); + let init = text( + server + .run_tool( + "codegraph_init", + json!({"path":dir.path(),"index":false,"detail":"minimal","format":"minimize"}), + ) + .await + .unwrap(), + ); + assert!(!init.contains('\n')); + assert_eq!(server.session.detail().await, DetailLevel::Minimal); + for name in [ + "codegraph_status", + "codegraph_graphcode_stats", + "codegraph_graphdoc_stats", + "codegraph_query_usage_report", + ] { + let compact = text(server.run_tool(name, json!({})).await.unwrap()); + assert!(!compact.contains('\n'), "{name}: {compact}"); + serde_json::from_str::(&compact).unwrap(); + let medium = text( + server + .run_tool(name, json!({"format":"medium"})) + .await + .unwrap(), + ); + assert!(medium.contains('\n'), "{name}: {medium}"); + } + let context = text( + server + .run_tool("codegraph_context", json!({"query":"missing"})) + .await + .unwrap(), + ); + assert_eq!( + serde_json::from_str::(&context).unwrap()["query"], + "missing" + ); + let path = server.session.root().await.unwrap().join("data.json"); + std::fs::write(&path, r#"{"enabled":false,"empty":""}"#).unwrap(); + let ingest = text( + server + .run_tool( + "codegraph_graphdoc_ingest", + json!({"path":path,"format":"json","output_format":"medium"}), + ) + .await + .unwrap(), + ); + assert!(ingest.contains('\n')); + let ingest: Value = serde_json::from_str(&ingest).unwrap(); + assert_eq!(ingest["path"], "data.json"); + let listed = text( + server + .run_tool("codegraph_graphdoc_list", json!({})) + .await + .unwrap(), + ); + assert!(!listed.contains('\n')); + let search = text( + server + .run_tool("codegraph_graphdoc_search", json!({"pattern":"enabled"})) + .await + .unwrap(), + ); + assert!(search.contains("false"), "{search}"); + let removed = text( + server + .run_tool( + "codegraph_graphdoc_remove", + json!({"doc_id":ingest["doc_id"]}), + ) + .await + .unwrap(), + ); + assert!(!removed.contains('\n')); + let deinit = text( + server + .run_tool("codegraph_deinit", json!({})) + .await + .unwrap(), + ); + assert!(!deinit.contains('\n')); + assert!(matches!( + server + .run_tool("codegraph_graphcode_stats", json!({})) + .await + .unwrap(), + ToolOutput::Error(_) + )); +} diff --git a/crates/codegraph-mcp/src/server-instructions.md b/crates/codegraph-mcp/src/server-instructions.md index e4da6080a..d63f06c3b 100644 --- a/crates/codegraph-mcp/src/server-instructions.md +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -68,20 +68,36 @@ re-index or restart; passing one with changed args is rejected. ## Output detail `detail` (per call, overrides session default): `minimal` = {id,name,kind,file, line}; `medium` (default) = +signature; `verbose` = full Symbol. -`codegraph_symbol {"id":…}` returns the full symbol for one target. `file` paths -are relative to the workspace root. +Every tool inherits session `detail` and `format`; per-call values override them. +Detail controls code symbols, including nested symbols and ambiguous matches; +non-symbol records retain their tool-specific information. Use `detail:verbose` +for full symbol metadata. File paths are relative to the workspace root. ## Response format (`minimize` = default) -- `minimize` — symbols are fixed-order positional arrays (schema below); no keys. - Ignores `detail`. -- `medium` — objects keep keys; default-valued fields (`null`, `false`, `""`, - `[]`, `{}`, and `0` for `scope_id`/`type_ref`/`end_line`) are omitted. Counts - (`total`,`limit`,`offset`,…) always stay. **Absent = default.** +Every successful response passes through the same formatter, including admin, +telemetry, documents, binaries, sandbox and diff tools. Errors and short textual +not-found messages remain readable text. +- `minimize` — compact JSON without indentation. Symbol arrays follow the + requested detail. Repeated object records become `{ "columns": [...], + "rows": [[...], ...] }` when this reduces serialized size. Columns are sorted; + every row preserves column positions, with `null` for absent/default cells. + Small lists can remain arrays of objects. Numeric chains and source text stay intact. +- `medium` — indented JSON with keyed objects, without record tables. +- Both omit default-valued metadata (`null`, `false`, `""`, `[]`, `{}`, and `0` + for `scope_id`/`type_ref`/`end_line`). Counts (`total`,`limit`,`offset`,…) + retain zero. **Absent metadata = default.** Document `value` and annotation + `args` payloads preserve literal values, including false, null and empty strings. +- `codegraph_context` returns JSON `{query,hits}` in both formats; each hit has + a symbol, callers, callees and optional source (default-valued fields may be absent). +- `codegraph_graphdoc_ingest` uses `output_format` for response formatting; + its existing `format` still selects `hcl|yaml|json|toml` input parsing. -Symbol array (`minimize`), 14 fixed fields in order: -`0` id, `1` name, `2` kind, `3` scope, `4` scope_id(0=global), `5` type_ref(0=none), -`6` type_name, `7` file(rel root), `8` line, `9` end_line(0=none), `10` signature, -`11` doc, `12` annotations, `13` language. Never reorder or truncate. +Symbol arrays (`minimize`) have a fixed layout for each detail: +- `minimal`: `[id,name,kind,file,line]` (5 fields). +- `medium`: `[id,name,kind,file,line,signature]` (6 fields). +- `verbose`: `[id,name,kind,scope,scope_id,type_ref,type_name,file,line,end_line,signature,doc,annotations,language]` (14 fields; legacy full layout). +Never remove default-valued array cells or reorder them. This replaces the old +always-14-field layout for minimal/medium detail; consumers must use the requested detail. Binary row array (`minimize`), 9 fixed fields in order: `0` id, `1` name, `2` kind, `3` addr, `4` end_addr, `5` path(rel root), `6` flag, diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 41fbf95d9..bd570e39e 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -29,7 +29,30 @@ struct ToolDef { schema: Value, } -fn tool(name: &'static str, desc: &'static str, schema: Value) -> ToolDef { +fn tool(name: &'static str, desc: &'static str, mut schema: Value) -> ToolDef { + let props = schema["properties"] + .as_object_mut() + .expect("tool properties"); + props.entry("detail").or_insert_with(|| { + json!({ + "type": "string", "enum": ["minimal", "medium", "verbose"], + "description": "Symbol detail; overrides the session default in either output format." + }) + }); + let format_key = if name == "codegraph_graphdoc_ingest" { + "output_format" + } else { + "format" + }; + let format = props.entry(format_key).or_insert_with(|| { + json!({ + "type": "string", "enum": ["minimize", "medium"] + }) + }); + format["description"] = json!("Response format; overrides session default. minimize = compact JSON with detail-aware symbol arrays and repeated records as {columns,rows}; medium = keyed JSON. See server instructions for array layouts."); + if name != "codegraph_init" { + format.as_object_mut().unwrap().remove("default"); + } ToolDef { name, desc, schema } } @@ -477,11 +500,12 @@ pub async fn dispatch_with_api( .iter() .map(|s| symbol_json(root.as_str(), s, detail, format)) .collect(); - return Ok(format!( - "ambiguous ({} matches):\n{}", - matches.len(), - emit_value(root.as_str(), Value::Array(matches))? - )); + return emit_value( + root.as_str(), + json!({ + "ambiguous": true, "matches": matches, "hint": "Retry with id alone." + }), + ); } return match r.symbol { Some(s) => emit_value( @@ -622,7 +646,7 @@ pub async fn dispatch_with_api( .and_then(|v| v.as_bool()) .unwrap_or(false), limit: args.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as u32, - format: Format::Markdown, + format: Format::Json, strip_prefix: Some(root.as_str().to_string()), }; Ok(api.context_markdown(&req).await?) @@ -1145,7 +1169,7 @@ async fn dispatch_binary_graph( // không cần thấy tiền tố absolute lặp lại trên từng dòng. /// Detail level cho một tool: arg `detail` ghi đè session default. -fn detail_from_args(args: &Value, session: DetailLevel) -> DetailLevel { +pub(crate) fn detail_from_args(args: &Value, session: DetailLevel) -> DetailLevel { args.get("detail") .and_then(|v| v.as_str()) .and_then(DetailLevel::parse) @@ -1160,12 +1184,189 @@ fn format_from_args(args: &Value, session: OutputStyle) -> OutputStyle { .unwrap_or(session) } +pub(crate) fn response_format_from_args( + name: &str, + args: &Value, + session: OutputStyle, +) -> OutputStyle { + if name == "codegraph_graphdoc_ingest" { + args.get("output_format") + .and_then(Value::as_str) + .and_then(OutputStyle::parse) + .unwrap_or(session) + } else { + format_from_args(args, session) + } +} + +/// Chung cho mọi response thành công, kể cả admin và các dataset phụ. +pub(crate) fn format_response( + root: &str, + text: &str, + detail: DetailLevel, + style: OutputStyle, +) -> Result { + let Ok(mut value) = serde_json::from_str::(text) else { + return Ok(text.to_owned()); + }; + normalize_response(&mut value, root, detail, style); + match style { + OutputStyle::Minimize => serde_json::to_string(&value), + OutputStyle::Medium => serde_json::to_string_pretty(&value), + } + .map_err(|e| Error::Invalid(e.to_string())) +} + +fn normalize_response(value: &mut Value, root: &str, detail: DetailLevel, style: OutputStyle) { + match value { + Value::Object(map) => { + let symbol_keys = [ + "id", + "name", + "kind", + "scope", + "scope_id", + "type_ref", + "type_name", + "file", + "line", + "end_line", + "signature", + "doc", + "annotations", + "language", + ]; + let symbol = ["id", "name", "kind", "file", "line"] + .iter() + .all(|key| map.contains_key(*key)) + && map.keys().all(|key| symbol_keys.contains(&key.as_str())); + let member_keys = ["id", "name", "kind", "line", "signature"]; + if matches!(detail, DetailLevel::Minimal) + && ["id", "name", "kind", "line"] + .iter() + .all(|key| map.contains_key(*key)) + && map.keys().all(|key| member_keys.contains(&key.as_str())) + { + map.remove("signature"); + } + if symbol { + let keys: &[&str] = match detail { + DetailLevel::Minimal => &["id", "name", "kind", "file", "line"], + DetailLevel::Medium => &["id", "name", "kind", "file", "line", "signature"], + DetailLevel::Verbose => &[ + "id", + "name", + "kind", + "scope", + "scope_id", + "type_ref", + "type_name", + "file", + "line", + "end_line", + "signature", + "doc", + "annotations", + "language", + ], + }; + if !matches!(detail, DetailLevel::Verbose) { + map.retain(|key, _| keys.contains(&key.as_str())); + } + if let Some(Value::String(path)) = map.get_mut("file") { + *path = strip_root_prefix(path, root).to_owned(); + } + if matches!(style, OutputStyle::Minimize) { + *value = Value::Array( + keys.iter() + .map(|key| { + map.get(*key) + // Detail medium: 0 nghĩa "absent" — cell null. + .filter(|cell| { + !matches!(detail, DetailLevel::Medium) + || !ZERO_SENTINEL_KEYS.contains(&key.as_ref()) + || !cell.is_u64() + || cell.as_u64() != Some(0) + }) + .cloned() + .unwrap_or(Value::Null) + }) + .collect(), + ); + return; + } + } + for (key, child) in map.iter_mut() { + // Giữ nguyên scalar document và annotation args. + if key == "value" || key == "args" { + continue; + } + if PATH_KEYS.contains(&key.as_str()) { + if let Value::String(path) = child { + *path = strip_root_prefix(path, root).to_owned(); + } + } + normalize_response(child, root, detail, style); + } + map.retain(|key, child| { + key == "value" || key == "args" || !is_default_value(key, child) + }); + } + Value::Array(items) => { + for item in items.iter_mut() { + normalize_response(item, root, detail, style); + } + if matches!(style, OutputStyle::Minimize) + && items.len() > 1 + && items.iter().all(Value::is_object) + { + let mut columns = std::collections::BTreeSet::new(); + for item in items.iter() { + columns.extend(item.as_object().unwrap().keys().cloned()); + } + let columns: Vec<_> = columns.into_iter().collect(); + let rows: Vec> = items + .iter() + .map(|item| { + columns + .iter() + .map(|key| item.get(key).cloned().unwrap_or(Value::Null)) + .collect() + }) + .collect(); + let table = json!({"columns": columns, "rows": rows}); + if serde_json::to_vec(&table).unwrap().len() + < serde_json::to_vec(items).unwrap().len() + { + *value = table; + } + } + } + _ => {} + } +} + /// Symbol JSON theo `detail` + `style`. `Minimize` (mặc định) → mảng vị trí cố /// định (order được document trong server-instructions.md; file đã relativize /// theo root — relativize_paths chỉ chạm object key, không chạm phần tử mảng); -/// `Medium` → object giữ key (field default bị lược sau trong `omit_defaults`). +/// `Medium` → object giữ key (field default bị lược trong formatter chung). fn symbol_json(root: &str, s: &Symbol, detail: DetailLevel, style: OutputStyle) -> Value { match style { + OutputStyle::Minimize if matches!(detail, DetailLevel::Minimal) => json!([ + s.id, + s.name, + s.kind.as_str(), + strip_root_prefix(&s.file, root), + s.line + ]), + OutputStyle::Minimize if matches!(detail, DetailLevel::Medium) => json!([ + s.id, + s.name, + s.kind.as_str(), + strip_root_prefix(&s.file, root), + s.line, + s.signature + ]), OutputStyle::Minimize => json!([ s.id, s.name, @@ -1236,6 +1437,9 @@ fn bin_row_json(root: &str, r: &codegraph_extract::BinSymbolRow, style: OutputSt /// Strip `root/` prefix khỏi một path — chỉ khi root là tiền tố theo boundary /// (`root` + `/`), tránh cắt nhầm `/root2/...`. Giữ nguyên nếu không khớp. pub(crate) fn strip_root_prefix<'a>(path: &'a str, root: &str) -> &'a str { + if root.is_empty() { + return path; + } if let Some(rest) = path.strip_prefix(root) { if let Some(rest) = rest.strip_prefix('/') { return rest; @@ -1252,6 +1456,9 @@ fn relativize_paths(v: &mut Value, root: &str) { match v { Value::Object(map) => { for (k, val) in map.iter_mut() { + if k == "value" || k == "args" { + continue; + } if PATH_KEYS.contains(&k.as_str()) { if let Some(s) = val.as_str() { *val = Value::String(strip_root_prefix(s, root).to_string()); @@ -1274,8 +1481,7 @@ fn relativize_paths(v: &mut Value, root: &str) { fn emit_value(root: &str, v: Value) -> Result { let mut v = v; relativize_paths(&mut v, root); - omit_defaults(&mut v); - serde_json::to_string_pretty(&v).map_err(|e| Error::Invalid(e.to_string())) + serde_json::to_string(&v).map_err(|e| Error::Invalid(e.to_string())) } /// `emit_value` cho bất kỳ type serializable nào (chuyển qua `to_value`). @@ -1301,32 +1507,6 @@ fn is_default_value(key: &str, v: &Value) -> bool { } } -/// Lược bỏ key có value mặc định trong mọi OBJECT (in-place). ARRAY không bao -/// giờ bị xóa phần tử — schema mảng vị trí cố định (style `minimize`) phải giữ -/// nguyên độ dài; chỉ object con bên trong được xử lý tiếp. -/// -/// Giữ thứ tự key (preserve_order): `mem::take` + rebuild — `Map::remove` là -/// swap-remove (đảo thứ tự), `shift_remove` không có sẵn trên mọi bản serde_json. -pub(crate) fn omit_defaults(v: &mut Value) { - match v { - Value::Object(map) => { - let old = std::mem::take(map); - for (k, mut child) in old { - omit_defaults(&mut child); - if !is_default_value(&k, &child) { - map.insert(k, child); - } - } - } - Value::Array(arr) => { - for item in arr.iter_mut() { - omit_defaults(item); - } - } - _ => {} - } -} - // ── Document tool dispatch ── pub async fn dispatch_doc_ingest( @@ -1342,7 +1522,7 @@ pub async fn dispatch_doc_ingest( .ingest_file(path, format.as_deref()) .await .map_err(|e| Error::Other(e.to_string()))?; - Ok(format!("ingested {path} → doc_id={inserted}")) + emit_value("", json!({"path": path, "doc_id": inserted})) } pub async fn dispatch_doc_search( @@ -1647,7 +1827,7 @@ pub async fn dispatch_doc_remove( .remove_document(doc_id) .await .map_err(|e| Error::Other(e.to_string()))?; - Ok(format!("removed doc {doc_id}")) + emit_value("", json!({"removed": doc_id})) } pub async fn dispatch_doc_stats(doc_graph: Arc) -> Result { @@ -1659,7 +1839,7 @@ pub async fn dispatch_doc_stats(doc_graph: Arc) -> Result .stats() .await .map_err(|e| Error::Other(e.to_string()))?; - Ok(format!("documents: {}\nnodes: {}", stats.docs, stats.nodes)) + emit_value("", json!({"documents": stats.docs, "nodes": stats.nodes})) } // ── Binary tool dispatch ── @@ -1683,11 +1863,16 @@ fn parse_bin_kind(s: &str) -> Option { } } -pub async fn dispatch_binary(root: &Utf8Path, name: &str, args: Value) -> Result { +pub async fn dispatch_binary( + root: &Utf8Path, + name: &str, + args: Value, + session_format: OutputStyle, +) -> Result { let graph = codegraph_extract::BinaryGraph::open_from_config(root) .await .map_err(|e| Error::Other(e.to_string()))?; - let format = format_from_args(&args, OutputStyle::Minimize); + let format = format_from_args(&args, session_format); match name { "codegraph_graphbin_list" => { let kind = args diff --git a/crates/codegraph-mcp/src/usage.rs b/crates/codegraph-mcp/src/usage.rs index 5acca5e5c..e32ba976b 100644 --- a/crates/codegraph-mcp/src/usage.rs +++ b/crates/codegraph-mcp/src/usage.rs @@ -140,6 +140,22 @@ fn collect_file_paths(v: &Value, out: &mut Vec) { } } Value::Array(arr) => { + let file_index = match arr.len() { + 5 | 6 => Some(3), + 14 => Some(7), + _ => None, + }; + if arr.first().is_some_and(Value::is_u64) + && arr + .get(2) + .and_then(Value::as_str) + .and_then(codegraph_core::SymbolKind::parse) + .is_some() + { + if let Some(path) = file_index.and_then(|i| arr.get(i)).and_then(Value::as_str) { + out.push(path.to_owned()); + } + } for val in arr { collect_file_paths(val, out); } From f42bdd8fd015f42d7ad9bc7fa173009868c40c05 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 17 Sep 2026 20:31:55 +0700 Subject: [PATCH 3/5] Fix lint --- crates/codegraph-mcp/src/tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index bd570e39e..84149cb15 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -1284,7 +1284,7 @@ fn normalize_response(value: &mut Value, root: &str, detail: DetailLevel, style: // Detail medium: 0 nghĩa "absent" — cell null. .filter(|cell| { !matches!(detail, DetailLevel::Medium) - || !ZERO_SENTINEL_KEYS.contains(&key.as_ref()) + || !ZERO_SENTINEL_KEYS.contains(key) || !cell.is_u64() || cell.as_u64() != Some(0) }) From 0c0e7a9a346f1e3aff45d79290288d77de6d5c51 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 17 Sep 2026 20:48:26 +0700 Subject: [PATCH 4/5] Change minimize to minimal --- crates/codegraph-api/src/session.rs | 10 ++-- crates/codegraph-api/src/tools.rs | 4 +- crates/codegraph-graphql/src/lib.rs | 4 +- crates/codegraph-graphql/src/mutation.rs | 2 +- crates/codegraph-mcp/src/callers_tests.rs | 14 +++--- crates/codegraph-mcp/src/lib.rs | 2 +- crates/codegraph-mcp/src/response_tests.rs | 25 ++++++---- .../codegraph-mcp/src/server-instructions.md | 10 ++-- crates/codegraph-mcp/src/tools.rs | 48 +++++++++---------- crates/codegraph/src/main.rs | 8 ++-- 10 files changed, 68 insertions(+), 59 deletions(-) diff --git a/crates/codegraph-api/src/session.rs b/crates/codegraph-api/src/session.rs index 44e24865f..6e50ba701 100644 --- a/crates/codegraph-api/src/session.rs +++ b/crates/codegraph-api/src/session.rs @@ -60,16 +60,16 @@ pub enum OutputStyle { /// Mặc định — nhỏ gọn nhất: symbol thành mảng vị trí cố định (chỉ value, /// order được document; value thiếu = sentinel null/0/""/[]). #[default] - Minimize, + Minimal, /// Giữ key, lược bỏ field có value mặc định (None/0/""/[]/{}). Medium, } impl OutputStyle { - /// Parse từ tên arg (`minimize`/`medium`) — `None` nếu lạ. + /// Parse từ tên arg (`minimal`/`medium`) — `None` nếu lạ. pub fn parse(s: &str) -> Option { Some(match s { - "minimize" => Self::Minimize, + "minimal" => Self::Minimal, "medium" => Self::Medium, _ => return None, }) @@ -77,7 +77,7 @@ impl OutputStyle { pub fn as_str(self) -> &'static str { match self { - Self::Minimize => "minimize", + Self::Minimal => "minimal", Self::Medium => "medium", } } @@ -221,7 +221,7 @@ impl Session { *self.detail.read().await } - /// Output format hiện tại (minimize/medium) cho mọi response. + /// Output format hiện tại (minimal/medium) cho mọi response. pub async fn format(&self) -> OutputStyle { *self.format.read().await } diff --git a/crates/codegraph-api/src/tools.rs b/crates/codegraph-api/src/tools.rs index cc890450c..06f7bb8f0 100644 --- a/crates/codegraph-api/src/tools.rs +++ b/crates/codegraph-api/src/tools.rs @@ -68,7 +68,7 @@ pub fn emit(root: &str, v: &T) -> Result { /// Serialize giữ nguyên structure (không lược default): các frontend formatter /// (vd MCP `format_response`) cần sentinel gốc (0 / [] / "") để dựng layout -/// `minimize` chi tiết-correct; pruning ở đây sẽ làm mất data trước formatter. +/// `minimal` chi tiết-correct; pruning ở đây sẽ làm mất data trước formatter. pub fn emit_unpruned(root: &str, v: Value) -> Result { let mut v = v; relativize_paths(&mut v, root); @@ -93,7 +93,7 @@ fn is_default_value(key: &str, v: &Value) -> bool { } /// Lược bỏ key có value mặc định trong mọi OBJECT (in-place). ARRAY không bao -/// giờ bị xóa phần tử — schema mảng vị trí cố định (style `minimize`) phải giữ +/// giờ bị xóa phần tử — schema mảng vị trí cố định (style `minimal`) phải giữ /// nguyên độ dài; chỉ object con bên trong được xử lý tiếp. pub fn omit_defaults(v: &mut Value) { match v { diff --git a/crates/codegraph-graphql/src/lib.rs b/crates/codegraph-graphql/src/lib.rs index 0a63f2b6d..6099bd7dd 100644 --- a/crates/codegraph-graphql/src/lib.rs +++ b/crates/codegraph-graphql/src/lib.rs @@ -165,7 +165,7 @@ mod tests { use tokio::sync::RwLock as TokioRwLock; fn make_state(mermaid: bool) -> Arc { - let session = Session::new_with_format(OutputStyle::Minimize); + let session = Session::new_with_format(OutputStyle::Minimal); let storage: Arc> = Arc::new(TokioRwLock::new(InMemoryStorage::default())); let doc_graph = Arc::new(TokioRwLock::new(DocumentGraph::new( @@ -185,7 +185,7 @@ mod tests { addr: "127.0.0.1:0".parse().unwrap(), api_key: None, root: None, - format: OutputStyle::Minimize, + format: OutputStyle::Minimal, allow_hosts: vec![], mermaid, } diff --git a/crates/codegraph-graphql/src/mutation.rs b/crates/codegraph-graphql/src/mutation.rs index 126a34f1a..521a908a8 100644 --- a/crates/codegraph-graphql/src/mutation.rs +++ b/crates/codegraph-graphql/src/mutation.rs @@ -21,7 +21,7 @@ impl Mutation { /// Bind session vào một workspace root: tạo `.codegraph/` + config, index /// CHỈ khi `index = true` (mặc định false — bind nhanh, không block). Sau /// đó mới gọi được các query đọc. `detail` = minimal/medium/verbose; - /// `format` = minimize/medium (không set → giữ seed từ CLI). + /// `format` = minimal/medium (không set → giữ seed từ CLI). async fn init( &self, ctx: &Context<'_>, diff --git a/crates/codegraph-mcp/src/callers_tests.rs b/crates/codegraph-mcp/src/callers_tests.rs index 4bb72a154..18b054fed 100644 --- a/crates/codegraph-mcp/src/callers_tests.rs +++ b/crates/codegraph-mcp/src/callers_tests.rs @@ -47,25 +47,25 @@ async fn callers_timeout_resume_dispatch() { .unwrap(); drop(idx); let api = GraphApi::new_with_index(Arc::new(SharedGraphIndex::open(Some(dsn)).await.unwrap())); - let minimized = dispatch_with_api( + let compact = dispatch_with_api( &api, root, DetailLevel::Medium, - OutputStyle::Minimize, + OutputStyle::Minimal, false, "codegraph_symbol", - json!({"id": a, "format": "minimize"}), + json!({"id": a, "format": "minimal"}), ) .await .unwrap(); - let minimized: Value = serde_json::from_str(&minimized).unwrap(); - assert_eq!(minimized.as_array().unwrap().len(), 6); + let compact: Value = serde_json::from_str(&compact).unwrap(); + assert_eq!(compact.as_array().unwrap().len(), 6); let context = dispatch_with_api( &api, root, DetailLevel::Minimal, - OutputStyle::Minimize, + OutputStyle::Minimal, false, "codegraph_context", json!({"query":"caller","depth":1}), @@ -76,7 +76,7 @@ async fn callers_timeout_resume_dispatch() { root.as_str(), &context, DetailLevel::Minimal, - OutputStyle::Minimize, + OutputStyle::Minimal, ) .unwrap(); let context: Value = serde_json::from_str(&context).unwrap(); diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index 77eb84c0d..34444c949 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -211,7 +211,7 @@ impl CodegraphServer { .and_then(|v| v.as_str()) .and_then(DetailLevel::parse) .unwrap_or_default(); - // Output format (minimize/medium) — None giữ nguyên seed từ CLI. + // Output format (minimal/medium) — None giữ nguyên seed từ CLI. let format = args .get("format") .and_then(|v| v.as_str()) diff --git a/crates/codegraph-mcp/src/response_tests.rs b/crates/codegraph-mcp/src/response_tests.rs index 376193dcf..cd96beb85 100644 --- a/crates/codegraph-mcp/src/response_tests.rs +++ b/crates/codegraph-mcp/src/response_tests.rs @@ -21,13 +21,13 @@ fn nested_symbols_respect_all_detail_and_format_combinations() { (DetailLevel::Medium, 6), (DetailLevel::Verbose, 14), ] { - for style in [OutputStyle::Minimize, OutputStyle::Medium] { + for style in [OutputStyle::Minimal, OutputStyle::Medium] { let input = json!({"symbol":symbol(),"matches":[symbol()],"source":"fn example() {\n false\n}"}); let output = tools::format_response("/repo", &input.to_string(), detail, style).unwrap(); let result: Value = serde_json::from_str(&output).unwrap(); assert_eq!(result["source"], input["source"]); - if style == OutputStyle::Minimize { + if style == OutputStyle::Minimal { assert!(!output.contains('\n')); assert_eq!(result["symbol"].as_array().unwrap().len(), size); assert_eq!(result["matches"][0], result["symbol"]); @@ -57,7 +57,7 @@ fn repeated_records_are_smaller_and_decodable() { "/repo", &input.to_string(), DetailLevel::Minimal, - OutputStyle::Minimize, + OutputStyle::Minimal, ) .unwrap(); let medium = tools::format_response( @@ -90,9 +90,9 @@ fn api_emitted_payloads_keep_sentinels_through_the_formatter() { let full = json!({"symbols":[symbol()]}); let raw = codegraph_api::tools::emit_value("/repo", full).unwrap(); let out = - tools::format_response("/repo", &raw, DetailLevel::Verbose, OutputStyle::Minimize).unwrap(); + tools::format_response("/repo", &raw, DetailLevel::Verbose, OutputStyle::Minimal).unwrap(); let result: Value = serde_json::from_str(&out).unwrap(); - // Minimize dựng mảng 14 cell từ object-symbol (thứ tự theo symbol_json); + // Minimal dựng mảng 14 cell từ object-symbol (thứ tự theo symbol_json); // sentinel phải là số 0 / [] gốc, không phải null do prune xảy ra trước. let cells = result["symbols"][0].as_array().unwrap(); assert_eq!(cells.len(), 14); @@ -114,7 +114,7 @@ fn document_values_and_annotation_args_are_preserved() { ] { let input = json!({"value":value,"args":{"enabled":false,"empty":""},"path":"/repo/a.json"}); - for style in [OutputStyle::Minimize, OutputStyle::Medium] { + for style in [OutputStyle::Minimal, OutputStyle::Medium] { let output = tools::format_response("/repo", &input.to_string(), DetailLevel::Minimal, style) .unwrap(); @@ -126,6 +126,15 @@ fn document_values_and_annotation_args_are_preserved() { } } +#[test] +fn minimal_format_name_round_trips() { + assert_eq!(OutputStyle::parse("minimal"), Some(OutputStyle::Minimal)); + assert_eq!(OutputStyle::Minimal.as_str(), "minimal"); + assert_eq!(OutputStyle::default(), OutputStyle::Minimal); + assert_eq!(OutputStyle::parse("medium"), Some(OutputStyle::Medium)); + assert_eq!(OutputStyle::Medium.as_str(), "medium"); +} + #[test] fn every_registered_tool_advertises_output_controls() { let tools = tools::rmcp_tools(); @@ -140,7 +149,7 @@ fn every_registered_tool_advertises_output_controls() { }; assert_eq!( props[key]["enum"], - json!(["minimize", "medium"]), + json!(["minimal", "medium"]), "{}", tool.name ); @@ -161,7 +170,7 @@ async fn server_routes_share_formatting_and_overrides() { server .run_tool( "codegraph_init", - json!({"path":dir.path(),"index":false,"detail":"minimal","format":"minimize"}), + json!({"path":dir.path(),"index":false,"detail":"minimal","format":"minimal"}), ) .await .unwrap(), diff --git a/crates/codegraph-mcp/src/server-instructions.md b/crates/codegraph-mcp/src/server-instructions.md index d63f06c3b..c68d27b19 100644 --- a/crates/codegraph-mcp/src/server-instructions.md +++ b/crates/codegraph-mcp/src/server-instructions.md @@ -9,7 +9,7 @@ One session per process. Bind before querying: non-blocking, does NOT index (`index` defaults `false`). Then `codegraph_index {}` builds/refreshes the index. Re-run with a new `path` to re-point. Optional defaults: `"detail":"minimal|medium|verbose"`, - `"format":"minimize|medium"`. + `"format":"minimal|medium"`. - `codegraph_deinit {}` — release session (index stays on disk). An unbound session refuses all query tools. A startup `--path` is already bound. @@ -73,11 +73,11 @@ Detail controls code symbols, including nested symbols and ambiguous matches; non-symbol records retain their tool-specific information. Use `detail:verbose` for full symbol metadata. File paths are relative to the workspace root. -## Response format (`minimize` = default) +## Response format (`minimal` = default) Every successful response passes through the same formatter, including admin, telemetry, documents, binaries, sandbox and diff tools. Errors and short textual not-found messages remain readable text. -- `minimize` — compact JSON without indentation. Symbol arrays follow the +- `minimal` — compact JSON without indentation. Symbol arrays follow the requested detail. Repeated object records become `{ "columns": [...], "rows": [[...], ...] }` when this reduces serialized size. Columns are sorted; every row preserves column positions, with `null` for absent/default cells. @@ -92,14 +92,14 @@ not-found messages remain readable text. - `codegraph_graphdoc_ingest` uses `output_format` for response formatting; its existing `format` still selects `hcl|yaml|json|toml` input parsing. -Symbol arrays (`minimize`) have a fixed layout for each detail: +Symbol arrays (`minimal`) have a fixed layout for each detail: - `minimal`: `[id,name,kind,file,line]` (5 fields). - `medium`: `[id,name,kind,file,line,signature]` (6 fields). - `verbose`: `[id,name,kind,scope,scope_id,type_ref,type_name,file,line,end_line,signature,doc,annotations,language]` (14 fields; legacy full layout). Never remove default-valued array cells or reorder them. This replaces the old always-14-field layout for minimal/medium detail; consumers must use the requested detail. -Binary row array (`minimize`), 9 fixed fields in order: +Binary row array (`minimal`), 9 fixed fields in order: `0` id, `1` name, `2` kind, `3` addr, `4` end_addr, `5` path(rel root), `6` flag, `7` lib, `8` signature. Returned by `codegraph_search_symbol` (`binary` section, `source: all|binary`), `codegraph_graphbin_list` and `codegraph_graphbin_addr`. diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 84149cb15..f0b063521 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -46,10 +46,10 @@ fn tool(name: &'static str, desc: &'static str, mut schema: Value) -> ToolDef { }; let format = props.entry(format_key).or_insert_with(|| { json!({ - "type": "string", "enum": ["minimize", "medium"] + "type": "string", "enum": ["minimal", "medium"] }) }); - format["description"] = json!("Response format; overrides session default. minimize = compact JSON with detail-aware symbol arrays and repeated records as {columns,rows}; medium = keyed JSON. See server instructions for array layouts."); + format["description"] = json!("Response format; overrides session default. minimal = compact JSON with detail-aware symbol arrays and repeated records as {columns,rows}; medium = keyed JSON. See server instructions for array layouts."); if name != "codegraph_init" { format.as_object_mut().unwrap().remove("default"); } @@ -78,7 +78,7 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol as a fixed-order positional array (default), medium = full object with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol as a fixed-order positional array (default), medium = full object with default-valued fields omitted." } } }), ), tool( @@ -90,7 +90,7 @@ fn tool_defs() -> Vec { "node": { "type": "integer" }, "depth": { "type": "integer", "default": 1 }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["node"] }), ), tool( @@ -99,7 +99,7 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "node": { "type": "integer" }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["node"] }), ), tool( @@ -109,7 +109,7 @@ fn tool_defs() -> Vec { "node": { "type": "integer" }, "max_depth": { "type": "integer", "default": 3 }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["node"] }), ), tool( @@ -118,7 +118,7 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "node": { "type": "integer" }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Detail for the embedded symbol (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["node"] }), ), tool( @@ -185,7 +185,7 @@ fn tool_defs() -> Vec { "path": { "type": "string", "description": "Absolute path of the workspace root to bind this session to." }, "index": { "type": "boolean", "default": false }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "default": "medium", "description": "Default symbol detail for list-tool responses: minimal = id/name/kind/file/line (fewest tokens), medium = + signature, verbose = full Symbol (doc, annotations, ...). Per-call detail overrides this." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "default": "minimize", "description": "Output format for every response: minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted. Per-call format overrides this." } + "format": { "type": "string", "enum": ["minimal", "medium"], "default": "minimal", "description": "Output format for every response: minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted. Per-call format overrides this." } }, "required": ["path"] }), ), tool( @@ -212,7 +212,7 @@ fn tool_defs() -> Vec { "resume": { "type": "string", "description": "Resume id from a previous timeout (or from a previous response with more pages) — retry the same call with this to continue where it stopped." }, "timeout_ms": { "type": "integer", "default": 20000, "description": "Soft time budget in ms; 0 = no limit. On timeout the tool errors with a resume id." }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } }, "required": ["query"] }), ), // ── Class queries (codegraph_graphcode_class / codegraph_graphcode_list_types) ── @@ -222,7 +222,7 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "class_name": { "type": "string" }, "id": { "type": "integer" }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = embedded class symbol as a fixed-order positional array (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = embedded class symbol as a fixed-order positional array (default), medium = objects with default-valued fields omitted." } } }), ), tool( @@ -233,7 +233,7 @@ fn tool_defs() -> Vec { "limit": { "type": "integer", "default": 20 }, "offset": { "type": "integer", "default": 0 }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." }, + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." }, "timeout_ms": { "type": "integer", "default": 20000, "description": "Soft time budget in ms; 0 = no limit. On timeout the tool errors with a resume id." }, "resume": { "type": "string", "description": "Resume id from a previous timeout — retry the same call with this to continue where it stopped." } } }), @@ -244,7 +244,7 @@ fn tool_defs() -> Vec { json!({ "type": "object", "properties": { "func_name": { "type": "string" }, "id": { "type": "integer" }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = function/params/locals as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = function/params/locals as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." } } }), ), // ── Annotation / call / dependency queries ── @@ -257,7 +257,7 @@ fn tool_defs() -> Vec { "limit": { "type": "integer", "default": 20 }, "offset": { "type": "integer", "default": 0 }, "detail": { "type": "string", "enum": ["minimal", "medium", "verbose"], "description": "Symbol detail for this call (overrides session default): minimal = id/name/kind/file/line, medium = + signature, verbose = full Symbol." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "description": "Output format for this call (overrides session default): minimize = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." }, + "format": { "type": "string", "enum": ["minimal", "medium"], "description": "Output format for this call (overrides session default): minimal = symbol items as fixed-order positional arrays (default), medium = objects with default-valued fields omitted." }, "timeout_ms": { "type": "integer", "default": 20000, "description": "Soft time budget in ms; 0 = no limit. On timeout the tool errors with a resume id." }, "resume": { "type": "string", "description": "Resume id from a previous timeout — retry the same call with this to continue where it stopped." } }, "required": ["annotation"] }), @@ -414,7 +414,7 @@ fn tool_defs() -> Vec { "order": { "type": "string", "enum": ["name", "addr", "id"], "default": "name" }, "offset": { "type": "integer", "default": 0 }, "limit": { "type": "integer", "default": 50, "description": "Max rows per page." }, - "format": { "type": "string", "enum": ["minimize", "medium"], "default": "minimize", "description": "Output format: minimize = rows as fixed-order positional arrays [id, name, kind, addr, end_addr, path, flag, lib, signature] (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "default": "minimal", "description": "Output format: minimal = rows as fixed-order positional arrays [id, name, kind, addr, end_addr, path, flag, lib, signature] (default), medium = objects with default-valued fields omitted." } } }), ), tool( @@ -424,7 +424,7 @@ fn tool_defs() -> Vec { "addr": { "type": "integer", "description": "Virtual address to look up (omit to list entrypoints)." }, "path": { "type": "string", "description": "Binary path for entrypoint listing." }, "limit": { "type": "integer", "default": 20 }, - "format": { "type": "string", "enum": ["minimize", "medium"], "default": "minimize", "description": "Output format: minimize = rows as fixed-order positional arrays [id, name, kind, addr, end_addr, path, flag, lib, signature] (default), medium = objects with default-valued fields omitted." } + "format": { "type": "string", "enum": ["minimal", "medium"], "default": "minimal", "description": "Output format: minimal = rows as fixed-order positional arrays [id, name, kind, addr, end_addr, path, flag, lib, signature] (default), medium = objects with default-valued fields omitted." } } }), ), tool( @@ -1211,7 +1211,7 @@ pub(crate) fn format_response( }; normalize_response(&mut value, root, detail, style); match style { - OutputStyle::Minimize => serde_json::to_string(&value), + OutputStyle::Minimal => serde_json::to_string(&value), OutputStyle::Medium => serde_json::to_string_pretty(&value), } .map_err(|e| Error::Invalid(e.to_string())) @@ -1276,7 +1276,7 @@ fn normalize_response(value: &mut Value, root: &str, detail: DetailLevel, style: if let Some(Value::String(path)) = map.get_mut("file") { *path = strip_root_prefix(path, root).to_owned(); } - if matches!(style, OutputStyle::Minimize) { + if matches!(style, OutputStyle::Minimal) { *value = Value::Array( keys.iter() .map(|key| { @@ -1316,7 +1316,7 @@ fn normalize_response(value: &mut Value, root: &str, detail: DetailLevel, style: for item in items.iter_mut() { normalize_response(item, root, detail, style); } - if matches!(style, OutputStyle::Minimize) + if matches!(style, OutputStyle::Minimal) && items.len() > 1 && items.iter().all(Value::is_object) { @@ -1346,20 +1346,20 @@ fn normalize_response(value: &mut Value, root: &str, detail: DetailLevel, style: } } -/// Symbol JSON theo `detail` + `style`. `Minimize` (mặc định) → mảng vị trí cố +/// Symbol JSON theo `detail` + `style`. `Minimal` (mặc định) → mảng vị trí cố /// định (order được document trong server-instructions.md; file đã relativize /// theo root — relativize_paths chỉ chạm object key, không chạm phần tử mảng); /// `Medium` → object giữ key (field default bị lược trong formatter chung). fn symbol_json(root: &str, s: &Symbol, detail: DetailLevel, style: OutputStyle) -> Value { match style { - OutputStyle::Minimize if matches!(detail, DetailLevel::Minimal) => json!([ + OutputStyle::Minimal if matches!(detail, DetailLevel::Minimal) => json!([ s.id, s.name, s.kind.as_str(), strip_root_prefix(&s.file, root), s.line ]), - OutputStyle::Minimize if matches!(detail, DetailLevel::Medium) => json!([ + OutputStyle::Minimal if matches!(detail, DetailLevel::Medium) => json!([ s.id, s.name, s.kind.as_str(), @@ -1367,7 +1367,7 @@ fn symbol_json(root: &str, s: &Symbol, detail: DetailLevel, style: OutputStyle) s.line, s.signature ]), - OutputStyle::Minimize => json!([ + OutputStyle::Minimal => json!([ s.id, s.name, s.kind.as_str(), @@ -1404,12 +1404,12 @@ fn symbol_json(root: &str, s: &Symbol, detail: DetailLevel, style: OutputStyle) } } -/// Binary symbol row JSON theo `style`. `Minimize` → mảng vị trí cố định +/// Binary symbol row JSON theo `style`. `Minimal` → mảng vị trí cố định /// [id, name, kind, addr, end_addr, path, flag, lib, signature] (path đã /// relativize); `Medium` → object (field default được lược trong `emit_value`). fn bin_row_json(root: &str, r: &codegraph_extract::BinSymbolRow, style: OutputStyle) -> Value { match style { - OutputStyle::Minimize => json!([ + OutputStyle::Minimal => json!([ r.id, r.name, r.kind, diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index ffa488a66..18e6a7df8 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -119,10 +119,10 @@ enum Cmd { #[arg(long = "allow-any-host")] allow_any_host: bool, /// Output format cho mọi response (Binance-style minimal): - /// minimize (mặc định) = symbol thành mảng vị trí cố định; medium = giữ + /// minimal (mặc định) = symbol thành mảng vị trí cố định; medium = giữ /// key, lược field có value mặc định. Ghi đè được theo session /// (codegraph_init {"format": ...}) và từng call (arg "format"). - #[arg(long, value_enum, default_value_t = OutputFormat::Minimize)] + #[arg(long, value_enum, default_value_t = OutputFormat::Minimal)] format: OutputFormat, /// Bật endpoint observability: `/health`, `/metrics`, `/metrics/prometheus`. #[arg(long = "enable-observability", default_value_t = true)] @@ -139,14 +139,14 @@ enum Cmd { #[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] enum OutputFormat { #[default] - Minimize, + Minimal, Medium, } impl OutputFormat { fn style(self) -> codegraph_mcp::OutputStyle { match self { - Self::Minimize => codegraph_mcp::OutputStyle::Minimize, + Self::Minimal => codegraph_mcp::OutputStyle::Minimal, Self::Medium => codegraph_mcp::OutputStyle::Medium, } } From 0779ddc2202b0aedb5c3ae74de3f555c70046a00 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 17 Sep 2026 20:57:15 +0700 Subject: [PATCH 5/5] Bump version v2.2.3 --- Cargo.lock | 26 ++++++++++++------------- Cargo.toml | 2 +- packaging/aur/codegraph-rs-bin/PKGBUILD | 2 +- packaging/choco/codegraph.nuspec | 2 +- packaging/winget/codegraph.yaml | 4 ++-- scripts/install.ps1 | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f278833e..830a844ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codegraph" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "camino", @@ -743,7 +743,7 @@ dependencies = [ [[package]] name = "codegraph-api" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "camino", @@ -760,7 +760,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "camino", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "codegraph-binary" -version = "2.2.2" +version = "2.2.3" dependencies = [ "camino", "codegraph-core", @@ -796,7 +796,7 @@ dependencies = [ [[package]] name = "codegraph-context" -version = "2.2.2" +version = "2.2.3" dependencies = [ "codegraph-core", "codegraph-graph", @@ -808,7 +808,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "2.2.2" +version = "2.2.3" dependencies = [ "async-graphql", "camino", @@ -819,7 +819,7 @@ dependencies = [ [[package]] name = "codegraph-docs" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "codegraph-core", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "2.2.2" +version = "2.2.3" dependencies = [ "camino", "codegraph-binary", @@ -875,7 +875,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "2.2.2" +version = "2.2.3" dependencies = [ "async-trait", "bincode", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "codegraph-graphql" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "async-graphql", @@ -928,7 +928,7 @@ dependencies = [ [[package]] name = "codegraph-installer" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "camino", @@ -944,7 +944,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "2.2.2" +version = "2.2.3" dependencies = [ "anyhow", "axum", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "codegraph-sboxes" -version = "2.2.2" +version = "2.2.3" dependencies = [ "camino", "codegraph-core", diff --git a/Cargo.toml b/Cargo.toml index 549f4865c..1e7626735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "2.2.2" +version = "2.2.3" edition = "2021" rust-version = "1.80" license = "MIT" diff --git a/packaging/aur/codegraph-rs-bin/PKGBUILD b/packaging/aur/codegraph-rs-bin/PKGBUILD index c1d568954..fabb856d2 100644 --- a/packaging/aur/codegraph-rs-bin/PKGBUILD +++ b/packaging/aur/codegraph-rs-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Hung Pham pkgname=codegraph-rs-bin -pkgver=2.2.2 +pkgver=2.2.3 pkgrel=1 pkgdesc="Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary)" arch=('x86_64' 'aarch64') diff --git a/packaging/choco/codegraph.nuspec b/packaging/choco/codegraph.nuspec index 867b14fa7..5835a5097 100644 --- a/packaging/choco/codegraph.nuspec +++ b/packaging/choco/codegraph.nuspec @@ -2,7 +2,7 @@ codegraph - 2.2.2 + 2.2.3 codegraph Hung Pham https://github.com/hungpham10/codegraph-rs diff --git a/packaging/winget/codegraph.yaml b/packaging/winget/codegraph.yaml index 1c421ed9f..071d517c4 100644 --- a/packaging/winget/codegraph.yaml +++ b/packaging/winget/codegraph.yaml @@ -6,7 +6,7 @@ # release time (or automate it in the release pipeline before submitting to # microsoft/winget-pkgs). PackageIdentifier: hungpham10.codegraph -PackageVersion: 2.2.2 +PackageVersion: 2.2.3 PackageName: codegraph Publisher: Hung Pham PublisherUrl: https://github.com/hungpham10/codegraph-rs @@ -18,7 +18,7 @@ PackageUrl: https://github.com/hungpham10/codegraph-rs InstallerType: zip Installers: - Architecture: x64 - InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.2.2/codegraph-x86_64-pc-windows-msvc.zip + InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.2.3/codegraph-x86_64-pc-windows-msvc.zip InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 InstallerType: zip ManifestType: singleton diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c40b41887..26ab0efc9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,11 +5,11 @@ # # Usage (pin a version / download the script first): # irm https://raw.githubusercontent.com/hungpham10/codegraph-rs/main/scripts/install.ps1 -OutFile install.ps1 -# .\install.ps1 -Version 2.2.2 +# .\install.ps1 -Version 2.2.3 [CmdletBinding()] param( - # Pin a specific version, e.g. "2.2.2". Empty = latest release. + # Pin a specific version, e.g. "2.2.3". Empty = latest release. [string]$Version )