Skip to content

Commit cc6bf43

Browse files
author
Your Name
committed
Merge branch 'atam-audit-fixes' — implement all 10 ATAM audit findings
2 parents c727e8d + 704eabc commit cc6bf43

10 files changed

Lines changed: 1066 additions & 82 deletions

File tree

crates/ci-cli/src/main.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ enum Commands {
2323
/// Database file path
2424
#[arg(long)]
2525
db_path: Option<PathBuf>,
26-
/// Tool preset: full (all 16 tools), orient, trace, edit
27-
#[arg(long, default_value = "full")]
28-
preset: String,
26+
/// Tool preset to register. If not provided, uses preset from config.json (default: "full").
27+
#[arg(long)]
28+
preset: Option<String>,
2929
},
3030
/// One-shot index of the project (stub)
3131
Index {
@@ -79,8 +79,11 @@ async fn main() -> Result<()> {
7979
} => {
8080
let root = std::fs::canonicalize(&project_root)?;
8181
let db = db_path.unwrap_or_else(|| ci_server::default_db_path(&root));
82-
tracing::info!("Starting MCP server for {} (preset={})", root.display(), preset);
83-
ci_server::serve_stdio_with_preset(root, db, preset).await?;
82+
// CLI flag takes precedence; fall back to config.json value (default: "full")
83+
let config = ci_core::config::load_config(&root).unwrap_or_default();
84+
let effective_preset = preset.unwrap_or_else(|| config.preset.clone());
85+
tracing::info!("Starting MCP server for {} (preset={})", root.display(), effective_preset);
86+
ci_server::serve_stdio_with_preset(root, db, effective_preset).await?;
8487
}
8588
Commands::Index { project_root } => {
8689
let root = std::fs::canonicalize(&project_root)?;
@@ -94,7 +97,8 @@ async fn main() -> Result<()> {
9497
}
9598
let mut conn = rusqlite::Connection::open(&db_path)?;
9699
ci_core::db::schema::init_db(&conn)?;
97-
ci_core::indexer::pipeline::run_indexing_pipeline(&mut conn, &root)?;
100+
let phase = std::sync::Arc::new(std::sync::RwLock::new(ci_core::types::IndexingPhase::Scanning));
101+
ci_core::indexer::pipeline::run_indexing_pipeline(&mut conn, &root, phase)?;
98102
let symbol_count: i64 =
99103
conn.query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))?;
100104
let file_count: i64 =

