Skip to content

Commit 02e09e5

Browse files
Your Nameclaude
andcommitted
fix(scip): re-run confidence-upgrade overlay on incremental reindex, not just server startup
run_overlay (the SCIP-backed upgrade of Rust call edges to `formal` confidence per ADR-0004) only ever ran once, right after the initial index at server startup — watcher::run_watch_loop's incremental reindex loop never called it. Any Rust file added or edited during a live `ci serve` session stayed capped at inferred/resolved/textual confidence until the next full restart. Simply calling it again wouldn't have helped on its own: its cache key was (rust-analyzer version, Cargo.lock hash) only, so an ordinary source edit with an unchanged lockfile would still cache-skip and silently do nothing. Threads a `dirty` parameter (a path@hash fingerprint of every indexed Rust file, via the new rust_source_dirty_keys) into the cache key so source-only changes actually invalidate it, and wires run_overlay into the watcher loop after every non-noop incremental reindex — cheap when nothing Rust-relevant changed, thanks to that same cache. Also fixes a latent instance of the same bug at the startup call site (a source-only change between two restarts, with the lockfile untouched, previously cache-skipped too), and adds an `indexing_status.scip_overlay` field (`{available, up_to_date}`) so an agent can observe overlay staleness without triggering a run. Verified live: editing a tracked .rs file while `ci serve` was running caused .codeindex/scip.cache's key to change (confirming a real rust-analyzer re-run, not a cache-skip), and indexing_status reflected up_to_date flipping accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d002a7e commit 02e09e5

5 files changed

Lines changed: 283 additions & 12 deletions

File tree

crates/ci-core/src/scip/cache.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,37 @@ mod tests {
2020
let b = overlay_cache_key("1.96.0", "hashBBB", &["src/x.rs".into()]);
2121
assert_ne!(a, b);
2222
}
23+
24+
/// Regression: before `dirty` was threaded into `run_overlay`, editing a
25+
/// Rust file's body with an unchanged `Cargo.lock`/toolchain produced the
26+
/// exact same key every time, so the overlay silently never re-ran for
27+
/// that file's new content. `dirty` (per-file content fingerprints) must
28+
/// change the key on its own, independent of lockfile/version.
29+
#[test]
30+
fn cache_key_changes_with_dirty_content_alone() {
31+
let a = overlay_cache_key("1.96.0", "hashAAA", &["src/x.rs@hash1".into()]);
32+
let b = overlay_cache_key("1.96.0", "hashAAA", &["src/x.rs@hash2".into()]);
33+
assert_ne!(
34+
a, b,
35+
"same version + lockfile but different file content must not collide"
36+
);
37+
}
38+
39+
#[test]
40+
fn cache_key_is_order_independent_over_dirty_set() {
41+
let a = overlay_cache_key(
42+
"1.96.0",
43+
"hashAAA",
44+
&["src/a.rs@h1".into(), "src/b.rs@h2".into()],
45+
);
46+
let b = overlay_cache_key(
47+
"1.96.0",
48+
"hashAAA",
49+
&["src/b.rs@h2".into(), "src/a.rs@h1".into()],
50+
);
51+
assert_eq!(
52+
a, b,
53+
"dirty set is sorted before hashing — order must not matter"
54+
);
55+
}
2356
}

