diff --git a/docs/design/intelligent-review-tier1.md b/docs/design/intelligent-review-tier1.md new file mode 100644 index 0000000..46afefc --- /dev/null +++ b/docs/design/intelligent-review-tier1.md @@ -0,0 +1,251 @@ +# Design: Intelligent Review — Wire Index to Review Pipeline + +## Problem + +Cora v0.10.0 punya **dua sistem terpisah** yang tidak saling terhubung: + +1. **Context Chain Resolver** (`engine/context/resolver.rs`) — regex-based file scan untuk cross-file dependency. Dipakai oleh `cora review`. +2. **Brain Intelligence** (`index/graph.rs`, `index/brain.rs`) — symbol index, call graph, FTS5+vector+graph RRF search. **Hanya** dipakai oleh MCP tools (`cora brain_search`, `cora find_callers`, dll). + +Review pipeline saat ini **tidak memanfaatkan symbol index**. Caller resolution di resolver pakai regex grep yang scan seluruh filesystem — lebih lambat, kurang akurat, dan dibatasi MAX 50 files. + +## Goal + +Membuat `cora review` otomatis memanfaatkan symbol index (brain intelligence) **kalau index tersedia**, dengan fallback ke regex-based resolver **kalau tidak ada index**. Zero breaking change. + +## Scope (Tier 1 — Minimum Viable Intelligence) + +### 1.1: Index-Aware Caller Resolution + +**File:** `src/engine/context/resolver.rs` → `resolve_callers()` + +**Current behavior:** +``` +resolve_callers() → Walk project files → regex grep per file → max 50 files +``` + +**Proposed behavior:** +``` +resolve_callers() → cek index available? + ├─ YES → graph::find_callers(conn, project_id, symbol_name, limit) + │ ✅ O(1) SQL query, exact match, unlimited scope + └─ NO → existing regex scan (fallback, unchanged) +``` + +**Implementation:** +- Import `crate::index::{open_global_index, ensure_project}` di resolver.rs +- Di awal `resolve_callers()`, cek apakah `open_global_index()` sukses +- Kalau sukses, loop `defs` dan query `graph::find_callers()` per symbol +- Convert `CallerResult` → `ContextEntry` (reuse existing `build_entry` logic) +- Kalau index gagal/error, fallthrough ke existing regex scan +- Log: `debug!("using index for caller resolution ({} symbols)", defs.len())` + +**Token budget:** Caller results dari index masuk ke token budget yang sama dengan existing context chain (`max_context_tokens`). Tidak ada budget tambahan. + +### 1.2: Impact Analysis Injection + +**File:** `src/engine/review.rs` — setelah context chain build (line ~197) + +**New behavior:** +``` +context_chain built → cek index available? + ├─ YES → untuk setiap defined symbol: + │ graph::impact_analysis(conn, project_id, symbol, depth=2) + │ → inject ke context sebagai "Blast Radius" section + └─ NO → skip (no extra context) +``` + +**Context format di prompt:** +``` +## Blast Radius (Code Intelligence) +### `authenticate_user` (modified in src/auth/handler.rs) + L1: login_handler (src/api/routes.rs:42) + L1: register_handler (src/api/routes.rs:89) + L2: main_router (src/main.rs:15) → login_handler + L2: api_middleware (src/middleware.rs:7) → login_handler +``` + +**Config:** Tambah field di `ContextConfig`: +```yaml +context_chain: + use_brain: true # NEW: enable/disable brain enrichment (default: true) + impact_depth: 2 # NEW: impact analysis depth (default: 2) +``` + +### 1.3: Affected Tests Suggestion + +**File:** `src/engine/review.rs` — inject setelah blast radius + +**Behavior:** Kalau index available, untuk setiap changed file: +1. Query `graph::find_callers()` untuk semua defined symbols di file +2. Filter callers yang ada di test files (naming: `*test*`, `*spec*`) +3. Inject sebagai context section: + +``` +## Affected Tests +- tests/auth_test.rs (callers: test_authenticate_success, test_authenticate_invalid_password) +- tests/integration/auth_spec.rs (callers: spec_login_flow) +``` + +**Ini memberi LLM konteks:** "File test ini mungkin perlu di-update" → LLM bisa flag missing test coverage. + +### 1.4: Brain Search Enrichment (Optional, Config-Gated) + +**File:** `src/engine/review.rs` — inject setelah affected tests + +**Behavior:** Kalau index available + `use_brain: true`: +1. Extract function/type names dari changed symbols +2. Untuk setiap symbol, `brain_search(conn, project_id, symbol_name, 3)` +3. Filter hasil yang relevan (file berbeda dari changed files) +4. Inject top-3 related symbols sebagai context: + +``` +## Related Patterns (Semantic Search) +- session_manager (src/auth/session.rs:1) — manages user sessions, related to auth flow +- token_refresh (src/auth/jwt.rs:56) — JWT token refresh, depends on authenticate result +``` + +**Catatan:** Brain search butuh vector embeddings (`embed_project`). Di CI, embedding build dari scratch ~2-5s untuk medium project. Bisa di-skip kalau embeddings belum ada — fallback ke FTS5-only. + +## Architecture + +``` +cora review (dengan index available) +│ +├─ Parse diff → diff_chunks +│ +├─ Extract symbols → outbound (what changed code calls) +│ → inbound (what changed code defines) +│ +├─ Context Chain Builder +│ ├─ Phase 1: resolve symbols (imports/types) → regex (existing) +│ ├─ Phase 2: resolve callers +│ │ ├─ 🆕 TRY index: graph::find_callers() → ContextEntry +│ │ └─ FALLBACK: regex scan (existing) +│ └─ Phase 3: assemble under token budget +│ +├─ 🆕 Brain Enrichment (if index available && use_brain: true) +│ ├─ impact_analysis() → "Blast Radius" section +│ ├─ find_affected_tests() → "Affected Tests" section +│ └─ brain_search() → "Related Patterns" section +│ +├─ Deterministic rules (existing, unchanged) +│ +└─ LLM review prompt + ├─ system prompt + ├─ user prompt (diff) + ├─ 🆕 brain context (blast radius + tests + related patterns) + ├─ context chain text (cross-file deps) + ├─ language-specific context + └─ profile instructions +``` + +## Files to Change + +| File | Change | Lines Est. | +|------|--------|------------| +| `src/engine/context/types.rs` | Add `use_brain: bool`, `impact_depth: u32` to `ContextConfig` | +15 | +| `src/engine/context/resolver.rs` | Wire `graph::find_callers()` as primary in `resolve_callers()`, fallback to regex | +40 | +| `src/engine/review.rs` | Add brain enrichment phase: impact, tests, related patterns injection | +80 | +| `src/hook/template.rs` | Add `cora index --quiet` before `cora review` in hook template | +2 | +| `src/config/schema.rs` | No change (ContextConfig already in Config struct) | 0 | + +**Total estimated:** ~137 LOC new code + +## Backward Compatibility + +| Scenario | Behavior | +|----------|----------| +| No index (fresh CI, no `cora index`) | Identical to current — regex scan, no brain context | +| Index exists, `use_brain: false` | Identical to current — regex scan, no brain context | +| Index exists, `use_brain: true` (default) | 🆕 Index-based callers + impact + tests + brain search | +| Index exists but no embeddings | Index callers + impact + tests work; brain search falls back to FTS5-only | +| Index exists, `cora review --staged` (local) | Same as above — works for both full-diff and staged | +| CI: `cora index && cora review --ci` | Full brain enrichment ✅ | + +## Pre-Commit Hook Change + +**Target:** Local dev, index persistent, full brain intelligence. + +### Current Hook Template (`src/hook/template.rs`) + +```bash +cora review --staged --format compact +``` + +### Proposed Hook Template + +```bash +cora index --quiet # NEW: incremental, ~0.014s (persistent local index) +cora review --staged --format compact # review with brain enrichment +``` + +**Why this works for pre-commit:** +- Index persistent di local (`~/.codecora/`) — build sekali, incremental update seterusnya +- First run: ~1.5s full index build (one-time cost) +- Subsequent runs: ~0.014s incremental (negligible) +- Full brain intelligence available: call graph, impact analysis, brain search, affected tests + +### Flow Diagram + +``` +Developer: git commit + │ + ▼ +Pre-commit hook fires + │ + ├─ cora index --quiet + │ ├─ mtime:size fingerprint check → skip unchanged files + │ ├─ Re-index changed files (staged → source files changed) + │ └─ Update call_graph + FTS5 + vectors + │ ⏱ ~0.014s (incremental) or ~1.5s (first time) + │ + ├─ cora review --staged --format compact + │ ├─ git diff --cached → staged diff + │ ├─ Context chain (regex + 🆕 index callers) + │ ├─ 🆕 Brain enrichment (impact, tests, related) + │ ├─ Deterministic rules + │ └─ LLM review + │ ⏱ ~5-15s (LLM API call) + │ + └─ Exit code: 0=pass, 1=warn, 2=block +``` + +### Hook Template Change + +**File:** `src/hook/template.rs` + +```diff +- if "$CORA_BIN" review --staged --format compact 2>/dev/null; then ++ "$CORA_BIN" index --quiet 2>/dev/null || true ++ if "$CORA_BIN" review --staged --format compact 2>/dev/null; then +``` + +Notes: +- `cora index` uses `--quiet` (suppress output) — developer only sees review result +- `|| true` on index — if index fails (no tree-sitter lang support, etc.), review still runs +- Index is **non-blocking** — hook only blocks on review findings (exit code 2) + +## CI Workflow Change (Future — NOT in scope) + +CI integration (cora-review-action) akan ditambahkan di Tier 2/3 setelah +pre-commit terbukti stable. Strategy: `cora index && cora review --ci` + optional cache. + +## Testing Plan + +1. **Unit test:** `resolver.rs` — mock index connection, verify `find_callers()` path returns correct `ContextEntry` +2. **Unit test:** `review.rs` — verify brain context injection format +3. **Integration test:** `cora index && cora review` on cora-code repo — verify callers come from graph not regex +4. **CI test:** Run in cora-code CI pipeline — verify `cora index` step completes in < 5s +5. **Regression:** Run review without index — verify identical output to pre-change + +## Open Questions + +1. **Vector embedding di CI:** `embed_project()` butuh CPU static token. Untuk Rust project 156 files, estimasi ~2-5s. Apakah worth it untuk semantic search di CI, atau cukup FTS5-only? +2. **Token budget sharing:** Brain context dan context chain share `max_context_tokens`. Apakah perlu budget terpisah untuk brain enrichment? +3. **Config naming:** `use_brain` vs `intelligent_review` vs `index_enriched`? + +## Non-Goals (Tier 2 & 3 — Future Work) + +- **Tier 2:** Intelligent rule enhancement (unused import via index, dead code flag, breaking change detection) +- **Tier 3:** Agentic review loop (multi-step LLM calls with tool verification) diff --git a/src/engine/context/resolver.rs b/src/engine/context/resolver.rs index 18b72db..d141d42 100644 --- a/src/engine/context/resolver.rs +++ b/src/engine/context/resolver.rs @@ -729,6 +729,72 @@ fn resolve_callers( return Vec::new(); } + // ── Index-based caller resolution (preferred) ─────────────────────── + if let Ok(conn) = crate::index::open_global_index() { + if let Ok(project_id) = crate::index::ensure_project(&conn, project_root) { + let mut entries = Vec::new(); + for def in defs { + if def.name.len() < 2 { + continue; + } + match crate::index::graph::find_callers(&conn, project_id, &def.name, 20) { + Ok(callers) => { + for caller in callers { + // Skip callers in the defining file itself + if caller.file == def.file { + continue; + } + let rel = caller.file.clone(); + if is_ignored(&rel, ignore_patterns) { + continue; + } + let label = match def.kind { + DefinitionKind::Function => { + format!("caller of fn {}", def.name) + } + DefinitionKind::Type => { + format!("usage of {}", def.name) + } + }; + entries.push(ContextEntry { + file: rel, + line_start: caller.line.saturating_sub(1).max(1), + line_end: caller.line + 1, + label, + priority: ContextPriority::CallerSite, + }); + } + } + Err(e) => { + debug!(symbol = %def.name, error = %e, "index caller lookup failed"); + } + } + } + if !entries.is_empty() { + debug!( + callers_found = entries.len(), + source = "index", + "resolved callers" + ); + return entries; + } + // Index found but no callers — fall through to regex scan + debug!("index caller lookup returned empty, falling back to regex"); + } + } + + // ── Regex-based fallback (original behavior) ─────────────────────── + debug!("using regex-based caller resolution"); + resolve_callers_regex(defs, project_root, ignore_patterns) +} + +/// Regex-based caller resolution — scans project files for symbol references. +/// Used as fallback when no symbol index is available. +fn resolve_callers_regex( + defs: &[DefinedSymbol], + project_root: &Path, + ignore_patterns: &[String], +) -> Vec { // Precompile a matcher per definition. Skip names that are too short/noisy. let matchers: Vec<(&DefinedSymbol, Regex)> = defs .iter() @@ -819,7 +885,9 @@ fn resolve_callers( debug!( callers_found = entries.len(), - files_scanned, "resolved callers" + files_scanned, + source = "regex", + "resolved callers" ); entries } @@ -1151,6 +1219,8 @@ mod tests { follow_depth: 1, include_tests: false, include_callers: false, + use_brain: false, + impact_depth: 2, }; let symbols = vec![ExtractedSymbol { @@ -1175,6 +1245,8 @@ mod tests { follow_depth: 1, include_tests: false, include_callers: false, + use_brain: false, + impact_depth: 2, }; let symbols = vec![ExtractedSymbol { diff --git a/src/engine/context/types.rs b/src/engine/context/types.rs index c3ff778..4a7ff56 100644 --- a/src/engine/context/types.rs +++ b/src/engine/context/types.rs @@ -41,6 +41,22 @@ pub struct ContextConfig { /// breaking signature/type changes can be flagged. Default: true. #[serde(default = "default_true")] pub include_callers: bool, + + /// When `true` (default), enrich the review prompt with code intelligence + /// from the symbol index: impact analysis (blast radius via call graph), + /// affected test detection, and semantic pattern search (brain). + /// Only active when an index exists (`cora index` has been run). + /// Set to `false` to disable brain enrichment even if an index is available. + #[serde(default = "default_true")] + pub use_brain: bool, + + /// Depth for impact analysis blast-radius traversal. + /// Depth 1 = direct callers only. + /// Depth 2 = callers of callers (recommended). + /// Depth 3+ = deep blast radius (higher token cost). + /// Default: 2. + #[serde(default = "default_impact_depth")] + pub impact_depth: u32, } fn default_true() -> bool { @@ -55,6 +71,10 @@ fn default_follow_depth() -> u32 { 1 } +fn default_impact_depth() -> u32 { + 2 +} + impl Default for ContextConfig { fn default() -> Self { Self { @@ -63,6 +83,8 @@ impl Default for ContextConfig { follow_depth: 1, include_tests: true, include_callers: true, + use_brain: true, + impact_depth: 2, } } } diff --git a/src/engine/review.rs b/src/engine/review.rs index 2be2f9d..cea2afd 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -236,6 +236,33 @@ async fn review_diff_inner( (Some(mem), Some(ctx)) => Some(format!("{mem}\n\n{ctx}")), (Some(mem), None) => Some(mem.to_string()), (None, ctx) => ctx, + }; + + // ── Brain enrichment phase (Tier 1) ────────────────────────────────── + // When use_brain is enabled and an index exists, enrich the review prompt + // with impact analysis, affected tests, and semantic pattern search. + let final_context = if config.context_chain.use_brain { + match build_brain_context( + &diff_chunks, + config.context_chain.impact_depth, + std::env::current_dir().unwrap_or_default().as_path(), + ) { + Some(brain_ctx) if !brain_ctx.is_empty() => { + debug!( + brain_context_len = brain_ctx.len(), + "brain enrichment applied" + ); + match final_context { + Some(ctx) => Some(format!( + "{ctx}\n\n## Code Intelligence (Brain)\n{brain_ctx}" + )), + None => Some(format!("## Code Intelligence (Brain)\n{brain_ctx}")), + } + } + _ => final_context, + } + } else { + final_context }; // but preserve deterministic rule findings even on LLM failure let llm_result: Result = if stream { llm::review_diff_stream( @@ -580,6 +607,151 @@ fn is_valid_file_path(issue_file: &str, valid_files: &[String]) -> bool { valid_files.iter().any(|f| f == issue_file) } +/// Build brain-enriched context from the symbol index. +/// +/// Queries the index for: +/// 1. **Impact analysis** — blast radius of changed symbols (who depends on them) +/// 2. **Affected tests** — test files that exercise the changed code +/// 3. **Brain search** — semantically related patterns across the codebase +/// +/// Returns `None` if no index is available or no results found. +fn build_brain_context( + diff_chunks: &[crate::engine::diff_parser::FileChunk], + impact_depth: u32, + project_root: &std::path::Path, +) -> Option { + // Try to open the global symbol index + let conn = crate::index::open_global_index().ok()?; + let project_id = crate::index::ensure_project(&conn, project_root).ok()?; + + // Extract defined symbols from the diff + let defs = crate::engine::context::extraction::extract_definitions_from_diff(diff_chunks); + if defs.is_empty() { + return None; + } + + let mut sections = Vec::new(); + + // ── 1. Impact Analysis ───────────────────────────────────────────── + let mut impact_lines: Vec = Vec::new(); + for def in &defs { + if def.name.len() < 2 { + continue; + } + if let Ok(nodes) = + crate::index::graph::impact_analysis(&conn, project_id, &def.name, impact_depth) + { + if !nodes.is_empty() { + impact_lines.push(format!( + "- `{}`: {} downstream caller(s)", + def.name, + nodes.len() + )); + // Show top callers (deduplicated by file) + let mut seen_files = std::collections::HashSet::new(); + for node in nodes.iter().take(5) { + if seen_files.insert(node.file.clone()) { + impact_lines.push(format!( + " - depth {}: {} ({}:{})", + node.depth, node.symbol, node.file, node.line + )); + } + } + if nodes.len() > 5 { + impact_lines.push(format!(" - ... and {} more", nodes.len() - 5)); + } + } + } + } + if !impact_lines.is_empty() { + sections.push(format!( + "### Impact Analysis (Blast Radius)\n{}", + impact_lines.join("\n") + )); + } + + // ── 2. Affected Tests ─────────────────────────────────────────────── + let mut test_files: std::collections::HashSet = std::collections::HashSet::new(); + for def in &defs { + if def.name.len() < 2 { + continue; + } + // Walk impact nodes, collect files containing "test" or "spec" + if let Ok(nodes) = crate::index::graph::impact_analysis( + &conn, project_id, &def.name, 1, // depth 1 is enough for test detection + ) { + for node in &nodes { + let lower = node.file.to_lowercase(); + if lower.contains("test") || lower.contains("spec") || lower.contains("_test") { + test_files.insert(node.file.clone()); + } + } + } + // Also search FTS5 for test symbols matching this function name + if let Ok(results) = + crate::index::brain::brain_search(&conn, project_id, &format!("test {}", def.name), 3) + { + for r in results { + let lower = r.file.to_lowercase(); + if lower.contains("test") || lower.contains("spec") || lower.contains("_test") { + test_files.insert(r.file); + } + } + } + } + if !test_files.is_empty() { + let mut test_list: Vec<_> = test_files.into_iter().collect(); + test_list.sort(); + sections.push(format!( + "### Potentially Affected Tests\n{}", + test_list + .iter() + .map(|f| format!("- `{f}`")) + .collect::>() + .join("\n") + )); + } + + // ── 3. Semantic Pattern Search ─────────────────────────────────────── + let mut brain_lines: Vec = Vec::new(); + let mut seen_brain: std::collections::HashSet = std::collections::HashSet::new(); + for def in defs.iter().take(5) { + // limit to 5 symbols to avoid excessive token cost + if def.name.len() < 2 { + continue; + } + if let Ok(results) = crate::index::brain::brain_search(&conn, project_id, &def.name, 3) { + for r in results { + // Skip results from the same file as the definition + if r.file == def.file { + continue; + } + if seen_brain.insert(format!("{}:{}", r.file, r.line)) { + brain_lines.push(format!( + "- `{}` in {}:{} (signals: {})", + r.name, + r.file, + r.line, + r.signals.join("+") + )); + } + } + } + } + if !brain_lines.is_empty() { + sections.push(format!( + "### Related Patterns (Semantic Search)\n{}", + brain_lines.join("\n") + )); + } + + if sections.is_empty() { + None + } else { + Some(sections.join("\n\n")) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/hook/template.rs b/src/hook/template.rs index 97cbe05..8ef8d88 100644 --- a/src/hook/template.rs +++ b/src/hook/template.rs @@ -1,6 +1,7 @@ /// Pre-commit hook template content. /// -/// The hook runs `cora review --staged --format compact` and checks the exit +/// The hook runs `cora index --quiet` (incremental, ~0.014s) then +/// `cora review --staged --format compact` and checks the exit /// code: 0 = ok (allow commit), 1 = error (allow commit), 2 = blocked (deny commit). pub const HOOK_TEMPLATE: &str = r#"#!/usr/bin/env bash # cora pre-commit hook — installed by `cora hook install` @@ -13,7 +14,10 @@ CORA_BIN="${CORA_BIN:-cora}" echo "🔍 Running cora code review..." -# Run cora review on staged changes +# Update the symbol index (incremental — fast, non-blocking) +"$CORA_BIN" index 2>/dev/null || true + +# Run cora review on staged changes (uses index for brain enrichment) if "$CORA_BIN" review --staged --format compact 2>/dev/null; then echo "✅ cora review passed." exit 0 diff --git a/src/index/ast.rs b/src/index/ast.rs index af42ffb..a8b74d1 100644 --- a/src/index/ast.rs +++ b/src/index/ast.rs @@ -1805,6 +1805,72 @@ fn extract_csharp( // ─── Ruby ────────────────────────────────────────────────────────── +/// Walk a Ruby class/module node to extract method definitions. +/// Handles the tree-sitter-ruby `body_statement` wrapper that sits +/// between the class/module node and its method children. +fn extract_methods_from_ruby_node( + parent: &tree_sitter::Node, + source: &str, + file_path: &str, + parent_name: &str, + nodes: &mut Vec, +) { + let mut mc = parent.walk(); + if mc.goto_first_child() { + loop { + let kind = mc.node().kind(); + if kind == "method" { + let mname = if let Some(n) = mc.node().child_by_field_name("name") { + node_text(&n, source) + } else { + String::new() + }; + if !mname.is_empty() { + nodes.push(AstNode { + name: mname, + kind: SymbolKind::Method, + file: file_path.to_string(), + line: (mc.node().start_position().row + 1) as u32, + signature: signature_for_node(&mc.node(), source), + parent: Some(parent_name.to_string()), + }); + } + } else if kind == "body_statement" || kind == "singleton_class" { + // tree-sitter-ruby wraps class/module contents in body_statement; + // recurse one level to find methods inside. + let mut inner = mc.node().walk(); + if inner.goto_first_child() { + loop { + if inner.node().kind() == "method" { + let mname = if let Some(n) = inner.node().child_by_field_name("name") { + node_text(&n, source) + } else { + String::new() + }; + if !mname.is_empty() { + nodes.push(AstNode { + name: mname, + kind: SymbolKind::Method, + file: file_path.to_string(), + line: (inner.node().start_position().row + 1) as u32, + signature: signature_for_node(&inner.node(), source), + parent: Some(parent_name.to_string()), + }); + } + } + if !inner.goto_next_sibling() { + break; + } + } + } + } + if !mc.goto_next_sibling() { + break; + } + } + } +} + fn extract_ruby( root: &tree_sitter::Node, source: &str, @@ -1818,7 +1884,7 @@ fn extract_ruby( node: &tree_sitter::Node, source: &str, file_path: &str, - nodes: &mut Vec, + mut nodes: &mut Vec, edges: &mut Vec, ) { match node.kind() { @@ -1868,32 +1934,27 @@ fn extract_ruby( signature: signature_for_node(node, source), parent: None, }); - // Methods inside class - let mut mc = node.walk(); - if mc.goto_first_child() { - loop { - if mc.node().kind() == "method" { - let mname = if let Some(n) = mc.node().child_by_field_name("name") { - node_text(&n, source) - } else { - String::new() - }; - if !mname.is_empty() { - nodes.push(AstNode { - name: mname, - kind: SymbolKind::Method, - file: file_path.to_string(), - line: (mc.node().start_position().row + 1) as u32, - signature: signature_for_node(&mc.node(), source), - parent: Some(name.clone()), - }); - } - } - if !mc.goto_next_sibling() { - break; - } - } - } + // Methods inside class (tree-sitter-ruby wraps them in body_statement) + extract_methods_from_ruby_node(node, source, file_path, &name, nodes); + } + } + "module" => { + let name = if let Some(n) = node.child_by_field_name("name") { + node_text(&n, source) + } else { + String::new() + }; + if !name.is_empty() { + nodes.push(AstNode { + name: name.clone(), + kind: SymbolKind::Module, + file: file_path.to_string(), + line: (node.start_position().row + 1) as u32, + signature: signature_for_node(node, source), + parent: None, + }); + // Methods inside module (same body_statement wrapping) + extract_methods_from_ruby_node(node, source, file_path, &name, nodes); } } "method" => { diff --git a/src/index/extract.rs b/src/index/extract.rs index cb3e6cc..e2c9623 100644 --- a/src/index/extract.rs +++ b/src/index/extract.rs @@ -1027,9 +1027,15 @@ end "#; let symbols = extract_symbols(code, "rb", "app.rb"); let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); - assert!(names.contains(&"ApplicationController")); - assert!(names.contains(&"authenticate_user")); - assert!(names.contains(&"Auth")); + assert!( + names.contains(&"ApplicationController"), + "missing ApplicationController, got: {names:?}" + ); + assert!( + names.contains(&"authenticate_user"), + "missing authenticate_user, got: {names:?}" + ); + assert!(names.contains(&"Auth"), "missing Auth, got: {names:?}"); } #[test]