diff --git a/src/index/mod.rs b/src/index/mod.rs index 6779382..a1edcea 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -9,6 +9,7 @@ mod ast; pub mod brain; mod extract; pub mod graph; +pub mod queries; pub mod schema; mod symbols; pub mod vector; diff --git a/src/index/queries.rs b/src/index/queries.rs new file mode 100644 index 0000000..a9c2fc2 --- /dev/null +++ b/src/index/queries.rs @@ -0,0 +1,415 @@ +//! Shared index queries used by both the CLI and the MCP server. +//! +//! The frontends (`cora affected`, `cora dead-code`, `cora.find_affected_tests`, +//! `cora.dead_code`) only parse input and format output; the query logic lives +//! here so the two can never diverge again (#568). + +use std::collections::BTreeSet; +use std::path::Path; + +use rusqlite::{Connection, ToSql}; + +use super::graph::{self, DeadCodeOptions, DeadCodeResult}; + +/// Max number of changed files accepted at an untrusted input boundary (MCP). +/// The query itself chunks its bind parameters and has no hard limit. +pub const MAX_AFFECTED_FILES: usize = 200; + +/// Default substrings that mark a caller's file as a test file. +pub const DEFAULT_TEST_FILE_MARKERS: &[&str] = &["test", "spec", "_test", "_spec"]; + +/// File extensions the naming-convention strategy generates candidates for. +const TEST_EXTENSIONS: &[&str] = &["rs", "go", "py", "ts", "tsx", "js", "jsx"]; + +/// Max bind parameters per statement chunk (SQLite's historical limit is 999). +const SQL_CHUNK: usize = 500; + +/// Options for [`find_affected_tests`]. +#[derive(Debug, Clone, Default)] +pub struct AffectedOptions { + /// Override for the substrings that identify a test file when scanning + /// callers (CLI `--filter`). `None` uses [`DEFAULT_TEST_FILE_MARKERS`]. + pub test_file_markers: Option>, +} + +/// Reject an oversized changed-file list (for untrusted callers such as MCP). +pub fn validate_changed_files(files: &[String]) -> anyhow::Result<()> { + if files.len() > MAX_AFFECTED_FILES { + anyhow::bail!( + "Parameter 'files' has {} entries; maximum is {MAX_AFFECTED_FILES}", + files.len() + ); + } + Ok(()) +} + +/// Escape `%`, `_` and the escape char itself for `LIKE ... ESCAPE '\'`. +pub fn escape_like(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '%' | '_' | '\\') { + out.push('\\'); + } + out.push(c); + } + out +} + +/// File stem of a path (`src/foo.rs` -> `foo`, `a/foo.test.ts` -> `foo.test`). +/// `None` when the path has no usable stem. +pub fn file_stem(file: &str) -> Option<&str> { + Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) +} + +/// Conventional test-file path suffixes for the given source files. +/// +/// For every stem and every extension in `rs go py ts tsx js jsx` we emit: +/// `{stem}_test.{ext}`, `test_{stem}.{ext}`, `{stem}.test.{ext}`, +/// `{stem}.spec.{ext}`, `tests/{stem}.{ext}` and `__tests__/{stem}.{ext}`. +/// Candidates are matched as path suffixes, deduplicated and sorted. +pub fn test_name_candidates(files: &[String]) -> Vec { + let mut names = BTreeSet::new(); + for stem in files.iter().filter_map(|f| file_stem(f)) { + for ext in TEST_EXTENSIONS { + names.extend([ + format!("{stem}_test.{ext}"), + format!("test_{stem}.{ext}"), + format!("{stem}.test.{ext}"), + format!("{stem}.spec.{ext}"), + format!("tests/{stem}.{ext}"), + format!("__tests__/{stem}.{ext}"), + ]); + } + } + names.into_iter().collect() +} + +fn as_refs(params: &[Box]) -> Vec<&dyn ToSql> { + params.iter().map(|p| p.as_ref()).collect() +} + +fn placeholders(n: usize) -> String { + vec!["?"; n].join(",") +} + +/// Find test files affected by a set of changed source files. +/// +/// Two strategies, unioned and returned sorted and deduplicated: +/// 1. symbols defined in the changed files whose callers live in test files; +/// 2. test files matching the naming conventions of [`test_name_candidates`]. +pub fn find_affected_tests( + conn: &Connection, + project_id: i64, + changed: &[String], + opts: &AffectedOptions, +) -> anyhow::Result> { + let mut affected: BTreeSet = BTreeSet::new(); + let default_markers: Vec = DEFAULT_TEST_FILE_MARKERS + .iter() + .map(|s| (*s).to_string()) + .collect(); + let markers = opts.test_file_markers.as_ref().unwrap_or(&default_markers); + + // Strategy 1: symbols in changed files -> caller files that are tests. + let mut symbols: BTreeSet = BTreeSet::new(); + for chunk in changed.chunks(SQL_CHUNK) { + let sql = format!( + "SELECT DISTINCT name FROM symbols WHERE file IN ({}) AND project_id = ?", + placeholders(chunk.len()) + ); + let mut params: Vec> = chunk + .iter() + .map(|f| Box::new(f.clone()) as Box) + .collect(); + params.push(Box::new(project_id)); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(as_refs(¶ms).as_slice(), |r| r.get::<_, String>(0))?; + for r in rows { + symbols.insert(r?); + } + } + let symbols: Vec = symbols.into_iter().collect(); + for chunk in symbols.chunks(SQL_CHUNK) { + let sql = format!( + "SELECT DISTINCT file FROM call_graph WHERE callee IN ({}) AND project_id = ?", + placeholders(chunk.len()) + ); + let mut params: Vec> = chunk + .iter() + .map(|s| Box::new(s.clone()) as Box) + .collect(); + params.push(Box::new(project_id)); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(as_refs(¶ms).as_slice(), |r| r.get::<_, String>(0))?; + for r in rows { + let file = r?; + if markers.iter().any(|m| file.contains(m.as_str())) { + affected.insert(file); + } + } + } + + // Strategy 2: naming convention, batched LIKE with ESCAPE. + let candidates = test_name_candidates(changed); + for chunk in candidates.chunks(SQL_CHUNK) { + let clause = (1..=chunk.len()) + .map(|i| format!("path LIKE '%' || ?{i} ESCAPE '\\'")) + .collect::>() + .join(" OR "); + let sql = format!( + "SELECT DISTINCT path FROM files WHERE ({clause}) AND project_id = ?{}", + chunk.len() + 1 + ); + let mut params: Vec> = chunk + .iter() + .map(|t| Box::new(escape_like(t)) as Box) + .collect(); + params.push(Box::new(project_id)); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(as_refs(¶ms).as_slice(), |r| r.get::<_, String>(0))?; + for r in rows { + affected.insert(r?); + } + } + + Ok(affected.into_iter().collect()) +} + +/// Caller-controlled dead-code flags (the config-derived part is added by +/// [`dead_code_options`]). +#[derive(Debug, Clone, Copy, Default)] +pub struct DeadCodeFlags { + pub include_tests: bool, + pub include_pub_api: bool, + pub min_lines: Option, +} + +/// Build [`DeadCodeOptions`] from caller flags plus `analysis.entry_point_patterns` +/// of the given config. Both frontends go through here. +pub fn dead_code_options( + config: &crate::config::schema::Config, + flags: DeadCodeFlags, +) -> DeadCodeOptions { + DeadCodeOptions { + include_tests: flags.include_tests, + min_lines: flags.min_lines, + entry_point_patterns: config.analysis.entry_point_patterns.clone(), + include_pub_api: flags.include_pub_api, + } +} + +/// Find potentially dead symbols using config-derived entry-point patterns. +pub fn find_dead_code( + conn: &Connection, + project_id: i64, + config: &crate::config::schema::Config, + flags: DeadCodeFlags, +) -> anyhow::Result> { + graph::find_dead_code(conn, project_id, &dead_code_options(config, flags)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::index::graph::{CallEdge, store_edges}; + + fn setup() -> (Connection, i64) { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + crate::index::schema::run_migrations(&conn).unwrap(); + let pid = crate::index::schema::get_or_create_project(&conn, "/tmp/q").unwrap(); + (conn, pid) + } + + fn add_file(conn: &Connection, pid: i64, path: &str) { + conn.execute( + "INSERT INTO files (project_id, path, fingerprint, last_indexed) VALUES (?1, ?2, 'f', 't')", + rusqlite::params![pid, path], + ) + .unwrap(); + } + + fn add_symbol(conn: &Connection, pid: i64, name: &str, file: &str) { + conn.execute( + "INSERT INTO symbols (name, kind, file, line, project_id) VALUES (?1, 'function', ?2, 1, ?3)", + rusqlite::params![name, file, pid], + ) + .unwrap(); + } + + fn call(conn: &Connection, pid: i64, callee: &str, file: &str) { + store_edges( + conn, + &[CallEdge { + caller: "caller_fn".into(), + callee: callee.into(), + file: file.into(), + line: 1, + }], + pid, + ) + .unwrap(); + } + + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| (*x).to_string()).collect() + } + + #[test] + fn stem_is_not_the_extension() { + assert_eq!(file_stem("foo.rs"), Some("foo")); + assert_eq!(file_stem("foo.test.ts"), Some("foo.test")); + assert_eq!(file_stem("dir/foo.py"), Some("foo")); + assert_eq!(file_stem(""), None); + assert_eq!(file_stem("/"), None); + + let names = test_name_candidates(&s(&["src/engine/review.rs"])); + assert!(names.contains(&"review_test.rs".into())); + assert!(names.contains(&"tests/review.rs".into())); + assert!(names.contains(&"test_review.py".into())); + assert!(names.contains(&"review.spec.tsx".into())); + assert!(names.contains(&"__tests__/review.js".into())); + assert!(names.contains(&"review.test.go".into())); + assert!( + !names + .iter() + .any(|n| n.starts_with("rs_") || n == "rs.test.ts") + ); + assert!(test_name_candidates(&s(&["", "/"])).is_empty()); + } + + #[test] + fn like_escaping() { + assert_eq!(escape_like("a_b%c\\d"), "a\\_b\\%c\\\\d"); + assert_eq!(escape_like("plain"), "plain"); + } + + #[test] + fn like_wildcards_match_literally() { + let (conn, pid) = setup(); + add_file(&conn, pid, "tests/a_b_test.rs"); + add_file(&conn, pid, "tests/axb_test.rs"); + add_file(&conn, pid, "tests/a%b_test.rs"); + add_file(&conn, pid, "tests/azzb_test.rs"); + let d = AffectedOptions::default(); + let got = find_affected_tests(&conn, pid, &s(&["src/a_b.rs"]), &d).unwrap(); + assert_eq!(got, s(&["tests/a_b_test.rs"])); + let got = find_affected_tests(&conn, pid, &s(&["src/a%b.rs"]), &d).unwrap(); + assert_eq!(got, s(&["tests/a%b_test.rs"])); + } + + #[test] + fn naming_convention_strategy() { + let (conn, pid) = setup(); + for p in [ + "src/foo_test.go", + "tests/foo.rs", + "pkg/test_foo.py", + "web/foo.test.ts", + "web/foo.spec.tsx", + "web/__tests__/foo.js", + "src/foo.rs", + "src/other_test.rs", + ] { + add_file(&conn, pid, p); + } + let d = AffectedOptions::default(); + let got = find_affected_tests(&conn, pid, &s(&["src/foo.rs"]), &d).unwrap(); + assert_eq!( + got, + s(&[ + "pkg/test_foo.py", + "src/foo_test.go", + "tests/foo.rs", + "web/__tests__/foo.js", + "web/foo.spec.tsx", + "web/foo.test.ts", + ]) + ); + } + + #[test] + fn caller_strategy_and_filter() { + let (conn, pid) = setup(); + add_symbol(&conn, pid, "do_work", "src/lib.rs"); + call(&conn, pid, "do_work", "tests/integration.rs"); + call(&conn, pid, "do_work", "src/main.rs"); + let d = AffectedOptions::default(); + let got = find_affected_tests(&conn, pid, &s(&["src/lib.rs"]), &d).unwrap(); + assert_eq!(got, s(&["tests/integration.rs"])); + + let opts = AffectedOptions { + test_file_markers: Some(s(&["main"])), + }; + let got = find_affected_tests(&conn, pid, &s(&["src/lib.rs"]), &opts).unwrap(); + assert_eq!(got, s(&["src/main.rs"])); + } + + #[test] + fn results_are_project_scoped() { + let (conn, pid) = setup(); + let other = crate::index::schema::get_or_create_project(&conn, "/tmp/other").unwrap(); + add_file(&conn, other, "tests/foo.rs"); + let d = AffectedOptions::default(); + let got = find_affected_tests(&conn, pid, &s(&["src/foo.rs"]), &d).unwrap(); + assert!(got.is_empty()); + } + + #[test] + fn many_files_are_chunked() { + let (conn, pid) = setup(); + let files: Vec = (0..1200).map(|i| format!("src/m{i}.rs")).collect(); + add_file(&conn, pid, "tests/m1199.rs"); + let got = find_affected_tests(&conn, pid, &files, &AffectedOptions::default()).unwrap(); + assert_eq!(got, s(&["tests/m1199.rs"])); + } + + #[test] + fn cap_enforced() { + let ok: Vec = (0..MAX_AFFECTED_FILES) + .map(|i| format!("f{i}.rs")) + .collect(); + assert!(validate_changed_files(&ok).is_ok()); + let too_many: Vec = (0..=MAX_AFFECTED_FILES) + .map(|i| format!("f{i}.rs")) + .collect(); + let err = validate_changed_files(&too_many).unwrap_err().to_string(); + assert!(err.contains("maximum is 200")); + } + + #[test] + fn dead_code_honors_entry_point_patterns() { + let (conn, pid) = setup(); + add_symbol(&conn, pid, "plugin_hook_entry", "src/a.rs"); + add_symbol(&conn, pid, "orphan_fn", "src/a.rs"); + let flags = DeadCodeFlags { + include_pub_api: true, + ..Default::default() + }; + let names = |cfg: &crate::config::schema::Config| -> Vec { + find_dead_code(&conn, pid, cfg, flags) + .unwrap() + .into_iter() + .map(|r| r.name) + .collect() + }; + + let plain = crate::config::schema::Config::default(); + let n = names(&plain); + assert!(n.contains(&"plugin_hook_entry".to_string())); + assert!(n.contains(&"orphan_fn".to_string())); + + let mut cfg = crate::config::schema::Config::default(); + cfg.analysis.entry_point_patterns = s(&["plugin_hook_*"]); + assert_eq!( + dead_code_options(&cfg, flags).entry_point_patterns, + s(&["plugin_hook_*"]) + ); + let n = names(&cfg); + assert!(!n.contains(&"plugin_hook_entry".to_string())); + assert!(n.contains(&"orphan_fn".to_string())); + } +} diff --git a/src/main.rs b/src/main.rs index 21cf0ff..c70c573 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1218,95 +1218,10 @@ async fn main() -> Result<()> { 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 - // Batch: fetch all symbols for all changed files in a single query - let all_symbols: Vec = { - let placeholders: String = - changed.iter().map(|_| "?").collect::>().join(","); - let n = changed.len() + 1; - let sql = format!( - "SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}" - ); - let mut stmt = conn.prepare(&sql)?; - let mut params: Vec> = changed - .iter() - .map(|f| Box::new(f.clone()) as Box) - .collect(); - params.push(Box::new(project_id)); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; - rows.filter_map(|r| r.ok()).collect() + let opts = index::queries::AffectedOptions { + test_file_markers: filter.map(|f| vec![f]), }; - - // Deduplicate symbols and resolve callers with a single set-based query - { - let mut seen_syms: std::collections::HashSet = - std::collections::HashSet::new(); - for sym_name in all_symbols { - if seen_syms.insert(sym_name.clone()) { - let callers = - index::graph::find_callers(&conn, project_id, &sym_name, 100)?; - for caller in callers { - 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) - // Prepare statement once before the loop - let mut stmt = conn.prepare( - "SELECT DISTINCT path FROM files WHERE path LIKE ?1 AND project_id = ?2", - )?; - 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 pattern = format!("%{tp}"); - let rows = stmt.query_map(rusqlite::params![pattern, project_id], |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(); + let sorted = index::queries::find_affected_tests(&conn, project_id, &changed, &opts)?; if json { println!("{}", serde_json::to_string_pretty(&sorted)?); @@ -1595,15 +1510,12 @@ async fn main() -> Result<()> { false, ) .unwrap_or_default(); - let entry_point_patterns = config.analysis.entry_point_patterns.clone(); - - let opts = index::graph::DeadCodeOptions { + let flags = index::queries::DeadCodeFlags { include_tests, - min_lines, - entry_point_patterns, include_pub_api: include_pub, + min_lines, }; - let results = index::graph::find_dead_code(&conn, project_id, &opts)?; + let results = index::queries::find_dead_code(&conn, project_id, &config, flags)?; if json { let out = serde_json::to_string_pretty(&results)?; println!("{out}"); diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index ba36890..7185d14 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -576,8 +576,6 @@ fn handle_find_impact(params: &serde_json::Value) -> ToolResult { } } -/// Max entries accepted in `cora.find_affected_tests` `files`. -const MAX_AFFECTED_FILES: usize = 200; /// Upper bounds for caller-supplied numeric parameters. const MAX_LIMIT: u64 = 500; const MAX_DEPTH: u64 = 10; @@ -594,41 +592,6 @@ fn clamped_u64(params: &serde_json::Value, key: &str, default: u64, max: u64) -> .min(max) } -/// Escape `%`, `_` and the escape char itself for use with `LIKE ... ESCAPE '\'`. -fn escape_like(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if matches!(c, '%' | '_' | '\\') { - out.push('\\'); - } - out.push(c); - } - out -} - -/// Conventional test-file name suffixes for the given source files. -fn test_name_candidates(files: &[String]) -> Vec { - let mut names = Vec::new(); - for file in files { - let Some(stem) = std::path::Path::new(file) - .file_stem() - .and_then(|s| s.to_str()) - .filter(|s| !s.is_empty()) - else { - continue; - }; - names.extend([ - format!("{stem}_test.rs"), - format!("tests/{stem}.rs"), - format!("{stem}_test.go"), - format!("test_{stem}.py"), - format!("{stem}.test.ts"), - format!("{stem}.spec.ts"), - ]); - } - names -} - fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { let files: Vec = match params.get("files").and_then(|v| v.as_array()) { Some(arr) => arr @@ -642,11 +605,8 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { if files.is_empty() { return ToolResult::error("Parameter 'files' must not be empty"); } - if files.len() > MAX_AFFECTED_FILES { - return ToolResult::error(format!( - "Parameter 'files' has {} entries; maximum is {MAX_AFFECTED_FILES}", - files.len() - )); + if let Err(e) = crate::index::queries::validate_changed_files(&files) { + return ToolResult::error(e.to_string()); } let (conn, project_id) = match open_index_db() { @@ -654,86 +614,16 @@ fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { Err(e) => return ToolResult::error(e.to_string()), }; - let patterns = ["test", "spec", "_test", "_spec"]; - let mut affected: std::collections::HashSet = std::collections::HashSet::new(); - - // Batch fetch all symbols for all files in a single query - let all_symbols: Vec = { - let placeholders = files.iter().map(|_| "?").collect::>().join(","); - let n = files.len() + 1; - let sql = format!( - "SELECT DISTINCT name FROM symbols WHERE file IN ({placeholders}) AND project_id = ?{n}" - ); - let mut stmt = match conn.prepare(&sql) { - Ok(s) => s, - Err(e) => return ToolResult::error(format!("DB error: {e}")), - }; - let mut params: Vec> = files - .iter() - .map(|f| Box::new(f.clone()) as Box) - .collect(); - params.push(Box::new(project_id)); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - let rows = match stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) { - Ok(r) => r, - Err(e) => return ToolResult::error(format!("DB error: {e}")), - }; - rows.filter_map(|r| r.ok()).collect() + let sorted = match crate::index::queries::find_affected_tests( + &conn, + project_id, + &files, + &crate::index::queries::AffectedOptions::default(), + ) { + Ok(s) => s, + Err(e) => return ToolResult::error(format!("DB error: {e}")), }; - // Deduplicate and traverse call graph once - { - let mut seen_syms: std::collections::HashSet = std::collections::HashSet::new(); - for sym_name in &all_symbols { - if seen_syms.insert(sym_name.clone()) { - if let Ok(callers) = - crate::index::graph::find_callers(&conn, project_id, sym_name, 100) - { - for caller in callers { - if patterns.iter().any(|p| caller.file.contains(*p)) { - affected.insert(caller.file.clone()); - } - } - } - } - } - } - - // Strategy 2: naming convention — batch all test name candidates - let test_names = test_name_candidates(&files); - - // Query with a single LIKE batch, scoped to project - if !test_names.is_empty() { - let n = test_names.len() + 1; - let sql = format!( - "SELECT DISTINCT path FROM files WHERE ({}) AND project_id = ?{n}", - (1..=test_names.len()) - .map(|i| format!("path LIKE '%' || ?{i} ESCAPE '\\'")) - .collect::>() - .join(" OR ") - ); - let mut stmt = match conn.prepare(&sql) { - Ok(s) => s, - Err(e) => return ToolResult::error(format!("DB error: {e}")), - }; - let mut params: Vec> = test_names - .iter() - .map(|t| Box::new(escape_like(t)) as Box) - .collect(); - params.push(Box::new(project_id)); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) { - for row in rows.map_while(Result::ok) { - affected.insert(row); - } - } - } - - let mut sorted: Vec = affected.into_iter().collect(); - sorted.sort(); - let json = serde_json::json!({ "affected_tests": sorted, "count": sorted.len(), @@ -1151,14 +1041,16 @@ fn handle_dead_code(params: &serde_json::Value) -> ToolResult { .and_then(|v| v.as_u64()) .map(|v| v.min(MAX_MIN_LINES) as u32); - let opts = crate::index::graph::DeadCodeOptions { + // Project-level `.cora.yaml` only (no env/global/secrets); entry-point + // patterns are plain names, not secrets, so they are honored like the CLI. + let config = load_project_config().unwrap_or_default(); + let flags = crate::index::queries::DeadCodeFlags { include_tests, - min_lines, - entry_point_patterns: vec![], include_pub_api, + min_lines, }; - match crate::index::graph::find_dead_code(&conn, project_id, &opts) { + match crate::index::queries::find_dead_code(&conn, project_id, &config, flags) { Ok(results) => { if results.is_empty() { return ToolResult::text("No dead code found."); @@ -1413,55 +1305,9 @@ mod tests { assert!(result.is_error); } - #[test] - fn test_name_candidates_use_file_stem_not_extension() { - let names = test_name_candidates(&["src/engine/review.rs".to_string()]); - assert!(names.contains(&"review_test.rs".to_string())); - assert!(names.contains(&"tests/review.rs".to_string())); - assert!(names.contains(&"test_review.py".to_string())); - assert!(names.contains(&"review.spec.ts".to_string())); - assert!( - !names - .iter() - .any(|n| n.contains("rs_test") || n.starts_with("rs")) - ); - - // Dotted stems keep everything before the last extension. - let names = test_name_candidates(&["web/app.config.ts".to_string()]); - assert!(names.contains(&"app.config.test.ts".to_string())); - - // No stem -> no candidates (never a bare "_test.rs" matching everything). - assert!(test_name_candidates(&["".to_string(), "/".to_string()]).is_empty()); - } - - #[test] - fn escape_like_escapes_wildcards() { - assert_eq!(escape_like("a_b%c\\d"), "a\\_b\\%c\\\\d"); - assert_eq!(escape_like("plain"), "plain"); - } - - #[test] - fn like_escape_matches_literally_in_sqlite() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - conn.execute("CREATE TABLE f (path TEXT)", []).unwrap(); - for p in ["a_b_test.rs", "axb_test.rs"] { - conn.execute("INSERT INTO f VALUES (?1)", [p]).unwrap(); - } - let names = test_name_candidates(&["src/a_b.rs".to_string()]); - let pat = escape_like(&names[0]); - let hits: Vec = conn - .prepare("SELECT path FROM f WHERE path LIKE '%' || ?1 ESCAPE '\\'") - .unwrap() - .query_map([pat], |r| r.get(0)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(hits, vec!["a_b_test.rs".to_string()]); - } - #[test] fn find_affected_tests_rejects_too_many_files() { - let files: Vec = (0..=MAX_AFFECTED_FILES) + let files: Vec = (0..=crate::index::queries::MAX_AFFECTED_FILES) .map(|i| format!("f{i}.rs")) .collect(); let result = handle_tool_call(