crates/ci-core/src/scip/mod.rs

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,22 @@ use crate::config::RustConfig;
2222
/// silent (finding nothing is the common, expected case for a checkout that
2323
/// never configured this at all, not worth a log line every session).
2424
///
25-
/// Caches on (rust-analyzer version, Cargo.lock hash): an unchanged toolchain
26-
/// and dependency set means a re-run would find the same call graph, so the
27-
/// (comparatively expensive) rust-analyzer pass is skipped and the previous
28-
/// upgrades — already persisted as `formal`/`ruled_out_by_scip` in the DB —
29-
/// stand. Per-file dirty tracking isn't wired here (would need a change-set
30-
/// the caller doesn't have at this point); this key alone is safe because it
31-
/// can only widen a "skip" into a "run" (any lockfile/toolchain difference
32-
/// invalidates it), never the reverse.
25+
/// Caches on (rust-analyzer version, Cargo.lock hash, `dirty`): an unchanged
26+
/// toolchain, dependency set, and Rust source state means a re-run would find
27+
/// the same call graph, so the (comparatively expensive) rust-analyzer pass
28+
/// is skipped and the previous upgrades — already persisted as
29+
/// `formal`/`ruled_out_by_scip` in the DB — stand. `dirty` is the caller's
30+
/// current Rust-source fingerprint (see `rust_source_dirty_keys`) — pass it
31+
/// so a source-only change (no lockfile/toolchain difference, e.g. editing a
32+
/// function body) still invalidates the cache instead of silently standing
33+
/// forever; an empty slice degrades to the old (lockfile/toolchain-only) key,
34+
/// which remains safe on its own because it can only widen a "skip" into a
35+
/// "run", never the reverse.
3336
pub fn run_overlay(
3437
conn: &Connection,
3538
root: &Path,
3639
rust: &RustConfig,
40+
dirty: &[String],
3741
) -> anyhow::Result<ingest::IngestStats> {
3842
if rust.scip.enabled == Some(false) {
3943
return Ok(ingest::IngestStats::default());
@@ -46,7 +50,7 @@ pub fn run_overlay(
4650
};
4751

4852
let cache_path = root.join(".codeindex").join("scip.cache");
49-
let key = cache::overlay_cache_key(&runner::binary_version(&bin), &lockfile_hash(root), &[]);
53+
let key = cache::overlay_cache_key(&runner::binary_version(&bin), &lockfile_hash(root), dirty);
5054
if std::fs::read_to_string(&cache_path).is_ok_and(|prev| prev.trim() == key) {
5155
tracing::info!("SCIP overlay: cache key unchanged, skipping rust-analyzer run");
5256
return Ok(ingest::IngestStats::default());
@@ -85,6 +89,75 @@ fn lockfile_hash(root: &Path) -> String {
8589
.unwrap_or_default()
8690
}
8791

92+
/// Fingerprint of every currently-indexed Rust file's content, for
93+
/// `run_overlay`'s `dirty` parameter — one `"path@hash"` entry per file
94+
/// (`hash` already computed by the indexer, so this is a cheap read, no
95+
/// re-hashing). Changes whenever any Rust file's content differs from what
96+
/// was indexed at the last successful overlay run, regardless of whether
97+
/// `Cargo.lock` or the rust-analyzer version also changed — see
98+
/// `run_overlay`'s doc comment for why that matters.
99+
pub fn rust_source_dirty_keys(conn: &Connection) -> Vec<String> {
100+
let mut stmt = match conn
101+
.prepare("SELECT path, hash FROM file_index WHERE language = 'rust' ORDER BY path")
102+
{
103+
Ok(s) => s,
104+
Err(_) => return Vec::new(),
105+
};
106+
stmt.query_map([], |r| {
107+
Ok(format!(
108+
"{}@{}",
109+
r.get::<_, String>(0)?,
110+
r.get::<_, String>(1)?
111+
))
112+
})
113+
.map(|rows| rows.filter_map(|r| r.ok()).collect())
114+
.unwrap_or_default()
115+
}
116+
117+
/// Cheap, non-invoking snapshot of the overlay's readiness — never spawns
118+
/// rust-analyzer, just checks binary presence and compares the cache key that
119+
/// `run_overlay` would compute against what's already on disk. Backs
120+
/// `indexing_status`'s `scip_overlay` field so an agent can tell whether the
121+
/// call graph for currently-edited Rust files has actually been upgraded by
122+
/// SCIP yet, without waiting on or triggering a real run. `None` when
123+
/// `rust.scip.enabled == Some(false)` — overlay is off, nothing to report.
124+
pub fn overlay_status(conn: &Connection, root: &Path, rust: &RustConfig) -> Option<OverlayStatus> {
125+
if rust.scip.enabled == Some(false) {
126+
return None;
127+
}
128+
let bin = runner::resolve_binary(rust.scip.binary.as_deref());
129+
let available = bin.is_some();
130+
let up_to_date = match &bin {
131+
Some(bin) => {
132+
let dirty = rust_source_dirty_keys(conn);
133+
let key = cache::overlay_cache_key(
134+
&runner::binary_version(bin),
135+
&lockfile_hash(root),
136+
&dirty,
137+
);
138+
let cache_path = root.join(".codeindex").join("scip.cache");
139+
std::fs::read_to_string(&cache_path).is_ok_and(|prev| prev.trim() == key)
140+
}
141+
None => false,
142+
};
143+
Some(OverlayStatus {
144+
available,
145+
up_to_date,
146+
})
147+
}
148+
149+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150+
pub struct OverlayStatus {
151+
/// `rust-analyzer` binary was found (PATH/rustup/VS Code) at last check.
152+
pub available: bool,
153+
/// The current Rust source fingerprint + toolchain + lockfile match the
154+
/// last successful overlay run's cache key — `false` means the next
155+
/// `run_overlay` call (or the next non-noop incremental reindex, if
156+
/// wired to call it) would actually invoke rust-analyzer again rather
157+
/// than cache-skip. Always `false` when `available` is `false`.
158+
pub up_to_date: bool,
159+
}
160+
88161
#[cfg(test)]
89162
mod tests {
90163
use super::*;
@@ -106,11 +179,71 @@ mod tests {
106179
},
107180
};
108181
assert_eq!(
109-
run_overlay(&conn, Path::new("."), &rust).unwrap(),
182+
run_overlay(&conn, Path::new("."), &rust, &[]).unwrap(),
110183
ingest::IngestStats::default()
111184
);
112185
}
113186

187+
#[test]
188+
fn rust_source_dirty_keys_reflects_path_and_hash_rust_only() {
189+
let conn = Connection::open_in_memory().unwrap();
190+
crate::db::schema::init_db(&conn).unwrap();
191+
conn.execute(
192+
"INSERT INTO file_index (path, hash, language, last_indexed) VALUES (?1, ?2, ?3, 0.0)",
193+
rusqlite::params!["src/a.rs", "hashA", "rust"],
194+
)
195+
.unwrap();
196+
conn.execute(
197+
"INSERT INTO file_index (path, hash, language, last_indexed) VALUES (?1, ?2, ?3, 0.0)",
198+
rusqlite::params!["src/main.py", "hashP", "python"],
199+
)
200+
.unwrap();
201+
202+
let keys = rust_source_dirty_keys(&conn);
203+
assert_eq!(
204+
keys,
205+
vec!["src/a.rs@hashA".to_string()],
206+
"must include only rust files, keyed by path+hash"
207+
);
208+
}
209+
210+
#[test]
211+
fn rust_source_dirty_keys_changes_when_a_file_hash_changes() {
212+
let conn = Connection::open_in_memory().unwrap();
213+
crate::db::schema::init_db(&conn).unwrap();
214+
conn.execute(
215+
"INSERT INTO file_index (path, hash, language, last_indexed) VALUES ('src/a.rs', 'hash1', 'rust', 0.0)",
216+
[],
217+
)
218+
.unwrap();
219+
let before = rust_source_dirty_keys(&conn);
220+
221+
conn.execute(
222+
"UPDATE file_index SET hash = 'hash2' WHERE path = 'src/a.rs'",
223+
[],
224+
)
225+
.unwrap();
226+
let after = rust_source_dirty_keys(&conn);
227+
228+
assert_ne!(
229+
before, after,
230+
"editing a rust file's content must change its dirty-key entry"
231+
);
232+
}
233+
234+
#[test]
235+
fn overlay_status_none_when_explicitly_disabled() {
236+
let conn = Connection::open_in_memory().unwrap();
237+
crate::db::schema::init_db(&conn).unwrap();
238+
let rust = RustConfig {
239+
scip: crate::config::ScipConfig {
240+
enabled: Some(false),
241+
binary: None,
242+
},
243+
};
244+
assert_eq!(overlay_status(&conn, Path::new("."), &rust), None);
245+
}
246+
114247
/// Live integration: real rust-analyzer against the Rust fixture workspace
115248
/// used throughout Phase A. Ignored by default -- requires rust-analyzer
116249
/// on PATH/rustup/VS Code, and a real `cargo metadata` resolve, neither of
@@ -133,7 +266,8 @@ mod tests {
133266
binary: None,
134267
},
135268
};
136-
let stats = run_overlay(&conn, &fixture, &rust).unwrap();
269+
let dirty = rust_source_dirty_keys(&conn);
270+
let stats = run_overlay(&conn, &fixture, &rust, &dirty).unwrap();
137271
assert!(
138272
stats.upgraded > 0,
139273
"expected at least one edge upgraded to formal"

crates/ci-server/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ pub async fn serve_stdio_with_preset(
113113
let rust_cfg = ci_core::config::load_config(&indexer_root)
114114
.map(|c| c.rust)
115115
.unwrap_or_default();
116-
match ci_core::scip::run_overlay(&conn, &indexer_root, &rust_cfg) {
116+
let dirty = ci_core::scip::rust_source_dirty_keys(&conn);
117+
match ci_core::scip::run_overlay(&conn, &indexer_root, &rust_cfg, &dirty) {
117118
Ok(stats) if stats.upgraded > 0 || stats.ruled_out > 0 => {
118119
// caller_count was computed by rebuild_graph before this
119120
// overlay flipped edge_confidence/ruled_out_by_scip on

crates/ci-server/src/tools/recover.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ impl CodeIntelligenceServer {
5151
"Still indexing — poll again or use search/source while edges build",
5252
)
5353
};
54+
55+
#[cfg(feature = "scip-overlay")]
56+
let scip_overlay = {
57+
let rust_cfg = ci_core::config::load_config(&self.project_root)
58+
.map(|c| c.rust)
59+
.unwrap_or_default();
60+
ci_core::scip::overlay_status(&conn, &self.project_root, &rust_cfg)
61+
.map(ScipOverlayStatusOutput::from)
62+
};
63+
#[cfg(not(feature = "scip-overlay"))]
64+
let scip_overlay: Option<ScipOverlayStatusOutput> = None;
65+
5466
serde_json::to_string_pretty(&IndexingStatusOutput {
5567
indexing_phase: phase,
5668
files_indexed: files,
@@ -60,6 +72,7 @@ impl CodeIntelligenceServer {
6072
embeddings_status: self.embed_status_str(),
6173
edges_ready: self.edges_ready(),
6274
last_updated: last_updated.map(epoch_to_iso8601),
75+
scip_overlay,
6376
suggested_next: self.filter_sn(sn),
6477
})
6578
.unwrap_or_default()
@@ -294,10 +307,53 @@ pub(crate) struct IndexingStatusOutput {
294307
pub(crate) edges_ready: bool,
295308
#[serde(skip_serializing_if = "Option::is_none")]
296309
pub(crate) last_updated: Option<String>,
310+
/// `None` when this build wasn't compiled with the `scip-overlay` feature,
311+
/// or `rust.scip.enabled` is explicitly `false` — nothing to report.
312+
/// Otherwise reflects whether Rust call edges are currently up to date
313+
/// with SCIP-upgraded (`formal`) confidence — see
314+
/// `ci_core::scip::overlay_status`.
315+
#[serde(skip_serializing_if = "Option::is_none")]
316+
pub(crate) scip_overlay: Option<ScipOverlayStatusOutput>,
297317
#[serde(skip_serializing_if = "Option::is_none")]
298318
pub(crate) suggested_next: Option<SuggestedNext>,
299319
}
300320

321+
/// Local mirror of `ci_core::scip::OverlayStatus` — that type lives in
322+
/// `ci-core`, which doesn't depend on `schemars`, so it can't derive
323+
/// `JsonSchema` itself. Only exists when this crate is built with the
324+
/// `scip-overlay` feature (the same gate `ci_core::scip` itself is behind).
325+
#[cfg(feature = "scip-overlay")]
326+
#[derive(Serialize, JsonSchema)]
327+
pub(crate) struct ScipOverlayStatusOutput {
328+
/// `rust-analyzer` binary was found (PATH/rustup/VS Code) at last check.
329+
pub(crate) available: bool,
330+
/// `false` means Rust source has changed since the last overlay run (or
331+
/// none has ever run) — the next non-noop incremental reindex will
332+
/// actually invoke rust-analyzer again rather than cache-skip.
333+
pub(crate) up_to_date: bool,
334+
}
335+
336+
#[cfg(feature = "scip-overlay")]
337+
impl From<ci_core::scip::OverlayStatus> for ScipOverlayStatusOutput {
338+
fn from(s: ci_core::scip::OverlayStatus) -> Self {
339+
Self {
340+
available: s.available,
341+
up_to_date: s.up_to_date,
342+
}
343+
}
344+
}
345+
346+
/// Stub so `IndexingStatusOutput`'s `scip_overlay` field type-checks
347+
/// identically regardless of the `scip-overlay` feature — always `None` when
348+
/// this build lacks the feature (see the `#[cfg(not(...))]` binding at the
349+
/// `indexing_status` call site).
350+
#[cfg(not(feature = "scip-overlay"))]
351+
#[derive(Serialize, JsonSchema)]
352+
pub(crate) struct ScipOverlayStatusOutput {
353+
pub(crate) available: bool,
354+
pub(crate) up_to_date: bool,
355+
}
356+
301357
// ---------------------------------------------------------------------------
302358
// Tool 14: locate
303359
// ---------------------------------------------------------------------------

crates/ci-server/src/watcher.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,53 @@ pub fn run_watch_loop(
140140
tracing::error!("Incremental chunk embedding failed: {e}");
141141
}
142142
}
143+
// Re-run the SCIP confidence-upgrade overlay so Rust call
144+
// edges touched by *this* reindex don't stay stuck below
145+
// `formal` for the rest of a long-running session — before
146+
// this, `run_overlay` only ever ran once at server startup
147+
// (see `lib.rs`), so any Rust file added/edited afterward
148+
// never got upgraded until the next restart. Cheap when
149+
// nothing Rust-relevant actually changed: `dirty` (the
150+
// current Rust source fingerprint) makes `run_overlay`'s own
151+
// cache key skip re-invoking rust-analyzer in that case —
152+
// see `run_overlay`'s doc comment.
153+
#[cfg(feature = "scip-overlay")]
154+
{
155+
let rust_cfg = ci_core::config::load_config(&project_root)
156+
.map(|c| c.rust)
157+
.unwrap_or_default();
158+
let dirty = ci_core::scip::rust_source_dirty_keys(&conn);
159+
match ci_core::scip::run_overlay(
160+
&conn,
161+
&project_root,
162+
&rust_cfg,
163+
&dirty,
164+
) {
165+
Ok(stats) if stats.upgraded > 0 || stats.ruled_out > 0 => {
166+
// caller_count was computed by this reindex's
167+
// rebuild_graph before the overlay flipped
168+
// edge_confidence/ruled_out_by_scip on some
169+
// edges — refresh or it goes stale immediately
170+
// relative to the columns it's filtered on.
171+
if let Err(e) =
172+
ci_core::indexer::pipeline::refresh_caller_counts(&conn)
173+
{
174+
tracing::warn!(
175+
"caller_count refresh after incremental SCIP overlay failed: {e}"
176+
);
177+
}
178+
tracing::info!(
179+
"Incremental SCIP overlay: {} edges upgraded, {} fan-out siblings ruled out",
180+
stats.upgraded,
181+
stats.ruled_out
182+
);
183+
}
184+
Ok(_) => {}
185+
Err(e) => tracing::warn!(
186+
"Incremental SCIP overlay error (base graph intact): {e}"
187+
),
188+
}
189+
}
143190
}
144191
Ok(_) => {}
145192
Err(e) => tracing::error!("Incremental reindex failed: {e}"),

0 commit comments

Comments
 (0)