crates/ci-core/src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,26 @@ pub fn load_config(project_root: &Path) -> anyhow::Result<Config> {
184184
pub fn default_config_json() -> String {
185185
serde_json::to_string_pretty(&Config::default()).unwrap_or_default()
186186
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
#[test]
193+
fn config_preset_defaults_to_full() {
194+
let config = Config::default();
195+
assert_eq!(config.preset, "full");
196+
}
197+
198+
#[test]
199+
fn config_preset_from_json() {
200+
let tmp = std::env::temp_dir().join(format!("ci_cfg_preset_{}", std::process::id()));
201+
let _ = std::fs::remove_dir_all(&tmp);
202+
std::fs::create_dir_all(&tmp).unwrap();
203+
std::fs::write(tmp.join("config.json"), r#"{"preset": "orient"}"#).unwrap();
204+
205+
let config = crate::config::load_config(&tmp).unwrap();
206+
assert_eq!(config.preset, "orient",
207+
"config.json preset must be loaded, got: {}", config.preset);
208+
}
209+
}

crates/ci-core/src/gitignore.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use std::fs;
2+
use std::path::Path;
3+
4+
use anyhow::Result;
5+
6+
pub fn ensure_gitignore(project_root: &Path) -> Result<()> {
7+
let path = project_root.join(".gitignore");
8+
if path.exists() {
9+
let content = fs::read_to_string(&path)?;
10+
if content.contains(".codeindex") {
11+
return Ok(());
12+
}
13+
// Append with a leading newline if file doesn't already end with one
14+
let suffix = if content.ends_with('\n') { ".codeindex/\n" } else { "\n.codeindex/\n" };
15+
fs::write(&path, format!("{content}{suffix}"))?;
16+
} else {
17+
fs::write(&path, ".codeindex/\n")?;
18+
}
19+
Ok(())
20+
}
21+
22+
#[cfg(test)]
23+
mod tests {
24+
use super::*;
25+
use std::fs;
26+
use std::path::PathBuf;
27+
28+
fn tmp_dir(suffix: &str) -> PathBuf {
29+
let dir = std::env::temp_dir().join(format!("ci_gi_{}_{}", suffix, std::process::id()));
30+
let _ = fs::remove_dir_all(&dir);
31+
fs::create_dir_all(&dir).unwrap();
32+
dir
33+
}
34+
35+
#[test]
36+
fn creates_gitignore_when_missing() {
37+
let dir = tmp_dir("create");
38+
ensure_gitignore(&dir).unwrap();
39+
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
40+
assert!(content.contains(".codeindex"), "must contain .codeindex, got: {content}");
41+
let _ = fs::remove_dir_all(&dir);
42+
}
43+
44+
#[test]
45+
fn appends_when_gitignore_exists_without_entry() {
46+
let dir = tmp_dir("append");
47+
fs::write(dir.join(".gitignore"), "target/\n").unwrap();
48+
ensure_gitignore(&dir).unwrap();
49+
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
50+
assert!(content.contains("target/"), "must preserve existing entries");
51+
assert!(content.contains(".codeindex"), "must add .codeindex entry");
52+
let _ = fs::remove_dir_all(&dir);
53+
}
54+
55+
#[test]
56+
fn idempotent_when_entry_already_present() {
57+
let dir = tmp_dir("idem");
58+
fs::write(dir.join(".gitignore"), "target/\n.codeindex/\n").unwrap();
59+
ensure_gitignore(&dir).unwrap();
60+
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
61+
// Must NOT have duplicate entries
62+
assert_eq!(content.matches(".codeindex").count(), 1, "must not duplicate entry");
63+
let _ = fs::remove_dir_all(&dir);
64+
}
65+
}

crates/ci-core/src/indexer/pipeline.rs

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -487,14 +487,22 @@ fn normalize_rel(base_dir: &str, rel: &str) -> String {
487487
/// Scan → extract symbols + call sites (tree-sitter) → rebuild graph
488488
/// (caller_count, coreness, is_hub). Everything is one transaction so the graph
489489
/// is never observed half-built.
490-
pub fn run_indexing_pipeline(conn: &mut Connection, project_root: &Path) -> rusqlite::Result<()> {
490+
pub fn run_indexing_pipeline(
491+
conn: &mut Connection,
492+
project_root: &Path,
493+
phase: std::sync::Arc<std::sync::RwLock<crate::types::IndexingPhase>>,
494+
) -> rusqlite::Result<()> {
495+
use crate::types::IndexingPhase;
496+
491497
let config = crate::config::load_config(project_root).unwrap_or_default();
492498
let entry_point_patterns = config.entry_points;
493499

494500
let mut files = Vec::new();
495501
collect_source_files(project_root, &mut files);
496502
files.sort();
497503

504+
*phase.write().unwrap() = IndexingPhase::Parsing;
505+
498506
let now = now_secs();
499507
let tx = conn.transaction()?;
500508

@@ -525,8 +533,13 @@ pub fn run_indexing_pipeline(conn: &mut Connection, project_root: &Path) -> rusq
525533
)?;
526534
}
527535

536+
*phase.write().unwrap() = IndexingPhase::BuildingEdges;
537+
528538
rebuild_graph(&tx, &config.hub_threshold)?;
529539
tx.commit()?;
540+
541+
*phase.write().unwrap() = IndexingPhase::Ready;
542+
530543
Ok(())
531544
}
532545

@@ -629,6 +642,34 @@ mod tests {
629642
conn.query_row(sql, [], |r| r.get(0)).unwrap()
630643
}
631644

645+
fn dummy_phase() -> std::sync::Arc<std::sync::RwLock<IndexingPhase>> {
646+
std::sync::Arc::new(std::sync::RwLock::new(IndexingPhase::Scanning))
647+
}
648+
649+
#[test]
650+
fn test_phase_advances_to_ready_after_pipeline() {
651+
use std::sync::{Arc, RwLock};
652+
use crate::types::IndexingPhase;
653+
654+
let dir = std::env::temp_dir().join(format!("ci_idx_phase_{}", std::process::id()));
655+
let _ = std::fs::remove_dir_all(&dir);
656+
std::fs::create_dir_all(&dir).unwrap();
657+
std::fs::write(dir.join("a.py"), "def hello():\n pass\n").unwrap();
658+
659+
let mut conn = Connection::open_in_memory().unwrap();
660+
init_db(&conn).unwrap();
661+
662+
let phase = Arc::new(RwLock::new(IndexingPhase::Scanning));
663+
run_indexing_pipeline(&mut conn, &dir, phase.clone()).unwrap();
664+
665+
assert_eq!(
666+
*phase.read().unwrap(),
667+
IndexingPhase::Ready,
668+
"Phase must be Ready after pipeline completes"
669+
);
670+
let _ = std::fs::remove_dir_all(&dir);
671+
}
672+
632673
#[test]
633674
fn test_phase_transition() {
634675
let mut sm = IndexStateMachine::new();
@@ -643,7 +684,7 @@ mod tests {
643684
std::fs::create_dir_all(&dir).unwrap();
644685
let mut conn = Connection::open_in_memory().unwrap();
645686
init_db(&conn).unwrap();
646-
assert!(run_indexing_pipeline(&mut conn, &dir).is_ok());
687+
assert!(run_indexing_pipeline(&mut conn, &dir, dummy_phase()).is_ok());
647688
let _ = std::fs::remove_dir_all(&dir);
648689
}
649690

@@ -660,7 +701,7 @@ mod tests {
660701

661702
let mut conn = Connection::open_in_memory().unwrap();
662703
init_db(&conn).unwrap();
663-
run_indexing_pipeline(&mut conn, &dir).unwrap();
704+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
664705

665706
assert_eq!(count(&conn, "SELECT COUNT(*) FROM symbols"), 2);
666707
assert_eq!(count(&conn, "SELECT COUNT(*) FROM file_index"), 1);
@@ -700,7 +741,7 @@ mod tests {
700741

701742
let mut conn = Connection::open_in_memory().unwrap();
702743
init_db(&conn).unwrap();
703-
run_indexing_pipeline(&mut conn, &dir).unwrap();
744+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
704745

705746
assert_eq!(
706747
count(
@@ -739,7 +780,7 @@ mod tests {
739780

740781
let mut conn_default = Connection::open_in_memory().unwrap();
741782
init_db(&conn_default).unwrap();
742-
run_indexing_pipeline(&mut conn_default, &dir).unwrap();
783+
run_indexing_pipeline(&mut conn_default, &dir, dummy_phase()).unwrap();
743784
assert_eq!(
744785
count(
745786
&conn_default,
@@ -756,7 +797,7 @@ mod tests {
756797
.unwrap();
757798
let mut conn_custom = Connection::open_in_memory().unwrap();
758799
init_db(&conn_custom).unwrap();
759-
run_indexing_pipeline(&mut conn_custom, &dir).unwrap();
800+
run_indexing_pipeline(&mut conn_custom, &dir, dummy_phase()).unwrap();
760801
assert_eq!(
761802
count(
762803
&conn_custom,
@@ -783,7 +824,7 @@ mod tests {
783824

784825
let mut conn = Connection::open_in_memory().unwrap();
785826
init_db(&conn).unwrap();
786-
run_indexing_pipeline(&mut conn, &dir).unwrap();
827+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
787828

788829
// The alias is de-referenced, so the edge points at helper.
789830
assert_eq!(
@@ -811,7 +852,7 @@ mod tests {
811852

812853
let mut conn = Connection::open_in_memory().unwrap();
813854
init_db(&conn).unwrap();
814-
run_indexing_pipeline(&mut conn, &dir).unwrap();
855+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
815856

816857
// import_edges populated and to_path resolved to the in-project file.
817858
let (to_path, module): (String, String) = conn
@@ -863,7 +904,7 @@ mod tests {
863904

864905
let mut conn = Connection::open_in_memory().unwrap();
865906
init_db(&conn).unwrap();
866-
run_indexing_pipeline(&mut conn, &dir).unwrap();
907+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
867908

868909
// Method is class-qualified.
869910
assert_eq!(
@@ -910,7 +951,7 @@ mod tests {
910951

911952
let mut conn = Connection::open_in_memory().unwrap();
912953
init_db(&conn).unwrap();
913-
run_indexing_pipeline(&mut conn, &dir).unwrap();
954+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
914955

915956
// Go method is tagged with its receiver type as class_context.
916957
assert_eq!(
@@ -943,7 +984,7 @@ mod tests {
943984

944985
let mut conn = Connection::open_in_memory().unwrap();
945986
init_db(&conn).unwrap();
946-
run_indexing_pipeline(&mut conn, &dir).unwrap();
987+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
947988
assert_eq!(count(&conn, "SELECT COUNT(*) FROM symbols"), 1);
948989

949990
// No change → no-op.

crates/ci-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod analysis;
22
pub mod config;
33
pub mod db;
4+
pub mod gitignore;
45
pub mod embedding;
56
pub mod fitness;
67
pub mod graph;

crates/ci-core/src/search.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ fn search_symbol(conn: &Connection, query: &str, limit: usize) -> rusqlite::Resu
150150
}
151151

152152
fn search_text(conn: &Connection, query: &str, limit: usize) -> rusqlite::Result<SearchOutput> {
153-
let fts_query = escape_fts5_query(query);
153+
let raw_query = escape_fts5_query(query);
154+
// FTS5 global column filter: {docstring} restricts ALL tokens to docstring column only
155+
let fts_query = format!("{{docstring}} : {raw_query}");
154156

155157
let mut stmt = conn.prepare(
156158
"SELECT s.qualified_name, s.name, s.path, s.line_start, s.line_end, s.kind,
@@ -515,6 +517,44 @@ mod tests {
515517
);
516518
}
517519

520+
#[test]
521+
fn test_search_text_does_not_match_name_only() {
522+
let conn = Connection::open_in_memory().unwrap();
523+
init_db(&conn).unwrap();
524+
525+
// Symbol: name contains "authorize", docstring is EMPTY
526+
conn.execute(
527+
"INSERT INTO symbols (name, qualified_name, kind, path, language, line_start, line_end, docstring, name_tokens)
528+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
529+
rusqlite::params![
530+
"authorize_user", "auth::authorize_user", "function",
531+
"auth.py", "python", 1, 10, "", "authorize user"
532+
],
533+
).unwrap();
534+
535+
// Symbol: name does NOT contain "authorize", docstring DOES
536+
conn.execute(
537+
"INSERT INTO symbols (name, qualified_name, kind, path, language, line_start, line_end, docstring, name_tokens)
538+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
539+
rusqlite::params![
540+
"check_perms", "auth::check_perms", "function",
541+
"auth.py", "python", 12, 20, "Checks if user can authorize the given action.", "check perms"
542+
],
543+
).unwrap();
544+
545+
let output = search(&conn, "authorize", SearchKind::Text, 10, None).unwrap();
546+
let names: Vec<&str> = output.results.iter().map(|r| r.name.as_str()).collect();
547+
548+
assert!(
549+
names.contains(&"check_perms"),
550+
"check_perms (docstring match) must appear, got: {names:?}"
551+
);
552+
assert!(
553+
!names.contains(&"authorize_user"),
554+
"authorize_user must NOT appear — its docstring is empty, got: {names:?}"
555+
);
556+
}
557+
518558
#[test]
519559
fn test_search_file() {
520560
let conn = setup_db_with_symbols();

crates/ci-server/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub async fn serve_stdio_with_preset(project_root: PathBuf, db_path: PathBuf, pr
2626
// unless built with the `embeddings` feature).
2727
ci_core::embedding::register_extension();
2828

29+
ci_core::gitignore::ensure_gitignore(&project_root)?;
30+
2931
let server = CodeIntelligenceServer::new_with_preset(project_root.clone(), db_path.clone(), preset)?;
3032
let ct = CancellationToken::new();
3133
let ct_clone = ct.clone();
@@ -52,12 +54,13 @@ pub async fn serve_stdio_with_preset(project_root: PathBuf, db_path: PathBuf, pr
5254
if let Ok(mut conn) = rusqlite::Connection::open(&indexer_db_path) {
5355
let _ = ci_core::db::schema::init_db(&conn);
5456
if let Err(e) =
55-
ci_core::indexer::pipeline::run_indexing_pipeline(&mut conn, &indexer_root)
57+
ci_core::indexer::pipeline::run_indexing_pipeline(&mut conn, &indexer_root, phase.clone())
5658
{
5759
tracing::error!("Background indexer failed: {}", e);
60+
// Reset to Scanning so callers don't see BuildingEdges forever on failure.
61+
*phase.write().unwrap() = ci_core::types::IndexingPhase::Scanning;
5862
} else {
59-
// Graph is fully built — tools may now report edges_ready.
60-
*phase.write().unwrap() = ci_core::types::IndexingPhase::Ready;
63+
// Ready is now set inside run_indexing_pipeline (after tx.commit)
6164
tracing::info!("Background indexing completed");
6265
}
6366
// Opt-in semantic embeddings, after the graph is built.

0 commit comments

Comments
 (0)