From 2bf93c345be519d25f5e8175b15b4556e5ce3c0d Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sun, 14 Jun 2026 14:15:30 +0700 Subject: [PATCH] feat: cora affected + index --watch + call graph edge extraction (#267, #269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #267 — cora affected: - Find test files affected by source changes - Strategy 1: reverse call graph traversal (changed symbols → callers in test files) - Strategy 2: naming convention (foo.rs → foo_test.rs, tests/foo.rs) - CLI: cora affected [files...] [--stdin] [--filter glob] [--json] - git diff --name-only | cora affected --stdin for CI pipeline - Runtime verified: src/main.rs → tests/cli_basic.rs + tests/config_loading.rs #269 — cora index --watch: - Poll-based auto-sync (2s interval, no extra deps) - Initial full index, then incremental re-index on file changes - Ctrl+C to stop - Real-time output of updated files Call graph edge extraction (wired to index pipeline): - extract_calls() in index/extract.rs: scope tracking + call site extraction - Reuses engine/context/extraction.rs symbol extraction - detect_function_entry() for Rust/Go/C/Java/Python scope tracking - is_builtin() filter to skip stdlib calls - Edges stored in call_graph table during index_file() - Runtime verified: cora callers execute_commit → 1 caller (main) cora impact open_index --depth 2 → 5 affected sites 552 tests pass, clippy clean. --- src/engine/context/extraction.rs | 2 +- src/index/extract.rs | 178 +++++++++++++++++++++++++++++ src/index/mod.rs | 15 ++- src/main.rs | 186 +++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 2 deletions(-) diff --git a/src/engine/context/extraction.rs b/src/engine/context/extraction.rs index 53f0fb5..ad871c5 100644 --- a/src/engine/context/extraction.rs +++ b/src/engine/context/extraction.rs @@ -73,7 +73,7 @@ static RE_JAVA_TYPE: LazyLock = const MAX_SYMBOLS_PER_FILE: usize = 50; /// Extract symbols from a single line of code for a given language. -fn extract_symbols_from_line(line: &str, language: &str) -> Vec { +pub fn extract_symbols_from_line(line: &str, language: &str) -> Vec { let mut symbols = Vec::new(); let mut seen = HashSet::new(); diff --git a/src/index/extract.rs b/src/index/extract.rs index 1adb711..8476615 100644 --- a/src/index/extract.rs +++ b/src/index/extract.rs @@ -351,6 +351,184 @@ fn extract_zig(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec Vec { + use crate::engine::context::extraction as ctx_extract; + use crate::engine::context::types::SymbolKind as CtxSymbolKind; + + let lines: Vec<&str> = content.lines().collect(); + let mut current_fn: Option = None; + let mut brace_depth: i32 = 0; + let mut calls = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + let line_no = (i + 1) as u32; + + // Track current function scope for Rust/Go/C/Java + if language == "rs" + || language == "go" + || language == "c" + || language == "cpp" + || language == "java" + { + // Check if we're entering a function + if let Some(fn_name) = detect_function_entry(line, language) { + current_fn = Some(fn_name); + brace_depth = 0; + } + } + + // Track brace depth for scope + brace_depth += line.chars().filter(|&c| c == '{').count() as i32 + - line.chars().filter(|&c| c == '}').count() as i32; + if brace_depth <= 0 && current_fn.is_some() { + current_fn = None; + } + + // Extract function calls from this line + let symbols = ctx_extract::extract_symbols_from_line(line, language); + for sym in symbols { + if let CtxSymbolKind::FunctionCall(name) = sym { + // Skip self-references and builtins + if name == current_fn.as_deref().unwrap_or("") { + continue; + } + if is_builtin(&name) { + continue; + } + if let Some(caller) = ¤t_fn { + calls.push(CallSite { + caller: caller.clone(), + callee: name, + file: file_path.to_string(), + line: line_no, + }); + } + } + } + } + + calls +} + +/// Detect function entry from a line (returns function name). +fn detect_function_entry(line: &str, language: &str) -> Option { + let trimmed = line.trim(); + + match language { + "rs" => { + // pub fn name( or fn name( + let re = regex::Regex::new(r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[(<]").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "go" => { + let re = regex::Regex::new(r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "c" | "cpp" | "h" | "hpp" => { + let re = regex::Regex::new(r"[\w:*]+\s+(\w+)\s*\([^;]*\)\s*\{").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "java" | "kt" => { + let re = regex::Regex::new(r"\b(\w+)\s*\([^)]*\)\s*\{").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + "py" => { + let re = regex::Regex::new(r"def\s+(\w+)").unwrap(); + re.captures(trimmed) + .map(|c| c.get(1).unwrap().as_str().to_string()) + } + _ => None, + } +} + +/// Check if a name is a builtin/keyword to skip. +fn is_builtin(name: &str) -> bool { + matches!( + name, + "if" | "for" + | "while" + | "match" + | "return" + | "break" + | "continue" + | "print" + | "println" + | "eprintln" + | "format" + | "write" + | "writeln" + | "vec" + | "Box" + | "Some" + | "None" + | "Ok" + | "Err" + | "Result" + | "Option" + | "String" + | "str" + | "Vec" + | "HashMap" + | "HashSet" + | "dbg" + | "todo" + | "unimplemented" + | "unreachable" + | "panic" + | "assert" + | "assert_eq" + | "assert_ne" + | "len" + | "is_empty" + | "push" + | "pop" + | "insert" + | "remove" + | "get" + | "set" + | "new" + | "default" + | "clone" + | "into" + | "from" + | "iter" + | "collect" + | "map" + | "filter" + | "fold" + | "next" + | "unwrap" + | "expect" + | "as_ref" + | "as_mut" + | "as_str" + | "to_string" + | "to_owned" + | "to_vec" + | "drop" + | "copy" + | "send" + | "sync" + | "main" + ) +} + +/// A function call site extracted from source code. +#[derive(Debug, Clone)] +pub struct CallSite { + pub caller: String, + pub callee: String, + pub file: String, + pub line: u32, +} + fn def(cap: regex::Captures, kind: SymbolKind, line: u32, file: &str, raw: &str) -> ExtractedDef { ExtractedDef { name: cap diff --git a/src/index/mod.rs b/src/index/mod.rs index 148cd0c..6fe76a5 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -91,9 +91,22 @@ pub fn index_file( count += 1; } + // Extract and store call graph edges + graph::clear_edges_for_file(&tx, file_path)?; + let call_sites = extract::extract_calls(content, language, file_path); + for site in &call_sites { + tx.execute( + "INSERT INTO call_graph (caller, callee, file, line) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![site.caller, site.callee, site.file, site.line as i64], + )?; + } + tx.commit()?; - debug!("Indexed {file_path}: {count} symbols ({language})"); + debug!( + "Indexed {file_path}: {count} symbols, {} edges ({language})", + call_sites.len() + ); Ok(count) } diff --git a/src/main.rs b/src/main.rs index f69fdab..d7963df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,6 +95,10 @@ enum Command { #[clap(long)] rebuild: bool, + /// Watch for file changes and auto-update index + #[clap(long)] + watch: bool, + /// Verbose output #[clap(long, short)] verbose: bool, @@ -154,6 +158,24 @@ enum Command { json: bool, }, + /// Find tests affected by changed files + Affected { + /// Changed files (space-separated). If empty, reads from git diff. + files: Vec, + + /// Read changed files from stdin (pipe from git diff --name-only) + #[clap(long)] + stdin: bool, + + /// Test file glob pattern (default: *test*, *spec*) + #[clap(long)] + filter: Option, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + /// Review staged changes, generate commit message, and commit Commit { /// YOLO mode — auto-commit without prompts @@ -509,6 +531,7 @@ async fn main() -> Result<()> { stats: show_stats, prune, rebuild, + watch, verbose, } => { let project_root = std::env::current_dir()?; @@ -544,6 +567,35 @@ async fn main() -> Result<()> { "{}", format!("Pruned {deleted} deleted files from index.").green() ); + } else if watch { + // Initial index + eprintln!("{}", "🔍 Initial index...".cyan()); + let stats = + index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + eprintln!( + "{}", + format!( + "✅ Indexed {} symbols. Watching for changes... (Ctrl+C to stop)", + stats.symbols_indexed + ) + .green() + ); + + // Poll loop: re-index changed files every 2 seconds + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let stats = index::index_project(&conn, &project_root, false)?; + if stats.files_indexed > 0 { + eprintln!( + "{}", + format!( + "🔄 Updated {} files, {} symbols", + stats.files_indexed, stats.symbols_indexed + ) + .cyan() + ); + } + } } else { eprintln!("{}", "🔍 Indexing project...".cyan()); let stats = @@ -720,6 +772,140 @@ async fn main() -> Result<()> { 0 } + Command::Affected { + files, + stdin, + filter, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run 'cora index' first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + + // Gather changed files + let mut changed: Vec = files; + if stdin { + use std::io::BufRead; + let stdin = std::io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + changed.push(trimmed); + } + } + } + + if changed.is_empty() { + // Fallback: get from git diff + let output = std::process::Command::new("git") + .args(["diff", "--name-only", "HEAD"]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + changed = stdout + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + } + + if changed.is_empty() { + eprintln!("{}", "No changed files detected.".yellow()); + std::process::exit(0); + } + + // Default test patterns + let patterns: Vec = filter.map(|f| vec![f]).unwrap_or_else(|| { + vec![ + "test".to_string(), + "spec".to_string(), + "_test".to_string(), + "_spec".to_string(), + ] + }); + + // Find test files that import/reference changed source files + let mut affected_tests: std::collections::HashSet = + std::collections::HashSet::new(); + + // Strategy 1: Find symbols in changed files, then find callers that are in test files + for file in &changed { + // Get all symbols defined in this file + let symbols_in_file: Vec = { + let mut stmt = + conn.prepare("SELECT DISTINCT name FROM symbols WHERE file = ?1")?; + let rows = + stmt.query_map(rusqlite::params![file], |row| row.get::<_, String>(0))?; + rows.filter_map(|r| r.ok()).collect() + }; + + // For each symbol, find callers + for sym_name in &symbols_in_file { + let callers = index::graph::find_callers(&conn, sym_name, 100)?; + for caller in callers { + // Check if caller file looks like a test file + if patterns.iter().any(|p| caller.file.contains(p.as_str())) { + affected_tests.insert(caller.file.clone()); + } + } + } + } + + // Strategy 2: Direct test file name convention (mod_test.rs, foo_test.go) + for file in &changed { + // For Rust: src/foo.rs → tests/foo.rs or src/foo.rs → src/foo_test.rs + let stem = file + .rsplit('/') + .next() + .unwrap_or(file) + .rsplit('.') + .next() + .unwrap_or(""); + let test_patterns = [ + format!("{stem}_test.rs"), + format!("tests/{stem}.rs"), + format!("test_{stem}.rs"), + format!("{stem}_test.go"), + format!("{stem}_test.py"), + format!("test_{stem}.py"), + format!("{stem}.test.ts"), + format!("{stem}.spec.ts"), + ]; + for tp in &test_patterns { + let mut stmt = + conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1")?; + let pattern = format!("%{tp}"); + let rows = + stmt.query_map(rusqlite::params![pattern], |row| row.get::<_, String>(0))?; + for f in rows.map_while(Result::ok) { + affected_tests.insert(f); + } + } + } + + let mut sorted: Vec = affected_tests.into_iter().collect(); + sorted.sort(); + + if json { + println!("{}", serde_json::to_string_pretty(&sorted)?); + } else if sorted.is_empty() { + eprintln!("{}", "No affected test files found.".yellow()); + } else { + println!( + "{}", + format!("Affected test files ({}):", sorted.len()).cyan() + ); + println!("{}", "\u{2500}".repeat(50).dimmed()); + for f in &sorted { + println!(" {}", f.white().bold()); + } + } + 0 + } + Command::Commit { yolo, force,