From 005fee066d7a14e0b4c4eb03b62ad32535ba02a9 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Fri, 9 Oct 2026 09:41:18 +0700 Subject: [PATCH 1/6] fix: mermaid graph connectivity + method chain order + closure body attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. structural_edges(): thêm rule cho marker→terminator/block-end (RETURN, BRANCH_END, SWITCH_END) để khắc phục node orphan (nổi rời graph) khi thân nhánh if/loop/switch chỉ chứa marker. 2. control_flow(): fix_call_edge_order() tính scope call theo stack CALL_ENTER/EXIT, đảo cạnh cho đúng runtime: - Nested call: con chạy trước cha (a.b().c() → c → b → a). - Cross-region (closure body): neo vào call owner (unwrap_or_else) thay vì nối tuần tự sau receiver (name_store.clone). - Thoát vùng call sang marker điều khiển: neo vào call cha ngoài cùng. 3. common.rs tag_into_var(): gán into_var vào call NGOÀI CÙNG (produce giá trị) thay vì call trong cùng — sửa biến gán method chain (self.x = storage.read().load_id() → gán cho load_id/map_err, không phải read). 4. mermaid.rs sanitize(): truncate 80 ký tự + '…' suffix; rút gọn label external call lấy segment cuối (map_err thay vì full chain). Test: all mermaid/graph/extract/mcp tests pass. --- crates/codegraph-api/src/mermaid.rs | 226 ++++++++++++++++-- .../codegraph-extract/src/languages/common.rs | 34 ++- crates/codegraph-extract/tests/chains.rs | 10 +- 3 files changed, 226 insertions(+), 44 deletions(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index 3ccb89ac3f..0af053ea2b 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -6,9 +6,10 @@ use crate::GraphApi; use codegraph_core::{ is_marker, marker_name, EffectType, FlowCall, FlowResult, SymbolId, MARKER_BRANCH_END, - MARKER_CALL_ENTER, MARKER_CALL_EXIT, MARKER_IF_FALSE, MARKER_IF_TRUE, MARKER_LOOP, - MARKER_LOOP_BACK, MARKER_STMT_END, MARKER_SWITCH_CASE, MARKER_SWITCH_CLOSE, MARKER_SWITCH_END, - MARKER_SWITCH_START, + MARKER_BREAK, MARKER_CALL_ENTER, MARKER_CALL_EXIT, MARKER_CONTINUE, MARKER_IF_FALSE, + MARKER_IF_TRUE, MARKER_LOOP, MARKER_LOOP_BACK, MARKER_RETURN, MARKER_STMT_END, + MARKER_SWITCH_CASE, MARKER_SWITCH_CLOSE, MARKER_SWITCH_END, MARKER_SWITCH_START, + MARKER_THROW, }; use std::collections::{HashMap, HashSet}; @@ -79,6 +80,11 @@ pub fn control_flow(flow: &FlowResult) -> String { let filtered_edges = structural_edges(&filtered_chain); all_edges.extend(filtered_edges); + // Method chain / closure body: chain lưu theo thứ tự emit (outer trước, inner + // sau) nhưng runtime chạy inner trước rồi mới outer. Điều chỉnh cạnh cho đúng + // thứ tự thực thi — xem `fix_call_edge_order`. + fix_call_edge_order(&flow.chain, &filtered, &mut all_edges); + // Dedup toàn bộ cạnh. all_edges.sort_unstable(); all_edges.dedup(); @@ -88,6 +94,134 @@ pub fn control_flow(flow: &FlowResult) -> String { out } +/// Lập bản đồ scope của từng node trong `filtered` (chỉ số đã lọc): +/// - `parent[i]`: chỉ số node call gần nhất bao lấy node `i` (MAX nếu top-level). +/// - `open[i]`: chỉ số gốc của `CALL_ENTER` mở ra vùng chứa node `i`. +fn call_scope( + chain: &[u64], + filtered: &[(usize, usize, u64)], +) -> (Vec, Vec) { + const NONE: usize = usize::MAX; + let n = filtered.len(); + let mut parent = vec![NONE; n]; + let mut open = vec![NONE; n]; + // Stack call đang mở; phần tử = chỉ số filtered của call sở hữu vùng đó, + // NONE = vùng vừa mở chưa gán owner (chờ node call đầu tiên). + let mut stack: Vec = Vec::new(); + let mut cur_open = NONE; + let mut fi = 0usize; + + for (i, &raw) in chain.iter().enumerate() { + match raw { + MARKER_CALL_ENTER => { + cur_open = i; + stack.push(NONE); + } + MARKER_CALL_EXIT => { + stack.pop(); + if stack.is_empty() { + cur_open = NONE; + } + } + MARKER_STMT_END => {} + _ => { + if fi >= n { + break; + } + parent[fi] = stack + .iter() + .rev() + .copied() + .find(|&x| x != NONE) + .unwrap_or(NONE); + open[fi] = cur_open; + if !is_marker(raw) { + if let Some(last) = stack.last() { + if *last == NONE { + let top = stack.len() - 1; + stack[top] = fi; + } + } + } + fi += 1; + } + } + } + (parent, open) +} + +/// Call cha ngoài cùng của node `x` (theo filtered index), `NONE` nếu top-level. +fn outermost_call(x: usize, parent: &[usize]) -> usize { + const NONE: usize = usize::MAX; + let mut cur = parent[x]; + let mut last = NONE; + while cur != NONE { + last = cur; + cur = parent[cur]; + } + last +} + +/// Sửa thứ tự cạnh giữa các call node cho đúng runtime. +/// +/// Chain emit theo pre-order nên node cha (call ngoài cùng) đứng TRƯỚC node con, +/// nhưng `a.b().c()` chạy `a.b()` rồi mới `.c()`. Ba trường hợp: +/// +/// 1. **Nested call** (`parent[b] == a`): đảo cạnh → `b → a` (con chạy trước). +/// 2. **Cross-region** (`a`, `b` thuộc 2 vùng con khác nhau của cùng call cha): +/// neo cạnh vào call sở hữu vùng thay vì nối tuần tự vô nghĩa. VD closure +/// `x.clone().unwrap_or_else(|| { ... })` — thân closure phải gắn với +/// `unwrap_or_else`, không phải nối tiếp sau `x.clone()`. +/// 3. **Rời vùng call sang control-flow marker**: neo vào call cha ngoài cùng. +fn fix_call_edge_order( + chain: &[u64], + filtered: &[(usize, usize, u64)], + edges: &mut [(usize, usize)], +) { + const NONE: usize = usize::MAX; + let n = filtered.len(); + if n < 2 { + return; + } + let (parent, open) = call_scope(chain, filtered); + let is_call = |i: usize| i < n && !is_marker(filtered[i].2); + let is_entry = |i: usize| { + i < n + && matches!( + filtered[i].2, + MARKER_IF_TRUE | MARKER_LOOP | MARKER_SWITCH_START + ) + }; + + for edge in edges.iter_mut() { + let (a, b) = (edge.0, edge.1); + if a >= n || b >= n { + continue; + } + // 1. Call lồng nhau — con chạy trước cha. + if is_call(a) && is_call(b) && parent[b] == a { + edge.0 = b; + edge.1 = a; + continue; + } + // 2. Sang vùng con khác của cùng call cha — neo vào owner của vùng `b`. + if is_call(a) && is_call(b) && open[a] != open[b] && parent[b] != a { + let owner = outermost_call(b, &parent); + if owner != NONE { + edge.0 = owner; + } + continue; + } + // 3. Từ trong vùng call ra marker điều khiển — neo vào call cha ngoài cùng. + if is_call(a) && is_entry(b) && open[a] != NONE && parent[a] != NONE { + let outer = outermost_call(a, &parent); + if outer != NONE && outer != a { + edge.0 = outer; + } + } + } +} + /// Phân loại một node → `(open, close, label)`. fn node_style( chain: &[u64], @@ -133,6 +267,20 @@ fn node_style( .map(|c| c.to_name.clone()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| desc.to_string()); + + // Rút gọn label dài của external call (desc chứa toàn bộ chain có xuống dòng/thụt lề). + // Lấy segment cuối cùng có ký tự chữ cái/số — ví dụ "map_err" từ "self.storage.read().await.load_next_id().await.map_err". + let label = if call.map(|c| c.to_id.is_none()).unwrap_or(false) && label.len() > 40 { + label + .split(['.', '(', ')', '[', ']', '{', '}', ' ', '\t', '\n', '\r']) + .rev() + .find(|s| !s.is_empty() && s.chars().any(|c| c.is_alphanumeric())) + .unwrap_or(&label) + .to_string() + } else { + label + }; + let mut label = label; if let Some(c) = call { if c.line > 0 { label.push_str(&format!(" · L{}", c.line)); @@ -194,8 +342,9 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // Ngăn xếp index của header loop đang mở. let mut loop_stack: Vec = Vec::new(); // Ngăn xếp entry point của các switch đang mở. - // Mỗi entry là index của node discriminant (điều kiện match) của switch đó. - let mut switch_stack: Vec = Vec::new(); + // Mỗi entry lưu (entry_index, vec) để ở SWITCH_CLOSE + // nối TẤT CẢ case end về node CLOSE, không chỉ case cuối. + let mut switch_stack: Vec<(usize, Vec)> = Vec::new(); // Ngăn xếp depth của call nesting (cho CALL_ENTER/CALL_EXIT). let mut call_stack: Vec = Vec::new(); @@ -211,23 +360,47 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // - marker -> non-marker: marker to first statement in body (but NOT from STMT_END) // - non-marker -> marker: for linear entry (IF_TRUE, LOOP, SWITCH_START) // AND for block end markers (BRANCH_END, SWITCH_END, SWITCH_CLOSE) - // - marker -> marker: NO sequential edge + // - marker -> marker: NO sequential edge (default) let is_linear_entry = matches!(curr, MARKER_IF_TRUE | MARKER_LOOP | MARKER_SWITCH_START); let is_block_end = matches!( curr, MARKER_BRANCH_END | MARKER_SWITCH_END | MARKER_SWITCH_CLOSE ); + let is_terminator = matches!( + curr, + MARKER_RETURN | MARKER_THROW | MARKER_BREAK | MARKER_CONTINUE + ); let prev_is_stmt_end = chain[prev] == MARKER_STMT_END; + // Prev marker đóng block (có thể nối đến linear-entry marker kế tiếp) + let prev_closes_block = matches!( + chain[prev], + MARKER_BRANCH_END | MARKER_SWITCH_END | MARKER_SWITCH_CLOSE + | MARKER_LOOP_BACK | MARKER_LOOP | MARKER_IF_TRUE | MARKER_SWITCH_START + ); if !prev_is_marker && !curr_is_marker { + // stmt -> stmt edges.push((prev, i)); } else if prev_is_marker && !curr_is_marker && !prev_is_stmt_end { - // marker -> non-marker, but NOT from STMT_END + // marker -> stmt (not from STMT_END) edges.push((prev, i)); - } else if !prev_is_marker && curr_is_marker && (is_linear_entry || is_block_end) { + } else if !prev_is_marker && curr_is_marker && (is_linear_entry || is_block_end || is_terminator) { + // stmt -> linear-entry / block-end / terminator + edges.push((prev, i)); + } else if prev_is_marker && curr_is_marker && is_linear_entry && prev_closes_block { + // Bug A: marker-closes-block -> linear-entry marker + // VD: SWITCH_CLOSE -> SWITCH_START, LOOP -> IF_TRUE, BRANCH_END -> IF_TRUE + edges.push((prev, i)); + } else if prev_is_marker && curr_is_marker && (is_terminator || is_block_end) { + // marker -> terminator/block-end marker: nhánh if/loop/switch-case mà + // thân (hoặc phần cuối thân) lại là MARKER, không phải call/statement. + // VD: `if c { return None; }` → IF_TRUE -> RETURN -> BRANCH_END; + // `match { A => return, .. }` → SWITCH_CASE -> RETURN -> SWITCH_END. + // Thiếu rule này thì RETURN/BRANCH_END/SWITCH_END bị ORPHAN (nổi + // rời ngoài graph). edges.push((prev, i)); } - // marker -> marker: no sequential edge + // marker -> marker: no sequential edge (except above) // non-marker -> branch marker (IF_FALSE, SWITCH_CASE): no sequential edge // STMT_END -> next statement: NO edge (separator) @@ -276,25 +449,30 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { MARKER_SWITCH_START => { // Switch start: prev is the discriminant (or previous statement) // Sequential edge from prev handled above - switch_stack.push(i); // SWITCH_START is the entry + switch_stack.push((i, Vec::new())); // (entry, case_ends) } MARKER_SWITCH_CASE => { - if let Some(&entry) = switch_stack.last() { - edges.push((entry, i)); + if let Some((entry, _)) = switch_stack.last() { + edges.push((*entry, i)); } else { edges.push((prev, i)); } } MARKER_SWITCH_END => { edges.push((prev, i)); - if (i + 1 >= n || chain[i + 1] != MARKER_SWITCH_CASE) && !switch_stack.is_empty() { - switch_stack.pop(); + // Gom SWITCH_END của case này, sẽ nối TẤT CẢ về SWITCH_CLOSE + if let Some((_entry, ends)) = switch_stack.last_mut() { + ends.push(i); } + // Không pop ở đây; đợi SWITCH_CLOSE để pop (chỉ có 1 SWITCH_CLOSE cuối cùng) } MARKER_SWITCH_CLOSE => { edges.push((prev, i)); - if !switch_stack.is_empty() { - switch_stack.pop(); + // Bug C: Nối TẤT CẢ case ends (SWITCH_END) đến SWITCH_CLOSE + if let Some((_entry, ends)) = switch_stack.pop() { + for end in ends { + edges.push((end, i)); + } } } MARKER_CALL_ENTER => { @@ -314,7 +492,10 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // edge from STMT_END to next statement, which we DON'T want. // Fix: treat STMT_END as a marker that should NOT connect to next. } - _ => {} + _ => { + // Bug B: Terminator (RETURN/THROW/BREAK/CONTINUE) đã nhận cạnh từ prev + // ở rule trên. Không cần xử lý thêm. + } } i += 1; } @@ -518,11 +699,12 @@ async fn bfs( /// Làm sạch label Mermaid: bỏ dấu ngoặc kép / xuống dòng, giới hạn 80 ký tự. fn sanitize(s: &str) -> String { - s.replace('"', "'") - .replace(['\n', '\r'], " ") - .chars() - .take(80) - .collect() + let cleaned = s.replace('"', "'").replace(['\n', '\r'], " "); + let mut out: String = cleaned.chars().take(80).collect(); + if out.chars().count() < cleaned.chars().count() { + out.push('…'); + } + out } #[cfg(test)] diff --git a/crates/codegraph-extract/src/languages/common.rs b/crates/codegraph-extract/src/languages/common.rs index ec6c90f9a0..a148343681 100644 --- a/crates/codegraph-extract/src/languages/common.rs +++ b/crates/codegraph-extract/src/languages/common.rs @@ -824,6 +824,20 @@ fn walk_chain( return; } + // Tag `into_var` vào call record ĐẦU TIÊN được emit sau `since` — đó là call + /// NGOÀI CÙNG của expression (walk_chain emit pre-order), tức call có kết quả + /// chính là giá trị của cả chain. Bỏ qua record nhãn nhánh (`call_name` rỗng) + /// do `emit_branch_label` tạo ra. + fn tag_into_var(ctx: &mut ChainCtx, since: usize, var: Option, is_new: bool) { + let Some(v) = var.filter(|v| !v.is_empty()) else { return }; + let Some(rec) = ctx.calls[since..] + .iter_mut() + .find(|r| !r.call_name.is_empty()) + else { return }; + rec.into_var = Some(v); + rec.is_new_var = is_new; + } + // 9. Let declaration (biến mới: `let x = expr`). if k == "let_declaration" { // Extract variable name from pattern. @@ -832,19 +846,10 @@ fn walk_chain( .and_then(|p| first_identifier(&p)) .and_then(|n| text(&n, ctx.src)); // Walk the initializer (rhs) to emit calls. - // Record call count before walking to detect if new calls were emitted. let calls_before = ctx.calls.len(); if let Some(value) = node.child_by_field_name("value") { walk_chain(ctx, &value, depth + 1, in_loop, condition.clone()); - // Only tag if new call records were emitted during the walk. - if ctx.calls.len() > calls_before { - if let Some(rec) = ctx.calls.last_mut() { - if let Some(v) = var { - rec.into_var = Some(v); - rec.is_new_var = true; - } - } - } + tag_into_var(ctx, calls_before, var, true); } return; } @@ -857,14 +862,7 @@ fn walk_chain( if let Some(v) = value { let calls_before = ctx.calls.len(); walk_chain(ctx, &v, depth + 1, in_loop, condition.clone()); - if ctx.calls.len() > calls_before { - if let Some(rec) = ctx.calls.last_mut() { - if let Some(var_name) = var { - rec.into_var = Some(var_name); - rec.is_new_var = false; - } - } - } + tag_into_var(ctx, calls_before, var, false); } return; } diff --git a/crates/codegraph-extract/tests/chains.rs b/crates/codegraph-extract/tests/chains.rs index 8556d00cca..9ba0ab2a5d 100644 --- a/crates/codegraph-extract/tests/chains.rs +++ b/crates/codegraph-extract/tests/chains.rs @@ -1388,24 +1388,26 @@ fn f() { } "#, ); + // with the fix: into_var is now on the OUTERMOST call (map_err), not innermost (read) + // this is correct because map_err produces the final value that gets assigned assert_eq!( c, [ "[CALL_ENTER]", - "self.storage.read().await.load_all_symbols().await.map_err", + "self.storage.read().await.load_all_symbols().await.map_err → symbols", "[CALL_ENTER]", "self.storage.read().await.load_all_symbols", "[CALL_ENTER]", - "self.storage.read → symbols", + "self.storage.read", "[CALL_EXIT]", "[CALL_EXIT]", "[CALL_EXIT]", "[CALL_ENTER]", - "self.storage.read().await.load_all_files().await.map_err", + "self.storage.read().await.load_all_files().await.map_err → self.files", "[CALL_ENTER]", "self.storage.read().await.load_all_files", "[CALL_ENTER]", - "self.storage.read → self.files", + "self.storage.read", "[CALL_EXIT]", "[CALL_EXIT]", "[CALL_EXIT]", From 3ab68a187ef5f3732b78995c3de7e842eb9dd139 Mon Sep 17 00:00:00 2001 From: hungpham10 <136320753+hungpham10@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:01:00 +0000 Subject: [PATCH 2/6] style: apply rustfmt --- crates/codegraph-api/src/mermaid.rs | 22 +++++++++++-------- .../codegraph-extract/src/languages/common.rs | 8 +++++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index 0af053ea2b..43bdb859d9 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -8,8 +8,7 @@ use codegraph_core::{ is_marker, marker_name, EffectType, FlowCall, FlowResult, SymbolId, MARKER_BRANCH_END, MARKER_BREAK, MARKER_CALL_ENTER, MARKER_CALL_EXIT, MARKER_CONTINUE, MARKER_IF_FALSE, MARKER_IF_TRUE, MARKER_LOOP, MARKER_LOOP_BACK, MARKER_RETURN, MARKER_STMT_END, - MARKER_SWITCH_CASE, MARKER_SWITCH_CLOSE, MARKER_SWITCH_END, MARKER_SWITCH_START, - MARKER_THROW, + MARKER_SWITCH_CASE, MARKER_SWITCH_CLOSE, MARKER_SWITCH_END, MARKER_SWITCH_START, MARKER_THROW, }; use std::collections::{HashMap, HashSet}; @@ -97,10 +96,7 @@ pub fn control_flow(flow: &FlowResult) -> String { /// Lập bản đồ scope của từng node trong `filtered` (chỉ số đã lọc): /// - `parent[i]`: chỉ số node call gần nhất bao lấy node `i` (MAX nếu top-level). /// - `open[i]`: chỉ số gốc của `CALL_ENTER` mở ra vùng chứa node `i`. -fn call_scope( - chain: &[u64], - filtered: &[(usize, usize, u64)], -) -> (Vec, Vec) { +fn call_scope(chain: &[u64], filtered: &[(usize, usize, u64)]) -> (Vec, Vec) { const NONE: usize = usize::MAX; let n = filtered.len(); let mut parent = vec![NONE; n]; @@ -374,8 +370,13 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // Prev marker đóng block (có thể nối đến linear-entry marker kế tiếp) let prev_closes_block = matches!( chain[prev], - MARKER_BRANCH_END | MARKER_SWITCH_END | MARKER_SWITCH_CLOSE - | MARKER_LOOP_BACK | MARKER_LOOP | MARKER_IF_TRUE | MARKER_SWITCH_START + MARKER_BRANCH_END + | MARKER_SWITCH_END + | MARKER_SWITCH_CLOSE + | MARKER_LOOP_BACK + | MARKER_LOOP + | MARKER_IF_TRUE + | MARKER_SWITCH_START ); if !prev_is_marker && !curr_is_marker { @@ -384,7 +385,10 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { } else if prev_is_marker && !curr_is_marker && !prev_is_stmt_end { // marker -> stmt (not from STMT_END) edges.push((prev, i)); - } else if !prev_is_marker && curr_is_marker && (is_linear_entry || is_block_end || is_terminator) { + } else if !prev_is_marker + && curr_is_marker + && (is_linear_entry || is_block_end || is_terminator) + { // stmt -> linear-entry / block-end / terminator edges.push((prev, i)); } else if prev_is_marker && curr_is_marker && is_linear_entry && prev_closes_block { diff --git a/crates/codegraph-extract/src/languages/common.rs b/crates/codegraph-extract/src/languages/common.rs index a148343681..89006cb04b 100644 --- a/crates/codegraph-extract/src/languages/common.rs +++ b/crates/codegraph-extract/src/languages/common.rs @@ -829,11 +829,15 @@ fn walk_chain( /// chính là giá trị của cả chain. Bỏ qua record nhãn nhánh (`call_name` rỗng) /// do `emit_branch_label` tạo ra. fn tag_into_var(ctx: &mut ChainCtx, since: usize, var: Option, is_new: bool) { - let Some(v) = var.filter(|v| !v.is_empty()) else { return }; + let Some(v) = var.filter(|v| !v.is_empty()) else { + return; + }; let Some(rec) = ctx.calls[since..] .iter_mut() .find(|r| !r.call_name.is_empty()) - else { return }; + else { + return; + }; rec.into_var = Some(v); rec.is_new_var = is_new; } From ea63b15af7fd37b1676d2169b1ce0df904664584 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Fri, 9 Oct 2026 10:07:56 +0700 Subject: [PATCH 3/6] fix(mermaid): preserve full method-chain label instead of last segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node_style() shortened external-call labels to the last dot-segment, so the outer call of a chain (whose CallRecord name is the whole chain text) rendered as just "ok" instead of "open_from_config(root).await.ok". Node identity was right; only the label was misleading. Replace the segment-shortening with whitespace collapsing (newline + indent -> single space) so the full chain stays readable, and sanitize() still caps at 80 chars with '…' for extreme cases. Before: c6[["ok · L1100 · ext"]] After: c6[["codegraph_extract::BinaryGraph::open_from_config(root) .await .ok · L1100 · ext"]] Tests: 11 mermaid + 39 extract lib + 104 graph all pass. --- crates/codegraph-api/src/mermaid.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index 43bdb859d9..b3c5021202 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -264,17 +264,16 @@ fn node_style( .filter(|n| !n.is_empty()) .unwrap_or_else(|| desc.to_string()); - // Rút gọn label dài của external call (desc chứa toàn bộ chain có xuống dòng/thụt lề). - // Lấy segment cuối cùng có ký tự chữ cái/số — ví dụ "map_err" từ "self.storage.read().await.load_next_id().await.map_err". - let label = if call.map(|c| c.to_id.is_none()).unwrap_or(false) && label.len() > 40 { - label - .split(['.', '(', ')', '[', ']', '{', '}', ' ', '\t', '\n', '\r']) - .rev() - .find(|s| !s.is_empty() && s.chars().any(|c| c.is_alphanumeric())) - .unwrap_or(&label) - .to_string() - } else { - label + // Rút gọn label dài của external call: desc/to_name chứa toàn bộ chain với + // newline + thụt lề (`self\n .storage\n .read()...`). Gộp whitespace thành + // 1 space để đọc được — vẫn giữ NGUYÊN chuỗi method chain, không cắt bớt + // segment (người đọc cần thấy `open_from_config(root).await.ok()` là gì). + let label = { + let collapsed: String = label + .split_whitespace() + .collect::>() + .join(" "); + collapsed }; let mut label = label; if let Some(c) = call { From b3ebb9fcb31f505f226b2d992b30acf21b4fed99 Mon Sep 17 00:00:00 2001 From: hungpham10 <136320753+hungpham10@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:08:45 +0000 Subject: [PATCH 4/6] style: apply rustfmt --- crates/codegraph-api/src/mermaid.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index b3c5021202..bde3279474 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -269,10 +269,7 @@ fn node_style( // 1 space để đọc được — vẫn giữ NGUYÊN chuỗi method chain, không cắt bớt // segment (người đọc cần thấy `open_from_config(root).await.ok()` là gì). let label = { - let collapsed: String = label - .split_whitespace() - .collect::>() - .join(" "); + let collapsed: String = label.split_whitespace().collect::>().join(" "); collapsed }; let mut label = label; From 9db619dc7b92fa8931a1d70c12cf2015f644df5a Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Fri, 9 Oct 2026 11:48:39 +0700 Subject: [PATCH 5/6] fix(mermaid): execution-order nodes + return/switch terminator semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 vấn đề cốt lõi trong flowchart control-flow: 1. RETURN/THROW/BREAK/CONTINUE bị nối vào BRANCH_END/SWITCH_END Rule marker->marker cũ cho phép moi edge, nen 'if c { return None; }' ve thanh IF_TRUE -> RETURN -> BRANCH_END -> tail: trong nhu return xong van chay tiep. Gio terminator KHONG con ra edge nao (no đói la dead-end đúng nghia), va false-path duoc dieu huong QUA BRANCH_END (cond -> BRANCH_END -> tail) de node merge khong bi orphan. Tuong tu cho SWITCH_END: case 'A => return' neo SWITCH_END vao case vua mo thay vi vao RETURN. 2. Method chain render nguoc thu tu thuc thi Chain emit PRE-ORDER (call ngoai cung truoc, call trong sau) nen mermaid ve 'bin_base -> ExtractConfig::load' va 'ok -> open_from_config'. Viết lai control_flow(): dung cay frame tu cap CALL_ENTER/CALL_EXIT roi phat lai POST-ORDER (con truoc, cha sau) qua execution_order(). Node + canh tuan tu tu do da dung runtime ma khong can va canh sau nay. Thay the fix_call_edge_order() (cach ve cu gay 6 orphan o new_with_storage) bang cach sap lai node. Ket qua (0 orphan / 0 unreachable, chi con dead-end la node cuoi): binary_graph_for : RETURN khong con noi toi BRANCH_END load -> bin_base, open_from_config -> ok new_with_storage : x.clone -> make -> unwrap_or_else (closure body giua) storage.clone -> Search::new -> ... -> Ok (Ok dung cuoi) main : giu nguyen 0 orphan / 0 unreachable rebuild : giu nguyen Test: +4 test moi (15/15 mermaid pass), 104 graph, 39 extract lib. --- crates/codegraph-api/src/mermaid.rs | 431 +++++++++++++++++++--------- 1 file changed, 288 insertions(+), 143 deletions(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index bde3279474..16a39a0771 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -43,48 +43,52 @@ pub fn control_flow(flow: &FlowResult) -> String { } filtered.push((filtered.len(), i, raw)); } + let n = filtered.len(); + + // Method chain lồng nhau: chain emit PRE-ORDER nên node call ngoài cùng đứng + // TRƯỚC node trong, nhưng `a.b().c()` chạy `a` -> `b` -> `c`. Đổi thứ tự + // (post-order trong vùng call lồng nhau) để node + cạnh tuần tự đã đúng + // runtime — không cần vá cạnh sau này. + let order = execution_order(&flow.chain, n); + debug_assert_eq!(order.len(), n); + + // old-filtered-index -> new position + let mut to_new = vec![0usize; n]; + for (new_i, &old_i) in order.iter().enumerate() { + to_new[old_i] = new_i; + } + + // Chain sau khi đổi thứ tự + bản đồ calls/labels theo chỉ số MỚI. + let new_chain: Vec = order.iter().map(|&o| filtered[o].2).collect(); + let mut new_calls: HashMap = HashMap::with_capacity(calls.len()); + let mut new_labels: HashMap = HashMap::with_capacity(labels.len()); + for (new_i, &old_i) in order.iter().enumerate() { + let orig_i = filtered[old_i].1; + if let Some(c) = calls.get(&orig_i) { + new_calls.insert(new_i, *c); + } + if let Some(l) = labels.get(&orig_i) { + new_labels.insert(new_i, *l); + } + } let mut out = String::from("flowchart TD\n"); - for (new_i, orig_i, raw) in &filtered { + for (new_i, raw) in new_chain.iter().enumerate() { + let orig_i = filtered[order[new_i]].1; let desc = flow .chain_desc - .get(*orig_i) + .get(orig_i) .map(String::as_str) .unwrap_or(""); - let (open, close, label) = node_style(&flow.chain, *orig_i, *raw, desc, &calls, &labels); + let (open, close, label) = + node_style(&new_chain, new_i, *raw, desc, &new_calls, &new_labels); out.push_str(&format!( " c{new_i}{open}\"{}\"{close}\n", sanitize(&label) )); } - // Cạnh cấu trúc, sắp xếp để output ổn định (không phụ thuộc thứ tự build). - // Gom tất cả cạnh từ cả 2 pass rồi dedup một lần để tránh duplicate edges. - let mut all_edges: Vec<(usize, usize)> = Vec::new(); - - // Pass 1: structural edges on original chain, remap to filtered indices. - let edges = structural_edges(&flow.chain); - let mut orig_to_filtered: Vec> = vec![None; flow.chain.len()]; - for (new_i, orig_i, _) in &filtered { - orig_to_filtered[*orig_i] = Some(*new_i); - } - for (a, b) in edges { - if let (Some(fa), Some(fb)) = (orig_to_filtered[a], orig_to_filtered[b]) { - all_edges.push((fa, fb)); - } - } - - // Pass 2: structural edges on filtered chain (preserves structural markers except CALL_ENTER/EXIT/STMT_END). - let filtered_chain: Vec = filtered.iter().map(|(_, _, raw)| *raw).collect(); - let filtered_edges = structural_edges(&filtered_chain); - all_edges.extend(filtered_edges); - - // Method chain / closure body: chain lưu theo thứ tự emit (outer trước, inner - // sau) nhưng runtime chạy inner trước rồi mới outer. Điều chỉnh cạnh cho đúng - // thứ tự thực thi — xem `fix_call_edge_order`. - fix_call_edge_order(&flow.chain, &filtered, &mut all_edges); - - // Dedup toàn bộ cạnh. + let mut all_edges = structural_edges(&new_chain); all_edges.sort_unstable(); all_edges.dedup(); for (a, b) in all_edges { @@ -93,30 +97,51 @@ pub fn control_flow(flow: &FlowResult) -> String { out } -/// Lập bản đồ scope của từng node trong `filtered` (chỉ số đã lọc): -/// - `parent[i]`: chỉ số node call gần nhất bao lấy node `i` (MAX nếu top-level). -/// - `open[i]`: chỉ số gốc của `CALL_ENTER` mở ra vùng chứa node `i`. -fn call_scope(chain: &[u64], filtered: &[(usize, usize, u64)]) -> (Vec, Vec) { - const NONE: usize = usize::MAX; - let n = filtered.len(); - let mut parent = vec![NONE; n]; - let mut open = vec![NONE; n]; - // Stack call đang mở; phần tử = chỉ số filtered của call sở hữu vùng đó, - // NONE = vùng vừa mở chưa gán owner (chờ node call đầu tiên). - let mut stack: Vec = Vec::new(); - let mut cur_open = NONE; +/// Item trong một frame: một node lẻ (marker / call không có con) hoặc một frame con. +enum FrameItem { + Node(usize), + Region(usize), +} + +/// Vùng mở bởi cặp `CALL_ENTER … CALL_EXIT`. Node đầu tiên trong vùng là call +/// sở hữu (`owner`); các node sau là receiver / argument / marker. +struct Frame { + owner: Option, + rest: Vec, +} + +/// Đổi thứ tự node theo THỰC THI (post-order trong vùng call lồng nhau). +/// +/// `walk_chain` gọi `chain_push(CALL_ENTER)` rồi `emit_call(cha)` rồi mới recurse +/// con — nên chain là pre-order: cha đứng trước con. Nhưng runtime chạy con +/// trước cha (`ExtractConfig::load(root)` → `.bin_base()`). Hàm này dựng cây +/// frame từ cặp CALL_ENTER/CALL_EXIT rồi phát lại post-order: **con trước, cha +/// sau**. Marker (IF_TRUE/RETURN/BRANCH_END…) giữ nguyên vị trí tương đối. +fn execution_order(chain: &[u64], n: usize) -> Vec { + let mut frames: Vec = vec![Frame { + owner: None, + rest: Vec::new(), + }]; + let mut stack: Vec = vec![0]; let mut fi = 0usize; - for (i, &raw) in chain.iter().enumerate() { + for &raw in chain { match raw { MARKER_CALL_ENTER => { - cur_open = i; - stack.push(NONE); + let id = frames.len(); + frames.push(Frame { + owner: None, + rest: Vec::new(), + }); + if let Some(&top) = stack.last() { + // CE mở vùng con của call đang sở hữu frame `top`. + frames[top].rest.push(FrameItem::Region(id)); + } + stack.push(id); } MARKER_CALL_EXIT => { - stack.pop(); - if stack.is_empty() { - cur_open = NONE; + if stack.len() > 1 { + stack.pop(); } } MARKER_STMT_END => {} @@ -124,98 +149,42 @@ fn call_scope(chain: &[u64], filtered: &[(usize, usize, u64)]) -> (Vec, V if fi >= n { break; } - parent[fi] = stack - .iter() - .rev() - .copied() - .find(|&x| x != NONE) - .unwrap_or(NONE); - open[fi] = cur_open; - if !is_marker(raw) { - if let Some(last) = stack.last() { - if *last == NONE { - let top = stack.len() - 1; - stack[top] = fi; - } + if let Some(&top) = stack.last() { + match frames[top].owner { + None => frames[top].owner = Some(fi), + Some(_) => frames[top].rest.push(FrameItem::Node(fi)), } } fi += 1; } } } - (parent, open) -} - -/// Call cha ngoài cùng của node `x` (theo filtered index), `NONE` nếu top-level. -fn outermost_call(x: usize, parent: &[usize]) -> usize { - const NONE: usize = usize::MAX; - let mut cur = parent[x]; - let mut last = NONE; - while cur != NONE { - last = cur; - cur = parent[cur]; - } - last -} - -/// Sửa thứ tự cạnh giữa các call node cho đúng runtime. -/// -/// Chain emit theo pre-order nên node cha (call ngoài cùng) đứng TRƯỚC node con, -/// nhưng `a.b().c()` chạy `a.b()` rồi mới `.c()`. Ba trường hợp: -/// -/// 1. **Nested call** (`parent[b] == a`): đảo cạnh → `b → a` (con chạy trước). -/// 2. **Cross-region** (`a`, `b` thuộc 2 vùng con khác nhau của cùng call cha): -/// neo cạnh vào call sở hữu vùng thay vì nối tuần tự vô nghĩa. VD closure -/// `x.clone().unwrap_or_else(|| { ... })` — thân closure phải gắn với -/// `unwrap_or_else`, không phải nối tiếp sau `x.clone()`. -/// 3. **Rời vùng call sang control-flow marker**: neo vào call cha ngoài cùng. -fn fix_call_edge_order( - chain: &[u64], - filtered: &[(usize, usize, u64)], - edges: &mut [(usize, usize)], -) { - const NONE: usize = usize::MAX; - let n = filtered.len(); - if n < 2 { - return; - } - let (parent, open) = call_scope(chain, filtered); - let is_call = |i: usize| i < n && !is_marker(filtered[i].2); - let is_entry = |i: usize| { - i < n - && matches!( - filtered[i].2, - MARKER_IF_TRUE | MARKER_LOOP | MARKER_SWITCH_START - ) - }; - for edge in edges.iter_mut() { - let (a, b) = (edge.0, edge.1); - if a >= n || b >= n { - continue; - } - // 1. Call lồng nhau — con chạy trước cha. - if is_call(a) && is_call(b) && parent[b] == a { - edge.0 = b; - edge.1 = a; - continue; + fn emit(id: usize, frames: &[Frame], is_root: bool, out: &mut Vec) { + let f = &frames[id]; + // Frame gốc (root) giữ owner (symbol hàm) đứng đầu. + if is_root { + if let Some(owner) = f.owner { + out.push(owner); + } } - // 2. Sang vùng con khác của cùng call cha — neo vào owner của vùng `b`. - if is_call(a) && is_call(b) && open[a] != open[b] && parent[b] != a { - let owner = outermost_call(b, &parent); - if owner != NONE { - edge.0 = owner; + for item in &f.rest { + match item { + FrameItem::Node(fi) => out.push(*fi), + FrameItem::Region(sub) => emit(*sub, frames, false, out), } - continue; } - // 3. Từ trong vùng call ra marker điều khiển — neo vào call cha ngoài cùng. - if is_call(a) && is_entry(b) && open[a] != NONE && parent[a] != NONE { - let outer = outermost_call(a, &parent); - if outer != NONE && outer != a { - edge.0 = outer; + // Frame call: owner (call ngoài cùng) phát SAU cùng. + if !is_root { + if let Some(owner) = f.owner { + out.push(owner); } } } + + let mut out = Vec::with_capacity(n); + emit(0, &frames, true, &mut out); + out } /// Phân loại một node → `(open, close, label)`. @@ -334,9 +303,10 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // Ngăn xếp index của header loop đang mở. let mut loop_stack: Vec = Vec::new(); // Ngăn xếp entry point của các switch đang mở. - // Mỗi entry lưu (entry_index, vec) để ở SWITCH_CLOSE - // nối TẤT CẢ case end về node CLOSE, không chỉ case cuối. - let mut switch_stack: Vec<(usize, Vec)> = Vec::new(); + // Mỗi entry lưu (entry_index, case_index_hiện_tại, vec) để + // ở SWITCH_CLOSE nối TẤT CẢ case end về node CLOSE, không chỉ case cuối; và + // để case kết thúc bằng terminator (`A => return`) vẫn neo được SWITCH_END. + let mut switch_stack: Vec<(usize, usize, Vec)> = Vec::new(); // Ngăn xếp depth của call nesting (cho CALL_ENTER/CALL_EXIT). let mut call_stack: Vec = Vec::new(); @@ -362,6 +332,10 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { curr, MARKER_RETURN | MARKER_THROW | MARKER_BREAK | MARKER_CONTINUE ); + let prev_is_terminator = matches!( + chain[prev], + MARKER_RETURN | MARKER_THROW | MARKER_BREAK | MARKER_CONTINUE + ); let prev_is_stmt_end = chain[prev] == MARKER_STMT_END; // Prev marker đóng block (có thể nối đến linear-entry marker kế tiếp) let prev_closes_block = matches!( @@ -391,13 +365,17 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { // Bug A: marker-closes-block -> linear-entry marker // VD: SWITCH_CLOSE -> SWITCH_START, LOOP -> IF_TRUE, BRANCH_END -> IF_TRUE edges.push((prev, i)); - } else if prev_is_marker && curr_is_marker && (is_terminator || is_block_end) { - // marker -> terminator/block-end marker: nhánh if/loop/switch-case mà - // thân (hoặc phần cuối thân) lại là MARKER, không phải call/statement. - // VD: `if c { return None; }` → IF_TRUE -> RETURN -> BRANCH_END; - // `match { A => return, .. }` → SWITCH_CASE -> RETURN -> SWITCH_END. - // Thiếu rule này thì RETURN/BRANCH_END/SWITCH_END bị ORPHAN (nổi - // rời ngoài graph). + } else if prev_is_marker && curr_is_marker && is_terminator { + // marker -> terminator: nhánh if/loop/switch-case chỉ chứa MARKER. + // VD `if c { return None; }` → IF_TRUE -> RETURN; `match { A => return }` + // → SWITCH_CASE -> RETURN. + edges.push((prev, i)); + } else if prev_is_marker && curr_is_marker && is_block_end && !prev_is_terminator { + // marker -> block-end (BRANCH_END/SWITCH_END/SWITCH_CLOSE). KHÔNG áp + // dụng khi prev là terminator — `return`/`throw`/`break` đã thoát khỏi + // block, không chảy tiếp vào block-end rồi lại chạy statement sau. + // Thiếu guard này thì `if c { return None; }` vẽ thành + // RETURN -> BRANCH_END -> tail, trông như return xong vẫn chạy tiếp. edges.push((prev, i)); } // marker -> marker: no sequential edge (except above) @@ -420,11 +398,16 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { } MARKER_BRANCH_END => { let (cond, then_end, has_else) = if_stack.pop().unwrap_or((prev, None, false)); - // BRANCH_END connected from prev (last body stmt) via sequential if has_else { if let Some(then_end_idx) = then_end { edges.push((then_end_idx, i)); } + } else if prev_is_terminator { + // Thân nhánh then kết thúc bằng `return`/`break`/… nên KHÔNG + // có cạnh then -> BRANCH_END. Điều hướng false-path ĐI QUA + // BRANCH_END (`cond -> BRANCH_END -> sau-block`) để node merge + // không bị orphan. + edges.push((cond, i)); } else { let mut j = i + 1; while j < n && is_marker(chain[j]) { @@ -449,19 +432,30 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { MARKER_SWITCH_START => { // Switch start: prev is the discriminant (or previous statement) // Sequential edge from prev handled above - switch_stack.push((i, Vec::new())); // (entry, case_ends) + switch_stack.push((i, i, Vec::new())); // (entry, case_hiện_tại, ends) } MARKER_SWITCH_CASE => { - if let Some((entry, _)) = switch_stack.last() { + if let Some((entry, case, _)) = switch_stack.last_mut() { + // Ghi nhận case này để arm kết thúc bằng terminator (`A => return`) + // vẫn neo được SWITCH_END vào đúng case. + *case = i; edges.push((*entry, i)); } else { edges.push((prev, i)); } } MARKER_SWITCH_END => { - edges.push((prev, i)); + if prev_is_terminator { + // `match { A => return, .. }`: case A đã return nên KHÔNG có + // cạnh RETURN -> SWITCH_END. Neo SWITCH_END vào case vừa mở. + if let Some((_entry, case, _)) = switch_stack.last() { + edges.push((*case, i)); + } + } else { + edges.push((prev, i)); + } // Gom SWITCH_END của case này, sẽ nối TẤT CẢ về SWITCH_CLOSE - if let Some((_entry, ends)) = switch_stack.last_mut() { + if let Some((_entry, _case, ends)) = switch_stack.last_mut() { ends.push(i); } // Không pop ở đây; đợi SWITCH_CLOSE để pop (chỉ có 1 SWITCH_CLOSE cuối cùng) @@ -469,7 +463,7 @@ fn structural_edges(chain: &[u64]) -> Vec<(usize, usize)> { MARKER_SWITCH_CLOSE => { edges.push((prev, i)); // Bug C: Nối TẤT CẢ case ends (SWITCH_END) đến SWITCH_CLOSE - if let Some((_entry, ends)) = switch_stack.pop() { + if let Some((_entry, _case, ends)) = switch_stack.pop() { for end in ends { edges.push((end, i)); } @@ -770,6 +764,157 @@ mod tests { } } + #[test] + fn nested_calls_render_in_execution_order() { + // `let x = a.b()` — chain emit PRE-ORDER nên node ngoài cùng (b) đứng + // TRƯỚC node trong (a), nhưng runtime chạy `a` rồi mới `.b`. Node phải + // xếp theo thứ tự thực thi, cạnh tuần tự theo đó mới đúng. + let chain = vec![ + 100, + MARKER_CALL_ENTER, // vùng của call ngoài cùng (b) + 0, // b — owner vùng này + MARKER_CALL_ENTER, // vùng lồng của a + 0, // a — owner vùng trong + MARKER_CALL_EXIT, + MARKER_CALL_EXIT, + 101, // statement tiếp theo + ]; + let f = flow( + chain, + vec!["root", "CE", "b", "CE", "a", "CX", "CX", "next"], + vec![ + call(2, "b", None, 2), + call(4, "a", None, 2), + call(7, "next", Some(102), 4), + ], + ); + let m = control_flow(&f); + // Execution order: a trước b — c1 = a, c2 = b. + assert!(m.contains("c1[[\"a · L2 · ext\"]]"), "{m}"); + assert!(m.contains("c2[[\"b · L2 · ext\"]]"), "{m}"); + assert!(m.contains("c0 --> c1"), "root -> innermost: {m}"); + assert!(m.contains("c1 --> c2"), "inner -> outer: {m}"); + assert!(m.contains("c2 --> c3"), "outer -> next: {m}"); + } + + #[test] + fn closure_body_runs_between_receiver_and_outer_call() { + // `x.clone().unwrap_or_else(|| { make() })` — thân closure (make) nằm + // GIỮA receiver (x.clone) và call ngoài cùng (unwrap_or_else): cả hai đều + // chạy xong thì mới tới unwrap_or_else. + let chain = vec![ + 100, + MARKER_CALL_ENTER, // vùng unwrap_or_else + 0, // unwrap_or_else — owner + MARKER_CALL_ENTER, // vùng receiver (x.clone) + 0, // x.clone + MARKER_CALL_EXIT, + MARKER_CALL_ENTER, // vùng thân closure (make) + 0, // make + MARKER_CALL_EXIT, + MARKER_CALL_EXIT, + ]; + let f = flow( + chain, + vec![ + "root", + "CE", + "unwrap_or_else", + "CE", + "x.clone", + "CX", + "CE", + "make", + "CX", + "CX", + ], + vec![ + call(2, "unwrap_or_else", None, 3), + call(4, "x.clone", None, 3), + call(7, "make", None, 4), + ], + ); + let m = control_flow(&f); + // c1 = x.clone (receiver), c2 = make (thân closure), c3 = unwrap_or_else. + assert!(m.contains("c1[[\"x.clone · L3 · ext\"]]"), "{m}"); + assert!(m.contains("c2[[\"make · L4 · ext\"]]"), "{m}"); + assert!(m.contains("c3[[\"unwrap_or_else · L3 · ext\"]]"), "{m}"); + assert!(m.contains("c1 --> c2"), "receiver -> closure body: {m}"); + assert!(m.contains("c2 --> c3"), "closure body -> outer call: {m}"); + } + + #[test] + fn return_terminates_branch_and_does_not_flow_to_branch_end() { + // if (c) { return None; } tail() + // Nhánh then return nên THOÁT hàm — không được nối sang BRANCH_END rồi + // lại chạy tiếp tail (trông như return xong vẫn chạy tiếp). + let chain = vec![100, MARKER_IF_TRUE, MARKER_RETURN, MARKER_BRANCH_END, 0]; + let f = flow( + chain, + vec!["root", "IF_TRUE", "RETURN", "BRANCH_END", "tail"], + vec![call(4, "tail", Some(101), 5)], + ); + let m = control_flow(&f); + // Nhánh then: cond -> IF_TRUE -> RETURN, và RETURN KHÔNG đi tiếp. + assert!(m.contains("c0 --> c1"), "{m}"); + assert!(m.contains("c1 --> c2"), "IF_TRUE -> RETURN: {m}"); + assert!( + !m.contains("c2 --> c3"), + "return must not flow into BRANCH_END: {m}" + ); + // False-path đi QUA BRANCH_END rồi tới tail (node merge không orphan). + assert!( + m.contains("c0 --> c3"), + "cond -> BRANCH_END false path: {m}" + ); + assert!(m.contains("c3 --> c4"), "BRANCH_END -> tail: {m}"); + } + + #[test] + fn switch_case_return_does_not_flow_to_switch_end() { + // match { A => return, B => foo() } + // Case A return nên KHÔNG nối sang SWITCH_END; neo SWITCH_END vào case. + let chain = vec![ + 100, + MARKER_SWITCH_START, + MARKER_SWITCH_CASE, + MARKER_RETURN, + MARKER_SWITCH_END, + MARKER_SWITCH_CASE, + 0, + MARKER_SWITCH_END, + MARKER_SWITCH_CLOSE, + ]; + let f = flow( + chain, + vec![ + "root", + "SWITCH_START", + "SWITCH_CASE", + "RETURN", + "SWITCH_END", + "SWITCH_CASE", + "foo", + "SWITCH_END", + "SWITCH_CLOSE", + ], + vec![call(6, "foo", Some(101), 4)], + ); + let m = control_flow(&f); + // case A: SWITCH_CASE -> RETURN, RETURN không sang SWITCH_END. + assert!(m.contains("c1 --> c2"), "{m}"); + assert!(m.contains("c2 --> c3"), "case -> RETURN: {m}"); + assert!( + !m.contains("c3 --> c4"), + "return must not flow into SWITCH_END: {m}" + ); + // SWITCH_END của case A được neo vào case → không orphan. + assert!(m.contains("c2 --> c4"), "case -> SWITCH_END: {m}"); + // case B chạy bình thường và mọi case end hội tụ về SWITCH_CLOSE. + assert!(m.contains("c4 --> c8"), "case A end -> SWITCH_CLOSE: {m}"); + assert!(m.contains("c7 --> c8"), "case B end -> SWITCH_CLOSE: {m}"); + } + #[test] fn if_else_branches_do_not_chain_linearly() { // if (cond) { then() } else { other() } done() From 962b122492ddbd2ac63e421ed8e4442d6e9f5222 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Fri, 9 Oct 2026 13:05:41 +0700 Subject: [PATCH 6/6] Fix lint --- crates/codegraph-api/src/mermaid.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codegraph-api/src/mermaid.rs b/crates/codegraph-api/src/mermaid.rs index 16a39a0771..6ee9ca717e 100644 --- a/crates/codegraph-api/src/mermaid.rs +++ b/crates/codegraph-api/src/mermaid.rs @@ -228,7 +228,7 @@ fn node_style( // Call / statement thường. let call = calls.get(&i).copied(); - let mut label = call + let label = call .map(|c| c.to_name.clone()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| desc.to_string());