diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..fd61547f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +/target +.infigraph/ +.git/ +node_modules/ +npm/bin/ +npm/platforms/*/bin/ +docker/**/repo diff --git a/README.md b/README.md index 445a742d..b84a85a3 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,12 @@ Automatically reindex on PR merge via GitHub webhook push events. See **[docs/WE --- +## Index Freshness + +Graph-backed MCP tools (`trace_callers`, `trace_callees`, `transitive_impact`, `find_all_references`) warn you when the graph may not match your current code — after a branch switch, rebase, uncommitted edit, or a watcher restart that missed changes. See **[docs/INDEX-FRESHNESS.md](docs/INDEX-FRESHNESS.md)**. + +--- + ## Installation ### System Requirements diff --git a/crates/infigraph-core/src/freshness.rs b/crates/infigraph-core/src/freshness.rs new file mode 100644 index 00000000..edbfa9e5 --- /dev/null +++ b/crates/infigraph-core/src/freshness.rs @@ -0,0 +1,170 @@ +//! Tracks the git commit a project's graph was last built from, so callers can +//! detect when the graph may no longer match the working tree (branch switch, +//! rebase, uncommitted edits, or a watcher that missed changes while down). + +use std::path::Path; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +const META_FILE: &str = "index_meta.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IndexMeta { + indexed_head: Option, + indexed_at: String, +} + +fn meta_path(root: &Path) -> std::path::PathBuf { + root.join(".infigraph").join(META_FILE) +} + +fn git_rev_parse_head(root: &Path) -> Option { + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn git_is_dirty(root: &Path) -> bool { + // `.infigraph/` itself is Infigraph's own bookkeeping (this file included). + // On a project that hasn't gitignored it yet, its mere presence would + // otherwise make every freshness check report "dirty" forever. + std::process::Command::new("git") + .args(["status", "--porcelain", "--", ".", ":!.infigraph"]) + .current_dir(root) + .output() + .map(|o| o.status.success() && !o.stdout.is_empty()) + .unwrap_or(false) +} + +/// Stamp the current git HEAD as the commit this project's graph was just +/// built from. Called after a successful `Infigraph::index()`/`index_files()`. +/// No-ops (does not error) if `root` isn't a git repository. +pub fn write_index_meta(root: &Path) -> Result<()> { + let meta = IndexMeta { + indexed_head: git_rev_parse_head(root), + indexed_at: chrono_now_rfc3339(), + }; + let path = meta_path(root); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(&meta)?)?; + Ok(()) +} + +fn chrono_now_rfc3339() -> String { + // Avoid pulling in a datetime crate for a single timestamp field: seconds + // since epoch is sufficient for "how stale is this" comparisons. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + secs.to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreshnessStatus { + /// Indexed HEAD matches current HEAD, tree is clean, no pending reindex. + Fresh, + /// Indexed HEAD differs from current HEAD, or the working tree is dirty. + Stale, + /// HEAD matches, but a watcher has files queued for reindex. + Updating, + /// Not a git repo, or the graph has no recorded indexed HEAD yet. + Unknown, +} + +impl std::fmt::Display for FreshnessStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + FreshnessStatus::Fresh => "fresh", + FreshnessStatus::Stale => "stale", + FreshnessStatus::Updating => "updating", + FreshnessStatus::Unknown => "unknown", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Clone)] +pub struct Freshness { + pub status: FreshnessStatus, + pub indexed_head: Option, + pub current_head: Option, + pub working_tree_dirty: bool, + pub pending_changes: usize, +} + +impl Freshness { + /// A one-line warning suitable for prepending to a tool response. + /// Returns `None` when status is `Fresh` (nothing to warn about) or + /// `Unknown` (not a git repo, or never indexed with freshness tracking — + /// nothing wrong has actually been detected, so there's nothing to warn). + pub fn warning_line(&self) -> Option { + if matches!( + self.status, + FreshnessStatus::Fresh | FreshnessStatus::Unknown + ) { + return None; + } + let indexed = self.indexed_head.as_deref().unwrap_or("unknown"); + let current = self.current_head.as_deref().unwrap_or("unknown"); + let mut reasons = Vec::new(); + if self.indexed_head != self.current_head { + reasons.push("branch/commit changed".to_string()); + } + if self.working_tree_dirty { + reasons.push("uncommitted changes".to_string()); + } + if self.pending_changes > 0 { + reasons.push(format!("{} file(s) pending reindex", self.pending_changes)); + } + let reason = if reasons.is_empty() { + self.status.to_string() + } else { + reasons.join(", ") + }; + Some(format!( + "⚠ {status}: indexed_head={indexed} current_head={current} ({reason}) — run index_project to refresh\n\n", + status = self.status + )) + } +} + +/// Compute freshness for `root` by comparing the recorded indexed HEAD against +/// the live git state. `pending_changes` should be the number of files a +/// running watcher has queued for reindex (0 if no watcher is tracked). +pub fn compute_freshness(root: &Path, pending_changes: usize) -> Freshness { + let current_head = git_rev_parse_head(root); + let working_tree_dirty = current_head.is_some() && git_is_dirty(root); + + let indexed_head = std::fs::read_to_string(meta_path(root)) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|m| m.indexed_head); + + let status = if current_head.is_none() || indexed_head.is_none() { + FreshnessStatus::Unknown + } else if indexed_head != current_head || working_tree_dirty { + FreshnessStatus::Stale + } else if pending_changes > 0 { + FreshnessStatus::Updating + } else { + FreshnessStatus::Fresh + }; + + Freshness { + status, + indexed_head, + current_head, + working_tree_dirty, + pending_changes, + } +} diff --git a/crates/infigraph-core/src/lib.rs b/crates/infigraph-core/src/lib.rs index 363827fb..8aa17a64 100644 --- a/crates/infigraph-core/src/lib.rs +++ b/crates/infigraph-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod diff; pub mod embed; pub mod export; pub mod extract; +pub mod freshness; pub mod graph; pub mod lang; pub mod learned; @@ -306,6 +307,8 @@ impl Infigraph { ); } + let _ = freshness::write_index_meta(&self.root); + Ok(IndexResult { total_files: total, indexed_files: indexed, @@ -457,6 +460,8 @@ impl Infigraph { } }); + let _ = freshness::write_index_meta(&self.root); + Ok(IndexResult { total_files: paths.len(), indexed_files: indexed, diff --git a/crates/infigraph-core/src/watch/mod.rs b/crates/infigraph-core/src/watch/mod.rs index 8bf9cb41..04d14536 100644 --- a/crates/infigraph-core/src/watch/mod.rs +++ b/crates/infigraph-core/src/watch/mod.rs @@ -139,6 +139,13 @@ where let (mut watcher, mut rx) = create_watcher(root, ignore_dirs)?; + // Reconcile once at startup: a process restart (crash, redeploy, laptop + // sleep) has no memory of what happened to the working tree while this + // watcher wasn't running. `notify` only reports events going forward, so + // without this check a branch switch or commit made during the gap is + // silently missed until something else touches those files again. + reconcile_on_start(root, &on_event); + loop { if stop_rx.try_recv().is_ok() { break; @@ -417,6 +424,44 @@ where }) } +/// Compare the graph's recorded indexed HEAD against live git state when a +/// watcher starts. `notify` only reports filesystem events going forward, so +/// a process restart (crash, redeploy, sleep/wake) has no way to learn about +/// commits or branch switches that happened while it was down. If HEAD moved, +/// emit one `Modified` event per file the two commits differ on so the +/// existing pending-reindex path picks them up immediately, instead of +/// waiting for a future unrelated file write to bring things back in sync. +fn reconcile_on_start(root: &Path, on_event: &impl Fn(WatchEvent)) { + let fresh = crate::freshness::compute_freshness(root, 0); + let (Some(indexed), Some(current)) = (&fresh.indexed_head, &fresh.current_head) else { + return; + }; + if indexed == current { + return; + } + + let output = std::process::Command::new("git") + .args(["diff", "--name-only", indexed, current]) + .current_dir(root) + .output(); + let Ok(output) = output else { return }; + if !output.status.success() { + return; + } + + for rel in String::from_utf8_lossy(&output.stdout).lines() { + if rel.is_empty() { + continue; + } + eprintln!("[watch] reconcile: {rel} changed while watcher was not running (indexed_head={indexed} current_head={current})"); + on_event(WatchEvent { + kind: WatchEventKind::Modified, + path: root.join(rel), + has_cross_file_calls: true, + }); + } +} + /// Open a short-lived Infigraph instance for batch work. fn open_transient(root: &Path, make_registry: &MR) -> Result where diff --git a/crates/infigraph-core/tests/freshness.rs b/crates/infigraph-core/tests/freshness.rs new file mode 100644 index 00000000..28689fa2 --- /dev/null +++ b/crates/infigraph-core/tests/freshness.rs @@ -0,0 +1,109 @@ +use infigraph_core::freshness::{compute_freshness, write_index_meta, FreshnessStatus}; +use std::process::Command; +use tempfile::TempDir; + +fn git(dir: &std::path::Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("git command failed to run"); + assert!(status.success(), "git {:?} failed", args); +} + +fn init_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + std::fs::write(dir.path().join("a.txt"), "hello").unwrap(); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "initial"]); + dir +} + +#[test] +fn unknown_when_not_a_git_repo() { + let dir = TempDir::new().unwrap(); + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Unknown); + // Unknown means "can't tell", not "known stale" — a non-git project is a + // supported use case and shouldn't get a permanent bogus warning. + assert!(fresh.warning_line().is_none()); +} + +#[test] +fn unknown_when_never_indexed() { + let dir = init_repo(); + // No index_meta.json written yet. + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Unknown); + assert!(fresh.warning_line().is_none()); +} + +#[test] +fn fresh_when_head_matches_and_clean() { + let dir = init_repo(); + write_index_meta(dir.path()).unwrap(); + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Fresh); + assert!(fresh.warning_line().is_none()); +} + +#[test] +fn stale_after_new_commit() { + let dir = init_repo(); + write_index_meta(dir.path()).unwrap(); + + std::fs::write(dir.path().join("b.txt"), "world").unwrap(); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "second"]); + + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Stale); + let warning = fresh.warning_line().unwrap(); + assert!(warning.contains("stale")); + assert!(warning.contains("branch/commit changed")); +} + +#[test] +fn stale_after_branch_switch() { + let dir = init_repo(); + write_index_meta(dir.path()).unwrap(); + + git(dir.path(), &["checkout", "-q", "-b", "other"]); + std::fs::write(dir.path().join("c.txt"), "branch").unwrap(); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "on other branch"]); + + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Stale); + assert_ne!(fresh.indexed_head, fresh.current_head); +} + +#[test] +fn stale_when_working_tree_dirty() { + let dir = init_repo(); + write_index_meta(dir.path()).unwrap(); + + // HEAD unchanged, but a tracked file has uncommitted edits. + std::fs::write(dir.path().join("a.txt"), "modified, uncommitted").unwrap(); + + let fresh = compute_freshness(dir.path(), 0); + assert_eq!(fresh.status, FreshnessStatus::Stale); + assert!(fresh.working_tree_dirty); + assert_eq!(fresh.indexed_head, fresh.current_head); + let warning = fresh.warning_line().unwrap(); + assert!(warning.contains("uncommitted changes")); +} + +#[test] +fn updating_when_pending_changes_but_head_matches() { + let dir = init_repo(); + write_index_meta(dir.path()).unwrap(); + + let fresh = compute_freshness(dir.path(), 3); + assert_eq!(fresh.status, FreshnessStatus::Updating); + let warning = fresh.warning_line().unwrap(); + assert!(warning.contains("3 file(s) pending reindex")); +} diff --git a/crates/infigraph-mcp/src/tools/analysis/call_graph.rs b/crates/infigraph-mcp/src/tools/analysis/call_graph.rs index feda5959..56d88e4c 100644 --- a/crates/infigraph-mcp/src/tools/analysis/call_graph.rs +++ b/crates/infigraph-mcp/src/tools/analysis/call_graph.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use infigraph_core::graph::GraphBackend; use serde_json::Value; -use super::super::helpers::{open_prism, save_analysis}; +use super::super::helpers::{open_prism, prepend_freshness_warning, save_analysis}; pub fn tool_detect_dead_code(args: &Value) -> Result { let prism = open_prism(args)?; @@ -41,10 +41,12 @@ pub fn tool_trace_callers(args: &Value) -> Result { let backend = prism.backend().context("not initialized")?; let callers = backend.callers_of(symbol_id)?; - if callers.is_empty() { - return Ok(format!("No callers found for '{}'", symbol_id)); - } - Ok(callers.join("\n")) + let out = if callers.is_empty() { + format!("No callers found for '{}'", symbol_id) + } else { + callers.join("\n") + }; + Ok(prepend_freshness_warning(prism.root(), out)) } pub fn tool_trace_callees(args: &Value) -> Result { @@ -56,10 +58,12 @@ pub fn tool_trace_callees(args: &Value) -> Result { let backend = prism.backend().context("not initialized")?; let callees = backend.callees_of(symbol_id)?; - if callees.is_empty() { - return Ok(format!("No callees found for '{}'", symbol_id)); - } - Ok(callees.join("\n")) + let out = if callees.is_empty() { + format!("No callees found for '{}'", symbol_id) + } else { + callees.join("\n") + }; + Ok(prepend_freshness_warning(prism.root(), out)) } pub fn tool_transitive_impact(args: &Value) -> Result { @@ -72,15 +76,16 @@ pub fn tool_transitive_impact(args: &Value) -> Result { let backend = prism.backend().context("not initialized")?; let impacted = backend.transitive_impact(symbol_id, depth)?; - if impacted.is_empty() { - return Ok(format!("No symbols affected by changes to '{}'", symbol_id)); - } - - let mut out = String::new(); - for row in &impacted { - out.push_str(&format!("{} {} ({})\n", row.kind, row.name, row.file)); - } - Ok(out) + let out = if impacted.is_empty() { + format!("No symbols affected by changes to '{}'", symbol_id) + } else { + let mut out = String::new(); + for row in &impacted { + out.push_str(&format!("{} {} ({})\n", row.kind, row.name, row.file)); + } + out + }; + Ok(prepend_freshness_warning(prism.root(), out)) } pub fn tool_get_architecture(args: &Value) -> Result { diff --git a/crates/infigraph-mcp/src/tools/graph.rs b/crates/infigraph-mcp/src/tools/graph.rs index 76c6e4ac..d5233146 100644 --- a/crates/infigraph-mcp/src/tools/graph.rs +++ b/crates/infigraph-mcp/src/tools/graph.rs @@ -8,7 +8,7 @@ use infigraph_core::graph::SessionStore; use infigraph_core::multi::Registry; use infigraph_languages::bundled_registry; -use super::helpers::{glob_matches, open_prism_read_only}; +use super::helpers::{glob_matches, open_prism_read_only, prepend_freshness_warning}; pub fn tool_query_graph(args: &Value) -> Result { let prism = open_prism_read_only(args)?; @@ -307,15 +307,16 @@ pub fn tool_find_all_references(args: &Value) -> Result { .context("missing 'symbol_id'")?; let refs = backend.find_all_references(symbol_id)?; - if refs.is_empty() { - return Ok(format!("No references found for '{}'", symbol_id)); - } - - let mut out = format!("References to '{}' ({} total):\n\n", symbol_id, refs.len()); - for r in &refs { - out.push_str(&format!(" {}:{} — in {}\n", r.file, r.line, r.caller_name)); - } - Ok(out) + let out = if refs.is_empty() { + format!("No references found for '{}'", symbol_id) + } else { + let mut out = format!("References to '{}' ({} total):\n\n", symbol_id, refs.len()); + for r in &refs { + out.push_str(&format!(" {}:{} — in {}\n", r.file, r.line, r.caller_name)); + } + out + }; + Ok(prepend_freshness_warning(prism.root(), out)) } pub fn tool_get_api_surface(args: &Value) -> Result { diff --git a/crates/infigraph-mcp/src/tools/helpers.rs b/crates/infigraph-mcp/src/tools/helpers.rs index 21d4a9a0..6195b115 100644 --- a/crates/infigraph-mcp/src/tools/helpers.rs +++ b/crates/infigraph-mcp/src/tools/helpers.rs @@ -120,6 +120,30 @@ fn apply_repo_filter(prism: &mut Infigraph, raw_path: &str) { #[cfg(not(feature = "remote"))] fn apply_repo_filter(_prism: &mut Infigraph, _raw_path: &str) {} +/// Prepend a stale-graph warning to a tool's output when the graph may not +/// reflect the current working tree (branch switch, rebase, uncommitted +/// changes, or a watcher with reindexing still pending). No-op (returns +/// `out` unchanged) when the graph is fresh or freshness can't be determined +/// (e.g. not a git repo). +pub fn prepend_freshness_warning(root: &std::path::Path, out: String) -> String { + let pending = pending_reindex_count(root); + let fresh = infigraph_core::freshness::compute_freshness(root, pending); + match fresh.warning_line() { + Some(warning) => format!("{warning}{out}"), + None => out, + } +} + +fn pending_reindex_count(root: &std::path::Path) -> usize { + let root_str = root.to_string_lossy().replace('\\', "/"); + let guard = super::watch::get_watchers(); + guard + .as_ref() + .and_then(|map| map.values().find(|e| e.path == root_str)) + .map(|e| e.pending_reindex.lock().unwrap().len()) + .unwrap_or(0) +} + pub fn find_infigraph_cli() -> Option { let bin_name = if cfg!(windows) { "infigraph.exe" diff --git a/crates/infigraph-mcp/src/tools/watch.rs b/crates/infigraph-mcp/src/tools/watch.rs index b53ab9d4..b248da02 100644 --- a/crates/infigraph-mcp/src/tools/watch.rs +++ b/crates/infigraph-mcp/src/tools/watch.rs @@ -309,6 +309,17 @@ pub fn tool_get_watch_status(args: &Value) -> Result { if let Some(entry) = map.get(id) { let pending = entry.pending_reindex.lock().unwrap(); let mut out = format!("Watcher: {id}\nType: code\nPath: {}\n", entry.path); + let fresh = infigraph_core::freshness::compute_freshness( + std::path::Path::new(&entry.path), + pending.len(), + ); + out.push_str(&format!( + "Freshness: {} (indexed_head={} current_head={} working_tree_dirty={})\n", + fresh.status, + fresh.indexed_head.as_deref().unwrap_or("unknown"), + fresh.current_head.as_deref().unwrap_or("unknown"), + fresh.working_tree_dirty + )); if pending.is_empty() { out.push_str("Status: OK — no pending reindex needed\n"); } else { diff --git a/crates/infigraph-mcp/tests/freshness_tools.rs b/crates/infigraph-mcp/tests/freshness_tools.rs new file mode 100644 index 00000000..f92236b3 --- /dev/null +++ b/crates/infigraph-mcp/tests/freshness_tools.rs @@ -0,0 +1,169 @@ +use std::sync::Mutex; + +use serde_json::json; + +use infigraph_mcp::tools::analysis::{ + tool_trace_callees, tool_trace_callers, tool_transitive_impact, +}; +use infigraph_mcp::tools::graph::{tool_find_all_references, tool_get_symbols_in_file}; +use infigraph_mcp::tools::index::tool_index_project; +use infigraph_mcp::tools::watch::get_watchers; + +static WATCHER_LOCK: Mutex<()> = Mutex::new(()); + +/// These tests assert on a graph frozen at a specific commit, but +/// `tool_index_project` auto-starts a watcher with `auto_resolve=true` — if +/// left running it will notice the file mutations below and reindex before +/// the assertions run, racing "stale" back to "fresh". Stop it immediately +/// after the initial index so the fixture stays frozen for the rest of the test. +fn stop_all_watchers() { + let mut guard = get_watchers(); + if let Some(map) = guard.as_mut() { + let ids: Vec = map.keys().cloned().collect(); + for id in ids { + if let Some(entry) = map.remove(&id) { + let _ = entry.stop_tx.send(()); + } + } + } + drop(guard); + std::thread::sleep(std::time::Duration::from_millis(300)); +} + +fn git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("git command failed to run"); + assert!(status.success(), "git {:?} failed", args); +} + +/// Sets up a git-backed fixture, indexes it, and returns (tempdir, path, +/// symbol_id of `helper`) for use across the freshness assertions below. +fn make_indexed_git_project() -> (tempfile::TempDir, String, String) { + let dir = tempfile::TempDir::new().expect("tmpdir"); + std::fs::write( + dir.path().join("lib.py"), + "def helper():\n return 1\n\ndef caller():\n return helper()\n", + ) + .unwrap(); + // Infigraph writes its own .claude/CLAUDE.md as a side effect of indexing + // (crates/infigraph-core/src/claude_md.rs) — a real project ignores it, + // same as this repo's own .gitignore does; without this the working tree + // would look "dirty" from Infigraph's own bookkeeping, not the user's edits. + std::fs::write(dir.path().join(".gitignore"), ".claude/\n.infigraph/\n").unwrap(); + + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "initial"]); + + let path = dir.path().to_string_lossy().to_string(); + tool_index_project(&json!({"path": &path})).expect("initial index"); + stop_all_watchers(); + + let symbols = tool_get_symbols_in_file(&json!({"path": &path, "file": "lib.py"})).unwrap(); + let symbol_id = symbols + .lines() + .find(|l| l.contains("helper")) + .and_then(|l| l.split("id=").nth(1)) + .map(|s| s.trim().to_string()) + .expect("helper symbol id should be present in get_symbols_in_file output"); + + (dir, path, symbol_id) +} + +/// A freshly indexed git repo with no changes since should report no +/// staleness warning on any of the graph-query tools named in the issue. +#[test] +fn fresh_graph_has_no_warning() { + let _guard = WATCHER_LOCK.lock().unwrap(); + let (_dir, path, symbol_id) = make_indexed_git_project(); + let args = json!({"path": &path, "symbol_id": &symbol_id}); + + for (name, out) in [ + ("trace_callers", tool_trace_callers(&args).unwrap()), + ("trace_callees", tool_trace_callees(&args).unwrap()), + ("transitive_impact", tool_transitive_impact(&args).unwrap()), + ( + "find_all_references", + tool_find_all_references(&args).unwrap(), + ), + ] { + assert!( + !out.contains("⚠"), + "{name} should have no staleness warning on a freshly indexed repo: {out}" + ); + } +} + +/// After a commit that the graph was never reindexed against, every one of +/// the four tools named in the issue should prepend a stale warning with the +/// correct indexed_head/current_head SHAs — instead of silently answering +/// from outdated data. +#[test] +fn stale_after_commit_warns_on_all_named_tools() { + let _guard = WATCHER_LOCK.lock().unwrap(); + let (dir, path, symbol_id) = make_indexed_git_project(); + let args = json!({"path": &path, "symbol_id": &symbol_id}); + + // Commit a change without reindexing — simulates a branch switch/rebase + // the graph hasn't caught up with yet. + std::fs::write( + dir.path().join("lib.py"), + "def helper():\n return 2\n\ndef caller():\n return helper()\n\ndef extra(): pass\n", + ) + .unwrap(); + git(dir.path(), &["commit", "-q", "-am", "second"]); + + for (name, out) in [ + ("trace_callers", tool_trace_callers(&args).unwrap()), + ("trace_callees", tool_trace_callees(&args).unwrap()), + ("transitive_impact", tool_transitive_impact(&args).unwrap()), + ( + "find_all_references", + tool_find_all_references(&args).unwrap(), + ), + ] { + assert!( + out.contains("⚠ stale"), + "{name} should warn when indexed HEAD no longer matches current HEAD: {out}" + ); + assert!( + out.contains("indexed_head=") && out.contains("current_head="), + "{name} warning should include both SHAs: {out}" + ); + assert!( + out.contains("branch/commit changed"), + "{name} warning should explain why it's stale: {out}" + ); + } +} + +/// Uncommitted edits to a tracked file (no new commit) should also trigger +/// the warning, distinctly reasoned as "uncommitted changes" rather than a +/// HEAD mismatch. +#[test] +fn dirty_working_tree_warns_without_a_new_commit() { + let _guard = WATCHER_LOCK.lock().unwrap(); + let (dir, path, symbol_id) = make_indexed_git_project(); + let args = json!({"path": &path, "symbol_id": &symbol_id}); + + std::fs::write( + dir.path().join("lib.py"), + "def helper():\n return 999 # uncommitted edit\n\ndef caller():\n return helper()\n", + ) + .unwrap(); + + let out = tool_trace_callers(&args).unwrap(); + assert!( + out.contains("⚠ stale"), + "dirty working tree should warn: {out}" + ); + assert!( + out.contains("uncommitted changes"), + "warning should call out uncommitted changes specifically: {out}" + ); +} diff --git a/crates/infigraph-mcp/tests/watcher_reindex.rs b/crates/infigraph-mcp/tests/watcher_reindex.rs index 16c1efd5..3c92a87c 100644 --- a/crates/infigraph-mcp/tests/watcher_reindex.rs +++ b/crates/infigraph-mcp/tests/watcher_reindex.rs @@ -1112,3 +1112,75 @@ fn test_is_watching_lifecycle() { "should not be watching after stop" ); } + +fn git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("git command failed to run"); + assert!(status.success(), "git {:?} failed", args); +} + +/// Simulates a process restart (crash, redeploy, laptop sleep): index a git +/// repo, stop the watcher, commit a change *while nothing is watching*, then +/// start a new watcher. The stale commit should be caught by startup +/// reconciliation (comparing indexed_head vs current git HEAD) and surface +/// as a pending reindex immediately — without waiting for any new file-write +/// event, since none will come for a file that was already changed on disk. +#[test] +fn test_watcher_restart_reconciles_missed_commit() { + let _guard = WATCHER_LOCK.lock().unwrap(); + let _cleanup = WatcherCleanup; + stop_all_watchers(); + init_watchers(); + + let (dir, path) = make_project(&[("lib.py", "def original(): return 1\n")]); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "initial"]); + + tool_index_project(&json!({"path": &path})).expect("initial index"); + stop_all_watchers(); + std::thread::sleep(Duration::from_millis(200)); + + // Commit a change with no watcher running — the scenario a live watcher + // would normally catch via a filesystem event, but here nothing is + // listening, so this is exactly the "missed while down" gap. + std::fs::write( + dir.path().join("lib.py"), + "def original(): return 1\n\ndef added_while_watcher_down(): return 2\n", + ) + .unwrap(); + git(dir.path(), &["commit", "-q", "-am", "second"]); + + let result = tool_watch_project(&json!({ + "path": &path, + "debounce_ms": 200 + })) + .unwrap(); + let id_line = result + .lines() + .find(|l| l.starts_with("ID:")) + .expect("should have ID line"); + let watcher_id = id_line.trim_start_matches("ID:").trim(); + + // Reconciliation runs once at watcher startup, before the event loop — + // give the spawned thread a brief moment to reach that point. + let reconciled = poll_until( + || { + let status = tool_get_watch_status(&json!({"watcher_id": watcher_id})).unwrap(); + status.contains("lib.py") + }, + Duration::from_secs(10), + "startup reconciliation should flag lib.py as pending reindex", + ); + + assert!( + reconciled, + "watcher restart should detect the commit made while it was down, \ + without needing a new file-write event" + ); +} diff --git a/docker/freshness-test/Dockerfile b/docker/freshness-test/Dockerfile new file mode 100644 index 00000000..2bab4b6f --- /dev/null +++ b/docker/freshness-test/Dockerfile @@ -0,0 +1,40 @@ +# Minimal, throwaway image for simulating index-freshness failure modes +# (issue #48): branch switch, process restart, and dirty working tree. +# Not part of CI — run manually via docker/freshness-test/run.sh. +# Kuzu (lbug) requires C++20 , unavailable in Debian bookworm's +# default g++-12 (and g++-13 isn't packaged for bookworm-backports on +# arm64) — trixie ships g++-14 by default, so build there instead. +FROM rust:1-slim-trixie AS build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + git \ + build-essential \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . +RUN cargo build --release -p infigraph-cli -p infigraph-mcp + +FROM debian:trixie-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + jq \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && git config --global user.email "test@example.com" \ + && git config --global user.name "Freshness Test" + +# Note: `infigraph index` auto-runs SCIP enrichment (downloads a +# Python/Node toolchain for scip-python) — harmless noise here since it +# fails gracefully without a working pip, but expect it in the logs. + +COPY --from=build /src/target/release/infigraph /usr/local/bin/infigraph +COPY --from=build /src/target/release/infigraph-mcp /usr/local/bin/infigraph-mcp +COPY docker/freshness-test/run.sh /usr/local/bin/run-freshness-test.sh +RUN chmod +x /usr/local/bin/run-freshness-test.sh + +ENTRYPOINT ["/usr/local/bin/run-freshness-test.sh"] diff --git a/docker/freshness-test/run.sh b/docker/freshness-test/run.sh new file mode 100644 index 00000000..38e2713e --- /dev/null +++ b/docker/freshness-test/run.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Simulates the three index-freshness failure modes from issue #48 against a +# real infigraph-mcp HTTP server: branch switch/new commit, process restart, +# and a dirty working tree. Exits non-zero on the first failed assertion. +set -euo pipefail + +REPO=/work/repo +mkdir -p "$REPO" +cd "$REPO" + +git init -q +git config user.email "test@example.com" +git config user.name "Freshness Test" +# Infigraph writes its own .infigraph/ (runtime state) and .claude/CLAUDE.md +# (agent instructions) as side effects of indexing — real projects ignore +# both; without this, both scenarios below would misreport "dirty" from +# Infigraph's own bookkeeping rather than the user's actual edits. +cat > .gitignore <<'EOF' +.infigraph/ +.claude/ +EOF +cat > lib.py <<'EOF' +def helper(): + return 1 + +def caller(): + return helper() +EOF +git add . +git commit -q -m "initial" + +PASS=0 +FAIL=0 + +assert_contains() { + local haystack="$1" needle="$2" desc="$3" + if echo "$haystack" | grep -qF -- "$needle"; then + echo " PASS: $desc" + PASS=$((PASS + 1)) + else + echo " FAIL: $desc" + echo " expected to find: $needle" + echo " in: $haystack" + FAIL=$((FAIL + 1)) + fi +} + +assert_not_contains() { + local haystack="$1" needle="$2" desc="$3" + if echo "$haystack" | grep -qF -- "$needle"; then + echo " FAIL: $desc" + echo " did not expect to find: $needle" + echo " in: $haystack" + FAIL=$((FAIL + 1)) + else + echo " PASS: $desc" + PASS=$((PASS + 1)) + fi +} + +mcp_call() { + local tool="$1" args_json="$2" + curl -s -X POST "http://127.0.0.1:8642/tools/mcp" \ + -H 'Content-Type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$args_json}}" \ + | jq -r '.result.content[0].text // .result // empty' +} + +# `infigraph index` auto-starts a background watcher holding the graph's +# write lock for its lifetime — stop it after every index so later commands +# (another index, `infigraph watch`, the MCP server) can open the graph. +stop_auto_watcher() { + infigraph watch-stop >/dev/null 2>&1 || true + sleep 1 +} + +echo "=== Scenario setup: initial index ===" +infigraph index --no-embed +stop_auto_watcher +# Symbol IDs are "{file}::{name}" (per `infigraph callers --help`); confirm +# helper() was actually indexed under this ID before relying on it below. +SYMBOL_ID="lib.py::helper" +infigraph symbols lib.py | grep -q "helper" || { + echo "FAIL: helper() not found in 'infigraph symbols lib.py' output" + exit 1 +} +echo "symbol_id = $SYMBOL_ID" + +echo "" +echo "=== Scenario 1: branch switch / new commit (no reindex) ===" +git checkout -q -b other-branch +cat >> lib.py <<'EOF' + +def added_on_other_branch(): + pass +EOF +git commit -q -am "change on other branch" + +infigraph-mcp --serve --mcp-port=8642 & +MCP_PID=$! +sleep 1 + +OUT=$(mcp_call "trace_callers" "{\"path\":\"$REPO\",\"symbol_id\":\"$SYMBOL_ID\"}") +assert_contains "$OUT" "stale" "trace_callers warns after branch switch" +assert_contains "$OUT" "indexed_head=" "trace_callers warning includes indexed_head" +assert_contains "$OUT" "current_head=" "trace_callers warning includes current_head" + +OUT=$(mcp_call "find_all_references" "{\"path\":\"$REPO\",\"symbol_id\":\"$SYMBOL_ID\"}") +assert_contains "$OUT" "stale" "find_all_references warns after branch switch" + +kill "$MCP_PID" 2>/dev/null || true +wait "$MCP_PID" 2>/dev/null || true +# infigraph-mcp is a supervisor/worker pair — the direct PID above is only +# the supervisor; make sure the worker (which actually holds the graph open) +# is gone too before the next scenario tries to open the same graph. +pkill -f infigraph-mcp 2>/dev/null || true +sleep 1 + +echo "" +echo "=== Reindex to clear staleness before next scenario ===" +infigraph index --no-embed +stop_auto_watcher + +echo "" +echo "=== Scenario 2: process restart misses a commit made while down ===" +infigraph watch --debounce 200 > /tmp/watch1.log 2>&1 & +WATCH_PID=$! +sleep 1 +kill "$WATCH_PID" 2>/dev/null || true +wait "$WATCH_PID" 2>/dev/null || true +sleep 1 + +cat >> lib.py <<'EOF' + +def added_while_watcher_down(): + pass +EOF +git commit -q -am "change while watcher was down" + +infigraph watch --debounce 200 > /tmp/watch2.log 2>&1 & +WATCH_PID=$! +sleep 2 +kill "$WATCH_PID" 2>/dev/null || true +wait "$WATCH_PID" 2>/dev/null || true + +assert_contains "$(cat /tmp/watch2.log)" "reconcile: lib.py" \ + "watcher restart reconciles the commit made while it was down" + +echo "" +echo "=== Reindex to clear staleness before next scenario ===" +infigraph index --no-embed +stop_auto_watcher + +echo "" +echo "=== Scenario 3: dirty working tree (no commit) ===" +cat >> lib.py <<'EOF' + +def uncommitted_edit(): + pass +EOF +# Deliberately not committed. + +infigraph-mcp --serve --mcp-port=8642 & +MCP_PID=$! +sleep 1 + +OUT=$(mcp_call "trace_callers" "{\"path\":\"$REPO\",\"symbol_id\":\"$SYMBOL_ID\"}") +assert_contains "$OUT" "stale" "trace_callers warns on dirty working tree" +assert_contains "$OUT" "uncommitted changes" "warning specifically names uncommitted changes" + +kill "$MCP_PID" 2>/dev/null || true +wait "$MCP_PID" 2>/dev/null || true +# infigraph-mcp is a supervisor/worker pair — the direct PID above is only +# the supervisor; make sure the worker (which actually holds the graph open) +# is gone too before the next scenario tries to open the same graph. +pkill -f infigraph-mcp 2>/dev/null || true +sleep 1 + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/docs/INDEX-FRESHNESS.md b/docs/INDEX-FRESHNESS.md new file mode 100644 index 00000000..7ba980e0 --- /dev/null +++ b/docs/INDEX-FRESHNESS.md @@ -0,0 +1,76 @@ +# Index Freshness + +Lets graph-backed MCP tools tell you when their answer might not match your current code, instead of silently answering from a stale graph. + +## In plain terms + +Infigraph keeps a persistent graph of your codebase, and a background file watcher normally keeps it in sync as you edit. But the watcher only reacts to one thing: a file changing on disk. Three common situations slip past that: + +- **You switch git branches or rebase.** Dozens of files can change at once; nothing tells the watcher "this was a branch switch, treat it as a bigger deal." +- **The watcher's process restarts** (crash, redeploy, laptop sleep/wake). It has no memory of what happened to the repo while it was down — commits made during that gap were previously never noticed. +- **You have uncommitted edits.** The graph reflects the last indexed commit, not what's currently in your editor. + +Before this change, none of that was visible in a tool's answer. Asking "who calls this function?" via `trace_callers` just returned a list — with no way to tell whether that list matched the code you're actually looking at. + +Now, those tools compare the commit the graph was built from against the repo's current state, and prepend a warning when they don't match: + +``` +⚠ stale: indexed_head=abc1234 current_head=def5678 (branch/commit changed) — run index_project to refresh + + +``` + +No warning appears when the graph is fresh, or when freshness can't be determined at all (e.g. a project that isn't a git repo) — the goal is to flag *known* staleness, not to nag. + +## What changed + +**New: `infigraph-core::freshness`** ([`crates/infigraph-core/src/freshness.rs`](../crates/infigraph-core/src/freshness.rs)) + +- `write_index_meta(root)` stamps the current git HEAD into `/.infigraph/index_meta.json` after a successful index. Called from `Infigraph::index()` and `Infigraph::index_files()` ([`crates/infigraph-core/src/lib.rs`](../crates/infigraph-core/src/lib.rs)) — i.e. both a full index and the watcher's incremental batch reindex. +- `compute_freshness(root, pending_changes)` returns a `Freshness { status, indexed_head, current_head, working_tree_dirty, pending_changes }` by comparing the stamped HEAD against `git rev-parse HEAD` and `git status --porcelain` (the latter excludes `.infigraph/` itself — see "Design notes" below). +- `FreshnessStatus` is one of: + + | Status | Meaning | + |---|---| + | `Fresh` | Indexed HEAD matches current HEAD, tree is clean, nothing pending. | + | `Stale` | Indexed HEAD differs from current HEAD, and/or the working tree is dirty. | + | `Updating` | HEAD matches and the tree is clean, but a watcher has files queued for reindex. | + | `Unknown` | Not a git repo, or the graph has never been indexed with freshness tracking. Not treated as a warning condition — see "Design notes". | + +- `Freshness::warning_line()` renders the `⚠ ...` line shown above, or `None` for `Fresh`/`Unknown`. + +**Watcher startup reconciliation** ([`crates/infigraph-core/src/watch/mod.rs`](../crates/infigraph-core/src/watch/mod.rs), `reconcile_on_start`) + +Called once, before the watcher's event loop starts. If the stored `indexed_head` differs from the live HEAD, it runs `git diff --name-only ` and feeds every changed file into the existing pending-reindex mechanism (the same one `has_cross_file_calls` events use) as if a live file-write event had just been seen for it. This is what makes a restarted watcher catch commits made while it was down, rather than waiting for some future unrelated file write to trigger a reindex. + +If `indexed_head` is no longer a reachable git object (e.g. the tree was rewritten with a hard rebase + gc while the watcher was down), `git diff` fails and reconciliation silently no-ops — `compute_freshness` will still correctly report `Stale` on the next query, but the watcher won't proactively queue the affected files until something else touches them. Known, narrow limitation, not yet addressed. + +**MCP tool wrapping** + +`prepend_freshness_warning(root, output)` ([`crates/infigraph-mcp/src/tools/helpers.rs`](../crates/infigraph-mcp/src/tools/helpers.rs)) computes freshness (folding in the current watcher's pending-reindex count, if one is registered for that path) and prepends the warning line when applicable. Applied to: + +- `trace_callers`, `trace_callees`, `transitive_impact` — [`crates/infigraph-mcp/src/tools/analysis/call_graph.rs`](../crates/infigraph-mcp/src/tools/analysis/call_graph.rs) +- `find_all_references` — [`crates/infigraph-mcp/src/tools/graph.rs`](../crates/infigraph-mcp/src/tools/graph.rs) + +`get_watch_status` (`crates/infigraph-mcp/src/tools/watch.rs`) also now includes freshness fields when queried with a `watcher_id`. + +**Not changed / explicitly out of scope:** the CLI's own `callers`/`callees`/`impact` commands (`crates/infigraph-cli/src/graph_commands.rs`) are a separate implementation from the MCP tools and do not carry this warning. Extending them is a reasonable follow-up but wasn't part of this fix's scope (the driving issue was specifically about MCP tool responses). + +## Design notes (for whoever extends this next) + +- **`Unknown` is not a warning condition.** A non-git project, or a graph indexed before this feature existed, has no `indexed_head` to compare against — that's "can't tell," not "known stale." If you add a new freshness-consuming call site, don't assume `!= Fresh` means "warn"; check for `Stale`/`Updating` specifically (or use `warning_line()`, which already encodes this). +- **`.infigraph/` is excluded from the dirty-tree check.** It's Infigraph's own runtime state (including `index_meta.json` itself); a project that hasn't gitignored it yet would otherwise always look dirty. Real projects are expected to `.gitignore` it (this repo does), but the check doesn't rely on that. +- **Freshness is per-project-root**, keyed off the same `.infigraph/` directory the graph itself lives in — no additional registry or global state. + +## Testing + +- Unit tests: [`crates/infigraph-core/tests/freshness.rs`](../crates/infigraph-core/tests/freshness.rs) — every `FreshnessStatus` branch against real temp git repos. +- Integration tests: [`crates/infigraph-mcp/tests/freshness_tools.rs`](../crates/infigraph-mcp/tests/freshness_tools.rs) (all four MCP tools, fresh/stale/dirty) and `test_watcher_restart_reconciles_missed_commit` in [`crates/infigraph-mcp/tests/watcher_reindex.rs`](../crates/infigraph-mcp/tests/watcher_reindex.rs). +- End-to-end simulation: [`docker/freshness-test/`](../docker/freshness-test/) builds real release binaries in a container and drives the MCP HTTP endpoint through all three scenarios (branch switch, process restart, dirty tree). Run manually with: + + ```bash + docker build -f docker/freshness-test/Dockerfile -t infigraph-freshness-test . + docker run --rm infigraph-freshness-test + ``` + + Not wired into CI — it's a throwaway sanity harness for this feature, kept for anyone who wants to re-verify the fix after touching related code.