Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44/// Marker-protected generations older than this absolute age are reclaimed even
45/// if a stale reader marker remains. Current and newest-previous generations are
46/// always retained, bounding the root-keyed callgraph store to roughly two or
47/// three large generations without adding user-visible configuration.
48const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
57
58fn write_amplification_baseline_enabled() -> bool {
59    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
60}
61
62type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
63
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65struct RebuildCooldownKey {
66    callgraph_dir: PathBuf,
67    project_key: String,
68}
69
70#[derive(Clone, Debug)]
71struct RebuildCooldownRecord {
72    project_root: PathBuf,
73    published_at: Instant,
74    cross_root_cooldown_armed: bool,
75}
76
77// Prevent repeated rebuilds when requests rapidly switch between project
78// roots. Allow the first successful rebuild for a different root; after that
79// transition, report the artifact as unavailable instead of publishing another
80// complete generation. Record only successful publications in this map.
81static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
82    OnceLock::new();
83
84#[derive(Clone, Debug, Eq, Hash, PartialEq)]
85struct RootRepairWarningKey {
86    project_key: String,
87}
88
89#[derive(Clone, Debug)]
90struct RootRepairWarningRecord {
91    window_start: Instant,
92    last_emitted: Instant,
93    entry_count: u64,
94    suppressed: u64,
95}
96
97static ROOT_REPAIR_WARNINGS: OnceLock<
98    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
99> = OnceLock::new();
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub(crate) struct CallgraphWriteMetricsSnapshot {
103    pub commits_60s: u64,
104    pub pages_or_bytes_written_60s: u64,
105}
106
107#[derive(Debug, Default)]
108struct CallgraphWriteMetrics {
109    window_start_ms: AtomicU64,
110    commits_60s: AtomicU64,
111    pages_or_bytes_written_60s: AtomicU64,
112}
113
114static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
115    OnceLock::new();
116
117fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
118    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
119    let mut metrics = metrics
120        .lock()
121        .expect("callgraph write metrics mutex poisoned");
122    Arc::clone(
123        metrics
124            .entry(project_key.to_string())
125            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
126    )
127}
128
129fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
130    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
131    if current_start == 0 {
132        let _ = metrics.window_start_ms.compare_exchange(
133            0,
134            now_ms,
135            AtomicOrdering::AcqRel,
136            AtomicOrdering::Acquire,
137        );
138        return;
139    }
140    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
141        return;
142    }
143    if metrics
144        .window_start_ms
145        .compare_exchange(
146            current_start,
147            now_ms,
148            AtomicOrdering::AcqRel,
149            AtomicOrdering::Acquire,
150        )
151        .is_ok()
152    {
153        metrics.commits_60s.store(0, AtomicOrdering::Release);
154        metrics
155            .pages_or_bytes_written_60s
156            .store(0, AtomicOrdering::Release);
157    }
158}
159
160impl CallgraphWriteMetrics {
161    fn record_commit(&self, pages_or_bytes_written: u64) {
162        let now_ms = unix_millis_now();
163        roll_callgraph_write_metric_window(self, now_ms);
164        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
165        self.pages_or_bytes_written_60s
166            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
167    }
168
169    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
170        roll_callgraph_write_metric_window(self, unix_millis_now());
171        CallgraphWriteMetricsSnapshot {
172            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
173            pages_or_bytes_written_60s: self
174                .pages_or_bytes_written_60s
175                .load(AtomicOrdering::Acquire),
176        }
177    }
178}
179
180pub(crate) fn callgraph_write_metrics_for_project(
181    project_key: &str,
182) -> CallgraphWriteMetricsSnapshot {
183    callgraph_write_metrics_for_key(project_key).snapshot()
184}
185
186pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
187    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
188        return CallgraphWriteMetricsSnapshot::default();
189    };
190    let metrics = metrics
191        .lock()
192        .expect("callgraph write metrics mutex poisoned");
193    metrics.values().map(|metrics| metrics.snapshot()).fold(
194        CallgraphWriteMetricsSnapshot::default(),
195        |total, current| CallgraphWriteMetricsSnapshot {
196            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
197            pages_or_bytes_written_60s: total
198                .pages_or_bytes_written_60s
199                .saturating_add(current.pages_or_bytes_written_60s),
200        },
201    )
202}
203
204const ROOT_REPAIR_WARNING_TEXT: &str =
205    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
206
207fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
208    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
209    let mut warnings = warnings.lock().ok()?;
210    let entry = warnings.entry(key);
211    let record = match entry {
212        Entry::Vacant(entry) => {
213            entry.insert(RootRepairWarningRecord {
214                window_start: now,
215                last_emitted: now,
216                entry_count: 1,
217                suppressed: 0,
218            });
219            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
220        }
221        Entry::Occupied(entry) => entry.into_mut(),
222    };
223
224    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
225        let suppressed = record.suppressed;
226        record.window_start = now;
227        record.last_emitted = now;
228        record.entry_count = 1;
229        record.suppressed = 0;
230        return Some(if suppressed == 0 {
231            ROOT_REPAIR_WARNING_TEXT.to_string()
232        } else {
233            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
234        });
235    }
236
237    record.entry_count = record.entry_count.saturating_add(1);
238    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
239        record.suppressed = record.suppressed.saturating_add(1);
240        None
241    } else {
242        record.last_emitted = now;
243        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
244    }
245}
246
247pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
248    next_root_repair_warning(
249        RootRepairWarningKey {
250            project_key: project_key.to_string(),
251        },
252        Instant::now(),
253    )
254}
255
256/// Return the number of repair entries in the active 60-second window.
257///
258/// The window start is returned for callers that need to show freshness without
259/// adding another status verdict or turning this into a user-facing setting.
260pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
261    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
262    let warnings = warnings.lock().ok()?;
263    let record = warnings.get(&RootRepairWarningKey {
264        project_key: project_key.to_string(),
265    })?;
266    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
267        .then_some((record.entry_count, record.window_start))
268}
269
270pub(crate) fn repair_entry_rate_total() -> u64 {
271    let Ok(warnings) = ROOT_REPAIR_WARNINGS
272        .get_or_init(|| Mutex::new(HashMap::new()))
273        .lock()
274    else {
275        return 0;
276    };
277    let now = Instant::now();
278    warnings
279        .values()
280        .filter(|record| {
281            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
282        })
283        .map(|record| record.entry_count)
284        .sum()
285}
286
287#[cfg(test)]
288pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
289    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
290    let mut warnings = warnings.lock().unwrap();
291    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
292        project_key: project_key.to_string(),
293    }) {
294        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
295    }
296}
297
298#[cfg(test)]
299mod root_repair_warning_tests {
300    use super::*;
301
302    #[test]
303    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
304        let key = RootRepairWarningKey {
305            project_key: "test-project".to_string(),
306        };
307        let first_at = Instant::now();
308        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
309        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
310        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
311        assert_eq!(
312            repair_entry_rate("test-project").map(|rate| rate.0),
313            Some(2)
314        );
315
316        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
317        assert!(repeated.ends_with("(repeated 1x in 60s)"));
318        expire_repair_entry_window_for_test("test-project");
319        assert!(repair_entry_rate("test-project").is_none());
320    }
321}
322
323#[cfg(test)]
324mod write_amplification_tests {
325    use super::*;
326    use std::fs;
327    use tempfile::tempdir;
328
329    #[test]
330    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
331        let temp = tempdir().unwrap();
332        let root = temp.path().join("root");
333        fs::create_dir_all(&root).unwrap();
334        let source = root.join("main.ts");
335        fs::write(&source, "export function main() {}\n").unwrap();
336        let store_dir = temp.path().join("store");
337        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
338
339        let conn = store.conn.lock().unwrap();
340        let synchronous: i64 = conn
341            .pragma_query_value(None, "synchronous", |row| row.get(0))
342            .unwrap();
343        let autocheckpoint: i64 = conn
344            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
345            .unwrap();
346        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
347        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
348        drop(conn);
349        store.cold_build(std::slice::from_ref(&source)).unwrap();
350        drop(store);
351
352        let readonly = CallGraphStore::open_readonly(store_dir, root)
353            .unwrap()
354            .expect("writer-created empty schema should be readable");
355        let conn = readonly.inner.conn.lock().unwrap();
356        let synchronous: i64 = conn
357            .pragma_query_value(None, "synchronous", |row| row.get(0))
358            .unwrap();
359        assert_eq!(synchronous, 1);
360    }
361
362    #[test]
363    fn own_refresh_skips_identical_extract_but_not_position_shift() {
364        let temp = tempdir().unwrap();
365        let root = temp.path().join("root");
366        fs::create_dir_all(&root).unwrap();
367        let source = root.join("main.ts");
368        fs::write(&source, "export function main() { return 1; }\n").unwrap();
369        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
370        store.cold_build(std::slice::from_ref(&source)).unwrap();
371        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
372        assert!(write_metrics.commits_60s > 0);
373        assert!(write_metrics.pages_or_bytes_written_60s > 0);
374
375        let before = store.conn.lock().unwrap().total_changes();
376        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
377        let (stats, _) = store
378            .refresh_files_profiled(std::slice::from_ref(&source))
379            .unwrap();
380        let after = store.conn.lock().unwrap().total_changes();
381        assert_eq!(stats.unchanged_extract_files, 1);
382        assert_eq!(stats.refreshed_own_files, 0);
383        assert_eq!(
384            after - before,
385            3,
386            "files, backend freshness, and the durable projection revision update"
387        );
388
389        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
390        let (shifted_stats, _) = store
391            .refresh_files_profiled(std::slice::from_ref(&source))
392            .unwrap();
393        assert_eq!(shifted_stats.unchanged_extract_files, 0);
394        assert_eq!(shifted_stats.refreshed_own_files, 1);
395    }
396
397    #[test]
398    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
399        let now = Instant::now();
400        assert!(idle_checkpoint_due(None, now));
401        assert!(!idle_checkpoint_due(
402            Some(now),
403            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
404        ));
405        assert!(idle_checkpoint_due(
406            Some(now),
407            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
408        ));
409    }
410
411    #[test]
412    fn write_metrics_decay_after_the_sixty_second_window() {
413        let key = format!("metrics-test-{}", now_nanos());
414        let metrics = callgraph_write_metrics_for_key(&key);
415        metrics.record_commit(17);
416        assert_eq!(metrics.snapshot().commits_60s, 1);
417        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
418        metrics.window_start_ms.store(
419            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
420            AtomicOrdering::Release,
421        );
422        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
423    }
424}
425
426#[cfg(test)]
427type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
428// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
429// thread running the cold build, and the only caller (a test) installs and
430// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
431// across parallel tests — one test's installed observer fired during ANOTHER
432// test's `cold_build_with_lease`, asserting against the wrong build's edges
433// (flaked on Windows CI under parallel scheduling). Production never sets it.
434thread_local! {
435    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
436        const { std::cell::RefCell::new(None) };
437    #[cfg(test)]
438    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
439        const { std::cell::RefCell::new(None) };
440    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
441        const { std::cell::RefCell::new(None) };
442    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
443    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
444        const { std::cell::Cell::new(false) };
445    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
446        const { std::cell::RefCell::new(None) };
447    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
448        const { std::cell::RefCell::new(None) };
449}
450
451mod dead_code_projection;
452pub use dead_code_projection::project_dead_code_snapshot;
453pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
454#[cfg(test)]
455pub(crate) use dead_code_projection::set_projection_before_open_observer;
456
457#[doc(hidden)]
458pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
459    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
460}
461
462#[cfg(test)]
463fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
464    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
465}
466
467#[cfg(test)]
468fn notify_cold_build_before_publish_observer() {
469    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
470    if let Some(observer) = observer {
471        observer();
472    }
473}
474
475#[cfg(not(test))]
476fn notify_cold_build_before_publish_observer() {}
477
478#[doc(hidden)]
479pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
480    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
481}
482
483#[doc(hidden)]
484pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
485    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
486}
487
488#[doc(hidden)]
489pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
490    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
491}
492
493struct PublishAdmissionGuard {
494    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
495}
496
497impl Drop for PublishAdmissionGuard {
498    fn drop(&mut self) {
499        PUBLISH_ADMISSION.with(|slot| {
500            *slot.borrow_mut() = self.previous.take();
501        });
502    }
503}
504
505pub(crate) fn with_publish_epoch<R>(
506    epoch: crate::root_cache::ArtifactPublishEpoch,
507    expected: u64,
508    run: impl FnOnce() -> R,
509) -> R {
510    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
511    let _guard = PublishAdmissionGuard { previous };
512    run()
513}
514
515fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
516    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
517    match admission {
518        Some((epoch, expected)) => epoch
519            .run_if_current(expected, publish)
520            .unwrap_or(Err(CallGraphStoreError::Superseded)),
521        None => publish(),
522    }
523}
524
525struct RefreshCommitAdmissionGuard {
526    previous: Option<(
527        SubcLifecycleAdmission,
528        Arc<std::sync::atomic::AtomicU64>,
529        u64,
530    )>,
531}
532
533impl Drop for RefreshCommitAdmissionGuard {
534    fn drop(&mut self) {
535        REFRESH_COMMIT_ADMISSION.with(|slot| {
536            *slot.borrow_mut() = self.previous.take();
537        });
538    }
539}
540
541fn with_refresh_commit_admission<R>(
542    lifecycle: SubcLifecycleAdmission,
543    generation_flag: Arc<std::sync::atomic::AtomicU64>,
544    expected_generation: u64,
545    run: impl FnOnce() -> R,
546) -> R {
547    let previous = REFRESH_COMMIT_ADMISSION
548        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
549    let _guard = RefreshCommitAdmissionGuard { previous };
550    run()
551}
552
553fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
554    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
555    let commit = || {
556        publish_if_current(|| {
557            tx.commit()?;
558            Ok(())
559        })
560    };
561    match admission {
562        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
563            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
564            .unwrap_or(Err(CallGraphStoreError::Superseded)),
565        None => commit(),
566    }
567}
568
569fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
570    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
571    if let Some(observer) = observer {
572        observer(temp_path, target_path);
573    }
574}
575
576#[derive(Debug)]
577pub enum CallGraphStoreError {
578    Io(std::io::Error),
579    Sqlite(rusqlite::Error),
580    Json(serde_json::Error),
581    Aft(AftError),
582    Lock(crate::fs_lock::AcquireError),
583    MissingCallerData { file: String },
584    Unavailable(String),
585    Superseded,
586    StaleFiles(Vec<String>),
587}
588
589impl fmt::Display for CallGraphStoreError {
590    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
591        match self {
592            Self::Io(error) => write!(formatter, "I/O error: {error}"),
593            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
594            Self::Json(error) => write!(formatter, "json error: {error}"),
595            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
596            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
597            Self::MissingCallerData { file } => {
598                write!(formatter, "missing extracted caller data for {file}")
599            }
600            Self::Unavailable(message) => {
601                write!(formatter, "callgraph store unavailable: {message}")
602            }
603            Self::Superseded => {
604                write!(formatter, "callgraph store build superseded before publish")
605            }
606            Self::StaleFiles(files) => {
607                write!(
608                    formatter,
609                    "callgraph store has stale files: {}",
610                    files.join(", ")
611                )
612            }
613        }
614    }
615}
616
617impl std::error::Error for CallGraphStoreError {}
618
619impl From<std::io::Error> for CallGraphStoreError {
620    fn from(error: std::io::Error) -> Self {
621        Self::Io(error)
622    }
623}
624
625impl From<rusqlite::Error> for CallGraphStoreError {
626    fn from(error: rusqlite::Error) -> Self {
627        Self::Sqlite(error)
628    }
629}
630
631impl From<serde_json::Error> for CallGraphStoreError {
632    fn from(error: serde_json::Error) -> Self {
633        Self::Json(error)
634    }
635}
636
637impl From<AftError> for CallGraphStoreError {
638    fn from(error: AftError) -> Self {
639        Self::Aft(error)
640    }
641}
642
643impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
644    fn from(error: crate::fs_lock::AcquireError) -> Self {
645        Self::Lock(error)
646    }
647}
648
649pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
650
651/// Config flag name gating whether the store is opened (default on). Production
652/// commands open it through `open_if_enabled` so the substrate can be disabled
653/// without code changes.
654pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
655
656#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
657pub struct CallGraphStoreOptions {
658    pub enabled: bool,
659}
660
661pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
662
663/// Shared context state that lets the refresh worker observe a store installed
664/// after its batch was opened. The worker clones the installed store Arc before
665/// checking it, so no context lock guard crosses the check or enqueue call.
666#[derive(Clone)]
667pub(crate) struct CallgraphRefreshState {
668    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
669    heavy_root_work_allowed: Arc<AtomicBool>,
670}
671
672impl CallgraphRefreshState {
673    pub(crate) fn new(
674        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
675        heavy_root_work_allowed: Arc<AtomicBool>,
676    ) -> Self {
677        Self {
678            store,
679            heavy_root_work_allowed,
680        }
681    }
682
683    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
684        self.store
685            .read()
686            .unwrap_or_else(std::sync::PoisonError::into_inner)
687            .as_ref()
688            .map(Arc::clone)
689    }
690}
691
692type WorkspaceCratePrefixes = HashMap<String, String>;
693
694#[derive(Clone, Debug, Default)]
695struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
696
697const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
698
699pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
700    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
701}
702
703#[derive(Clone, Debug, Hash, PartialEq, Eq)]
704struct RefreshRoot {
705    callgraph_dir: PathBuf,
706    project_root: PathBuf,
707}
708
709#[derive(Clone)]
710pub(crate) struct CallgraphRefreshTicket {
711    lifecycle: SubcLifecycleAdmission,
712    generation_flag: Arc<std::sync::atomic::AtomicU64>,
713    expected_generation: u64,
714    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
715    expected_publish_epoch: u64,
716}
717
718impl CallgraphRefreshTicket {
719    pub(crate) fn new(
720        lifecycle: SubcLifecycleAdmission,
721        generation_flag: Arc<std::sync::atomic::AtomicU64>,
722        expected_generation: u64,
723        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
724        expected_publish_epoch: u64,
725    ) -> Self {
726        Self {
727            lifecycle,
728            generation_flag,
729            expected_generation,
730            publish_epoch,
731            expected_publish_epoch,
732        }
733    }
734
735    fn is_current(&self) -> bool {
736        self.lifecycle
737            .is_current(self.generation_flag.as_ref(), self.expected_generation)
738            && self.publish_epoch.current() == self.expected_publish_epoch
739    }
740}
741
742#[derive(Clone)]
743struct RefreshBatch {
744    root: RefreshRoot,
745    paths: BTreeSet<PathBuf>,
746    pending_sinks: Vec<PendingCallGraphStorePaths>,
747    refresh_states: Vec<CallgraphRefreshState>,
748    ticket: Option<CallgraphRefreshTicket>,
749}
750
751impl RefreshBatch {
752    fn defer(&self) {
753        for sink in &self.pending_sinks {
754            sink.lock().extend(self.paths.iter().cloned());
755        }
756    }
757
758    fn defer_after_open_failure(&self) {
759        self.defer();
760        if self
761            .ticket
762            .as_ref()
763            .is_some_and(|ticket| !ticket.is_current())
764            || !self
765                .refresh_states
766                .iter()
767                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
768        {
769            return;
770        }
771
772        let ready_store_installed = self.refresh_states.iter().any(|state| {
773            let store = state.installed_store_snapshot();
774            store.is_some_and(|store| {
775                store.project_root() == self.root.project_root
776                    && !store.is_legacy_fallback()
777                    && store.is_current()
778            })
779        });
780        if !ready_store_installed {
781            return;
782        }
783
784        // This re-check and the ready-store install's pending-sink take form a
785        // check-then-act handoff: after this defer, exactly one site observes
786        // the parked paths with a ready current store, so no polling is needed.
787        for sink in &self.pending_sinks {
788            let paths = {
789                let mut pending = sink.lock();
790                self.paths
791                    .iter()
792                    .filter(|path| pending.remove(*path))
793                    .cloned()
794                    .collect::<Vec<_>>()
795            };
796            if paths.is_empty() {
797                continue;
798            }
799            let _ = enqueue_callgraph_store_refresh_inner(
800                self.root.callgraph_dir.clone(),
801                self.root.project_root.clone(),
802                paths,
803                Arc::clone(sink),
804                self.refresh_states.clone(),
805                self.ticket.clone(),
806            );
807        }
808    }
809
810    fn merge(
811        &mut self,
812        paths: impl IntoIterator<Item = PathBuf>,
813        sink: PendingCallGraphStorePaths,
814        refresh_states: Vec<CallgraphRefreshState>,
815        ticket: Option<CallgraphRefreshTicket>,
816    ) {
817        self.paths.extend(paths);
818        if ticket.is_some() {
819            self.ticket = ticket;
820        }
821        if !self
822            .pending_sinks
823            .iter()
824            .any(|existing| Arc::ptr_eq(existing, &sink))
825        {
826            self.pending_sinks.push(sink);
827        }
828        for refresh_state in refresh_states {
829            if !self.refresh_states.iter().any(|existing| {
830                Arc::ptr_eq(&existing.store, &refresh_state.store)
831                    && Arc::ptr_eq(
832                        &existing.heavy_root_work_allowed,
833                        &refresh_state.heavy_root_work_allowed,
834                    )
835            }) {
836                self.refresh_states.push(refresh_state);
837            }
838        }
839    }
840}
841
842#[derive(Default)]
843struct RefreshQueue {
844    order: VecDeque<RefreshRoot>,
845    queued: HashMap<RefreshRoot, RefreshBatch>,
846    active: Option<RefreshBatch>,
847    shutdown_requested: bool,
848}
849
850struct RefreshWorkerShared {
851    queue: Mutex<RefreshQueue>,
852    wake: Condvar,
853}
854
855struct RefreshWorker {
856    shared: Arc<RefreshWorkerShared>,
857    thread: Mutex<Option<JoinHandle<()>>>,
858}
859
860struct RefreshWorkerWatchdog {
861    first_path: PathBuf,
862    batch_len: usize,
863    started: Instant,
864}
865
866impl RefreshWorkerWatchdog {
867    fn start(paths: &[PathBuf]) -> Self {
868        Self {
869            first_path: paths
870                .first()
871                .expect("non-empty callgraph refresh batch has a first path")
872                .clone(),
873            batch_len: paths.len(),
874            started: Instant::now(),
875        }
876    }
877}
878
879impl Drop for RefreshWorkerWatchdog {
880    fn drop(&mut self) {
881        let elapsed = self.started.elapsed();
882        if elapsed < REFRESH_WORKER_WARN_AFTER {
883            return;
884        }
885        let path = if self.batch_len == 1 {
886            self.first_path.display().to_string()
887        } else {
888            format!(
889                "{} (+{} paths)",
890                self.first_path.display(),
891                self.batch_len - 1
892            )
893        };
894        log::warn!(
895            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
896            path,
897            elapsed.as_millis()
898        );
899        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
900            log::warn!(
901                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
902                path,
903                elapsed.as_millis()
904            );
905        }
906    }
907}
908
909impl RefreshWorker {
910    fn spawn() -> Arc<Self> {
911        let shared = Arc::new(RefreshWorkerShared {
912            queue: Mutex::new(RefreshQueue::default()),
913            wake: Condvar::new(),
914        });
915        let thread_shared = Arc::clone(&shared);
916        let thread = std::thread::Builder::new()
917            .name("aft-callgraph-refresh".to_string())
918            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
919            .expect("failed to spawn callgraph refresh worker");
920        Arc::new(Self {
921            shared,
922            thread: Mutex::new(Some(thread)),
923        })
924    }
925
926    fn enqueue(
927        &self,
928        root: RefreshRoot,
929        paths: Vec<PathBuf>,
930        pending_sink: PendingCallGraphStorePaths,
931        refresh_states: Vec<CallgraphRefreshState>,
932        ticket: Option<CallgraphRefreshTicket>,
933    ) -> bool {
934        let mut queue = self
935            .shared
936            .queue
937            .lock()
938            .expect("callgraph refresh queue mutex poisoned");
939        if queue.shutdown_requested {
940            pending_sink.lock().extend(paths);
941            return false;
942        }
943        if let Some(batch) = queue.queued.get_mut(&root) {
944            batch.merge(paths, pending_sink, refresh_states, ticket);
945        } else {
946            queue.order.push_back(root.clone());
947            queue.queued.insert(
948                root.clone(),
949                RefreshBatch {
950                    root,
951                    paths: paths.into_iter().collect(),
952                    pending_sinks: vec![pending_sink],
953                    refresh_states,
954                    ticket,
955                },
956            );
957        }
958        self.shared.wake.notify_one();
959        true
960    }
961
962    fn shutdown_with_budget(&self, budget: Duration) -> bool {
963        let deadline = Instant::now() + budget;
964        let mut queue = self
965            .shared
966            .queue
967            .lock()
968            .expect("callgraph refresh queue mutex poisoned");
969        queue.shutdown_requested = true;
970        self.shared.wake.notify_one();
971        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
972            let remaining = deadline.saturating_duration_since(Instant::now());
973            let (next, _) = self
974                .shared
975                .wake
976                .wait_timeout(queue, remaining)
977                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
978            queue = next;
979        }
980        let drained = queue.active.is_none() && queue.order.is_empty();
981        if !drained {
982            if let Some(active) = queue.active.as_ref() {
983                active.defer();
984            }
985            for batch in queue.queued.values() {
986                batch.defer();
987            }
988            queue.order.clear();
989            queue.queued.clear();
990        }
991        drop(queue);
992
993        if drained {
994            if let Some(thread) = self
995                .thread
996                .lock()
997                .expect("callgraph refresh worker thread mutex poisoned")
998                .take()
999            {
1000                let _ = thread.join();
1001            }
1002        }
1003        drained
1004    }
1005}
1006
1007static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1008
1009pub fn enqueue_callgraph_store_refresh(
1010    callgraph_dir: PathBuf,
1011    project_root: PathBuf,
1012    paths: Vec<PathBuf>,
1013    pending_sink: PendingCallGraphStorePaths,
1014) -> bool {
1015    enqueue_callgraph_store_refresh_inner(
1016        callgraph_dir,
1017        project_root,
1018        paths,
1019        pending_sink,
1020        Vec::new(),
1021        None,
1022    )
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1027    callgraph_dir: PathBuf,
1028    project_root: PathBuf,
1029    paths: Vec<PathBuf>,
1030    pending_sink: PendingCallGraphStorePaths,
1031    ticket: CallgraphRefreshTicket,
1032) -> bool {
1033    enqueue_callgraph_store_refresh_inner(
1034        callgraph_dir,
1035        project_root,
1036        paths,
1037        pending_sink,
1038        Vec::new(),
1039        Some(ticket),
1040    )
1041}
1042
1043pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1044    callgraph_dir: PathBuf,
1045    project_root: PathBuf,
1046    paths: Vec<PathBuf>,
1047    pending_sink: PendingCallGraphStorePaths,
1048    refresh_state: CallgraphRefreshState,
1049    ticket: CallgraphRefreshTicket,
1050) -> bool {
1051    enqueue_callgraph_store_refresh_inner(
1052        callgraph_dir,
1053        project_root,
1054        paths,
1055        pending_sink,
1056        vec![refresh_state],
1057        Some(ticket),
1058    )
1059}
1060
1061fn enqueue_callgraph_store_refresh_inner(
1062    callgraph_dir: PathBuf,
1063    project_root: PathBuf,
1064    paths: Vec<PathBuf>,
1065    pending_sink: PendingCallGraphStorePaths,
1066    refresh_states: Vec<CallgraphRefreshState>,
1067    ticket: Option<CallgraphRefreshTicket>,
1068) -> bool {
1069    if paths.is_empty() {
1070        return true;
1071    }
1072    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1073    let worker = {
1074        let mut worker = slot
1075            .lock()
1076            .expect("callgraph refresh worker mutex poisoned");
1077        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1078    };
1079    worker.enqueue(
1080        RefreshRoot {
1081            callgraph_dir,
1082            project_root,
1083        },
1084        paths,
1085        pending_sink,
1086        refresh_states,
1087        ticket,
1088    )
1089}
1090
1091pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1092    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1093}
1094
1095#[doc(hidden)]
1096pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1097    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1098    let worker = slot
1099        .lock()
1100        .expect("callgraph refresh worker mutex poisoned")
1101        .clone();
1102    let Some(worker) = worker else {
1103        return true;
1104    };
1105    let drained = worker.shutdown_with_budget(budget);
1106    if drained {
1107        let mut current = slot
1108            .lock()
1109            .expect("callgraph refresh worker mutex poisoned");
1110        if current
1111            .as_ref()
1112            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1113        {
1114            *current = None;
1115        }
1116    }
1117    drained
1118}
1119
1120fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1121    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1122}
1123
1124fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1125    // The worker owns these caches so maps are shared only by refreshes for the
1126    // same canonical root and disappear when the worker shuts down.
1127    let mut workspace_crate_prefixes = HashMap::new();
1128    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1129    loop {
1130        let batch = {
1131            let mut queue = shared
1132                .queue
1133                .lock()
1134                .expect("callgraph refresh queue mutex poisoned");
1135            loop {
1136                if let Some(root) = queue.order.pop_front() {
1137                    let batch = queue
1138                        .queued
1139                        .remove(&root)
1140                        .expect("queued callgraph refresh root has a batch");
1141                    queue.active = Some(batch.clone());
1142                    break batch;
1143                }
1144                if queue.shutdown_requested {
1145                    return;
1146                }
1147                queue = shared
1148                    .wake
1149                    .wait(queue)
1150                    .expect("callgraph refresh queue mutex poisoned while waiting");
1151            }
1152        };
1153
1154        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1155
1156        let mut queue = shared
1157            .queue
1158            .lock()
1159            .expect("callgraph refresh queue mutex poisoned");
1160        queue.active = None;
1161        let became_idle = queue.order.is_empty();
1162        shared.wake.notify_all();
1163        drop(queue);
1164
1165        if became_idle {
1166            let checkpoint_due = idle_checkpoint_due(
1167                last_idle_checkpoints.get(&batch.root).copied(),
1168                Instant::now(),
1169            );
1170            if checkpoint_due {
1171                if let Some(store) = store {
1172                    if store.checkpoint_wal_truncate() {
1173                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1174                    }
1175                }
1176            }
1177        }
1178    }
1179}
1180
1181fn process_callgraph_refresh_batch(
1182    batch: &RefreshBatch,
1183    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1184) -> Option<CallGraphStore> {
1185    // A manifest event is an invalidation signal, not a source file to parse.
1186    // Drop the root's map even for a superseded batch: the filesystem changed,
1187    // and a later configure must never inherit crate membership from before it.
1188    if batch
1189        .paths
1190        .iter()
1191        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1192    {
1193        workspace_crate_prefixes.remove(&batch.root);
1194    }
1195
1196    let paths = batch
1197        .paths
1198        .iter()
1199        .filter(|path| crate::parser::detect_language(path).is_some())
1200        .cloned()
1201        .collect::<Vec<_>>();
1202    if paths.is_empty() {
1203        return None;
1204    }
1205    note_refresh_worker_batch_for_test(&batch.root.project_root);
1206    if batch
1207        .ticket
1208        .as_ref()
1209        .is_some_and(|ticket| !ticket.is_current())
1210    {
1211        // Superseded before starting: park the paths so the next configure's
1212        // pending replay (or unbind cleanup) decides their fate.
1213        batch.defer();
1214        return None;
1215    }
1216    let workspace_crate_prefix_cache =
1217        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1218    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1219    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1220    note_refresh_worker_call_for_test(&batch.root.project_root);
1221    let opened = if test_seam.fail_open {
1222        Ok(None)
1223    } else {
1224        CallGraphStore::open_ready(
1225            batch.root.callgraph_dir.clone(),
1226            batch.root.project_root.clone(),
1227        )
1228    };
1229    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1230        // The gate is deliberately after open_ready so tests can hold a failed
1231        // open between its result and the defer that parks the batch.
1232        let _ = gate.held_tx.send(());
1233        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1234    }
1235    let store = match opened {
1236        Ok(Some(store)) => store,
1237        Ok(None) => {
1238            batch.defer_after_open_failure();
1239            return None;
1240        }
1241        Err(error) => {
1242            batch.defer_after_open_failure();
1243            crate::slog_warn!(
1244                "callgraph store writer open failed during refresh; deferred paths: {}",
1245                error
1246            );
1247            return None;
1248        }
1249    };
1250    if !test_seam.delay.is_zero() {
1251        std::thread::sleep(test_seam.delay);
1252    }
1253    if batch
1254        .ticket
1255        .as_ref()
1256        .is_some_and(|ticket| !ticket.is_current())
1257    {
1258        // This is a superseded-ticket defer, not an open-failure defer: leave
1259        // the paths for the replacement configure instead of self-replaying.
1260        batch.defer();
1261        return Some(store);
1262    }
1263    let refresh_result = if test_seam.fail_refresh {
1264        Err(CallGraphStoreError::Unavailable(
1265            "injected refresh worker failure".to_string(),
1266        ))
1267    } else if let Some(ticket) = &batch.ticket {
1268        with_publish_epoch(
1269            ticket.publish_epoch.clone(),
1270            ticket.expected_publish_epoch,
1271            || {
1272                with_refresh_commit_admission(
1273                    ticket.lifecycle.clone(),
1274                    Arc::clone(&ticket.generation_flag),
1275                    ticket.expected_generation,
1276                    || {
1277                        store
1278                            .refresh_files_with_workspace_crate_prefix_cache(
1279                                &paths,
1280                                workspace_crate_prefix_cache.clone(),
1281                            )
1282                            .map(|_| ())
1283                    },
1284                )
1285            },
1286        )
1287    } else {
1288        store
1289            .refresh_files_with_workspace_crate_prefix_cache(
1290                &paths,
1291                workspace_crate_prefix_cache.clone(),
1292            )
1293            .map(|_| ())
1294    };
1295    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1296        // The commit lost the fence race: a newer configure or publication
1297        // owns the store now. Defer instead of stale-marking — the paths were
1298        // never committed, and the replacement generation re-indexes them.
1299        batch.defer();
1300        return Some(store);
1301    }
1302    if let Err(error) = refresh_result {
1303        crate::slog_warn!("callgraph store refresh failed: {}", error);
1304        match store.mark_files_stale(&paths) {
1305            Ok(marked) => {
1306                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1307                crate::slog_warn!(
1308                    "marked {} callgraph store file(s) stale after refresh failure",
1309                    marked.len()
1310                );
1311            }
1312            Err(mark_error) => crate::slog_warn!(
1313                "failed to mark callgraph store files stale after refresh failure: {}",
1314                mark_error
1315            ),
1316        }
1317    } else {
1318        crate::logging::note_callgraph_invalidations(paths.len());
1319    }
1320    Some(store)
1321}
1322
1323fn workspace_crate_prefix_cache_for_root(
1324    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1325    root: &RefreshRoot,
1326) -> WorkspaceCratePrefixCache {
1327    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1328        // Eviction only costs a future rebuild; it cannot make resolution stale.
1329        if let Some(evicted) = caches.keys().next().cloned() {
1330            caches.remove(&evicted);
1331        }
1332    }
1333    caches.entry(root.clone()).or_default().clone()
1334}
1335
1336#[derive(Clone, Copy, Default)]
1337struct RefreshWorkerTestSeam {
1338    delay: Duration,
1339    fail_refresh: bool,
1340    fail_open: bool,
1341    refresh_calls: usize,
1342    worker_calls: usize,
1343    stale_marks: usize,
1344}
1345
1346static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1347    OnceLock::new();
1348
1349struct RefreshWorkerTestGate {
1350    held_tx: crossbeam_channel::Sender<()>,
1351    release_rx: crossbeam_channel::Receiver<()>,
1352}
1353
1354static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1355    OnceLock::new();
1356
1357#[doc(hidden)]
1358pub fn install_callgraph_refresh_worker_test_gate(
1359    project_root: PathBuf,
1360) -> (
1361    crossbeam_channel::Receiver<()>,
1362    crossbeam_channel::Sender<()>,
1363) {
1364    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1365    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1366    REFRESH_WORKER_TEST_GATES
1367        .get_or_init(|| Mutex::new(HashMap::new()))
1368        .lock()
1369        .expect("callgraph refresh test gate mutex poisoned")
1370        .insert(
1371            project_root,
1372            RefreshWorkerTestGate {
1373                held_tx,
1374                release_rx,
1375            },
1376        );
1377    (held_rx, release_tx)
1378}
1379
1380fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1381    REFRESH_WORKER_TEST_GATES
1382        .get_or_init(|| Mutex::new(HashMap::new()))
1383        .lock()
1384        .expect("callgraph refresh test gate mutex poisoned")
1385        .remove(project_root)
1386}
1387
1388fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1389    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1390        return RefreshWorkerTestSeam::default();
1391    };
1392    seams
1393        .lock()
1394        .expect("callgraph refresh test seam mutex poisoned")
1395        .get(project_root)
1396        .copied()
1397        .unwrap_or_default()
1398}
1399
1400fn note_refresh_worker_batch_for_test(project_root: &Path) {
1401    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1402        if let Some(seam) = seams
1403            .lock()
1404            .expect("callgraph refresh test seam mutex poisoned")
1405            .get_mut(project_root)
1406        {
1407            seam.worker_calls += 1;
1408        }
1409    }
1410}
1411
1412fn note_refresh_worker_call_for_test(project_root: &Path) {
1413    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1414        if let Some(seam) = seams
1415            .lock()
1416            .expect("callgraph refresh test seam mutex poisoned")
1417            .get_mut(project_root)
1418        {
1419            seam.refresh_calls += 1;
1420        }
1421    }
1422}
1423
1424fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1425    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1426        if let Some(seam) = seams
1427            .lock()
1428            .expect("callgraph refresh test seam mutex poisoned")
1429            .get_mut(project_root)
1430        {
1431            seam.stale_marks += 1;
1432        }
1433    }
1434}
1435
1436#[doc(hidden)]
1437pub fn set_callgraph_refresh_worker_test_seam(
1438    project_root: PathBuf,
1439    delay: Duration,
1440    fail_refresh: bool,
1441) {
1442    REFRESH_WORKER_TEST_SEAMS
1443        .get_or_init(|| Mutex::new(HashMap::new()))
1444        .lock()
1445        .expect("callgraph refresh test seam mutex poisoned")
1446        .insert(
1447            project_root,
1448            RefreshWorkerTestSeam {
1449                delay,
1450                fail_refresh,
1451                ..RefreshWorkerTestSeam::default()
1452            },
1453        );
1454}
1455
1456#[doc(hidden)]
1457pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1458    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1459        if let Some(seam) = seams
1460            .lock()
1461            .expect("callgraph refresh test seam mutex poisoned")
1462            .get_mut(&project_root)
1463        {
1464            seam.fail_open = enabled;
1465        }
1466    }
1467}
1468
1469#[doc(hidden)]
1470pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1471    let seam = refresh_worker_test_seam(project_root);
1472    (seam.refresh_calls, seam.stale_marks)
1473}
1474
1475#[doc(hidden)]
1476pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1477    refresh_worker_test_seam(project_root).worker_calls
1478}
1479
1480#[doc(hidden)]
1481pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1482    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1483        seams
1484            .lock()
1485            .expect("callgraph refresh test seam mutex poisoned")
1486            .remove(project_root);
1487    }
1488}
1489
1490#[derive(Debug)]
1491pub struct CallGraphStore {
1492    project_root: PathBuf,
1493    project_key: String,
1494    /// The concrete on-disk DB file this store opened. With the generation
1495    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1496    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1497    sqlite_path: PathBuf,
1498    /// Root-keyed directory whose pointer controls this store. For a legacy
1499    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1500    /// newly published root-keyed generation invalidates the fallback reader.
1501    publication_dir: PathBuf,
1502    /// True only when the root-keyed read path opened data from a legacy
1503    /// harness partition. Writer-capable callers use this to schedule migration
1504    /// without making read-only/worktree callers acquire a writer lease.
1505    legacy_fallback: bool,
1506    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1507    /// or `None` when it opened the legacy single-file DB. Used to detect when
1508    /// another process has published a newer generation so this process can
1509    /// drop its connection and reopen (see `current_generation`).
1510    generation: Option<String>,
1511    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1512    read_marker: Option<crate::root_cache::ReadMarker>,
1513    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1514    // Failed validations are not cached, so a later successful build remains visible.
1515    database_ready: AtomicBool,
1516    write_metrics: Arc<CallgraphWriteMetrics>,
1517    conn: Mutex<Connection>,
1518}
1519
1520#[derive(Debug)]
1521pub struct ReadonlyCallGraphStore {
1522    inner: CallGraphStore,
1523}
1524
1525pub trait CallGraphRead {
1526    fn project_root(&self) -> &Path;
1527    fn project_key(&self) -> &str;
1528    fn sqlite_path(&self) -> &Path;
1529    fn is_current(&self) -> bool;
1530    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1531    fn indexed_file_count(&self) -> Result<usize>;
1532    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1533    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1534    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1535    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1536    fn direct_callers_for_symbols(
1537        &self,
1538        targets: &[(String, String)],
1539    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1540        targets
1541            .iter()
1542            .cloned()
1543            .map(|target| {
1544                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1545                Ok((target, callers))
1546            })
1547            .collect()
1548    }
1549    fn direct_caller_counts_of(
1550        &self,
1551        targets: &[(String, String)],
1552    ) -> Result<HashMap<(String, String), usize>>;
1553    fn outgoing_calls_for_symbols(
1554        &self,
1555        sources: &[(String, String)],
1556    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1557    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1558        -> Result<StoreCallersResult>;
1559    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1560    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1561    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1562    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1563    fn call_tree(
1564        &self,
1565        file_rel: &Path,
1566        symbol: &str,
1567        depth: usize,
1568    ) -> Result<callgraph::CallTreeNode>;
1569    fn trace_to(
1570        &self,
1571        file_rel: &Path,
1572        symbol: &str,
1573        max_depth: usize,
1574    ) -> Result<callgraph::TraceToResult>;
1575    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1576    fn trace_to_symbol(
1577        &self,
1578        file_rel: &Path,
1579        symbol: &str,
1580        to_symbol: &str,
1581        to_file: Option<&Path>,
1582        max_depth: usize,
1583    ) -> Result<callgraph::TraceToSymbolResult>;
1584}
1585
1586#[derive(Debug, Clone, PartialEq, Eq)]
1587enum OpenRootRepair {
1588    None,
1589    ReRooted,
1590    NeedsRebuild {
1591        previous_roots: Vec<String>,
1592        current_root: String,
1593        reason: String,
1594    },
1595}
1596
1597struct OpenedStore {
1598    store: CallGraphStore,
1599    root_repair: OpenRootRepair,
1600}
1601
1602#[derive(Clone, Debug)]
1603struct LegacyCallgraphPartition {
1604    harness: String,
1605    dir: PathBuf,
1606    key: String,
1607    bytes: u64,
1608    freshness: Option<SystemTime>,
1609}
1610
1611#[derive(Clone, Debug)]
1612struct LegacyCallgraphTarget {
1613    partition: LegacyCallgraphPartition,
1614    sqlite_path: PathBuf,
1615    generation: Option<String>,
1616    source_bytes: u64,
1617    source_blake3: String,
1618}
1619
1620#[derive(Clone, Debug)]
1621struct SourceFingerprint {
1622    bytes: u64,
1623    blake3: String,
1624}
1625
1626#[derive(Clone, Debug)]
1627struct PublishedLegacyMigration {
1628    generation: String,
1629    migrated_bytes: u64,
1630}
1631
1632#[derive(Debug, Clone)]
1633pub struct ColdBuildStats {
1634    pub files: usize,
1635    pub nodes: usize,
1636    pub refs: usize,
1637    pub edges: usize,
1638    pub failed_files: Vec<String>,
1639    pub elapsed_ms: u128,
1640}
1641
1642#[derive(Debug, Clone)]
1643pub struct IncrementalStats {
1644    pub changed_files: Vec<String>,
1645    pub surface_changed: Vec<String>,
1646    pub deleted_files: Vec<String>,
1647    pub dependency_selected_refs: usize,
1648    pub refreshed_own_files: usize,
1649    pub unchanged_extract_files: usize,
1650}
1651
1652/// Phase timings for the copy-based incremental refresh benchmark.
1653#[doc(hidden)]
1654#[derive(Debug, Clone, Default, PartialEq, Eq)]
1655pub struct RefreshFilesProfile {
1656    pub parse: Duration,
1657    pub dependency_selection: Duration,
1658    pub row_deletes: Duration,
1659    pub row_inserts: Duration,
1660    pub dependent_parse: Duration,
1661    pub index_load: Duration,
1662    pub ref_resolution: Duration,
1663    pub method_dispatch: Duration,
1664    pub commit: Duration,
1665    pub total: Duration,
1666}
1667
1668impl RefreshFilesProfile {
1669    pub fn report(&self) -> String {
1670        format!(
1671            "parse={}ms dependency_selection={}ms row_deletes={}ms row_inserts={}ms dependent_parse={}ms index_load={}ms ref_resolution={}ms method_dispatch={}ms commit={}ms total={}ms",
1672            self.parse.as_millis(),
1673            self.dependency_selection.as_millis(),
1674            self.row_deletes.as_millis(),
1675            self.row_inserts.as_millis(),
1676            self.dependent_parse.as_millis(),
1677            self.index_load.as_millis(),
1678            self.ref_resolution.as_millis(),
1679            self.method_dispatch.as_millis(),
1680            self.commit.as_millis(),
1681            self.total.as_millis(),
1682        )
1683    }
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1687pub struct StoredEdge {
1688    pub source_file: String,
1689    pub source_symbol: String,
1690    pub target_file: String,
1691    pub target_symbol: String,
1692    pub kind: String,
1693    pub line: u32,
1694}
1695
1696#[derive(Debug, Clone, PartialEq, Eq)]
1697pub struct StoreNode {
1698    node_id: String,
1699    pub file: String,
1700    pub symbol: String,
1701    pub name: String,
1702    pub kind: String,
1703    pub line: u32,
1704    pub end_line: u32,
1705    pub signature: Option<String>,
1706    pub exported: bool,
1707    pub is_entry_point: bool,
1708    pub lang: LangId,
1709}
1710
1711#[cfg(test)]
1712impl StoreNode {
1713    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1714        Self {
1715            node_id: format!("{file}:{symbol}"),
1716            file: file.to_string(),
1717            symbol: symbol.to_string(),
1718            name: symbol.to_string(),
1719            kind: "function".to_string(),
1720            line: 1,
1721            end_line: 1,
1722            signature: None,
1723            exported: is_entry_point,
1724            is_entry_point,
1725            lang: LangId::TypeScript,
1726        }
1727    }
1728}
1729
1730#[derive(Debug, Clone, PartialEq, Eq)]
1731pub struct StoreCallSite {
1732    pub caller: StoreNode,
1733    pub target_file: String,
1734    pub target_symbol: String,
1735    pub target: Option<StoreNode>,
1736    pub line: u32,
1737    pub byte_start: usize,
1738    pub byte_end: usize,
1739    pub resolved: bool,
1740    pub provenance: String,
1741}
1742
1743impl StoreCallSite {
1744    pub fn approximate(&self) -> bool {
1745        self.provenance == PROVENANCE_NAME_MATCH
1746    }
1747
1748    pub fn resolved_by(&self) -> &str {
1749        &self.provenance
1750    }
1751
1752    pub fn supplemental_resolution(&self) -> Option<&str> {
1753        match self.provenance.as_str() {
1754            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1755            _ => None,
1756        }
1757    }
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq)]
1761pub struct StoreUnresolvedCall {
1762    pub caller: StoreNode,
1763    pub symbol: String,
1764    pub full_ref: Option<String>,
1765    pub line: u32,
1766    pub byte_start: usize,
1767    pub byte_end: usize,
1768}
1769
1770#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct StoreCallersResult {
1772    pub target: StoreNode,
1773    pub callers: Vec<StoreCallSite>,
1774    pub scanned_files: usize,
1775    pub depth_limited: bool,
1776    pub truncated: usize,
1777}
1778
1779#[derive(Debug, Clone, PartialEq, Eq)]
1780pub struct StoreImpactCaller {
1781    pub site: StoreCallSite,
1782    pub signature: Option<String>,
1783    pub is_entry_point: bool,
1784    pub call_expression: Option<String>,
1785    pub parameters: Vec<String>,
1786}
1787
1788#[derive(Debug, Clone, PartialEq, Eq)]
1789pub struct StoreImpactResult {
1790    pub target: StoreNode,
1791    pub parameters: Vec<String>,
1792    pub callers: Vec<StoreImpactCaller>,
1793    pub depth_limited: bool,
1794    pub truncated: usize,
1795}
1796
1797#[derive(Debug, Clone)]
1798struct ExtractFailure {
1799    rel_path: String,
1800    freshness: Option<FileFreshness>,
1801}
1802
1803#[derive(Debug, Clone)]
1804struct BuildExtractsResult {
1805    extracts: Vec<FileExtract>,
1806    failures: Vec<ExtractFailure>,
1807}
1808
1809#[derive(Debug, Clone)]
1810enum StoreForwardCall {
1811    Resolved(StoreCallSite),
1812    Unresolved(StoreUnresolvedCall),
1813}
1814
1815impl StoreForwardCall {
1816    fn byte_start(&self) -> usize {
1817        match self {
1818            Self::Resolved(site) => site.byte_start,
1819            Self::Unresolved(call) => call.byte_start,
1820        }
1821    }
1822
1823    fn line(&self) -> u32 {
1824        match self {
1825            Self::Resolved(site) => site.line,
1826            Self::Unresolved(call) => call.line,
1827        }
1828    }
1829}
1830
1831#[derive(Debug, Clone)]
1832struct FileExtract {
1833    rel_path: String,
1834    freshness: FileFreshness,
1835    lang: LangId,
1836    data: FileCallData,
1837    nodes: Vec<NodeRecord>,
1838    raw_refs: Vec<RawRef>,
1839    dispatch_hints: Vec<DispatchHint>,
1840    surface_fingerprint: String,
1841}
1842
1843#[derive(Debug, Clone)]
1844struct NodeRecord {
1845    id: String,
1846    file_path: String,
1847    name: String,
1848    scoped_name: String,
1849    kind: String,
1850    range: Range,
1851    range_ordinal: u32,
1852    signature: Option<String>,
1853    exported: bool,
1854    is_default_export: bool,
1855    is_type_like: bool,
1856    is_callgraph_entry_point: bool,
1857}
1858
1859#[derive(Debug, Clone)]
1860struct RawRef {
1861    ref_id: String,
1862    caller_node: Option<String>,
1863    caller_symbol: Option<String>,
1864    caller_file: String,
1865    kind: String,
1866    short_name: Option<String>,
1867    full_ref: Option<String>,
1868    module_path: Option<String>,
1869    import_kind: Option<String>,
1870    local_name: Option<String>,
1871    requested_name: Option<String>,
1872    namespace_alias: Option<String>,
1873    wildcard: bool,
1874    line: u32,
1875    byte_start: usize,
1876    byte_end: usize,
1877    dependencies: BTreeSet<String>,
1878}
1879
1880#[derive(Debug, Clone)]
1881struct ResolvedRef {
1882    raw: RawRef,
1883    status: String,
1884    target_node: Option<String>,
1885    target_file: Option<String>,
1886    target_symbol: Option<String>,
1887    dependencies: BTreeSet<String>,
1888    edge: Option<EdgeRecord>,
1889}
1890
1891#[derive(Debug, Clone)]
1892struct EdgeRecord {
1893    edge_id: String,
1894    source_node: String,
1895    target_node: Option<String>,
1896    target_file: String,
1897    target_symbol: String,
1898    kind: String,
1899    line: u32,
1900}
1901
1902#[derive(Debug, Clone)]
1903struct DispatchHint {
1904    id: String,
1905    method_name: String,
1906    caller_node: String,
1907    file: String,
1908    line: u32,
1909    byte_start: usize,
1910    byte_end: usize,
1911}
1912
1913#[derive(Debug, Clone)]
1914struct NameMatchRef {
1915    ref_id: String,
1916    caller_node: String,
1917    caller_file: String,
1918    caller_symbol: String,
1919    caller_signature: Option<String>,
1920    receiver_expression: String,
1921    receiver: String,
1922    method_name: String,
1923    colon_dispatch: bool,
1924    line: u32,
1925    lang: String,
1926}
1927
1928#[derive(Debug, Clone)]
1929struct NameMatchCandidate {
1930    node_id: String,
1931    file_path: String,
1932    scoped_name: String,
1933    kind: String,
1934    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
1935    start_line: u32,
1936}
1937
1938#[derive(Debug, Clone)]
1939struct FileRow {
1940    surface_fingerprint: String,
1941    freshness: FileFreshness,
1942}
1943
1944#[derive(Debug, Clone)]
1945struct DbFileIndex {
1946    lang: Option<LangId>,
1947    exports: HashSet<String>,
1948    default_export: Option<String>,
1949    export_aliases: HashMap<String, String>,
1950    node_by_scoped: HashMap<String, String>,
1951    node_by_bare: HashMap<String, String>,
1952    node_kind_by_id: HashMap<String, String>,
1953    module_targets: HashMap<String, Option<String>>,
1954    reexports: Vec<ReexportIndex>,
1955}
1956
1957#[derive(Debug, Clone)]
1958struct ReexportIndex {
1959    target_file: Option<String>,
1960    named: HashMap<String, String>,
1961    wildcard: bool,
1962}
1963
1964#[derive(Debug, Clone)]
1965struct ProjectIndex<'a> {
1966    project_root: PathBuf,
1967    files: HashMap<String, DbFileIndex>,
1968    caller_data: HashMap<String, &'a FileCallData>,
1969    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1970    /// watcher events replace the cache before another batch can resolve refs.
1971    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1972    /// its own workspace mapping.
1973    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1974}
1975
1976impl ProjectIndex<'_> {
1977    /// Resolve a crate name to its `src` prefix, building the workspace map on
1978    /// first use. Refresh-worker indexes for the same root share this cache.
1979    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1980        self.workspace_crate_prefixes
1981            .0
1982            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1983            .get(crate_name)
1984            .cloned()
1985    }
1986}
1987
1988impl CallGraphStore {
1989    pub fn open_if_enabled(
1990        options: CallGraphStoreOptions,
1991        callgraph_dir: PathBuf,
1992        project_root: PathBuf,
1993    ) -> Result<Option<Self>> {
1994        if !options.enabled {
1995            return Ok(None);
1996        }
1997        Self::open(callgraph_dir, project_root).map(Some)
1998    }
1999
2000    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2001        let project_key = crate::search_index::artifact_cache_key(&project_root);
2002        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2003        else {
2004            return Err(CallGraphStoreError::Unavailable(
2005                "writer capability denied; use the read-only callgraph opener".to_string(),
2006            ));
2007        };
2008        std::fs::create_dir_all(&callgraph_dir)?;
2009        // Resolve the current generation via the pointer (falling back to the
2010        // legacy single-file DB). If nothing is published yet, open the legacy
2011        // path so a brand-new store still gets a writable DB + schema.
2012        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2013            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2014        let OpenedStore { store, root_repair } = Self::open_at_path(
2015            project_root.clone(),
2016            project_key,
2017            sqlite_path,
2018            generation,
2019            true,
2020            Some(Arc::clone(&writer_lease)),
2021            None,
2022        )?;
2023        match root_repair {
2024            OpenRootRepair::NeedsRebuild { .. } => {
2025                log_root_repair_rebuild(&root_repair);
2026                drop(store);
2027                drop(writer_lease);
2028                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2029                let (store, _stats) =
2030                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2031                Ok(store)
2032            }
2033            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2034        }
2035    }
2036
2037    pub fn open_readonly(
2038        callgraph_dir: PathBuf,
2039        project_root: PathBuf,
2040    ) -> Result<Option<ReadonlyCallGraphStore>> {
2041        let project_key = crate::search_index::artifact_cache_key(&project_root);
2042        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2043        {
2044            let conn = open_readonly_connection(&sqlite_path)?;
2045            if !database_ready(&conn).unwrap_or(false) {
2046                return Ok(None);
2047            }
2048            let marker_label = generation.as_deref().unwrap_or("legacy");
2049            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2050            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2051                Self::from_connection(
2052                    project_root,
2053                    project_key,
2054                    sqlite_path,
2055                    callgraph_dir,
2056                    false,
2057                    generation,
2058                    None,
2059                    Some(read_marker),
2060                    conn,
2061                ),
2062            )));
2063        }
2064
2065        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2066            return Ok(None);
2067        };
2068        crate::slog_warn!(
2069            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2070            target.partition.harness,
2071            target.sqlite_path.display()
2072        );
2073        let conn = open_readonly_connection(&target.sqlite_path)?;
2074        if !database_ready(&conn).unwrap_or(false) {
2075            return Ok(None);
2076        }
2077        let marker_label =
2078            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2079        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2080        Ok(Some(ReadonlyCallGraphStore::from_inner(
2081            Self::from_connection(
2082                project_root,
2083                project_key,
2084                target.sqlite_path,
2085                callgraph_dir,
2086                true,
2087                target.generation,
2088                None,
2089                Some(read_marker),
2090                conn,
2091            ),
2092        )))
2093    }
2094
2095    /// Open the currently-published ready store with write access so moved-root
2096    /// metadata can be repaired before projection readers consume it. Unlike
2097    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2098    /// ready generation exists, it returns `Ok(None)` instead of creating an
2099    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2100    pub fn open_ready_repairing(
2101        callgraph_dir: PathBuf,
2102        project_root: PathBuf,
2103    ) -> Result<Option<Self>> {
2104        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2105    }
2106
2107    /// Open a ready store for bounded maintenance work without repairing root
2108    /// metadata or starting a cold rebuild. A store that needs either action is
2109    /// reported as unavailable so a background build can own that work.
2110    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2111        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2112    }
2113
2114    pub fn open_ready_no_rebuild(
2115        callgraph_dir: PathBuf,
2116        project_root: PathBuf,
2117    ) -> Result<Option<Self>> {
2118        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2119    }
2120
2121    fn open_ready_with_rebuild_policy(
2122        callgraph_dir: PathBuf,
2123        project_root: PathBuf,
2124        allow_cold_build: bool,
2125        allow_root_repair: bool,
2126    ) -> Result<Option<Self>> {
2127        let project_key = crate::search_index::artifact_cache_key(&project_root);
2128        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2129        else {
2130            return Ok(None);
2131        };
2132        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2133        else {
2134            return Ok(None);
2135        };
2136        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2137            project_root.clone(),
2138            project_key.clone(),
2139            sqlite_path,
2140            generation,
2141            true,
2142            Some(Arc::clone(&writer_lease)),
2143            None,
2144            allow_root_repair,
2145        )?;
2146        match root_repair {
2147            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2148                log_root_repair_rebuild(&root_repair);
2149                drop(store);
2150                drop(writer_lease);
2151                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2152                let (store, _stats) =
2153                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2154                Ok(Some(store))
2155            }
2156            OpenRootRepair::NeedsRebuild { .. } => {
2157                if let Some(message) = note_repair_entry(&project_key) {
2158                    crate::slog_warn!("{message}");
2159                }
2160                Ok(None)
2161            }
2162            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2163        }
2164    }
2165
2166    pub fn cold_build_with_lease(
2167        callgraph_dir: PathBuf,
2168        project_root: PathBuf,
2169        files: &[PathBuf],
2170    ) -> Result<(Self, ColdBuildStats)> {
2171        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2172    }
2173
2174    pub fn cold_build_with_lease_chunked(
2175        callgraph_dir: PathBuf,
2176        project_root: PathBuf,
2177        files: &[PathBuf],
2178        chunk_size: usize,
2179    ) -> Result<(Self, ColdBuildStats)> {
2180        Self::cold_build_with_lease_chunked_inner(
2181            callgraph_dir,
2182            project_root,
2183            files,
2184            chunk_size,
2185            false,
2186        )
2187    }
2188
2189    pub(crate) fn force_cold_build_with_lease_chunked(
2190        callgraph_dir: PathBuf,
2191        project_root: PathBuf,
2192        files: &[PathBuf],
2193        chunk_size: usize,
2194    ) -> Result<(Self, ColdBuildStats)> {
2195        Self::cold_build_with_lease_chunked_inner(
2196            callgraph_dir,
2197            project_root,
2198            files,
2199            chunk_size,
2200            true,
2201        )
2202    }
2203
2204    fn cold_build_with_lease_chunked_inner(
2205        callgraph_dir: PathBuf,
2206        project_root: PathBuf,
2207        files: &[PathBuf],
2208        chunk_size: usize,
2209        require_new_publication: bool,
2210    ) -> Result<(Self, ColdBuildStats)> {
2211        let project_key = crate::search_index::artifact_cache_key(&project_root);
2212        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2213        else {
2214            let operation = if require_new_publication {
2215                "forced rebuild"
2216            } else {
2217                "cold build"
2218            };
2219            return Err(CallGraphStoreError::Unavailable(format!(
2220                "{operation} could not acquire writer capability"
2221            )));
2222        };
2223        std::fs::create_dir_all(&callgraph_dir)?;
2224        let (stats, generation) = Self::cold_build_publish_locked(
2225            &callgraph_dir,
2226            &project_root,
2227            &project_key,
2228            files,
2229            chunk_size,
2230            Arc::clone(&writer_lease),
2231        )?;
2232        let store = Self::open_generation(
2233            &callgraph_dir,
2234            project_root,
2235            project_key,
2236            generation,
2237            writer_lease,
2238        )?;
2239        Ok((store, stats))
2240    }
2241
2242    pub fn ensure_built_with_lease(
2243        callgraph_dir: PathBuf,
2244        project_root: PathBuf,
2245        files: &[PathBuf],
2246    ) -> Result<(Self, Option<ColdBuildStats>)> {
2247        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2248    }
2249
2250    pub fn ensure_built_with_lease_chunked(
2251        callgraph_dir: PathBuf,
2252        project_root: PathBuf,
2253        files: &[PathBuf],
2254        chunk_size: usize,
2255    ) -> Result<(Self, Option<ColdBuildStats>)> {
2256        let project_key = crate::search_index::artifact_cache_key(&project_root);
2257        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2258        else {
2259            return Err(CallGraphStoreError::Unavailable(
2260                "callgraph ensure could not acquire writer capability".to_string(),
2261            ));
2262        };
2263        std::fs::create_dir_all(&callgraph_dir)?;
2264        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2265        // Another process may have published a ready generation while we waited
2266        // for the lock — open it instead of rebuilding. If that generation is
2267        // from this same project at an older filesystem root, repair the root
2268        // metadata in-place while still holding the build lease. If data rows
2269        // contain absolute paths, publish a fresh generation under this lease
2270        // rather than recursively reacquiring the same lock.
2271        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2272        {
2273            let OpenedStore { store, root_repair } = Self::open_at_path(
2274                project_root.clone(),
2275                project_key.clone(),
2276                sqlite_path,
2277                generation,
2278                true,
2279                Some(Arc::clone(&writer_lease)),
2280                None,
2281            )?;
2282            match root_repair {
2283                OpenRootRepair::NeedsRebuild { .. } => {
2284                    log_root_repair_rebuild(&root_repair);
2285                    drop(store);
2286                    let (stats, generation) = Self::cold_build_publish_locked(
2287                        &callgraph_dir,
2288                        &project_root,
2289                        &project_key,
2290                        files,
2291                        chunk_size,
2292                        Arc::clone(&writer_lease),
2293                    )?;
2294                    let store = Self::open_generation(
2295                        &callgraph_dir,
2296                        project_root,
2297                        project_key,
2298                        generation,
2299                        writer_lease,
2300                    )?;
2301                    return Ok((store, Some(stats)));
2302                }
2303                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2304                    return Ok((store, None));
2305                }
2306            }
2307        }
2308        if let Some(store) = try_legacy_migration_or_fallback(
2309            &callgraph_dir,
2310            &project_root,
2311            &project_key,
2312            Arc::clone(&writer_lease),
2313        )? {
2314            return Ok((store, None));
2315        }
2316        let (stats, generation) = Self::cold_build_publish_locked(
2317            &callgraph_dir,
2318            &project_root,
2319            &project_key,
2320            files,
2321            chunk_size,
2322            Arc::clone(&writer_lease),
2323        )?;
2324        let store = Self::open_generation(
2325            &callgraph_dir,
2326            project_root,
2327            project_key,
2328            generation,
2329            writer_lease,
2330        )?;
2331        Ok((store, Some(stats)))
2332    }
2333
2334    /// Migrate a legacy harness-partition store without falling through to a
2335    /// cold build. This is used after a query has already opened a read-only
2336    /// fallback: the caller runs it on the same limited background lane as cold
2337    /// builds while queries continue using that fallback. Public so crash/retry
2338    /// tests can drive the migration synchronously on a thread where the
2339    /// thread-local failure seams apply.
2340    pub fn migrate_legacy_with_lease(
2341        callgraph_dir: PathBuf,
2342        project_root: PathBuf,
2343    ) -> Result<Option<Self>> {
2344        let project_key = crate::search_index::artifact_cache_key(&project_root);
2345        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2346        else {
2347            return Ok(None);
2348        };
2349        std::fs::create_dir_all(&callgraph_dir)?;
2350        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2351
2352        // Another writer may have completed the migration while this worker was
2353        // waiting for the lease. Adopt its root-keyed generation rather than
2354        // copying the legacy source a second time.
2355        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2356        {
2357            let OpenedStore { store, root_repair } = Self::open_at_path(
2358                project_root,
2359                project_key,
2360                sqlite_path,
2361                generation,
2362                true,
2363                Some(writer_lease),
2364                None,
2365            )?;
2366            return match root_repair {
2367                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2368                OpenRootRepair::NeedsRebuild { reason, .. } => {
2369                    Err(CallGraphStoreError::Unavailable(format!(
2370                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2371                    )))
2372                }
2373            };
2374        }
2375
2376        let store = try_legacy_migration_or_fallback(
2377            &callgraph_dir,
2378            &project_root,
2379            &project_key,
2380            writer_lease,
2381        )?;
2382        // A disk-floor or backup-budget failure returns a readable legacy store.
2383        // Keep the already-resident fallback instead of sending this duplicate
2384        // reader through the background-install channel.
2385        Ok(store.filter(|store| !store.is_legacy_fallback()))
2386    }
2387
2388    /// Build a fresh DB and publish it as a new generation, then atomically flip
2389    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2390    /// succeeds even when other processes hold an older generation open (the
2391    /// multi-TUI Windows case). The builder owns the temp + generation files
2392    /// exclusively (unique pid+nanos names), so it can rename/replace them
2393    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2394    ///
2395    /// Returns the published generation file name so callers open exactly the
2396    /// generation they built (avoiding a race where a concurrent build's flip
2397    /// would otherwise reopen a different generation).
2398    fn cold_build_publish_locked(
2399        callgraph_dir: &Path,
2400        project_root: &Path,
2401        project_key: &str,
2402        files: &[PathBuf],
2403        chunk_size: usize,
2404        writer_lease: Arc<crate::root_cache::WriterLease>,
2405    ) -> Result<(ColdBuildStats, String)> {
2406        if let Some((previous_root, remaining)) =
2407            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2408        {
2409            return Err(CallGraphStoreError::Unavailable(format!(
2410                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2411                previous_root.display(),
2412                remaining.as_millis()
2413            )));
2414        }
2415        let generation = generation_file_name(project_key);
2416        let gen_path = callgraph_dir.join(&generation);
2417        let temp_path = callgraph_dir.join(format!(
2418            "{generation}.tmp.{}.{}",
2419            std::process::id(),
2420            now_nanos()
2421        ));
2422        remove_sqlite_file_set(&temp_path);
2423
2424        let stats = {
2425            let temp_store = Self::open_at_path(
2426                project_root.to_path_buf(),
2427                project_key.to_string(),
2428                temp_path.clone(),
2429                None,
2430                false,
2431                Some(Arc::clone(&writer_lease)),
2432                None,
2433            )?
2434            .store;
2435            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2436            let _ = temp_store.checkpoint_wal_truncate();
2437            temp_store.prepare_for_atomic_swap()?;
2438            stats
2439        };
2440
2441        notify_cold_build_before_publish_observer();
2442        let publication = publish_if_current(|| {
2443            verify_writer_lease(&writer_lease)?;
2444            // Move the finished build to its final generation path. This target is
2445            // brand-new and owned by us, so the rename never hits an open file.
2446            remove_sqlite_file_set(&gen_path);
2447            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2448            crate::fs_lock::sync_parent(&gen_path);
2449            remove_sqlite_sidecars(&gen_path);
2450
2451            notify_cold_build_swap_observer(&temp_path, &gen_path);
2452
2453            // Atomically publish the new generation, then best-effort GC old ones.
2454            verify_writer_lease(&writer_lease)?;
2455            publish_pointer(callgraph_dir, project_key, &generation)?;
2456            gc_old_generations(callgraph_dir, project_key, &generation);
2457            // Store-wide orphan sweep on the same cadence: reclaims aged build
2458            // temps for roots that no longer build here, which the per-root GC
2459            // above never reaches.
2460            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2461            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2462                let inspect_root =
2463                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2464                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2465                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2466            }
2467            Ok(())
2468        });
2469        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2470            remove_sqlite_file_set(&temp_path);
2471        }
2472        publication?;
2473        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2474        Ok((stats, generation))
2475    }
2476
2477    /// Open a specific just-published generation (read-write, WAL) so a builder
2478    /// returns a store pinned to exactly what it built.
2479    fn open_generation(
2480        callgraph_dir: &Path,
2481        project_root: PathBuf,
2482        project_key: String,
2483        generation: String,
2484        writer_lease: Arc<crate::root_cache::WriterLease>,
2485    ) -> Result<Self> {
2486        let gen_path = callgraph_dir.join(&generation);
2487        Ok(Self::open_at_path(
2488            project_root,
2489            project_key,
2490            gen_path,
2491            Some(generation),
2492            true,
2493            Some(writer_lease),
2494            None,
2495        )?
2496        .store)
2497    }
2498
2499    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2500        let project_key = crate::search_index::artifact_cache_key(project_root);
2501        // A cold build is needed unless a ready generation (or ready legacy DB)
2502        // is currently published.
2503        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2504    }
2505
2506    fn open_at_path(
2507        project_root: PathBuf,
2508        project_key: String,
2509        sqlite_path: PathBuf,
2510        generation: Option<String>,
2511        use_wal: bool,
2512        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2513        read_marker: Option<crate::root_cache::ReadMarker>,
2514    ) -> Result<OpenedStore> {
2515        Self::open_at_path_with_root_repair(
2516            project_root,
2517            project_key,
2518            sqlite_path,
2519            generation,
2520            use_wal,
2521            writer_lease,
2522            read_marker,
2523            true,
2524        )
2525    }
2526
2527    fn open_at_path_with_root_repair(
2528        project_root: PathBuf,
2529        project_key: String,
2530        sqlite_path: PathBuf,
2531        generation: Option<String>,
2532        use_wal: bool,
2533        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2534        read_marker: Option<crate::root_cache::ReadMarker>,
2535        allow_root_repair: bool,
2536    ) -> Result<OpenedStore> {
2537        if let Some(lease) = writer_lease.as_ref() {
2538            verify_writer_lease(lease)?;
2539        }
2540        if let Some(parent) = sqlite_path.parent() {
2541            std::fs::create_dir_all(parent)?;
2542        }
2543        let mut conn = Connection::open(&sqlite_path)?;
2544        if use_wal {
2545            configure_connection(&conn)?;
2546        } else {
2547            configure_build_connection(&conn)?;
2548        }
2549        if let Some(lease) = writer_lease.as_ref() {
2550            verify_writer_lease(lease)?;
2551        }
2552        initialize_schema(&conn)?;
2553        if let Some(lease) = writer_lease.as_ref() {
2554            verify_writer_lease(lease)?;
2555        }
2556        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2557        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2558            (Some(marker), _, _) => Some(marker),
2559            (None, Some(label), Some(cache_dir)) => {
2560                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2561            }
2562            (None, _, _) => None,
2563        };
2564        let publication_dir = sqlite_path
2565            .parent()
2566            .map(Path::to_path_buf)
2567            .unwrap_or_default();
2568        let store = Self::from_connection(
2569            project_root,
2570            project_key,
2571            sqlite_path,
2572            publication_dir,
2573            false,
2574            generation,
2575            writer_lease,
2576            read_marker,
2577            conn,
2578        );
2579        Ok(OpenedStore { store, root_repair })
2580    }
2581
2582    fn prepare_for_atomic_swap(&self) -> Result<()> {
2583        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2584        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2585        Ok(())
2586    }
2587
2588    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2589        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2590            self.sqlite_path
2591                .parent()
2592                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2593        });
2594        if protected_reader {
2595            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2596        } else {
2597            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2598        }
2599    }
2600
2601    fn from_connection(
2602        project_root: PathBuf,
2603        project_key: String,
2604        sqlite_path: PathBuf,
2605        publication_dir: PathBuf,
2606        legacy_fallback: bool,
2607        generation: Option<String>,
2608        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2609        read_marker: Option<crate::root_cache::ReadMarker>,
2610        conn: Connection,
2611    ) -> Self {
2612        let write_metrics = callgraph_write_metrics_for_key(&project_key);
2613        Self {
2614            project_root,
2615            project_key,
2616            sqlite_path,
2617            publication_dir,
2618            legacy_fallback,
2619            generation,
2620            writer_lease,
2621            read_marker,
2622            database_ready: AtomicBool::new(false),
2623            write_metrics,
2624            conn: Mutex::new(conn),
2625        }
2626    }
2627
2628    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2629        if self.database_ready.load(AtomicOrdering::Acquire) {
2630            return Ok(());
2631        }
2632        ensure_database_ready(conn)?;
2633        self.database_ready.store(true, AtomicOrdering::Release);
2634        Ok(())
2635    }
2636
2637    pub fn project_root(&self) -> &Path {
2638        &self.project_root
2639    }
2640
2641    pub fn project_key(&self) -> &str {
2642        &self.project_key
2643    }
2644
2645    pub fn sqlite_path(&self) -> &Path {
2646        &self.sqlite_path
2647    }
2648
2649    /// The generation file named by the publication pointer when this store opened.
2650    pub(crate) fn projection_generation(&self) -> Option<&str> {
2651        self.generation.as_deref()
2652    }
2653
2654    /// Read the durable revision that changes in the same transaction as graph writes.
2655    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2656        self.refresh_read_marker()?;
2657        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2658        self.ensure_ready(&conn)?;
2659        projection_write_revision(&conn)
2660    }
2661
2662    /// Whether this store is reading from a legacy harness partition because
2663    /// the root-keyed store has not published a generation yet.
2664    pub fn is_legacy_fallback(&self) -> bool {
2665        self.legacy_fallback
2666    }
2667
2668    pub(crate) fn is_legacy_migration(&self) -> bool {
2669        self.generation.as_deref().is_some_and(|generation| {
2670            migration_generation_requires_manifest(generation)
2671                && migration_manifest_valid(&self.publication_dir, generation)
2672        })
2673    }
2674
2675    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2676        self.writer_lease.as_ref().map(|lease| lease.epoch())
2677    }
2678
2679    fn verify_writer_lease(&self) -> Result<()> {
2680        let Some(lease) = self.writer_lease.as_ref() else {
2681            return Err(CallGraphStoreError::Unavailable(
2682                "callgraph store opened read-only; write API is unavailable".to_string(),
2683            ));
2684        };
2685        verify_writer_lease(lease)
2686    }
2687
2688    fn refresh_read_marker(&self) -> Result<()> {
2689        if let Some(marker) = self.read_marker.as_ref() {
2690            marker.touch_if_due()?;
2691        }
2692        Ok(())
2693    }
2694
2695    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2696        self.write_metrics
2697            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2698    }
2699
2700    fn checkpoint_wal_truncate(&self) -> bool {
2701        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2702        checkpoint_wal_truncate(&conn)
2703    }
2704
2705    /// True if this store still reflects the currently-published generation.
2706    /// Cheap (one small pointer-file read). When false, another process (or a
2707    /// local cold rebuild) has published a newer generation and the holder
2708    /// should drop this store and reopen via the pointer to converge. A missing
2709    /// pointer keeps the current store (legacy DB still valid, or transient).
2710    pub fn is_current(&self) -> bool {
2711        let _ = self.refresh_read_marker();
2712        match (
2713            read_pointer(&self.publication_dir, &self.project_key),
2714            &self.generation,
2715        ) {
2716            // Even when both generations happen to have the same filename, the
2717            // root-keyed pointer names a different directory from the fallback.
2718            (Some(_), _) if self.legacy_fallback => false,
2719            (Some(published), Some(opened)) => &published == opened,
2720            // A generation now supersedes the legacy single-file DB we opened.
2721            (Some(_), None) => false,
2722            // No pointer: keep serving (legacy DB, or an anomalous pointer
2723            // removal where our open generation file is still valid).
2724            (None, _) => true,
2725        }
2726    }
2727
2728    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2729        self.cold_build_chunked(files, 0)
2730    }
2731
2732    pub fn cold_build_chunked(
2733        &self,
2734        files: &[PathBuf],
2735        chunk_size: usize,
2736    ) -> Result<ColdBuildStats> {
2737        let started = Instant::now();
2738        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2739        macro_rules! phase {
2740            ($label:expr, $t:expr) => {
2741                if bench {
2742                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2743                    let _ = std::io::Write::flush(&mut std::io::stderr());
2744                }
2745            };
2746        }
2747        let files = normalize_file_list(&self.project_root, files)?;
2748
2749        if chunk_size == 0 {
2750            let t = Instant::now();
2751            let build = build_extracts_parallel(&self.project_root, &files);
2752            phase!("extract_parallel", t);
2753            let extracts = build.extracts;
2754            let failures = build.failures;
2755            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2756
2757            let t = Instant::now();
2758            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2759            phase!("build_index", t);
2760            let t = Instant::now();
2761            let mut resolved_refs = Vec::new();
2762            for extract in &extracts {
2763                for raw_ref in &extract.raw_refs {
2764                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2765                }
2766            }
2767            phase!("resolve_refs", t);
2768            let ref_count = resolved_refs.len();
2769            let edge_count = resolved_refs
2770                .iter()
2771                .filter(|item| item.edge.is_some())
2772                .count();
2773
2774            let t = Instant::now();
2775            self.verify_writer_lease()?;
2776            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2777            let total_changes_before = conn.total_changes();
2778            let tx = conn.transaction()?;
2779            clear_tables(&tx)?;
2780            insert_meta(&tx)?;
2781            drop_cold_build_secondary_indexes(&tx)?;
2782            {
2783                let workspace_root = self.project_root.display().to_string();
2784                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2785                for extract in &extracts {
2786                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2787                }
2788                for failure in &failures {
2789                    insert_backend_state_prepared(
2790                        &mut inserts.backend_state,
2791                        &workspace_root,
2792                        &failure.rel_path,
2793                        failure
2794                            .freshness
2795                            .as_ref()
2796                            .map(|freshness| &freshness.content_hash),
2797                        "stale",
2798                    )?;
2799                }
2800                for resolved in &resolved_refs {
2801                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2802                }
2803            }
2804            create_cold_build_secondary_indexes(&tx)?;
2805            let supplemental_edge_count =
2806                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2807            set_meta_ready(&tx, true)?;
2808            tx.commit()?;
2809            self.record_commit(total_changes_before, &conn);
2810            phase!("sqlite_insert", t);
2811
2812            let elapsed_ms = started.elapsed().as_millis();
2813            crate::slog_info!(
2814                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2815                extracts.len(),
2816                node_count,
2817                ref_count,
2818                edge_count + supplemental_edge_count,
2819                elapsed_ms
2820            );
2821            return Ok(ColdBuildStats {
2822                files: extracts.len(),
2823                nodes: node_count,
2824                refs: ref_count,
2825                edges: edge_count + supplemental_edge_count,
2826                failed_files: failures
2827                    .into_iter()
2828                    .map(|failure| failure.rel_path)
2829                    .collect(),
2830                elapsed_ms,
2831            });
2832        }
2833
2834        // Chunked implementation: parse and resolve in batches to reduce peak
2835        // memory during cold build without changing the persisted graph.
2836        let t = Instant::now();
2837        self.verify_writer_lease()?;
2838        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2839        let total_changes_before = conn.total_changes();
2840        let tx = conn.transaction()?;
2841        clear_tables(&tx)?;
2842        insert_meta(&tx)?;
2843        drop_cold_build_secondary_indexes(&tx)?;
2844
2845        let mut all_raw_refs = Vec::new();
2846        let mut failures = Vec::new();
2847        let mut node_count = 0;
2848        let mut files_parsed = 0;
2849
2850        let mut persistent_call_data = Vec::new();
2851        let mut file_to_call_data_index = HashMap::new();
2852        let mut files_index = HashMap::new();
2853
2854        let workspace_root = self.project_root.display().to_string();
2855
2856        {
2857            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2858            for chunk in files.chunks(chunk_size) {
2859                let build = build_extracts_parallel(&self.project_root, chunk);
2860                failures.extend(build.failures.clone());
2861
2862                for extract in build.extracts {
2863                    files_parsed += 1;
2864                    node_count += extract.nodes.len();
2865                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2866
2867                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2868                    files_index.insert(extract.rel_path.clone(), db_file_index);
2869
2870                    persistent_call_data.push(extract.data);
2871                    let idx = persistent_call_data.len() - 1;
2872                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2873
2874                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2875                }
2876                for failure in &build.failures {
2877                    insert_backend_state_prepared(
2878                        &mut inserts.backend_state,
2879                        &workspace_root,
2880                        &failure.rel_path,
2881                        failure
2882                            .freshness
2883                            .as_ref()
2884                            .map(|freshness| &freshness.content_hash),
2885                        "stale",
2886                    )?;
2887                }
2888            }
2889        }
2890
2891        let mut caller_data = HashMap::new();
2892        for (rel_path, idx) in &file_to_call_data_index {
2893            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2894        }
2895        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2896        let index = ProjectIndex::from_parts(
2897            &self.project_root,
2898            files_index,
2899            caller_data,
2900            WorkspaceCratePrefixCache::default(),
2901        );
2902
2903        let mut resolved_refs = Vec::new();
2904        for (_, raw_refs) in all_raw_refs {
2905            for raw_ref in raw_refs {
2906                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2907            }
2908        }
2909
2910        let ref_count = resolved_refs.len();
2911        let edge_count = resolved_refs
2912            .iter()
2913            .filter(|item| item.edge.is_some())
2914            .count();
2915
2916        {
2917            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2918            for resolved in &resolved_refs {
2919                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2920            }
2921        }
2922        create_cold_build_secondary_indexes(&tx)?;
2923        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2924            &tx,
2925            &self.project_root,
2926            &indexed_caller_files,
2927            chunk_size,
2928        )?;
2929        set_meta_ready(&tx, true)?;
2930        bump_projection_write_revision(&tx)?;
2931        tx.commit()?;
2932        self.record_commit(total_changes_before, &conn);
2933        phase!("sqlite_insert", t);
2934
2935        let elapsed_ms = started.elapsed().as_millis();
2936        crate::slog_info!(
2937            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2938            files_parsed,
2939            node_count,
2940            ref_count,
2941            edge_count + supplemental_edge_count,
2942            elapsed_ms
2943        );
2944        Ok(ColdBuildStats {
2945            files: files_parsed,
2946            nodes: node_count,
2947            refs: ref_count,
2948            edges: edge_count + supplemental_edge_count,
2949            failed_files: failures
2950                .into_iter()
2951                .map(|failure| failure.rel_path)
2952                .collect(),
2953            elapsed_ms,
2954        })
2955    }
2956
2957    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2958        self.refresh_files_with_workspace_crate_prefix_cache(
2959            changed_files,
2960            WorkspaceCratePrefixCache::default(),
2961        )
2962    }
2963
2964    fn refresh_files_with_workspace_crate_prefix_cache(
2965        &self,
2966        changed_files: &[PathBuf],
2967        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2968    ) -> Result<IncrementalStats> {
2969        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2970            changed_files,
2971            workspace_crate_prefixes,
2972        )?;
2973        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2974            eprintln!("refresh_files phases: {}", profile.report());
2975        }
2976        Ok(stats)
2977    }
2978
2979    /// Run an incremental refresh and return phase timings for an offline store copy.
2980    #[doc(hidden)]
2981    pub fn refresh_files_profiled(
2982        &self,
2983        changed_files: &[PathBuf],
2984    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2985        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2986            changed_files,
2987            WorkspaceCratePrefixCache::default(),
2988        )
2989    }
2990
2991    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2992        &self,
2993        changed_files: &[PathBuf],
2994        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2995    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2996        let total_started = Instant::now();
2997        let mut profile = RefreshFilesProfile::default();
2998        self.verify_writer_lease()?;
2999        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3000        let total_changes_before = conn.total_changes();
3001        let tx = conn.transaction()?;
3002        ensure_database_ready(&tx)?;
3003        let mut changed = Vec::new();
3004        let mut surface_changed = BTreeSet::new();
3005        let mut deleted = BTreeSet::new();
3006        let mut own_refresh = BTreeSet::new();
3007        let mut candidate_own_refresh = BTreeSet::new();
3008        let mut unchanged_extracts = 0usize;
3009        let mut selected_ref_ids = BTreeSet::new();
3010        let mut selected_refs_by_caller = BTreeMap::new();
3011        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3012
3013        for input in changed_files {
3014            let abs_path = normalize_file_path(&self.project_root, input)?;
3015            let rel_path = relative_path(&self.project_root, &abs_path);
3016            changed.push(rel_path.clone());
3017            let old_row = load_file_row(&tx, &rel_path)?;
3018            if !abs_path.exists() {
3019                if old_row.is_some() {
3020                    surface_changed.insert(rel_path.clone());
3021                    deleted.insert(rel_path.clone());
3022                    let started = Instant::now();
3023                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3024                    profile.dependency_selection += started.elapsed();
3025                    record_dependent_refs(
3026                        &mut selected_ref_ids,
3027                        &mut selected_refs_by_caller,
3028                        dependent_refs,
3029                    );
3030                    let started = Instant::now();
3031                    delete_file_rows(&tx, &rel_path)?;
3032                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3033                    profile.row_deletes += started.elapsed();
3034                }
3035                continue;
3036            }
3037
3038            if let Some(row) = &old_row {
3039                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3040                    FreshnessVerdict::HotFresh => continue,
3041                    FreshnessVerdict::ContentFresh {
3042                        new_mtime,
3043                        new_size,
3044                    } => {
3045                        update_file_fresh_metadata(
3046                            &tx,
3047                            &self.project_root,
3048                            &rel_path,
3049                            &row.freshness.content_hash,
3050                            new_mtime,
3051                            new_size,
3052                        )?;
3053                        continue;
3054                    }
3055                    FreshnessVerdict::Deleted => {
3056                        surface_changed.insert(rel_path.clone());
3057                        deleted.insert(rel_path.clone());
3058                        let started = Instant::now();
3059                        let dependent_refs =
3060                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3061                        profile.dependency_selection += started.elapsed();
3062                        record_dependent_refs(
3063                            &mut selected_ref_ids,
3064                            &mut selected_refs_by_caller,
3065                            dependent_refs,
3066                        );
3067                        let started = Instant::now();
3068                        delete_file_rows(&tx, &rel_path)?;
3069                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3070                        profile.row_deletes += started.elapsed();
3071                        continue;
3072                    }
3073                    FreshnessVerdict::Stale => {}
3074                }
3075            }
3076
3077            let started = Instant::now();
3078            let extract = build_file_extract(&self.project_root, &abs_path)?;
3079            profile.parse += started.elapsed();
3080            let surface_is_changed = old_row
3081                .as_ref()
3082                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3083                .unwrap_or(true);
3084            if surface_is_changed {
3085                surface_changed.insert(rel_path.clone());
3086                let started = Instant::now();
3087                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3088                profile.dependency_selection += started.elapsed();
3089                record_dependent_refs(
3090                    &mut selected_ref_ids,
3091                    &mut selected_refs_by_caller,
3092                    dependent_refs,
3093                );
3094            }
3095            candidate_own_refresh.insert(rel_path.clone());
3096            changed_extracts.insert(rel_path, extract);
3097        }
3098
3099        let dependency_selected_refs = selected_ref_ids.len();
3100        let mut touched_callers: BTreeSet<String> =
3101            selected_refs_by_caller.keys().cloned().collect();
3102        touched_callers.extend(candidate_own_refresh.iter().cloned());
3103
3104        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3105        for rel_path in &touched_callers {
3106            if deleted.contains(rel_path) {
3107                continue;
3108            }
3109            if let Some(extract) = changed_extracts.get(rel_path) {
3110                caller_extracts.insert(rel_path.clone(), extract.clone());
3111                continue;
3112            }
3113            let abs_path = self.project_root.join(rel_path);
3114            if abs_path.exists() {
3115                let started = Instant::now();
3116                let extract = build_file_extract(&self.project_root, &abs_path)?;
3117                profile.dependent_parse += started.elapsed();
3118                caller_extracts.insert(rel_path.clone(), extract);
3119            }
3120        }
3121
3122        let started = Instant::now();
3123        let index = ProjectIndex::from_db_and_callers(
3124            &tx,
3125            &self.project_root,
3126            &caller_extracts,
3127            workspace_crate_prefixes,
3128        )?;
3129        profile.index_load += started.elapsed();
3130
3131        for rel_path in &candidate_own_refresh {
3132            let Some(extract) = changed_extracts.get(rel_path) else {
3133                continue;
3134            };
3135            if !write_amplification_baseline_enabled()
3136                && stored_extract_matches(&tx, rel_path, extract, &index)?
3137            {
3138                unchanged_extracts += 1;
3139                update_file_fresh_metadata(
3140                    &tx,
3141                    &self.project_root,
3142                    rel_path,
3143                    &extract.freshness.content_hash,
3144                    extract.freshness.mtime,
3145                    extract.freshness.size,
3146                )?;
3147                continue;
3148            }
3149
3150            own_refresh.insert(rel_path.clone());
3151            let started = Instant::now();
3152            delete_file_rows(&tx, rel_path)?;
3153            profile.row_deletes += started.elapsed();
3154            let started = Instant::now();
3155            insert_file_extract(&tx, &self.project_root, extract)?;
3156            profile.row_inserts += started.elapsed();
3157        }
3158
3159        let dependency_callers = touched_callers
3160            .iter()
3161            .filter(|rel_path| {
3162                !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3163            })
3164            .cloned()
3165            .collect::<Vec<_>>();
3166        for rel_path in dependency_callers {
3167            let Some(extract) = caller_extracts.get(&rel_path) else {
3168                continue;
3169            };
3170            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3171                continue;
3172            }
3173
3174            own_refresh.insert(rel_path.clone());
3175            let started = Instant::now();
3176            delete_file_rows(&tx, &rel_path)?;
3177            profile.row_deletes += started.elapsed();
3178            let started = Instant::now();
3179            insert_file_extract(&tx, &self.project_root, extract)?;
3180            profile.row_inserts += started.elapsed();
3181        }
3182        let started = Instant::now();
3183        for rel_path in &touched_callers {
3184            if deleted.contains(rel_path) {
3185                continue;
3186            }
3187            let Some(extract) = caller_extracts.get(rel_path) else {
3188                continue;
3189            };
3190            if own_refresh.contains(rel_path) {
3191                delete_refs_for_caller(&tx, rel_path)?;
3192                for raw_ref in &extract.raw_refs {
3193                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3194                    insert_resolved_ref(&tx, &resolved)?;
3195                }
3196                continue;
3197            }
3198
3199            let selected_for_caller = selected_refs_by_caller
3200                .get(rel_path)
3201                .cloned()
3202                .unwrap_or_default();
3203            delete_ref_ids(&tx, &selected_for_caller)?;
3204            for raw_ref in &extract.raw_refs {
3205                if selected_for_caller.contains(&raw_ref.ref_id) {
3206                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3207                    insert_resolved_ref(&tx, &resolved)?;
3208                }
3209            }
3210        }
3211        profile.ref_resolution += started.elapsed();
3212
3213        let started = Instant::now();
3214        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3215        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3216        profile.method_dispatch += started.elapsed();
3217
3218        bump_projection_write_revision(&tx)?;
3219        let started = Instant::now();
3220        commit_incremental_if_current(tx)?;
3221        self.record_commit(total_changes_before, &conn);
3222        profile.commit += started.elapsed();
3223        profile.total = total_started.elapsed();
3224        Ok((
3225            IncrementalStats {
3226                changed_files: changed,
3227                surface_changed: surface_changed.into_iter().collect(),
3228                deleted_files: deleted.into_iter().collect(),
3229                dependency_selected_refs,
3230                refreshed_own_files: own_refresh.len(),
3231                unchanged_extract_files: unchanged_extracts,
3232            },
3233            profile,
3234        ))
3235    }
3236
3237    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3238        self.cold_build(current_files)
3239    }
3240
3241    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3242        self.verify_writer_lease()?;
3243        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3244        let total_changes_before = conn.total_changes();
3245        let tx = conn.transaction()?;
3246        let mut marked = Vec::new();
3247        for path in files {
3248            let abs_path = normalize_file_path(&self.project_root, path)?;
3249            let rel_path = relative_path(&self.project_root, &abs_path);
3250            let freshness = cache_freshness::collect(&abs_path).ok();
3251            mark_backend_state(
3252                &tx,
3253                &self.project_root,
3254                &rel_path,
3255                freshness.as_ref().map(|freshness| &freshness.content_hash),
3256                "stale",
3257            )?;
3258            marked.push(rel_path);
3259        }
3260        bump_projection_write_revision(&tx)?;
3261        tx.commit()?;
3262        self.record_commit(total_changes_before, &conn);
3263        marked.sort();
3264        marked.dedup();
3265        Ok(marked)
3266    }
3267
3268    pub fn stale_files(&self) -> Result<Vec<String>> {
3269        self.refresh_read_marker()?;
3270        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3271        let mut stmt = conn.prepare(
3272            "SELECT DISTINCT file_path FROM backend_file_state
3273             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3274             ORDER BY file_path",
3275        )?;
3276        let rows = stmt.query_map(
3277            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3278            |row| row.get::<_, String>(0),
3279        )?;
3280        rows.collect::<std::result::Result<Vec<_>, _>>()
3281            .map_err(Into::into)
3282    }
3283
3284    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3285        self.refresh_read_marker()?;
3286        let rel_path = relative_path(
3287            &self.project_root,
3288            &normalize_file_path(&self.project_root, file)?,
3289        );
3290        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3291        conn.query_row(
3292            "SELECT status FROM backend_file_state
3293             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3294             ORDER BY updated_at DESC LIMIT 1",
3295            params![
3296                BACKEND_TREESITTER,
3297                self.project_root.display().to_string(),
3298                rel_path
3299            ],
3300            |row| row.get(0),
3301        )
3302        .optional()
3303        .map_err(Into::into)
3304    }
3305
3306    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3307        self.refresh_read_marker()?;
3308        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3309        self.ensure_ready(&conn)?;
3310        edge_snapshot_with_conn(&conn)
3311    }
3312
3313    pub fn indexed_file_count(&self) -> Result<usize> {
3314        self.refresh_read_marker()?;
3315        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3316        self.ensure_ready(&conn)?;
3317        indexed_file_count(&conn)
3318    }
3319
3320    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3321        self.refresh_read_marker()?;
3322        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3323        let rel_path = relative_path(&self.project_root, &abs_path);
3324        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3325        self.ensure_ready(&conn)?;
3326        resolve_node_for_rel(&conn, &rel_path, symbol)
3327    }
3328
3329    /// Return all positional nodes matching a legacy symbol query in a file.
3330    ///
3331    /// Consumers that need legacy compatibility can collapse these by
3332    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3333    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3334        self.refresh_read_marker()?;
3335        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3336        let rel_path = relative_path(&self.project_root, &abs_path);
3337        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3338        self.ensure_ready(&conn)?;
3339        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3340    }
3341
3342    /// Return all positional nodes matching a symbol query anywhere in the store.
3343    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3344        self.refresh_read_marker()?;
3345        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3346        self.ensure_ready(&conn)?;
3347        nodes_matching_symbol(&conn, symbol)
3348    }
3349
3350    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3351    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3352        self.refresh_read_marker()?;
3353        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3354        let rel_path = relative_path(&self.project_root, &abs_path);
3355        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3356        self.ensure_ready(&conn)?;
3357        direct_callers_for_tuple(&conn, &rel_path, symbol)
3358    }
3359
3360    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
3361    pub fn direct_callers_for_symbols(
3362        &self,
3363        targets: &[(String, String)],
3364    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3365        if targets.is_empty() {
3366            return Ok(HashMap::new());
3367        }
3368        self.refresh_read_marker()?;
3369        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3370        self.ensure_ready(&conn)?;
3371        direct_callers_for_tuples(&conn, targets)
3372    }
3373
3374    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3375    pub fn direct_caller_counts_of(
3376        &self,
3377        targets: &[(String, String)],
3378    ) -> Result<HashMap<(String, String), usize>> {
3379        if targets.is_empty() {
3380            return Ok(HashMap::new());
3381        }
3382        self.refresh_read_marker()?;
3383        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3384        self.ensure_ready(&conn)?;
3385        direct_caller_counts_for_tuples(&conn, targets)
3386    }
3387
3388    pub fn callers_of(
3389        &self,
3390        file_rel: &Path,
3391        symbol: &str,
3392        depth: usize,
3393    ) -> Result<StoreCallersResult> {
3394        let target = self.node_for(file_rel, symbol)?;
3395        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3396        self.ensure_ready(&conn)?;
3397        let effective_depth = depth.max(1);
3398        let mut visited = HashSet::new();
3399        let mut callers = Vec::new();
3400        let mut depth_limited = false;
3401        let mut truncated = 0usize;
3402        collect_callers_recursive(
3403            &conn,
3404            &target.file,
3405            &target.symbol,
3406            effective_depth,
3407            0,
3408            &mut visited,
3409            &mut callers,
3410            &mut depth_limited,
3411            &mut truncated,
3412        )?;
3413        Ok(StoreCallersResult {
3414            target,
3415            callers,
3416            scanned_files: indexed_file_count(&conn)?,
3417            depth_limited,
3418            truncated,
3419        })
3420    }
3421
3422    pub fn impact_of(
3423        &self,
3424        file_rel: &Path,
3425        symbol: &str,
3426        depth: usize,
3427    ) -> Result<StoreImpactResult> {
3428        let callers = self.callers_of(file_rel, symbol, depth)?;
3429        let target_parameters = callers
3430            .target
3431            .signature
3432            .as_deref()
3433            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3434            .unwrap_or_default();
3435        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3436        for site in &callers.callers {
3437            source_lines_by_file
3438                .entry(site.caller.file.clone())
3439                .or_insert_with(|| {
3440                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3441                });
3442        }
3443        let enriched = callers
3444            .callers
3445            .iter()
3446            .map(|site| StoreImpactCaller {
3447                site: site.clone(),
3448                signature: site.caller.signature.clone(),
3449                is_entry_point: site.caller.is_entry_point,
3450                call_expression: source_lines_by_file
3451                    .get(&site.caller.file)
3452                    .and_then(|lines| lines.as_ref())
3453                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3454                    .cloned(),
3455                parameters: site
3456                    .caller
3457                    .signature
3458                    .as_deref()
3459                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3460                    .unwrap_or_default(),
3461            })
3462            .collect();
3463        Ok(StoreImpactResult {
3464            target: callers.target,
3465            parameters: target_parameters,
3466            callers: enriched,
3467            depth_limited: callers.depth_limited,
3468            truncated: callers.truncated,
3469        })
3470    }
3471
3472    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3473        self.refresh_read_marker()?;
3474        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3475        self.ensure_ready(&conn)?;
3476        outgoing_calls_for_node(&conn, node)
3477    }
3478
3479    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3480    pub fn outgoing_calls_for_symbols(
3481        &self,
3482        sources: &[(String, String)],
3483    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3484        if sources.is_empty() {
3485            return Ok(HashMap::new());
3486        }
3487        self.refresh_read_marker()?;
3488        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3489        self.ensure_ready(&conn)?;
3490        outgoing_calls_for_symbol_tuples(&conn, sources)
3491    }
3492
3493    /// Return resolved direct self-call refs suppressed from the general edge table.
3494    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3495        self.refresh_read_marker()?;
3496        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3497        self.ensure_ready(&conn)?;
3498        resolved_self_calls_for_node(&conn, node)
3499    }
3500
3501    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3502        self.refresh_read_marker()?;
3503        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3504        self.ensure_ready(&conn)?;
3505        unresolved_calls_for_node(&conn, node)
3506    }
3507
3508    pub fn call_tree(
3509        &self,
3510        file_rel: &Path,
3511        symbol: &str,
3512        max_depth: usize,
3513    ) -> Result<callgraph::CallTreeNode> {
3514        let node = self.node_for(file_rel, symbol)?;
3515        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3516        self.ensure_ready(&conn)?;
3517        let mut visited = HashSet::new();
3518        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3519    }
3520
3521    pub fn trace_to(
3522        &self,
3523        file_rel: &Path,
3524        symbol: &str,
3525        max_depth: usize,
3526    ) -> Result<callgraph::TraceToResult> {
3527        let target = self.node_for(file_rel, symbol)?;
3528        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3529        self.ensure_ready(&conn)?;
3530        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3531
3532        #[derive(Clone)]
3533        struct PathElem {
3534            node: StoreNode,
3535        }
3536
3537        let initial = vec![PathElem {
3538            node: target.clone(),
3539        }];
3540        let mut complete_paths = Vec::new();
3541        if target.is_entry_point {
3542            complete_paths.push(initial.clone());
3543        }
3544
3545        let mut queue = vec![(initial, 0usize)];
3546        let mut max_depth_reached = false;
3547        let mut truncated_paths = 0usize;
3548
3549        while let Some((path, depth)) = queue.pop() {
3550            if depth >= effective_max {
3551                max_depth_reached = true;
3552                continue;
3553            }
3554            let Some(current) = path.last() else {
3555                continue;
3556            };
3557            let callers =
3558                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3559            if callers.is_empty() {
3560                if path.len() > 1 {
3561                    truncated_paths += 1;
3562                }
3563                continue;
3564            }
3565
3566            let mut has_new_path = false;
3567            for site in callers {
3568                if path.iter().any(|elem| {
3569                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3570                }) {
3571                    continue;
3572                }
3573                has_new_path = true;
3574                let mut new_path = path.clone();
3575                new_path.push(PathElem {
3576                    node: site.caller.clone(),
3577                });
3578                if site.caller.is_entry_point {
3579                    complete_paths.push(new_path.clone());
3580                }
3581                queue.push((new_path, depth + 1));
3582            }
3583            if !has_new_path && path.len() > 1 {
3584                truncated_paths += 1;
3585            }
3586        }
3587
3588        let mut paths: Vec<callgraph::TracePath> = complete_paths
3589            .into_iter()
3590            .map(|mut elems| {
3591                elems.reverse();
3592                let hops = elems
3593                    .iter()
3594                    .enumerate()
3595                    .map(|(index, elem)| callgraph::TraceHop {
3596                        symbol: elem.node.symbol.clone(),
3597                        file: elem.node.file.clone(),
3598                        line: elem.node.line,
3599                        signature: elem.node.signature.clone(),
3600                        is_entry_point: index == 0 && elem.node.is_entry_point,
3601                    })
3602                    .collect();
3603                callgraph::TracePath { hops }
3604            })
3605            .collect();
3606        paths.sort_by(|left, right| {
3607            let left_entry = left
3608                .hops
3609                .first()
3610                .map(|hop| hop.symbol.as_str())
3611                .unwrap_or("");
3612            let right_entry = right
3613                .hops
3614                .first()
3615                .map(|hop| hop.symbol.as_str())
3616                .unwrap_or("");
3617            left_entry
3618                .cmp(right_entry)
3619                .then(left.hops.len().cmp(&right.hops.len()))
3620        });
3621        let entry_points_found = paths
3622            .iter()
3623            .filter_map(|path| path.hops.first())
3624            .filter(|hop| hop.is_entry_point)
3625            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3626            .collect::<HashSet<_>>()
3627            .len();
3628
3629        Ok(callgraph::TraceToResult {
3630            target_symbol: target.symbol,
3631            target_file: target.file,
3632            total_paths: paths.len(),
3633            paths,
3634            entry_points_found,
3635            max_depth_reached,
3636            truncated_paths,
3637        })
3638    }
3639
3640    pub fn trace_to_symbol_candidates(
3641        &self,
3642        to_symbol: &str,
3643    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3644        self.refresh_read_marker()?;
3645        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3646        self.ensure_ready(&conn)?;
3647        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3648        for node in nodes_matching_symbol(&conn, to_symbol)? {
3649            candidates_by_file
3650                .entry(node.file)
3651                .and_modify(|line| *line = (*line).min(node.line))
3652                .or_insert(node.line);
3653        }
3654        let mut candidates: Vec<_> = candidates_by_file
3655            .into_iter()
3656            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3657            .collect();
3658        candidates
3659            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3660        Ok(candidates)
3661    }
3662
3663    pub fn trace_to_symbol(
3664        &self,
3665        file_rel: &Path,
3666        symbol: &str,
3667        to_symbol: &str,
3668        to_file: Option<&Path>,
3669        max_depth: usize,
3670    ) -> Result<callgraph::TraceToSymbolResult> {
3671        let origin = self.node_for(file_rel, symbol)?;
3672        let target_file = to_file
3673            .map(|path| normalize_file_path(&self.project_root, path))
3674            .transpose()?
3675            .map(|path| relative_path(&self.project_root, &path));
3676        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3677        self.ensure_ready(&conn)?;
3678        let effective_max = if max_depth == 0 {
3679            10
3680        } else {
3681            max_depth.min(16)
3682        };
3683
3684        let start_hop = trace_to_symbol_hop(&origin);
3685        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3686            return Ok(callgraph::TraceToSymbolResult {
3687                path: Some(vec![start_hop]),
3688                complete: true,
3689                reason: None,
3690            });
3691        }
3692
3693        let mut queue = VecDeque::new();
3694        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3695        let mut visited = HashSet::new();
3696        visited.insert((origin.file.clone(), origin.symbol.clone()));
3697        let mut max_depth_exhausted = false;
3698
3699        while let Some((current, path, depth)) = queue.pop_front() {
3700            let callees = outgoing_calls_for_node(&conn, &current)?
3701                .into_iter()
3702                .filter_map(|site| site.target)
3703                .collect::<Vec<_>>();
3704
3705            if depth >= effective_max {
3706                if callees
3707                    .iter()
3708                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3709                {
3710                    max_depth_exhausted = true;
3711                }
3712                continue;
3713            }
3714
3715            for callee in callees {
3716                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3717                    continue;
3718                }
3719                let mut next_path = path.clone();
3720                next_path.push(trace_to_symbol_hop(&callee));
3721                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3722                    return Ok(callgraph::TraceToSymbolResult {
3723                        path: Some(next_path),
3724                        complete: true,
3725                        reason: None,
3726                    });
3727                }
3728                queue.push_back((callee, next_path, depth + 1));
3729            }
3730        }
3731
3732        if max_depth_exhausted {
3733            Ok(callgraph::TraceToSymbolResult {
3734                path: None,
3735                complete: false,
3736                reason: Some("max_depth_exhausted".to_string()),
3737            })
3738        } else {
3739            Ok(callgraph::TraceToSymbolResult {
3740                path: None,
3741                complete: true,
3742                reason: Some("no_path_found".to_string()),
3743            })
3744        }
3745    }
3746}
3747
3748impl ReadonlyCallGraphStore {
3749    fn from_inner(inner: CallGraphStore) -> Self {
3750        Self { inner }
3751    }
3752
3753    pub fn project_root(&self) -> &Path {
3754        self.inner.project_root()
3755    }
3756
3757    pub fn project_key(&self) -> &str {
3758        self.inner.project_key()
3759    }
3760
3761    pub fn sqlite_path(&self) -> &Path {
3762        self.inner.sqlite_path()
3763    }
3764
3765    pub(crate) fn projection_generation(&self) -> Option<&str> {
3766        self.inner.projection_generation()
3767    }
3768
3769    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3770        self.inner.projection_write_revision()
3771    }
3772
3773    /// Report the open generation handle. SQLite-owned allocations are measured
3774    /// once by the process-wide SQLite allocator counters.
3775    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3776        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3777    }
3778
3779    /// Whether this reader is temporarily serving a legacy harness partition.
3780    pub fn is_legacy_fallback(&self) -> bool {
3781        self.inner.is_legacy_fallback()
3782    }
3783
3784    pub fn is_current(&self) -> bool {
3785        self.inner.is_current()
3786    }
3787
3788    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3789        self.inner.edge_snapshot()
3790    }
3791
3792    pub fn indexed_file_count(&self) -> Result<usize> {
3793        self.inner.indexed_file_count()
3794    }
3795
3796    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3797        self.inner.node_for(file_rel, symbol)
3798    }
3799
3800    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3801        self.inner.nodes_for(file_rel, symbol)
3802    }
3803
3804    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3805        self.inner.nodes_matching(symbol)
3806    }
3807
3808    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3809        self.inner.direct_callers_of(file_rel, symbol)
3810    }
3811
3812    pub fn direct_callers_for_symbols(
3813        &self,
3814        targets: &[(String, String)],
3815    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3816        self.inner.direct_callers_for_symbols(targets)
3817    }
3818
3819    pub fn direct_caller_counts_of(
3820        &self,
3821        targets: &[(String, String)],
3822    ) -> Result<HashMap<(String, String), usize>> {
3823        self.inner.direct_caller_counts_of(targets)
3824    }
3825
3826    pub fn callers_of(
3827        &self,
3828        file_rel: &Path,
3829        symbol: &str,
3830        depth: usize,
3831    ) -> Result<StoreCallersResult> {
3832        self.inner.callers_of(file_rel, symbol, depth)
3833    }
3834
3835    pub fn impact_of(
3836        &self,
3837        file_rel: &Path,
3838        symbol: &str,
3839        depth: usize,
3840    ) -> Result<StoreImpactResult> {
3841        self.inner.impact_of(file_rel, symbol, depth)
3842    }
3843
3844    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3845        self.inner.outgoing_calls_of(node)
3846    }
3847
3848    pub fn outgoing_calls_for_symbols(
3849        &self,
3850        sources: &[(String, String)],
3851    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3852        self.inner.outgoing_calls_for_symbols(sources)
3853    }
3854
3855    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3856        self.inner.resolved_self_calls_of(node)
3857    }
3858
3859    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3860        self.inner.unresolved_calls_of(node)
3861    }
3862
3863    pub fn call_tree(
3864        &self,
3865        file_rel: &Path,
3866        symbol: &str,
3867        depth: usize,
3868    ) -> Result<callgraph::CallTreeNode> {
3869        self.inner.call_tree(file_rel, symbol, depth)
3870    }
3871
3872    pub fn trace_to(
3873        &self,
3874        file_rel: &Path,
3875        symbol: &str,
3876        max_depth: usize,
3877    ) -> Result<callgraph::TraceToResult> {
3878        self.inner.trace_to(file_rel, symbol, max_depth)
3879    }
3880
3881    pub fn trace_to_symbol_candidates(
3882        &self,
3883        to_symbol: &str,
3884    ) -> Result<Vec<TraceToSymbolCandidate>> {
3885        self.inner.trace_to_symbol_candidates(to_symbol)
3886    }
3887
3888    pub fn trace_to_symbol(
3889        &self,
3890        file_rel: &Path,
3891        symbol: &str,
3892        to_symbol: &str,
3893        to_file: Option<&Path>,
3894        max_depth: usize,
3895    ) -> Result<callgraph::TraceToSymbolResult> {
3896        self.inner
3897            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3898    }
3899}
3900
3901impl CallGraphRead for CallGraphStore {
3902    fn project_root(&self) -> &Path {
3903        CallGraphStore::project_root(self)
3904    }
3905    fn project_key(&self) -> &str {
3906        CallGraphStore::project_key(self)
3907    }
3908    fn sqlite_path(&self) -> &Path {
3909        CallGraphStore::sqlite_path(self)
3910    }
3911    fn is_current(&self) -> bool {
3912        CallGraphStore::is_current(self)
3913    }
3914    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3915        CallGraphStore::edge_snapshot(self)
3916    }
3917    fn indexed_file_count(&self) -> Result<usize> {
3918        CallGraphStore::indexed_file_count(self)
3919    }
3920    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3921        CallGraphStore::node_for(self, file_rel, symbol)
3922    }
3923    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3924        CallGraphStore::nodes_for(self, file_rel, symbol)
3925    }
3926    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3927        CallGraphStore::nodes_matching(self, symbol)
3928    }
3929    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3930        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3931    }
3932    fn direct_callers_for_symbols(
3933        &self,
3934        targets: &[(String, String)],
3935    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3936        CallGraphStore::direct_callers_for_symbols(self, targets)
3937    }
3938    fn direct_caller_counts_of(
3939        &self,
3940        targets: &[(String, String)],
3941    ) -> Result<HashMap<(String, String), usize>> {
3942        CallGraphStore::direct_caller_counts_of(self, targets)
3943    }
3944    fn callers_of(
3945        &self,
3946        file_rel: &Path,
3947        symbol: &str,
3948        depth: usize,
3949    ) -> Result<StoreCallersResult> {
3950        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3951    }
3952    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3953        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3954    }
3955    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3956        CallGraphStore::outgoing_calls_of(self, node)
3957    }
3958    fn outgoing_calls_for_symbols(
3959        &self,
3960        sources: &[(String, String)],
3961    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3962        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3963    }
3964    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3965        CallGraphStore::resolved_self_calls_of(self, node)
3966    }
3967    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3968        CallGraphStore::unresolved_calls_of(self, node)
3969    }
3970    fn call_tree(
3971        &self,
3972        file_rel: &Path,
3973        symbol: &str,
3974        depth: usize,
3975    ) -> Result<callgraph::CallTreeNode> {
3976        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3977    }
3978    fn trace_to(
3979        &self,
3980        file_rel: &Path,
3981        symbol: &str,
3982        max_depth: usize,
3983    ) -> Result<callgraph::TraceToResult> {
3984        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3985    }
3986    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3987        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3988    }
3989    fn trace_to_symbol(
3990        &self,
3991        file_rel: &Path,
3992        symbol: &str,
3993        to_symbol: &str,
3994        to_file: Option<&Path>,
3995        max_depth: usize,
3996    ) -> Result<callgraph::TraceToSymbolResult> {
3997        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3998    }
3999}
4000
4001impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4002    fn project_root(&self) -> &Path {
4003        (**self).project_root()
4004    }
4005    fn project_key(&self) -> &str {
4006        (**self).project_key()
4007    }
4008    fn sqlite_path(&self) -> &Path {
4009        (**self).sqlite_path()
4010    }
4011    fn is_current(&self) -> bool {
4012        (**self).is_current()
4013    }
4014    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4015        (**self).edge_snapshot()
4016    }
4017    fn indexed_file_count(&self) -> Result<usize> {
4018        (**self).indexed_file_count()
4019    }
4020    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4021        (**self).node_for(file_rel, symbol)
4022    }
4023    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4024        (**self).nodes_for(file_rel, symbol)
4025    }
4026    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4027        (**self).nodes_matching(symbol)
4028    }
4029    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4030        (**self).direct_callers_of(file_rel, symbol)
4031    }
4032    fn direct_callers_for_symbols(
4033        &self,
4034        targets: &[(String, String)],
4035    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4036        (**self).direct_callers_for_symbols(targets)
4037    }
4038    fn direct_caller_counts_of(
4039        &self,
4040        targets: &[(String, String)],
4041    ) -> Result<HashMap<(String, String), usize>> {
4042        (**self).direct_caller_counts_of(targets)
4043    }
4044    fn callers_of(
4045        &self,
4046        file_rel: &Path,
4047        symbol: &str,
4048        depth: usize,
4049    ) -> Result<StoreCallersResult> {
4050        (**self).callers_of(file_rel, symbol, depth)
4051    }
4052    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4053        (**self).impact_of(file_rel, symbol, depth)
4054    }
4055    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4056        (**self).outgoing_calls_of(node)
4057    }
4058    fn outgoing_calls_for_symbols(
4059        &self,
4060        sources: &[(String, String)],
4061    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4062        (**self).outgoing_calls_for_symbols(sources)
4063    }
4064    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4065        (**self).resolved_self_calls_of(node)
4066    }
4067    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4068        (**self).unresolved_calls_of(node)
4069    }
4070    fn call_tree(
4071        &self,
4072        file_rel: &Path,
4073        symbol: &str,
4074        depth: usize,
4075    ) -> Result<callgraph::CallTreeNode> {
4076        (**self).call_tree(file_rel, symbol, depth)
4077    }
4078    fn trace_to(
4079        &self,
4080        file_rel: &Path,
4081        symbol: &str,
4082        max_depth: usize,
4083    ) -> Result<callgraph::TraceToResult> {
4084        (**self).trace_to(file_rel, symbol, max_depth)
4085    }
4086    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4087        (**self).trace_to_symbol_candidates(to_symbol)
4088    }
4089    fn trace_to_symbol(
4090        &self,
4091        file_rel: &Path,
4092        symbol: &str,
4093        to_symbol: &str,
4094        to_file: Option<&Path>,
4095        max_depth: usize,
4096    ) -> Result<callgraph::TraceToSymbolResult> {
4097        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4098    }
4099}
4100
4101impl CallGraphRead for ReadonlyCallGraphStore {
4102    fn project_root(&self) -> &Path {
4103        self.project_root()
4104    }
4105    fn project_key(&self) -> &str {
4106        self.project_key()
4107    }
4108    fn sqlite_path(&self) -> &Path {
4109        self.sqlite_path()
4110    }
4111    fn is_current(&self) -> bool {
4112        self.is_current()
4113    }
4114    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4115        self.edge_snapshot()
4116    }
4117    fn indexed_file_count(&self) -> Result<usize> {
4118        self.indexed_file_count()
4119    }
4120    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4121        self.node_for(file_rel, symbol)
4122    }
4123    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4124        self.nodes_for(file_rel, symbol)
4125    }
4126    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4127        self.nodes_matching(symbol)
4128    }
4129    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4130        self.direct_callers_of(file_rel, symbol)
4131    }
4132    fn direct_callers_for_symbols(
4133        &self,
4134        targets: &[(String, String)],
4135    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4136        self.direct_callers_for_symbols(targets)
4137    }
4138    fn direct_caller_counts_of(
4139        &self,
4140        targets: &[(String, String)],
4141    ) -> Result<HashMap<(String, String), usize>> {
4142        self.direct_caller_counts_of(targets)
4143    }
4144    fn callers_of(
4145        &self,
4146        file_rel: &Path,
4147        symbol: &str,
4148        depth: usize,
4149    ) -> Result<StoreCallersResult> {
4150        self.callers_of(file_rel, symbol, depth)
4151    }
4152    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4153        self.impact_of(file_rel, symbol, depth)
4154    }
4155    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4156        self.outgoing_calls_of(node)
4157    }
4158    fn outgoing_calls_for_symbols(
4159        &self,
4160        sources: &[(String, String)],
4161    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4162        self.outgoing_calls_for_symbols(sources)
4163    }
4164    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4165        self.resolved_self_calls_of(node)
4166    }
4167    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4168        self.unresolved_calls_of(node)
4169    }
4170    fn call_tree(
4171        &self,
4172        file_rel: &Path,
4173        symbol: &str,
4174        depth: usize,
4175    ) -> Result<callgraph::CallTreeNode> {
4176        self.call_tree(file_rel, symbol, depth)
4177    }
4178    fn trace_to(
4179        &self,
4180        file_rel: &Path,
4181        symbol: &str,
4182        max_depth: usize,
4183    ) -> Result<callgraph::TraceToResult> {
4184        self.trace_to(file_rel, symbol, max_depth)
4185    }
4186    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4187        self.trace_to_symbol_candidates(to_symbol)
4188    }
4189    fn trace_to_symbol(
4190        &self,
4191        file_rel: &Path,
4192        symbol: &str,
4193        to_symbol: &str,
4194        to_file: Option<&Path>,
4195        max_depth: usize,
4196    ) -> Result<callgraph::TraceToSymbolResult> {
4197        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4198    }
4199}
4200
4201fn indexed_file_count(conn: &Connection) -> Result<usize> {
4202    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4203    Ok(count.max(0) as usize)
4204}
4205
4206fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4207    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4208    match candidates.as_slice() {
4209        [candidate] => Ok(candidate.clone()),
4210        [] => Err(AftError::SymbolNotFound {
4211            name: symbol.to_string(),
4212            file: rel_path.to_string(),
4213        }
4214        .into()),
4215        _ => Err(AftError::AmbiguousSymbol {
4216            name: symbol.to_string(),
4217            candidates: candidates
4218                .iter()
4219                .map(|candidate| candidate.symbol.clone())
4220                .collect(),
4221        }
4222        .into()),
4223    }
4224}
4225
4226fn nodes_for_file_matching_symbol(
4227    conn: &Connection,
4228    rel_path: &str,
4229    symbol: &str,
4230) -> Result<Vec<StoreNode>> {
4231    let qualified_query = symbol.contains("::");
4232    let sql = if qualified_query {
4233        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4234                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4235         FROM nodes n JOIN files f ON f.path = n.file_path
4236         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4237         ORDER BY n.scoped_name, n.start_line, n.start_col"
4238    } else {
4239        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4240                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4241         FROM nodes n JOIN files f ON f.path = n.file_path
4242         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4243         ORDER BY n.scoped_name, n.start_line, n.start_col"
4244    };
4245    let mut stmt = conn.prepare(sql)?;
4246    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4247    rows.collect::<std::result::Result<Vec<_>, _>>()
4248        .map_err(Into::into)
4249}
4250
4251fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4252    let qualified_query = symbol.contains("::");
4253    let sql = if qualified_query {
4254        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4255                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4256         FROM nodes n JOIN files f ON f.path = n.file_path
4257         WHERE n.scoped_name = ?1
4258         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4259    } else {
4260        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4261                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4262         FROM nodes n JOIN files f ON f.path = n.file_path
4263         WHERE n.scoped_name = ?1 OR n.name = ?1
4264         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4265    };
4266    let mut stmt = conn.prepare(sql)?;
4267    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4268    rows.collect::<std::result::Result<Vec<_>, _>>()
4269        .map_err(Into::into)
4270}
4271
4272fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4273    store_node_from_row_at(row, 0)
4274}
4275
4276fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4277    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4278    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4279    let lang_label_value: String = row.get(offset + 10)?;
4280    Ok(StoreNode {
4281        node_id: row.get(offset)?,
4282        file: row.get(offset + 1)?,
4283        symbol: row.get(offset + 2)?,
4284        name: row.get(offset + 3)?,
4285        kind: row.get(offset + 4)?,
4286        line: start_line.saturating_add(1),
4287        end_line: end_line.saturating_add(1),
4288        signature: row.get(offset + 7)?,
4289        exported: row.get::<_, i64>(offset + 8)? != 0,
4290        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4291        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4292    })
4293}
4294
4295fn optional_store_node_from_row_at(
4296    row: &rusqlite::Row<'_>,
4297    offset: usize,
4298) -> rusqlite::Result<Option<StoreNode>> {
4299    if row.get::<_, Option<String>>(offset)?.is_some() {
4300        store_node_from_row_at(row, offset).map(Some)
4301    } else {
4302        Ok(None)
4303    }
4304}
4305
4306#[allow(clippy::too_many_arguments)]
4307fn collect_callers_recursive(
4308    conn: &Connection,
4309    file: &str,
4310    symbol: &str,
4311    max_depth: usize,
4312    current_depth: usize,
4313    visited: &mut HashSet<(String, String)>,
4314    result: &mut Vec<StoreCallSite>,
4315    depth_limited: &mut bool,
4316    truncated: &mut usize,
4317) -> Result<()> {
4318    if current_depth >= max_depth {
4319        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4320        if omitted > 0 {
4321            *depth_limited = true;
4322            *truncated += omitted;
4323        }
4324        return Ok(());
4325    }
4326
4327    if !visited.insert((file.to_string(), symbol.to_string())) {
4328        return Ok(());
4329    }
4330
4331    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4332    for site in sites {
4333        result.push(site.clone());
4334        if current_depth + 1 < max_depth {
4335            collect_callers_recursive(
4336                conn,
4337                &site.caller.file,
4338                &site.caller.symbol,
4339                max_depth,
4340                current_depth + 1,
4341                visited,
4342                result,
4343                depth_limited,
4344                truncated,
4345            )?;
4346        } else {
4347            let omitted =
4348                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4349            if omitted > 0 {
4350                *depth_limited = true;
4351                *truncated += omitted;
4352            }
4353        }
4354    }
4355    Ok(())
4356}
4357
4358// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4359const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4360
4361fn direct_caller_counts_for_tuples(
4362    conn: &Connection,
4363    targets: &[(String, String)],
4364) -> Result<HashMap<(String, String), usize>> {
4365    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4366    let mut counts = unique_targets
4367        .iter()
4368        .cloned()
4369        .map(|target| (target, 0usize))
4370        .collect::<HashMap<_, _>>();
4371
4372    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4373    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4374        let requested_values = (0..chunk.len())
4375            .map(|_| "(?, ?)")
4376            .collect::<Vec<_>>()
4377            .join(", ");
4378        let sql = format!(
4379            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4380             deduped AS (
4381                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4382                 FROM requested requested
4383                 JOIN edges e
4384                   ON e.target_file = requested.target_file
4385                  AND e.target_symbol = requested.target_symbol
4386                  AND e.kind = 'call'
4387                 JOIN refs r ON r.ref_id = e.ref_id
4388                 JOIN nodes src ON src.id = e.source_node
4389                 JOIN files src_file ON src_file.path = src.file_path
4390                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4391             )
4392             SELECT target_file, target_symbol, COUNT(*)
4393             FROM deduped
4394             GROUP BY target_file, target_symbol"
4395        );
4396        let bindings = chunk
4397            .iter()
4398            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4399        let mut stmt = conn.prepare(&sql)?;
4400        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4401            Ok((
4402                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4403                row.get::<_, i64>(2)?,
4404            ))
4405        })?;
4406        for row in rows {
4407            let (target, count) = row?;
4408            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4409        }
4410    }
4411
4412    Ok(counts)
4413}
4414
4415fn direct_caller_count_for_tuple(
4416    conn: &Connection,
4417    target_file: &str,
4418    target_symbol: &str,
4419) -> Result<usize> {
4420    let count: i64 = conn.query_row(
4421        "SELECT COUNT(*)
4422         FROM edges e
4423         JOIN refs r ON r.ref_id = e.ref_id
4424         JOIN nodes src ON src.id = e.source_node
4425         JOIN files src_file ON src_file.path = src.file_path
4426         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4427        params![target_file, target_symbol],
4428        |row| row.get(0),
4429    )?;
4430    Ok(usize::try_from(count).unwrap_or(usize::MAX))
4431}
4432
4433fn direct_callers_for_tuple(
4434    conn: &Connection,
4435    target_file: &str,
4436    target_symbol: &str,
4437) -> Result<Vec<StoreCallSite>> {
4438    let mut stmt = conn.prepare(
4439        "SELECT e.target_file, e.target_symbol, e.line,
4440                r.byte_start, r.byte_end, r.status, e.provenance,
4441                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4442                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4443                src_file.lang,
4444                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4445                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4446                tgt_file.lang
4447         FROM edges e
4448         JOIN refs r ON r.ref_id = e.ref_id
4449         JOIN nodes src ON src.id = e.source_node
4450         JOIN files src_file ON src_file.path = src.file_path
4451         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4452             ON tgt.id = e.target_node
4453         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4454         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4455    )?;
4456    let rows = stmt.query_map(
4457        params![target_file, target_symbol],
4458        direct_call_site_from_row,
4459    )?;
4460    rows.collect::<std::result::Result<Vec<_>, _>>()
4461        .map_err(Into::into)
4462}
4463
4464fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4465    let caller = store_node_from_row_at(row, 7)?;
4466    let target = optional_store_node_from_row_at(row, 18)?;
4467    Ok(StoreCallSite {
4468        caller,
4469        target_file: row.get(0)?,
4470        target_symbol: row.get(1)?,
4471        target,
4472        line: row.get::<_, i64>(2)?.max(0) as u32,
4473        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4474        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4475        resolved: row.get::<_, String>(5)? == "resolved",
4476        provenance: row.get(6)?,
4477    })
4478}
4479
4480fn direct_callers_for_tuples(
4481    conn: &Connection,
4482    targets: &[(String, String)],
4483) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4484    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4485    let mut callers_by_target = unique_targets
4486        .iter()
4487        .cloned()
4488        .map(|target| (target, Vec::new()))
4489        .collect::<HashMap<_, _>>();
4490    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4491
4492    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4493        let requested_values = (0..chunk.len())
4494            .map(|_| "(?, ?)")
4495            .collect::<Vec<_>>()
4496            .join(", ");
4497        let sql = format!(
4498            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4499             SELECT e.target_file, e.target_symbol, e.line,
4500                    r.byte_start, r.byte_end, r.status, e.provenance,
4501                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4502                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4503                    src_file.lang,
4504                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4505                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4506                    tgt_file.lang
4507             FROM requested requested
4508             JOIN edges e
4509               ON e.target_file = requested.target_file
4510              AND e.target_symbol = requested.target_symbol
4511              AND e.kind = 'call'
4512             JOIN refs r ON r.ref_id = e.ref_id
4513             JOIN nodes src ON src.id = e.source_node
4514             JOIN files src_file ON src_file.path = src.file_path
4515             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4516                 ON tgt.id = e.target_node
4517             ORDER BY e.target_file, e.target_symbol, e.source_node,
4518                      r.byte_start, r.line, r.ref_id"
4519        );
4520        let bindings = chunk
4521            .iter()
4522            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4523        let mut stmt = conn.prepare(&sql)?;
4524        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4525            let call = direct_call_site_from_row(row)?;
4526            let target_key = (call.target_file.clone(), call.target_symbol.clone());
4527            Ok((target_key, call))
4528        })?;
4529        for row in rows {
4530            let (target, call) = row?;
4531            callers_by_target
4532                .get_mut(&target)
4533                .expect("batched caller row belongs to a requested target")
4534                .push(call);
4535        }
4536    }
4537
4538    Ok(callers_by_target)
4539}
4540
4541// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4542const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4543// Outgoing-edge batches bind one source node per parameter.
4544const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4545
4546fn outgoing_calls_for_symbol_tuples(
4547    conn: &Connection,
4548    sources: &[(String, String)],
4549) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4550    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4551    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4552    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4553    let source_nodes = unique_sources
4554        .iter()
4555        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4556        .cloned()
4557        .collect::<Vec<_>>();
4558    let source_nodes_by_id = source_nodes
4559        .iter()
4560        .cloned()
4561        .map(|node| (node.node_id.clone(), node))
4562        .collect::<HashMap<_, _>>();
4563    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4564
4565    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4566        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4567        let sql = format!(
4568            "SELECT e.source_node,
4569                    e.target_file, e.target_symbol, e.line,
4570                    r.byte_start, r.byte_end, r.status, e.provenance,
4571                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4572                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4573                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4574                    tgt_file.lang
4575             FROM edges e
4576             JOIN refs r ON r.ref_id = e.ref_id
4577             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4578             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4579             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4580             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4581        );
4582        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4583        let mut stmt = conn.prepare(&sql)?;
4584        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4585            let source_node_id = row.get::<_, String>(0)?;
4586            let caller = source_nodes_by_id
4587                .get(&source_node_id)
4588                .expect("batched outgoing row belongs to a requested source node")
4589                .clone();
4590            let target = optional_store_node_from_row_at(row, 8)?;
4591            Ok((
4592                source_node_id,
4593                StoreCallSite {
4594                    caller,
4595                    target_file: row.get(1)?,
4596                    target_symbol: row.get(2)?,
4597                    target,
4598                    line: row.get::<_, i64>(3)?.max(0) as u32,
4599                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4600                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4601                    resolved: row.get::<_, String>(6)? == "resolved",
4602                    provenance: row.get(7)?,
4603                },
4604            ))
4605        })?;
4606        for row in rows {
4607            let (source_node_id, call) = row?;
4608            calls_by_node.entry(source_node_id).or_default().push(call);
4609        }
4610    }
4611
4612    let mut calls_by_source = HashMap::new();
4613    for source in &unique_sources {
4614        let mut calls = Vec::new();
4615        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4616            for node in nodes {
4617                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4618                    calls.extend(node_calls);
4619                }
4620            }
4621        }
4622        calls_by_source.insert(source.clone(), calls);
4623    }
4624
4625    // Resolve each logical target once for the whole frontier. Keeping this separate
4626    // preserves positional-symbol representatives without a correlated lookup per edge.
4627    let target_tuples = calls_by_source
4628        .values()
4629        .flatten()
4630        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4631        .collect::<Vec<_>>();
4632    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4633    for calls in calls_by_source.values_mut() {
4634        for call in calls {
4635            if let Some(target) = target_nodes
4636                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4637                .and_then(|nodes| nodes.first())
4638            {
4639                call.target = Some(target.clone());
4640            }
4641        }
4642    }
4643
4644    Ok(calls_by_source)
4645}
4646
4647fn nodes_for_symbol_tuples(
4648    conn: &Connection,
4649    symbols: &[(String, String)],
4650) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4651    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4652    let mut nodes_by_symbol = unique_symbols
4653        .iter()
4654        .cloned()
4655        .map(|symbol| (symbol, Vec::new()))
4656        .collect::<HashMap<_, _>>();
4657    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4658
4659    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4660        let requested_values = (0..chunk.len())
4661            .map(|_| "(?, ?)")
4662            .collect::<Vec<_>>()
4663            .join(", ");
4664        let sql = format!(
4665            "WITH requested(file, symbol) AS (VALUES {requested_values})
4666             SELECT requested.file, requested.symbol,
4667                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4668                    node.start_line, node.end_line, node.signature, node.exported,
4669                    node.is_callgraph_entry_point, node_file.lang
4670             FROM requested
4671             JOIN nodes node INDEXED BY idx_nodes_file
4672               ON node.file_path = requested.file
4673              AND node.scoped_name = requested.symbol
4674             JOIN files node_file ON node_file.path = node.file_path
4675             ORDER BY requested.file, requested.symbol,
4676                      node.scoped_name, node.start_line, node.end_line,
4677                      node.start_col, node.range_ordinal"
4678        );
4679        let bindings = chunk
4680            .iter()
4681            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4682        let mut stmt = conn.prepare(&sql)?;
4683        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4684            Ok((
4685                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4686                store_node_from_row_at(row, 2)?,
4687            ))
4688        })?;
4689        for row in rows {
4690            let (symbol, node) = row?;
4691            nodes_by_symbol.entry(symbol).or_default().push(node);
4692        }
4693    }
4694
4695    Ok(nodes_by_symbol)
4696}
4697
4698fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4699    let mut stmt = conn.prepare(
4700        "SELECT e.target_file, e.target_symbol, e.line,
4701                r.byte_start, r.byte_end, r.status, e.provenance,
4702                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4703                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4704                tgt_file.lang
4705         FROM edges e
4706         JOIN refs r ON r.ref_id = e.ref_id
4707         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4708             ON tgt.id = e.target_node
4709         WHERE e.kind = 'call' AND e.source_node = ?1
4710         ORDER BY r.byte_start, r.line, r.ref_id",
4711    )?;
4712    let rows = stmt.query_map(params![node.node_id], |row| {
4713        let target = optional_store_node_from_row_at(row, 7)?;
4714        Ok(StoreCallSite {
4715            caller: node.clone(),
4716            target_file: row.get(0)?,
4717            target_symbol: row.get(1)?,
4718            target,
4719            line: row.get::<_, i64>(2)?.max(0) as u32,
4720            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4721            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4722            resolved: row.get::<_, String>(5)? == "resolved",
4723            provenance: row.get(6)?,
4724        })
4725    })?;
4726    rows.collect::<std::result::Result<Vec<_>, _>>()
4727        .map_err(Into::into)
4728}
4729
4730fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4731    let mut stmt = conn.prepare(
4732        "SELECT r.target_file, r.target_symbol, r.line,
4733                r.byte_start, r.byte_end, r.status, r.provenance,
4734                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4735                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4736                tgt_file.lang
4737         FROM refs r
4738         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4739             ON tgt.id = r.target_node
4740         WHERE r.caller_node = ?1
4741           AND r.kind = 'call'
4742           AND r.status <> 'unresolved'
4743           AND r.target_file = ?2
4744           AND r.target_symbol = ?3
4745           AND r.provenance = ?4
4746           AND NOT EXISTS (
4747               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4748           )
4749         ORDER BY r.byte_start, r.line, r.ref_id",
4750    )?;
4751    let rows = stmt.query_map(
4752        params![
4753            &node.node_id,
4754            &node.file,
4755            &node.symbol,
4756            PROVENANCE_TREESITTER
4757        ],
4758        |row| {
4759            let target = optional_store_node_from_row_at(row, 7)?;
4760            Ok(StoreCallSite {
4761                caller: node.clone(),
4762                target_file: row.get(0)?,
4763                target_symbol: row.get(1)?,
4764                target,
4765                line: row.get::<_, i64>(2)?.max(0) as u32,
4766                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4767                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4768                resolved: row.get::<_, String>(5)? == "resolved",
4769                provenance: row.get(6)?,
4770            })
4771        },
4772    )?;
4773    rows.collect::<std::result::Result<Vec<_>, _>>()
4774        .map_err(Into::into)
4775}
4776
4777fn unresolved_calls_for_node(
4778    conn: &Connection,
4779    node: &StoreNode,
4780) -> Result<Vec<StoreUnresolvedCall>> {
4781    let mut stmt = conn.prepare(
4782        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4783         FROM refs
4784         WHERE caller_node = ?1
4785           AND kind = 'call'
4786           AND status = 'unresolved'
4787           AND NOT EXISTS (
4788               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4789           )
4790         ORDER BY byte_start, line, ref_id",
4791    )?;
4792    let rows = stmt.query_map(params![node.node_id], |row| {
4793        Ok(StoreUnresolvedCall {
4794            caller: node.clone(),
4795            symbol: row.get(0)?,
4796            full_ref: row.get(1)?,
4797            line: row.get::<_, i64>(2)?.max(0) as u32,
4798            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4799            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4800        })
4801    })?;
4802    rows.collect::<std::result::Result<Vec<_>, _>>()
4803        .map_err(Into::into)
4804}
4805
4806fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4807    let mut calls = Vec::new();
4808    calls.extend(
4809        outgoing_calls_for_node(conn, node)?
4810            .into_iter()
4811            .map(StoreForwardCall::Resolved),
4812    );
4813    calls.extend(
4814        unresolved_calls_for_node(conn, node)?
4815            .into_iter()
4816            .map(StoreForwardCall::Unresolved),
4817    );
4818    calls.sort_by(|left, right| {
4819        left.byte_start()
4820            .cmp(&right.byte_start())
4821            .then(left.line().cmp(&right.line()))
4822    });
4823    Ok(calls)
4824}
4825
4826fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4827    let resolved_count: i64 = conn.query_row(
4828        "SELECT COUNT(*)
4829         FROM edges e
4830         JOIN refs r ON r.ref_id = e.ref_id
4831         WHERE e.kind = 'call' AND e.source_node = ?1",
4832        params![&node.node_id],
4833        |row| row.get(0),
4834    )?;
4835    let unresolved_count: i64 = conn.query_row(
4836        "SELECT COUNT(*)
4837         FROM refs
4838         WHERE caller_node = ?1
4839           AND kind = 'call'
4840           AND status = 'unresolved'
4841           AND NOT EXISTS (
4842               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4843           )",
4844        params![&node.node_id],
4845        |row| row.get(0),
4846    )?;
4847    let total = resolved_count.saturating_add(unresolved_count);
4848    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4849}
4850
4851fn call_tree_inner(
4852    conn: &Connection,
4853    node: &StoreNode,
4854    max_depth: usize,
4855    current_depth: usize,
4856    visited: &mut HashSet<(String, String)>,
4857) -> Result<callgraph::CallTreeNode> {
4858    let visit_key = (node.file.clone(), node.symbol.clone());
4859    if visited.contains(&visit_key) {
4860        return Ok(callgraph::CallTreeNode {
4861            name: node.symbol.clone(),
4862            file: node.file.clone(),
4863            line: node.line,
4864            signature: node.signature.clone(),
4865            resolved: true,
4866            children: Vec::new(),
4867            depth_limited: false,
4868            truncated: 0,
4869        });
4870    }
4871    visited.insert(visit_key.clone());
4872
4873    let mut children = Vec::new();
4874    let mut depth_limited = false;
4875    let mut truncated = 0usize;
4876
4877    if current_depth < max_depth {
4878        let calls = forward_calls_for_node(conn, node)?;
4879        for call in calls {
4880            match call {
4881                StoreForwardCall::Resolved(site) => {
4882                    if let Some(target) = site.target {
4883                        let child =
4884                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4885                        depth_limited |= child.depth_limited;
4886                        truncated += child.truncated;
4887                        children.push(child);
4888                    } else {
4889                        children.push(callgraph::CallTreeNode {
4890                            name: site.target_symbol,
4891                            file: site.target_file,
4892                            line: site.line,
4893                            signature: None,
4894                            resolved: false,
4895                            children: Vec::new(),
4896                            depth_limited: false,
4897                            truncated: 0,
4898                        });
4899                    }
4900                }
4901                StoreForwardCall::Unresolved(call) => {
4902                    children.push(callgraph::CallTreeNode {
4903                        name: call.symbol,
4904                        file: call.caller.file,
4905                        line: call.line,
4906                        signature: None,
4907                        resolved: false,
4908                        children: Vec::new(),
4909                        depth_limited: false,
4910                        truncated: 0,
4911                    });
4912                }
4913            }
4914        }
4915    } else {
4916        truncated = forward_call_count_for_node(conn, node)?;
4917        depth_limited = truncated > 0;
4918    }
4919
4920    visited.remove(&visit_key);
4921    Ok(callgraph::CallTreeNode {
4922        name: node.symbol.clone(),
4923        file: node.file.clone(),
4924        line: node.line,
4925        signature: node.signature.clone(),
4926        resolved: true,
4927        children,
4928        depth_limited,
4929        truncated,
4930    })
4931}
4932
4933fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4934    callgraph::TraceToSymbolHop {
4935        symbol: node.symbol.clone(),
4936        file: node.file.clone(),
4937        line: node.line,
4938    }
4939}
4940
4941fn trace_to_symbol_matches_target(
4942    node: &StoreNode,
4943    to_symbol: &str,
4944    to_file: Option<&str>,
4945) -> bool {
4946    if !symbol_query_matches(&node.symbol, to_symbol) {
4947        return false;
4948    }
4949    match to_file {
4950        Some(file) => node.file == file,
4951        None => true,
4952    }
4953}
4954
4955fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4956    symbol == query || unqualified_name(symbol) == query
4957}
4958
4959fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4960    let source = std::fs::read_to_string(path).ok()?;
4961    Some(source.lines().map(|line| line.trim().to_string()).collect())
4962}
4963
4964#[doc(hidden)]
4965pub fn live_callgraph_edge_snapshot(
4966    project_root: &Path,
4967    files: &[PathBuf],
4968) -> Result<BTreeSet<StoredEdge>> {
4969    let files = normalize_file_list(project_root, files)?;
4970    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4971    let mut file_data = Vec::new();
4972    for file in &files {
4973        let canon = canonicalize_path(file);
4974        let data = graph.build_file(&canon)?.clone();
4975        file_data.push((canon, data));
4976    }
4977
4978    let mut edges = BTreeSet::new();
4979    for (caller_file, data) in &file_data {
4980        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4981            for call_site in call_sites {
4982                let resolution = graph.resolve_cross_file_edge(
4983                    &call_site.full_callee,
4984                    &call_site.callee_name,
4985                    caller_file,
4986                    &data.import_block,
4987                );
4988                let (target_file, target_symbol) = match resolution {
4989                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4990                    EdgeResolution::Unresolved { callee_name } => {
4991                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4992                            continue;
4993                        }
4994                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4995                            data,
4996                            caller_file,
4997                            &callee_name,
4998                        ) else {
4999                            continue;
5000                        };
5001                        (caller_file.clone(), target_symbol)
5002                    }
5003                };
5004                if target_file == *caller_file && target_symbol == *caller_symbol {
5005                    continue;
5006                }
5007                edges.insert(StoredEdge {
5008                    source_file: relative_path(project_root, caller_file),
5009                    source_symbol: caller_symbol.clone(),
5010                    target_file: relative_path(project_root, &target_file),
5011                    target_symbol,
5012                    kind: "call".to_string(),
5013                    line: call_site.line,
5014                });
5015            }
5016        }
5017    }
5018    Ok(edges)
5019}
5020
5021fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5022{
5023    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5024}
5025
5026fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5027    RebuildCooldownKey {
5028        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5029            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5030        project_key: project_key.to_string(),
5031    }
5032}
5033
5034fn rebuild_cooldown_denial(
5035    callgraph_dir: &Path,
5036    project_key: &str,
5037    project_root: &Path,
5038    now: Instant,
5039) -> Option<(PathBuf, Duration)> {
5040    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5041    let records = rebuild_cooldown_records()
5042        .lock()
5043        .unwrap_or_else(std::sync::PoisonError::into_inner);
5044    let record = records.get(&key)?;
5045    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5046        return None;
5047    }
5048    let elapsed = now.saturating_duration_since(record.published_at);
5049    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5050}
5051
5052fn record_successful_rebuild(
5053    callgraph_dir: &Path,
5054    project_key: &str,
5055    project_root: &Path,
5056    published_at: Instant,
5057) {
5058    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5059    let mut records = rebuild_cooldown_records()
5060        .lock()
5061        .unwrap_or_else(std::sync::PoisonError::into_inner);
5062    if records.len() >= 4_096 && !records.contains_key(&key) {
5063        if let Some(evict) = records.keys().next().cloned() {
5064            records.remove(&evict);
5065        }
5066    }
5067    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5068        previous.cross_root_cooldown_armed || previous.project_root != project_root
5069    });
5070    records.insert(
5071        key,
5072        RebuildCooldownRecord {
5073            project_root: project_root.to_path_buf(),
5074            published_at,
5075            cross_root_cooldown_armed,
5076        },
5077    );
5078}
5079
5080fn acquire_writer_lease(
5081    callgraph_dir: &Path,
5082    project_key: &str,
5083    project_root: &Path,
5084) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5085    crate::root_cache::WriterLease::acquire_shared(
5086        crate::root_cache::RootCacheDomain::Callgraph,
5087        callgraph_dir,
5088        project_key,
5089        project_root,
5090    )
5091    .map_err(CallGraphStoreError::from)
5092}
5093
5094fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5095    if lease.verify()? {
5096        Ok(())
5097    } else {
5098        Err(CallGraphStoreError::Unavailable(format!(
5099            "callgraph writer lease for key {} lost epoch {}; aborting write",
5100            lease.key(),
5101            lease.epoch()
5102        )))
5103    }
5104}
5105
5106fn legacy_migration_completion_line(
5107    project_key: &str,
5108    method: &str,
5109    legacy_bytes: u64,
5110    migrated_bytes: u64,
5111) -> String {
5112    format!(
5113        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5114    )
5115}
5116
5117fn log_legacy_migration_completion(
5118    project_key: &str,
5119    method: &str,
5120    legacy_bytes: u64,
5121    migrated_bytes: u64,
5122) {
5123    crate::slog_info!(
5124        "{}",
5125        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5126    );
5127}
5128
5129fn try_legacy_migration_or_fallback(
5130    callgraph_dir: &Path,
5131    project_root: &Path,
5132    project_key: &str,
5133    writer_lease: Arc<crate::root_cache::WriterLease>,
5134) -> Result<Option<CallGraphStore>> {
5135    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5136    if partitions.is_empty() {
5137        return Ok(None);
5138    }
5139
5140    for partition in &partitions {
5141        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5142            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5143                return open_legacy_fallback_store(
5144                    callgraph_dir,
5145                    project_root,
5146                    project_key,
5147                    &partitions,
5148                );
5149            }
5150            match publish_generation_copy_migration(
5151                callgraph_dir,
5152                project_key,
5153                &source,
5154                Arc::clone(&writer_lease),
5155            ) {
5156                Ok(published) => {
5157                    log_legacy_migration_completion(
5158                        project_key,
5159                        "generation_copy",
5160                        source.source_bytes,
5161                        published.migrated_bytes,
5162                    );
5163                    return CallGraphStore::open_generation(
5164                        callgraph_dir,
5165                        project_root.to_path_buf(),
5166                        project_key.to_string(),
5167                        published.generation,
5168                        writer_lease,
5169                    )
5170                    .map(Some);
5171                }
5172                Err(error) => {
5173                    crate::slog_warn!(
5174                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5175                        source.sqlite_path.display(),
5176                        error
5177                    );
5178                    return open_legacy_fallback_store(
5179                        callgraph_dir,
5180                        project_root,
5181                        project_key,
5182                        &partitions,
5183                    );
5184                }
5185            }
5186        }
5187
5188        if let Some(source) = current_legacy_generation(partition)? {
5189            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5190                return open_legacy_fallback_store(
5191                    callgraph_dir,
5192                    project_root,
5193                    project_key,
5194                    &partitions,
5195                );
5196            }
5197            match publish_backup_migration(
5198                callgraph_dir,
5199                project_key,
5200                &source,
5201                Arc::clone(&writer_lease),
5202            ) {
5203                Ok(published) => {
5204                    log_legacy_migration_completion(
5205                        project_key,
5206                        "sqlite_backup",
5207                        source.source_bytes,
5208                        published.migrated_bytes,
5209                    );
5210                    return CallGraphStore::open_generation(
5211                        callgraph_dir,
5212                        project_root.to_path_buf(),
5213                        project_key.to_string(),
5214                        published.generation,
5215                        writer_lease,
5216                    )
5217                    .map(Some);
5218                }
5219                Err(error) => {
5220                    crate::slog_warn!(
5221                        "root-keyed callgraph backup migration failed from {}: {}",
5222                        source.sqlite_path.display(),
5223                        error
5224                    );
5225                    return open_legacy_fallback_store(
5226                        callgraph_dir,
5227                        project_root,
5228                        project_key,
5229                        &partitions,
5230                    );
5231                }
5232            }
5233        }
5234    }
5235
5236    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5237}
5238
5239fn open_legacy_fallback_store(
5240    callgraph_dir: &Path,
5241    project_root: &Path,
5242    project_key: &str,
5243    partitions: &[LegacyCallgraphPartition],
5244) -> Result<Option<CallGraphStore>> {
5245    let Some(target) = first_ready_legacy_target(partitions)? else {
5246        return Ok(None);
5247    };
5248    crate::slog_warn!(
5249        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5250        target.partition.harness,
5251        target.sqlite_path.display()
5252    );
5253    let conn = open_readonly_connection(&target.sqlite_path)?;
5254    if !database_ready(&conn).unwrap_or(false) {
5255        return Ok(None);
5256    }
5257    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5258    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5259    Ok(Some(CallGraphStore::from_connection(
5260        project_root.to_path_buf(),
5261        project_key.to_string(),
5262        target.sqlite_path,
5263        callgraph_dir.to_path_buf(),
5264        true,
5265        target.generation,
5266        None,
5267        Some(read_marker),
5268        conn,
5269    )))
5270}
5271
5272fn migration_disk_floor_allows(
5273    source: &LegacyCallgraphTarget,
5274    callgraph_dir: &Path,
5275) -> Result<bool> {
5276    let available = migration_available_disk(callgraph_dir)?;
5277    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5278        source.source_bytes,
5279        available,
5280    );
5281    if decision.should_skip_copy() {
5282        crate::slog_warn!(
5283            "{}",
5284            decision.warning_message(&source.sqlite_path, callgraph_dir)
5285        );
5286        return Ok(false);
5287    }
5288    Ok(true)
5289}
5290
5291fn migration_available_disk(path: &Path) -> Result<u64> {
5292    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5293        return Ok(bytes);
5294    }
5295    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5296}
5297
5298fn legacy_callgraph_partitions(
5299    callgraph_dir: &Path,
5300    project_key: &str,
5301) -> Result<Vec<LegacyCallgraphPartition>> {
5302    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5303        return Ok(Vec::new());
5304    };
5305    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5306    let mut partitions = inventory
5307        .into_iter()
5308        .filter(|entry| {
5309            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5310                && entry.key == project_key
5311        })
5312        .map(|entry| {
5313            let dir = if entry.path.is_dir() {
5314                entry.path.clone()
5315            } else {
5316                entry
5317                    .path
5318                    .parent()
5319                    .map(Path::to_path_buf)
5320                    .unwrap_or_else(|| entry.path.clone())
5321            };
5322            LegacyCallgraphPartition {
5323                harness: entry.harness,
5324                dir,
5325                key: entry.key,
5326                bytes: entry.bytes,
5327                freshness: entry.callgraph_pointer_mtime,
5328            }
5329        })
5330        .collect::<Vec<_>>();
5331    partitions.sort_by(|left, right| {
5332        right
5333            .freshness
5334            .cmp(&left.freshness)
5335            .then_with(|| right.bytes.cmp(&left.bytes))
5336            .then_with(|| left.harness.cmp(&right.harness))
5337    });
5338    Ok(partitions)
5339}
5340
5341fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5342    let domain_dir = callgraph_dir.parent()?;
5343    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5344        return None;
5345    }
5346    domain_dir.parent().map(Path::to_path_buf)
5347}
5348
5349pub(crate) fn all_legacy_partitions_migrated_for_keys(
5350    callgraph_dir: &Path,
5351    configured_keys: &BTreeSet<String>,
5352) -> Result<bool> {
5353    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5354        return Ok(false);
5355    };
5356    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5357        .into_iter()
5358        .filter(|entry| {
5359            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5360                && configured_keys.contains(&entry.key)
5361        })
5362        .map(|entry| entry.key)
5363        .collect::<BTreeSet<_>>();
5364    if legacy_keys.is_empty() {
5365        return Ok(false);
5366    }
5367
5368    for key in legacy_keys {
5369        let migrated_dir = storage_root.join("callgraph").join(&key);
5370        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5371            return Ok(false);
5372        };
5373        if !migration_generation_requires_manifest(&generation)
5374            || !migration_manifest_valid(&migrated_dir, &generation)
5375        {
5376            return Ok(false);
5377        }
5378    }
5379    Ok(true)
5380}
5381
5382fn newest_superseded_legacy_generation(
5383    partition: &LegacyCallgraphPartition,
5384) -> Result<Option<LegacyCallgraphTarget>> {
5385    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5386        return Ok(None);
5387    };
5388    let prefix = format!("{}.g", partition.key);
5389    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5390        return Ok(None);
5391    };
5392    let mut candidates = Vec::new();
5393    for entry in entries.flatten() {
5394        let name = entry.file_name().to_string_lossy().to_string();
5395        if name == current
5396            || name.contains(".tmp.")
5397            || !name.starts_with(&prefix)
5398            || !name.ends_with(".sqlite")
5399        {
5400            continue;
5401        }
5402        let path = entry.path();
5403        if !db_path_ready(&path) {
5404            continue;
5405        }
5406        let modified = entry
5407            .metadata()
5408            .and_then(|metadata| metadata.modified())
5409            .unwrap_or(SystemTime::UNIX_EPOCH);
5410        candidates.push((modified, path, name));
5411    }
5412    candidates.sort_by(|left, right| right.0.cmp(&left.0));
5413    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5414        return Ok(None);
5415    };
5416    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5417    Ok(Some(LegacyCallgraphTarget {
5418        partition: partition.clone(),
5419        sqlite_path,
5420        generation: Some(generation),
5421        source_bytes,
5422        source_blake3: String::new(),
5423    }))
5424}
5425
5426fn current_legacy_generation(
5427    partition: &LegacyCallgraphPartition,
5428) -> Result<Option<LegacyCallgraphTarget>> {
5429    let Some(target) = ready_legacy_target(partition)? else {
5430        return Ok(None);
5431    };
5432    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5433    if has_superseded {
5434        return Ok(None);
5435    }
5436    Ok(Some(target))
5437}
5438
5439fn freshest_legacy_fallback_target(
5440    callgraph_dir: &Path,
5441    project_key: &str,
5442) -> Result<Option<LegacyCallgraphTarget>> {
5443    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5444    first_ready_legacy_target(&partitions)
5445}
5446
5447fn first_ready_legacy_target(
5448    partitions: &[LegacyCallgraphPartition],
5449) -> Result<Option<LegacyCallgraphTarget>> {
5450    for partition in partitions {
5451        if let Some(target) = ready_legacy_target(partition)? {
5452            return Ok(Some(target));
5453        }
5454    }
5455    Ok(None)
5456}
5457
5458fn ready_legacy_target(
5459    partition: &LegacyCallgraphPartition,
5460) -> Result<Option<LegacyCallgraphTarget>> {
5461    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5462        let sqlite_path = partition.dir.join(&generation);
5463        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5464            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5465            return Ok(Some(LegacyCallgraphTarget {
5466                partition: partition.clone(),
5467                sqlite_path,
5468                generation: Some(generation),
5469                source_bytes,
5470                source_blake3: String::new(),
5471            }));
5472        }
5473    }
5474
5475    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5476    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5477        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5478        return Ok(Some(LegacyCallgraphTarget {
5479            partition: partition.clone(),
5480            sqlite_path,
5481            generation: None,
5482            source_bytes,
5483            source_blake3: String::new(),
5484        }));
5485    }
5486    Ok(None)
5487}
5488
5489fn publish_generation_copy_migration(
5490    callgraph_dir: &Path,
5491    project_key: &str,
5492    source: &LegacyCallgraphTarget,
5493    writer_lease: Arc<crate::root_cache::WriterLease>,
5494) -> Result<PublishedLegacyMigration> {
5495    let generation = migration_generation_file_name(project_key, "copy");
5496    let temp_path = migration_temp_path(callgraph_dir, &generation);
5497    remove_sqlite_file_set(&temp_path);
5498    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5499    fail_after_temp_copy_for_test()?;
5500
5501    let mut source = source.clone();
5502    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5503    source.source_blake3 = fingerprint.blake3;
5504    let generation = publish_migrated_generation(
5505        callgraph_dir,
5506        project_key,
5507        &generation,
5508        &temp_path,
5509        &source,
5510        fingerprint.bytes,
5511        writer_lease,
5512        "generation_copy",
5513    )?;
5514    Ok(PublishedLegacyMigration {
5515        generation,
5516        migrated_bytes: fingerprint.bytes,
5517    })
5518}
5519
5520fn publish_backup_migration(
5521    callgraph_dir: &Path,
5522    project_key: &str,
5523    source: &LegacyCallgraphTarget,
5524    writer_lease: Arc<crate::root_cache::WriterLease>,
5525) -> Result<PublishedLegacyMigration> {
5526    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5527        return Err(CallGraphStoreError::Unavailable(
5528            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5529        ));
5530    }
5531
5532    let generation = migration_generation_file_name(project_key, "backup");
5533    let temp_path = migration_temp_path(callgraph_dir, &generation);
5534    remove_sqlite_file_set(&temp_path);
5535
5536    let source_conn = open_readonly_connection(&source.sqlite_path)?;
5537    let mut destination = Connection::open(&temp_path)?;
5538    destination.busy_timeout(Duration::from_secs(5))?;
5539    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5540    let started = Instant::now();
5541    let mut retries = 0;
5542    loop {
5543        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5544            rusqlite::backup::StepResult::Done => break,
5545            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5546            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5547                retries += 1;
5548                if retries > MIGRATION_BACKUP_RETRY_BUDGET
5549                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5550                {
5551                    return Err(CallGraphStoreError::Unavailable(format!(
5552                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5553                    )));
5554                }
5555                std::thread::sleep(Duration::from_millis(20));
5556            }
5557            _ => {
5558                return Err(CallGraphStoreError::Unavailable(
5559                    "legacy callgraph backup returned an unknown step result".to_string(),
5560                ));
5561            }
5562        }
5563    }
5564    drop(backup);
5565
5566    let integrity: String =
5567        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5568    if integrity != "ok" {
5569        return Err(CallGraphStoreError::Unavailable(format!(
5570            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5571        )));
5572    }
5573    if !database_ready(&destination)? {
5574        return Err(CallGraphStoreError::Unavailable(
5575            "legacy callgraph backup produced a database without ready metadata".to_string(),
5576        ));
5577    }
5578    destination.execute_batch("PRAGMA optimize;")?;
5579    drop(destination);
5580    sync_file(&temp_path)?;
5581    fail_after_temp_copy_for_test()?;
5582
5583    let mut source = source.clone();
5584    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5585    source.source_blake3 = fingerprint.blake3;
5586    let generation = publish_migrated_generation(
5587        callgraph_dir,
5588        project_key,
5589        &generation,
5590        &temp_path,
5591        &source,
5592        fingerprint.bytes,
5593        writer_lease,
5594        "sqlite_backup",
5595    )?;
5596    Ok(PublishedLegacyMigration {
5597        generation,
5598        migrated_bytes: fingerprint.bytes,
5599    })
5600}
5601
5602fn publish_migrated_generation(
5603    callgraph_dir: &Path,
5604    project_key: &str,
5605    generation: &str,
5606    temp_path: &Path,
5607    source: &LegacyCallgraphTarget,
5608    migrated_bytes: u64,
5609    writer_lease: Arc<crate::root_cache::WriterLease>,
5610    method: &str,
5611) -> Result<String> {
5612    let gen_path = callgraph_dir.join(generation);
5613    checkpoint_sqlite_before_publication(temp_path);
5614    let publication = publish_if_current(|| {
5615        verify_writer_lease(&writer_lease)?;
5616        remove_sqlite_file_set(&gen_path);
5617        rename_sqlite_file_set(temp_path, &gen_path)?;
5618        crate::fs_lock::sync_parent(&gen_path);
5619
5620        verify_writer_lease(&writer_lease)?;
5621        publish_pointer(callgraph_dir, project_key, generation)?;
5622        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5623        Ok(generation.to_string())
5624    });
5625    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5626        remove_sqlite_file_set(temp_path);
5627    }
5628    publication
5629}
5630
5631fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5632    if let Some(parent) = destination.parent() {
5633        std::fs::create_dir_all(parent)?;
5634    }
5635    for suffix in SQLITE_FILE_SET_SUFFIXES {
5636        let source_path = sqlite_file_set_path(source, suffix);
5637        if !source_path.is_file() {
5638            continue;
5639        }
5640        let destination_path = sqlite_file_set_path(destination, suffix);
5641        std::fs::copy(&source_path, &destination_path)?;
5642        sync_file(&destination_path)?;
5643    }
5644    Ok(())
5645}
5646
5647fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5648    for suffix in SQLITE_FILE_SET_SUFFIXES {
5649        let source_path = sqlite_file_set_path(source, suffix);
5650        if !source_path.exists() {
5651            continue;
5652        }
5653        let destination_path = sqlite_file_set_path(destination, suffix);
5654        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5655            let _ = std::fs::remove_file(&source_path);
5656            return Err(error.into());
5657        }
5658    }
5659    Ok(())
5660}
5661
5662fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5663    let mut bytes = 0_u64;
5664    for suffix in SQLITE_FILE_SET_SUFFIXES {
5665        let member = sqlite_file_set_path(path, suffix);
5666        if !member.is_file() {
5667            continue;
5668        }
5669        bytes = bytes.saturating_add(member.metadata()?.len());
5670    }
5671    Ok(bytes)
5672}
5673
5674fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5675    let mut hasher = blake3::Hasher::new();
5676    let mut bytes = 0_u64;
5677    let mut buffer = [0_u8; 64 * 1024];
5678    for suffix in SQLITE_FILE_SET_SUFFIXES {
5679        let member = sqlite_file_set_path(path, suffix);
5680        if !member.is_file() {
5681            continue;
5682        }
5683        hasher.update(suffix.as_bytes());
5684        let mut file = std::fs::File::open(&member)?;
5685        loop {
5686            let read = file.read(&mut buffer)?;
5687            if read == 0 {
5688                break;
5689            }
5690            bytes = bytes.saturating_add(read as u64);
5691            hasher.update(&buffer[..read]);
5692        }
5693    }
5694    Ok(SourceFingerprint {
5695        bytes,
5696        blake3: hash_to_hex(hasher.finalize()),
5697    })
5698}
5699
5700fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5701    if suffix.is_empty() {
5702        path.to_path_buf()
5703    } else {
5704        PathBuf::from(format!("{}{suffix}", path.display()))
5705    }
5706}
5707
5708fn sync_file(path: &Path) -> Result<()> {
5709    let file = std::fs::OpenOptions::new()
5710        .read(true)
5711        .write(true)
5712        .open(path)?;
5713    file.sync_all()?;
5714    Ok(())
5715}
5716
5717fn fail_after_temp_copy_for_test() -> Result<()> {
5718    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5719        return Err(CallGraphStoreError::Unavailable(
5720            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5721        ));
5722    }
5723    Ok(())
5724}
5725
5726fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5727    format!(
5728        "{project_key}.g{}.{}{}{}.sqlite",
5729        now_nanos(),
5730        std::process::id(),
5731        MIGRATION_GENERATION_TAG,
5732        method
5733    )
5734}
5735
5736fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5737    callgraph_dir.join(format!(
5738        "{generation}.tmp.{}.{}",
5739        std::process::id(),
5740        now_nanos()
5741    ))
5742}
5743
5744fn write_migration_manifest(
5745    callgraph_dir: &Path,
5746    generation: &str,
5747    source: &LegacyCallgraphTarget,
5748    migrated_bytes: u64,
5749    method: &str,
5750) -> Result<()> {
5751    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5752    let temp_path = manifest_path.with_extension(format!(
5753        "migration.json.tmp.{}.{}",
5754        std::process::id(),
5755        now_nanos()
5756    ));
5757    let manifest = serde_json::json!({
5758        "version": MIGRATION_MANIFEST_VERSION,
5759        "method": method,
5760        "target_generation": generation,
5761        "source_harness": source.partition.harness,
5762        "source_path": source.sqlite_path.display().to_string(),
5763        "source_generation": source.generation,
5764        "source_bytes": source.source_bytes,
5765        "source_blake3": source.source_blake3,
5766        "migrated_bytes": migrated_bytes,
5767    });
5768    {
5769        use std::io::Write as _;
5770        let mut file = std::fs::File::create(&temp_path)?;
5771        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5772        file.write_all(b"\n")?;
5773        file.sync_all()?;
5774    }
5775    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5776        let _ = std::fs::remove_file(&temp_path);
5777        return Err(error.into());
5778    }
5779    crate::fs_lock::sync_parent(&manifest_path);
5780    Ok(())
5781}
5782
5783fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5784    callgraph_dir.join(format!("{generation}.migration.json"))
5785}
5786
5787fn migration_generation_requires_manifest(generation: &str) -> bool {
5788    generation.contains(MIGRATION_GENERATION_TAG)
5789}
5790
5791fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5792    if !migration_generation_requires_manifest(generation) {
5793        return true;
5794    }
5795    let path = migration_manifest_path(callgraph_dir, generation);
5796    let Ok(bytes) = std::fs::read(path) else {
5797        return false;
5798    };
5799    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5800        return false;
5801    };
5802    value.get("version").and_then(serde_json::Value::as_u64)
5803        == Some(MIGRATION_MANIFEST_VERSION as u64)
5804        && value
5805            .get("target_generation")
5806            .and_then(serde_json::Value::as_str)
5807            == Some(generation)
5808        && value
5809            .get("source_bytes")
5810            .and_then(serde_json::Value::as_u64)
5811            .is_some_and(|bytes| bytes > 0)
5812        && value
5813            .get("source_blake3")
5814            .and_then(serde_json::Value::as_str)
5815            .is_some_and(|hash| hash.len() == 64)
5816}
5817
5818fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5819    let pointer_generation = read_pointer(callgraph_dir, project_key);
5820    if let Some(generation) = pointer_generation.as_deref() {
5821        if migration_generation_requires_manifest(generation)
5822            && !migration_manifest_valid(callgraph_dir, generation)
5823        {
5824            let path = callgraph_dir.join(generation);
5825            remove_sqlite_file_set(&path);
5826            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5827            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5828        }
5829    }
5830
5831    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5832        return;
5833    };
5834    for entry in entries.flatten() {
5835        let name = entry.file_name().to_string_lossy().to_string();
5836        let path = entry.path();
5837        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5838            let _ = std::fs::remove_file(path);
5839            continue;
5840        }
5841        if name.starts_with(&format!("{project_key}.g"))
5842            && name.ends_with(".sqlite")
5843            && name.contains(MIGRATION_GENERATION_TAG)
5844            && pointer_generation.as_deref() != Some(&name)
5845            && !migration_manifest_valid(callgraph_dir, &name)
5846        {
5847            remove_sqlite_file_set(&path);
5848            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5849        }
5850    }
5851    crate::fs_lock::sync_parent(callgraph_dir);
5852}
5853
5854fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5855    let mut hasher = blake3::Hasher::new();
5856    hasher.update(path.to_string_lossy().as_bytes());
5857    if let Some(generation) = generation {
5858        hasher.update(generation.as_bytes());
5859    }
5860    let digest = hash_to_hex(hasher.finalize());
5861    format!("legacy-{}", &digest[..16])
5862}
5863
5864fn open_readonly_connection(path: &Path) -> Result<Connection> {
5865    let uri = sqlite_readonly_uri(path);
5866    let conn = Connection::open_with_flags(
5867        &uri,
5868        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5869    )?;
5870    conn.pragma_update(
5871        None,
5872        "synchronous",
5873        if write_amplification_baseline_enabled() {
5874            "FULL"
5875        } else {
5876            "NORMAL"
5877        },
5878    )?;
5879    conn.busy_timeout(reader_busy_timeout())?;
5880    conn.execute_batch("PRAGMA query_only=ON;")?;
5881    Ok(conn)
5882}
5883
5884fn reader_busy_timeout() -> Duration {
5885    let jitter = (now_nanos() % 500) as u64;
5886    Duration::from_millis(250 + jitter)
5887}
5888
5889fn sqlite_readonly_uri(path: &Path) -> String {
5890    let raw = path.to_string_lossy().replace('\\', "/");
5891    let encoded = percent_encode_sqlite_uri_path(&raw);
5892    if raw.starts_with('/') {
5893        format!("file://{encoded}?mode=ro")
5894    } else if raw.as_bytes().get(1) == Some(&b':') {
5895        format!("file:///{encoded}?mode=ro")
5896    } else {
5897        format!("file:{encoded}?mode=ro")
5898    }
5899}
5900
5901fn percent_encode_sqlite_uri_path(path: &str) -> String {
5902    let mut encoded = String::with_capacity(path.len());
5903    for byte in path.bytes() {
5904        match byte {
5905            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5906                encoded.push(byte as char)
5907            }
5908            _ => encoded.push_str(&format!("%{byte:02X}")),
5909        }
5910    }
5911    encoded
5912}
5913
5914fn configure_connection(conn: &Connection) -> Result<()> {
5915    conn.pragma_update(None, "journal_mode", "WAL")?;
5916    let baseline = write_amplification_baseline_enabled();
5917    conn.pragma_update(
5918        None,
5919        "synchronous",
5920        if baseline { "FULL" } else { "NORMAL" },
5921    )?;
5922    conn.pragma_update(
5923        None,
5924        "wal_autocheckpoint",
5925        if baseline {
5926            1_000
5927        } else {
5928            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5929        },
5930    )?;
5931    conn.pragma_update(None, "busy_timeout", 5_000)?;
5932    Ok(())
5933}
5934
5935fn configure_build_connection(conn: &Connection) -> Result<()> {
5936    conn.pragma_update(None, "journal_mode", "DELETE")?;
5937    conn.pragma_update(
5938        None,
5939        "synchronous",
5940        if write_amplification_baseline_enabled() {
5941            "FULL"
5942        } else {
5943            "NORMAL"
5944        },
5945    )?;
5946    conn.pragma_update(None, "busy_timeout", 5_000)?;
5947    Ok(())
5948}
5949
5950/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
5951/// private temporary copy before publishing it; a busy reader is harmless because
5952/// the next publication or cleanup pass can retry without affecting the source.
5953fn checkpoint_sqlite_before_publication(path: &Path) {
5954    let Ok(conn) = Connection::open(path) else {
5955        return;
5956    };
5957    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5958    let _ = conn.busy_timeout(Duration::from_secs(5));
5959    let _ = checkpoint_wal_truncate(&conn);
5960}
5961
5962fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5963    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5964        row.get::<_, i64>(0)
5965    }) {
5966        Ok(0) => true,
5967        Ok(_) => false,
5968        Err(rusqlite::Error::SqliteFailure(error, _))
5969            if matches!(
5970                error.code,
5971                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5972            ) =>
5973        {
5974            false
5975        }
5976        Err(error) => {
5977            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5978            false
5979        }
5980    }
5981}
5982
5983fn initialize_schema(conn: &Connection) -> Result<()> {
5984    conn.execute_batch(
5985        "CREATE TABLE IF NOT EXISTS files (
5986            path                TEXT PRIMARY KEY,
5987            content_hash        TEXT NOT NULL,
5988            mtime_ns            INTEGER NOT NULL,
5989            size                INTEGER NOT NULL,
5990            lang                TEXT NOT NULL,
5991            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5992            is_public_api       INTEGER NOT NULL DEFAULT 0,
5993            surface_fingerprint TEXT NOT NULL,
5994            indexed_at          INTEGER NOT NULL
5995        );
5996
5997        CREATE TABLE IF NOT EXISTS nodes (
5998            id                         TEXT PRIMARY KEY,
5999            file_path                  TEXT NOT NULL,
6000            name                       TEXT NOT NULL,
6001            scoped_name                TEXT NOT NULL,
6002            kind                       TEXT NOT NULL,
6003            start_line                 INTEGER NOT NULL,
6004            start_col                  INTEGER NOT NULL,
6005            end_line                   INTEGER NOT NULL,
6006            end_col                    INTEGER NOT NULL,
6007            range_ordinal              INTEGER NOT NULL,
6008            signature                  TEXT,
6009            exported                   INTEGER NOT NULL,
6010            is_default_export          INTEGER NOT NULL,
6011            is_type_like               INTEGER NOT NULL,
6012            is_callgraph_entry_point   INTEGER NOT NULL,
6013            provenance                 TEXT NOT NULL,
6014            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6015        );
6016        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6017        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6018        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6019
6020        CREATE TABLE IF NOT EXISTS refs (
6021            ref_id          TEXT PRIMARY KEY,
6022            caller_node     TEXT,
6023            caller_file     TEXT NOT NULL,
6024            kind            TEXT NOT NULL,
6025            short_name      TEXT,
6026            full_ref        TEXT,
6027            module_path     TEXT,
6028            import_kind     TEXT,
6029            local_name      TEXT,
6030            requested_name  TEXT,
6031            namespace_alias TEXT,
6032            wildcard        INTEGER NOT NULL DEFAULT 0,
6033            line            INTEGER NOT NULL,
6034            byte_start      INTEGER NOT NULL,
6035            byte_end        INTEGER NOT NULL,
6036            status          TEXT NOT NULL,
6037            target_node     TEXT,
6038            target_file     TEXT,
6039            target_symbol   TEXT,
6040            provenance      TEXT NOT NULL
6041        );
6042        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6043        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6044        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6045        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6046        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6047
6048        CREATE TABLE IF NOT EXISTS file_dependencies (
6049            file_path   TEXT NOT NULL,
6050            dep_file    TEXT NOT NULL,
6051            PRIMARY KEY(file_path, dep_file)
6052        );
6053        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6054
6055        CREATE TABLE IF NOT EXISTS edges (
6056            edge_id       TEXT PRIMARY KEY,
6057            ref_id        TEXT NOT NULL,
6058            source_node   TEXT NOT NULL,
6059            target_node   TEXT,
6060            target_file   TEXT NOT NULL,
6061            target_symbol TEXT NOT NULL,
6062            kind          TEXT NOT NULL,
6063            line          INTEGER NOT NULL,
6064            provenance    TEXT NOT NULL
6065        );
6066        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6067        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6068        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6069        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6070
6071        CREATE TABLE IF NOT EXISTS dispatch_hints (
6072            id           TEXT PRIMARY KEY,
6073            method_name  TEXT NOT NULL,
6074            caller_node  TEXT NOT NULL,
6075            file         TEXT NOT NULL,
6076            line         INTEGER NOT NULL,
6077            byte_start   INTEGER NOT NULL,
6078            byte_end     INTEGER NOT NULL,
6079            provenance   TEXT NOT NULL
6080        );
6081        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6082
6083        CREATE TABLE IF NOT EXISTS type_ref_names (
6084            name TEXT PRIMARY KEY
6085        );
6086
6087        CREATE TABLE IF NOT EXISTS backend_file_state (
6088            backend        TEXT NOT NULL,
6089            workspace_root TEXT NOT NULL,
6090            file_path      TEXT NOT NULL,
6091            content_hash   TEXT NOT NULL,
6092            status         TEXT NOT NULL,
6093            updated_at     INTEGER NOT NULL,
6094            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6095        );
6096        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6097
6098        CREATE TABLE IF NOT EXISTS meta (
6099            k TEXT PRIMARY KEY,
6100            v TEXT NOT NULL
6101        );",
6102    )?;
6103    insert_meta(conn)?;
6104    Ok(())
6105}
6106
6107fn insert_meta(conn: &Connection) -> Result<()> {
6108    conn.execute(
6109        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6110        params![SCHEMA_VERSION.to_string()],
6111    )?;
6112    conn.execute(
6113        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6114        params![schema_fingerprint()],
6115    )?;
6116    conn.execute(
6117        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6118        [],
6119    )?;
6120    Ok(())
6121}
6122
6123/// Return the durable revision paired atomically with graph mutations. Stores
6124/// created by older binaries lack the revision row, so callers cannot detect
6125/// in-place graph changes and must not cache their snapshots.
6126fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6127    let revision: Option<String> = conn
6128        .query_row(
6129            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6130            [],
6131            |row| row.get(0),
6132        )
6133        .optional()?;
6134    revision
6135        .map(|revision| {
6136            revision.parse::<u64>().map_err(|error| {
6137                CallGraphStoreError::Unavailable(format!(
6138                    "callgraph projection write revision is invalid: {error}"
6139                ))
6140            })
6141        })
6142        .transpose()
6143}
6144
6145/// Advance the projection revision inside the graph mutation transaction so a
6146/// cached snapshot never survives an in-place refresh.
6147fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6148    tx.execute(
6149        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6150         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6151        [],
6152    )?;
6153    Ok(())
6154}
6155
6156fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6157    conn.execute(
6158        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6159        params![if ready { "1" } else { "0" }],
6160    )?;
6161    Ok(())
6162}
6163
6164fn database_ready(conn: &Connection) -> Result<bool> {
6165    let schema_version: Option<String> = conn
6166        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6167            row.get(0)
6168        })
6169        .optional()?;
6170    let fingerprint: Option<String> = conn
6171        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6172            row.get(0)
6173        })
6174        .optional()?;
6175    let ready: Option<String> = conn
6176        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6177        .optional()?;
6178
6179    let expected_schema = SCHEMA_VERSION.to_string();
6180    let expected_fingerprint = schema_fingerprint();
6181    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6182        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6183        && ready.as_deref() == Some("1"))
6184}
6185
6186fn ensure_database_ready(conn: &Connection) -> Result<()> {
6187    if database_ready(conn)? {
6188        Ok(())
6189    } else {
6190        Err(CallGraphStoreError::Unavailable(
6191            "database is missing, stale, or mid-build".to_string(),
6192        ))
6193    }
6194}
6195
6196fn schema_fingerprint() -> String {
6197    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6198    // edge sources, broader call extraction) even if the table SHAPE is
6199    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6200    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6201    let input =
6202        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6203    hash_to_hex(blake3::hash(input.as_bytes()))
6204}
6205
6206fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6207    tx.execute_batch(
6208        "DELETE FROM edges;
6209         DELETE FROM file_dependencies;
6210         DELETE FROM refs;
6211         DELETE FROM dispatch_hints;
6212         DELETE FROM type_ref_names;
6213         DELETE FROM backend_file_state;
6214         DELETE FROM nodes;
6215         DELETE FROM files;",
6216    )?;
6217    Ok(())
6218}
6219
6220fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6221    tx.execute_batch(
6222        "DROP INDEX IF EXISTS idx_nodes_file;
6223         DROP INDEX IF EXISTS idx_nodes_name;
6224         DROP INDEX IF EXISTS idx_nodes_scoped;
6225         DROP INDEX IF EXISTS idx_refs_short_name;
6226         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6227         DROP INDEX IF EXISTS idx_refs_caller_file;
6228         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6229         DROP INDEX IF EXISTS idx_refs_target_file;
6230         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6231         DROP INDEX IF EXISTS idx_edges_source_kind;
6232         DROP INDEX IF EXISTS idx_edges_target_kind;
6233         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6234         DROP INDEX IF EXISTS idx_edges_ref_id;
6235         DROP INDEX IF EXISTS idx_dispatch_hints_method;
6236         DROP INDEX IF EXISTS idx_backend_file_state_file;",
6237    )?;
6238    Ok(())
6239}
6240
6241fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6242    tx.execute_batch(
6243        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6244         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6245         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6246         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6247         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6248         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6249         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6250         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6251         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6252         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6253         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6254         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6255         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6256         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6257         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6258    )?;
6259    Ok(())
6260}
6261
6262const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6263    ("files", "path"),
6264    ("nodes", "file_path"),
6265    ("refs", "caller_file"),
6266    ("refs", "target_file"),
6267    ("file_dependencies", "file_path"),
6268    ("file_dependencies", "dep_file"),
6269    ("edges", "target_file"),
6270    ("dispatch_hints", "file"),
6271    ("backend_file_state", "file_path"),
6272];
6273
6274/// Reconcile `backend_file_state.workspace_root` when the opener's project root
6275/// differs from what is stored. The store key is the git-root commit hash, so
6276/// multiple live checkouts/clones share one on-disk generation.
6277///
6278/// Cheap in-place re-root is only safe when every previously stored root path is
6279/// gone from disk (true move/rename). If any stale root still exists, another
6280/// clone is still alive and rewriting metadata would ping-pong relative rows
6281/// between trees (possibly on different branches). We then return
6282/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
6283/// opener. That can make each clone rebuild on open when they alternate — bounded
6284/// by open frequency — but each rebuild is correct for its opener, unlike silent
6285/// cross-clone corruption.
6286fn reconcile_workspace_roots(
6287    conn: &mut Connection,
6288    project_root: &Path,
6289    allow_repair: bool,
6290) -> Result<OpenRootRepair> {
6291    let roots = stored_workspace_roots(conn)?;
6292    let current_root = project_root.display().to_string();
6293    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6294        return Ok(OpenRootRepair::None);
6295    }
6296
6297    if let Some(sample) = sample_absolute_data_path(conn)? {
6298        return Ok(OpenRootRepair::NeedsRebuild {
6299            previous_roots: roots,
6300            current_root,
6301            reason: format!("absolute store data path row {sample}"),
6302        });
6303    }
6304
6305    for stored_root in roots.iter() {
6306        if stored_root == &current_root {
6307            continue;
6308        }
6309        if Path::new(stored_root).exists() {
6310            let reason = format!(
6311                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6312            );
6313            return Ok(OpenRootRepair::NeedsRebuild {
6314                previous_roots: roots,
6315                current_root,
6316                reason,
6317            });
6318        }
6319    }
6320
6321    if !allow_repair {
6322        return Ok(OpenRootRepair::NeedsRebuild {
6323            previous_roots: roots,
6324            current_root,
6325            reason: "workspace root metadata requires deferred repair".to_string(),
6326        });
6327    }
6328
6329    publish_if_current(|| {
6330        let tx = conn.transaction()?;
6331        tx.execute(
6332            "UPDATE OR IGNORE backend_file_state
6333             SET workspace_root = ?1
6334             WHERE workspace_root <> ?1",
6335            params![&current_root],
6336        )?;
6337        tx.execute(
6338            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6339            params![&current_root],
6340        )?;
6341        tx.commit()?;
6342        Ok(())
6343    })?;
6344
6345    crate::slog_info!(
6346        "callgraph store re-rooted from {} to {}",
6347        roots.join(", "),
6348        current_root
6349    );
6350    Ok(OpenRootRepair::ReRooted)
6351}
6352
6353fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6354    let mut stmt = conn.prepare(
6355        "SELECT DISTINCT workspace_root
6356         FROM backend_file_state
6357         ORDER BY workspace_root",
6358    )?;
6359    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6360    rows.collect::<std::result::Result<Vec<_>, _>>()
6361        .map_err(Into::into)
6362}
6363
6364fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6365    for (table, column) in STORE_DATA_PATH_COLUMNS {
6366        let sql = format!(
6367            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6368        );
6369        let mut stmt = conn.prepare(&sql)?;
6370        let mut rows = stmt.query([])?;
6371        while let Some(row) = rows.next()? {
6372            let value: String = row.get(0)?;
6373            if stored_path_is_absolute(&value) {
6374                return Ok(Some(format!("{table}.{column}={value}")));
6375            }
6376        }
6377    }
6378    Ok(None)
6379}
6380
6381fn stored_path_is_absolute(value: &str) -> bool {
6382    if value.is_empty() {
6383        return false;
6384    }
6385    if Path::new(value).is_absolute() || value.starts_with('/') {
6386        return true;
6387    }
6388    let bytes = value.as_bytes();
6389    if bytes.len() >= 3
6390        && bytes[1] == b':'
6391        && (bytes[2] == b'/' || bytes[2] == b'\\')
6392        && bytes[0].is_ascii_alphabetic()
6393    {
6394        return true;
6395    }
6396    value.starts_with("\\\\") || value.starts_with("//")
6397}
6398
6399fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6400    if let OpenRootRepair::NeedsRebuild {
6401        previous_roots,
6402        current_root,
6403        reason,
6404    } = repair
6405    {
6406        crate::slog_info!(
6407            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6408            previous_roots.join(", "),
6409            current_root,
6410            reason
6411        );
6412    }
6413}
6414
6415/// Nanosecond clock used to make temp/generation file names unique.
6416fn now_nanos() -> u128 {
6417    SystemTime::now()
6418        .duration_since(UNIX_EPOCH)
6419        .unwrap_or(Duration::ZERO)
6420        .as_nanos()
6421}
6422
6423/// The pointer file `<dir>/<key>.current`. Its single line names the current
6424/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
6425/// can always be atomically replaced via rename even on Windows — Rust opens
6426/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
6427fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6428    callgraph_dir.join(format!("{project_key}.current"))
6429}
6430
6431/// The legacy single-file DB path used before the generation scheme. Still read
6432/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
6433/// build publishes a generation.
6434fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6435    callgraph_dir.join(format!("{project_key}.sqlite"))
6436}
6437
6438/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
6439/// cold build writes a brand-new generation file, so publishing NEVER replaces
6440/// a file another process holds open (the root Windows fix).
6441fn generation_file_name(project_key: &str) -> String {
6442    format!(
6443        "{project_key}.g{}.{}.sqlite",
6444        now_nanos(),
6445        std::process::id()
6446    )
6447}
6448
6449/// Read the pointer; returns the generation file name if present and non-empty.
6450fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6451    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6452    let name = text.trim();
6453    if name.is_empty() {
6454        None
6455    } else {
6456        Some(name.to_string())
6457    }
6458}
6459
6460/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
6461/// `ready` flag). Uses a throwaway read-only connection.
6462fn db_path_ready(path: &Path) -> bool {
6463    (|| -> Result<bool> {
6464        let conn = open_readonly_connection(path)?;
6465        database_ready(&conn)
6466    })()
6467    .unwrap_or(false)
6468}
6469
6470/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
6471/// where `generation` is `Some(name)` for a pointer-published generation or
6472/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
6473/// published (caller treats that as "needs cold build").
6474///
6475/// Handles the GC race (the pointer names a generation that was just deleted) by
6476/// re-reading the pointer and retrying a few times.
6477fn resolve_ready_target(
6478    callgraph_dir: &Path,
6479    project_key: &str,
6480) -> Option<(PathBuf, Option<String>)> {
6481    for _ in 0..5 {
6482        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6483            let gen_path = callgraph_dir.join(&generation);
6484            if gen_path.is_file() {
6485                return (migration_manifest_valid(callgraph_dir, &generation)
6486                    && db_path_ready(&gen_path))
6487                .then_some((gen_path, Some(generation)));
6488            }
6489            // Pointer names a missing generation (a GC/publish race): re-read the
6490            // pointer and retry rather than failing the reader.
6491            std::thread::sleep(Duration::from_millis(5));
6492            continue;
6493        }
6494        // No pointer: fall back to the legacy single-file DB if it is ready.
6495        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6496        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6497    }
6498    None
6499}
6500
6501/// Atomically publish `generation` as the current store by flipping the pointer
6502/// file. Writes a temp file, fsyncs, then renames over the pointer — never
6503/// replacing an open DB file, so it succeeds cross-platform.
6504fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6505    let pointer = pointer_path(callgraph_dir, project_key);
6506    let tmp = callgraph_dir.join(format!(
6507        "{project_key}.current.tmp.{}.{}",
6508        std::process::id(),
6509        now_nanos()
6510    ));
6511    {
6512        use std::io::Write as _;
6513        let mut file = std::fs::File::create(&tmp)?;
6514        file.write_all(generation.as_bytes())?;
6515        file.write_all(b"\n")?;
6516        file.sync_all()?;
6517    }
6518    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6519        let _ = std::fs::remove_file(&tmp);
6520        return Err(error.into());
6521    }
6522    crate::fs_lock::sync_parent(&pointer);
6523    Ok(())
6524}
6525
6526#[derive(Clone, Debug)]
6527struct GenerationGcCandidate {
6528    name: String,
6529    path: PathBuf,
6530    modified: SystemTime,
6531}
6532
6533/// Best-effort GC of superseded generation files. The current pointer target and
6534/// newest previous generation are always retained. Older generations are removed
6535/// when they have no protected read marker, or after the absolute retention TTL
6536/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
6537/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
6538fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6539    let temp_grace = Duration::from_secs(60);
6540    let now = SystemTime::now();
6541    let pointer_current =
6542        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6543    let gen_prefix = format!("{project_key}.g");
6544    let tmp_prefixes = [
6545        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
6546        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
6547        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
6548    ];
6549    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6550        return;
6551    };
6552    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6553    for entry in entries.flatten() {
6554        let name = entry.file_name();
6555        let name = name.to_string_lossy().to_string();
6556        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6557        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6558
6559        // Orphaned temp files from a crashed build/publish: remove once aged out.
6560        if name.contains(".tmp.") {
6561            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6562                let _ = std::fs::remove_file(entry.path());
6563            }
6564            continue;
6565        }
6566
6567        // Superseded legacy single-file DB: best-effort delete once a generation
6568        // is published (ignored if another process still holds it open).
6569        if name == format!("{project_key}.sqlite") {
6570            remove_sqlite_file_set(&entry.path());
6571            continue;
6572        }
6573
6574        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6575            gens.push(GenerationGcCandidate {
6576                name,
6577                path: entry.path(),
6578                modified: mtime,
6579            });
6580        }
6581    }
6582
6583    let mut superseded = gens
6584        .iter()
6585        .filter(|generation| generation.name != pointer_current)
6586        .collect::<Vec<_>>();
6587    superseded.sort_by(|left, right| {
6588        right
6589            .modified
6590            .cmp(&left.modified)
6591            .then_with(|| right.name.cmp(&left.name))
6592    });
6593    let previous = superseded.first().map(|generation| generation.name.clone());
6594
6595    for generation in gens {
6596        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6597        if generation.name == pointer_current
6598            || Some(generation.name.as_str()) == previous.as_deref()
6599        {
6600            continue;
6601        }
6602
6603        let age = now
6604            .duration_since(generation.modified)
6605            .unwrap_or(Duration::ZERO);
6606        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6607            continue;
6608        }
6609
6610        remove_sqlite_file_set(&generation.path);
6611        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6612        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6613            callgraph_dir,
6614            &generation.name,
6615        ));
6616    }
6617}
6618
6619fn remove_sqlite_file_set(path: &Path) {
6620    let _ = std::fs::remove_file(path);
6621    remove_sqlite_sidecars(path);
6622}
6623
6624fn remove_sqlite_sidecars(path: &Path) {
6625    let path_text = path.to_string_lossy();
6626    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6627    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6628    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6629}
6630
6631/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
6632///
6633/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
6634/// place on success; a build that dies (process kill, crash, host restart) leaves
6635/// the temporary behind. The largest observed cold build finishes well under a
6636/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
6637/// never rename. A live build's temporary is minutes old at most.
6638///
6639/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
6640/// liveness check read false-positive on exactly the oldest files — the ones most
6641/// worth deleting: in production an orphan's embedded pid had been recycled to an
6642/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
6643/// cannot lie that way, so it is the honest orphan predicate.
6644const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6645
6646/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
6647/// same cadence as [`gc_old_generations`] (after a generation is published) but,
6648/// unlike it, is not scoped to the building root: it covers every directory in the
6649/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
6650///
6651/// That last case is the production hole this fixes. The per-root cleanup in
6652/// [`gc_old_generations`] only fires when a root actually builds, so when activity
6653/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
6654/// store's orphans become permanent — gigabytes accumulated in a legacy store
6655/// whose roots no longer built there, while the active store stayed clean. A
6656/// sibling root that still builds triggers this pass and cleans both layouts.
6657fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6658    sweep_orphaned_build_temps(callgraph_dir);
6659    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6660        return;
6661    };
6662    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6663
6664    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
6665    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6666        for entry in entries.flatten() {
6667            if entry.path().is_dir() {
6668                sweep_orphaned_build_temps(&entry.path());
6669            }
6670        }
6671    }
6672
6673    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
6674    if let Ok(entries) = std::fs::read_dir(&storage_root) {
6675        for entry in entries.flatten() {
6676            let legacy_dir = entry.path().join(domain);
6677            if legacy_dir.is_dir() {
6678                sweep_orphaned_build_temps(&legacy_dir);
6679            }
6680        }
6681    }
6682}
6683
6684/// Sweep one callgraph directory, removing build temporaries older than
6685/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
6686fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6687    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6688}
6689
6690/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
6691/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
6692fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6693    let now = SystemTime::now();
6694    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6695        return;
6696    };
6697    let mut removed_any = false;
6698    for entry in entries.flatten() {
6699        let name = entry.file_name().to_string_lossy().to_string();
6700        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
6701        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
6702        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
6703        // without that substring — a completed `.sqlite` generation, a pointer, a
6704        // read-marker dir — is left alone: those belong to generation GC.
6705        if !name.contains(".sqlite.tmp.") {
6706            continue;
6707        }
6708        let mtime = entry
6709            .metadata()
6710            .and_then(|meta| meta.modified())
6711            .unwrap_or(now);
6712        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6713            continue;
6714        }
6715        // Deletion races a concurrent build finishing: that build renames the temp
6716        // into place, so the file is gone by the time we unlink. The 24h age makes
6717        // this overlap practically impossible, but treat a missing file as success
6718        // (the rename won) rather than an error, and never touch a path that does
6719        // not match the temporary shape above.
6720        match std::fs::remove_file(entry.path()) {
6721            Ok(()) => removed_any = true,
6722            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6723            Err(_) => {}
6724        }
6725    }
6726    if removed_any {
6727        crate::fs_lock::sync_parent(callgraph_dir);
6728    }
6729}
6730
6731/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6732/// the global all-cores rayon pool. The store cold-build is the heaviest
6733/// background pass (parse-dominated) and runs on a separate thread off the
6734/// single-threaded request loop; left unbounded it monopolizes every core and
6735/// starves the bridge so interactive tools time out (the same starvation the
6736/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6737/// match the main thread, since the extract walks tree-sitter ASTs.
6738fn build_pool_size() -> usize {
6739    std::thread::available_parallelism()
6740        .map(|parallelism| parallelism.get())
6741        .unwrap_or(1)
6742        .div_ceil(2)
6743        .clamp(1, 8)
6744}
6745
6746fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6747    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6748        Ok(extract) => Ok(extract),
6749        Err(error) => {
6750            let abs_path =
6751                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6752            let rel_path = relative_path(project_root, &abs_path);
6753            let freshness = cache_freshness::collect(&abs_path).ok();
6754            log::debug!(
6755                "callgraph store: skipping {} during cold build: {}",
6756                abs_path.display(),
6757                error
6758            );
6759            Err(ExtractFailure {
6760                rel_path,
6761                freshness,
6762            })
6763        }
6764    };
6765
6766    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6767        files.par_iter().map(extract_one).collect()
6768    };
6769
6770    // Run inside a dedicated bounded pool when one builds; fall back to the
6771    // global pool only if the bounded pool can't be constructed.
6772    let results = match rayon::ThreadPoolBuilder::new()
6773        .num_threads(build_pool_size())
6774        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6775        .stack_size(8 * 1024 * 1024)
6776        .build()
6777    {
6778        Ok(pool) => pool.install(run),
6779        Err(error) => {
6780            log::warn!(
6781                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6782            );
6783            run()
6784        }
6785    };
6786
6787    let mut extracts = Vec::new();
6788    let mut failures = Vec::new();
6789    for result in results {
6790        match result {
6791            Ok(extract) => extracts.push(extract),
6792            Err(failure) => failures.push(failure),
6793        }
6794    }
6795    BuildExtractsResult { extracts, failures }
6796}
6797
6798fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6799    let metadata = std::fs::metadata(path)?;
6800    let size = metadata.len();
6801    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6802        cache_freshness::zero_hash()
6803    } else if source.len() as u64 == size {
6804        cache_freshness::hash_bytes(source.as_bytes())
6805    } else {
6806        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6807    };
6808    Ok(FileFreshness {
6809        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6810        size,
6811        content_hash,
6812    })
6813}
6814
6815fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6816    let abs_path = normalize_file_path(project_root, path)?;
6817    let rel_path = relative_path(project_root, &abs_path);
6818    let source = std::fs::read_to_string(&abs_path)?;
6819    let freshness = collect_source_freshness(&abs_path, &source)?;
6820    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6821    let lang = data.lang;
6822    if lang == LangId::Rust {
6823        extend_rust_imports_with_nested_uses(&source, &mut data);
6824    }
6825    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6826    let node_by_scoped: HashMap<String, String> = nodes
6827        .iter()
6828        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6829        .collect();
6830    let import_dependencies =
6831        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6832    let line_index = LineIndex::new(&source);
6833    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6834    let rust_reexports = if lang == LangId::Rust {
6835        collect_rust_pub_use_reexport_refs(
6836            project_root,
6837            &abs_path,
6838            &rel_path,
6839            &data.import_block.imports,
6840            &line_index,
6841        )
6842    } else {
6843        ReexportRefs {
6844            raw_refs: Vec::new(),
6845            surface_parts: Vec::new(),
6846        }
6847    };
6848    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6849    let mut raw_refs = Vec::new();
6850    raw_refs.extend(build_call_refs(
6851        &rel_path,
6852        &data,
6853        &node_by_scoped,
6854        &import_dependencies,
6855    ));
6856    raw_refs.extend(build_value_ref_refs(
6857        &rel_path,
6858        &data,
6859        &node_by_scoped,
6860        &import_dependencies,
6861    ));
6862    raw_refs.extend(build_import_refs(
6863        project_root,
6864        &abs_path,
6865        &rel_path,
6866        &data.import_block.imports,
6867        &line_index,
6868    ));
6869    let mut surface_parts = reexports.surface_parts;
6870    surface_parts.extend(rust_reexports.surface_parts);
6871    surface_parts.extend(source_less_exports.surface_parts);
6872    raw_refs.extend(reexports.raw_refs);
6873    raw_refs.extend(rust_reexports.raw_refs);
6874    raw_refs.extend(source_less_exports.raw_refs);
6875    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6876    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6877
6878    Ok(FileExtract {
6879        rel_path,
6880        freshness,
6881        lang,
6882        data,
6883        nodes,
6884        raw_refs,
6885        dispatch_hints,
6886        surface_fingerprint,
6887    })
6888}
6889
6890fn build_node_records(
6891    rel_path: &str,
6892    source: &str,
6893    data: &FileCallData,
6894) -> Result<Vec<NodeRecord>> {
6895    let mut records = Vec::new();
6896    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6897    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6898    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6899
6900    for (scoped_name, meta) in metadata {
6901        let name = unqualified_name(scoped_name).to_string();
6902        let range = selection_range(source, scoped_name, &name, &meta.range);
6903        let range_key = (
6904            range.start_line,
6905            range.start_col,
6906            range.end_line,
6907            range.end_col,
6908        );
6909        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6910        let range_ordinal = *ordinal;
6911        *ordinal += 1;
6912        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6913        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6914        let is_default_export = data
6915            .default_export_symbol
6916            .as_deref()
6917            .map(|default| default == scoped_name || default == name)
6918            .unwrap_or(false);
6919        records.push(NodeRecord {
6920            id,
6921            file_path: rel_path.to_string(),
6922            name: name.clone(),
6923            scoped_name: scoped_name.clone(),
6924            kind: symbol_kind_label(&meta.kind).to_string(),
6925            range,
6926            range_ordinal,
6927            signature: meta.signature.clone(),
6928            exported,
6929            is_default_export,
6930            is_type_like: is_type_like(&meta.kind),
6931            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6932                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6933        });
6934    }
6935
6936    Ok(records)
6937}
6938
6939fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6940    if scoped_name == TOP_LEVEL_SYMBOL {
6941        return Range {
6942            start_line: 0,
6943            start_col: 0,
6944            end_line: 0,
6945            end_col: 0,
6946        };
6947    }
6948    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6949        return fallback.clone();
6950    };
6951    let start_col = fallback.start_col as usize;
6952    let search_start = start_col.min(line.len());
6953    if let Some(offset) = line[search_start..].find(name) {
6954        let col = search_start + offset;
6955        return Range {
6956            start_line: fallback.start_line,
6957            start_col: col as u32,
6958            end_line: fallback.start_line,
6959            end_col: (col + name.len()) as u32,
6960        };
6961    }
6962    if let Some(offset) = line.find(name) {
6963        return Range {
6964            start_line: fallback.start_line,
6965            start_col: offset as u32,
6966            end_line: fallback.start_line,
6967            end_col: (offset + name.len()) as u32,
6968        };
6969    }
6970    Range {
6971        start_line: fallback.start_line,
6972        start_col: fallback.start_col,
6973        end_line: fallback.start_line,
6974        end_col: fallback.start_col.saturating_add(name.len() as u32),
6975    }
6976}
6977
6978fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6979    if scoped_name == TOP_LEVEL_SYMBOL {
6980        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6981    }
6982    let input = format!(
6983        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6984        range.start_line, range.start_col, range.end_line, range.end_col
6985    );
6986    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6987}
6988
6989fn build_call_refs(
6990    rel_path: &str,
6991    data: &FileCallData,
6992    node_by_scoped: &HashMap<String, String>,
6993    import_dependencies: &BTreeSet<String>,
6994) -> Vec<RawRef> {
6995    build_callable_refs(
6996        rel_path,
6997        &data.calls_by_symbol,
6998        node_by_scoped,
6999        import_dependencies,
7000        "call",
7001    )
7002}
7003
7004fn build_value_ref_refs(
7005    rel_path: &str,
7006    data: &FileCallData,
7007    node_by_scoped: &HashMap<String, String>,
7008    import_dependencies: &BTreeSet<String>,
7009) -> Vec<RawRef> {
7010    build_callable_refs(
7011        rel_path,
7012        &data.value_refs_by_symbol,
7013        node_by_scoped,
7014        import_dependencies,
7015        "value_ref",
7016    )
7017}
7018
7019fn build_callable_refs(
7020    rel_path: &str,
7021    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7022    node_by_scoped: &HashMap<String, String>,
7023    import_dependencies: &BTreeSet<String>,
7024    kind: &str,
7025) -> Vec<RawRef> {
7026    let mut refs = Vec::new();
7027    let mut ordinal = 0usize;
7028    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7029    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7030    for (caller_symbol, call_sites) in symbols {
7031        let caller_node = node_by_scoped.get(caller_symbol).cloned();
7032        for call_site in call_sites {
7033            ordinal += 1;
7034            let ref_id = ref_id(&[
7035                rel_path,
7036                kind,
7037                caller_symbol,
7038                &call_site.line.to_string(),
7039                &call_site.byte_start.to_string(),
7040                &call_site.byte_end.to_string(),
7041                &call_site.full_callee,
7042                &ordinal.to_string(),
7043            ]);
7044            refs.push(RawRef {
7045                ref_id,
7046                caller_node: caller_node.clone(),
7047                caller_symbol: Some(caller_symbol.clone()),
7048                caller_file: rel_path.to_string(),
7049                kind: kind.to_string(),
7050                short_name: Some(call_site.callee_name.clone()),
7051                full_ref: Some(call_site.full_callee.clone()),
7052                module_path: None,
7053                import_kind: None,
7054                local_name: Some(call_site.callee_name.clone()),
7055                requested_name: Some(call_site.callee_name.clone()),
7056                namespace_alias: namespace_alias(&call_site.full_callee),
7057                wildcard: false,
7058                line: call_site.line,
7059                byte_start: call_site.byte_start,
7060                byte_end: call_site.byte_end,
7061                dependencies: import_dependencies.clone(),
7062            });
7063        }
7064    }
7065    refs
7066}
7067
7068fn build_import_refs(
7069    project_root: &Path,
7070    abs_path: &Path,
7071    rel_path: &str,
7072    imports: &[ImportStatement],
7073    line_index: &LineIndex,
7074) -> Vec<RawRef> {
7075    let mut refs = Vec::new();
7076    for (index, import) in imports.iter().enumerate() {
7077        let import_kind = import_kind_label(import.kind).to_string();
7078        let local_name = import_local_names(import).join(",");
7079        let requested_name = import_requested_names(import).join(",");
7080        let ref_id = ref_id(&[
7081            rel_path,
7082            "import",
7083            &import.byte_range.start.to_string(),
7084            &import.byte_range.end.to_string(),
7085            &import.module_path,
7086            &index.to_string(),
7087        ]);
7088        refs.push(RawRef {
7089            ref_id,
7090            caller_node: None,
7091            caller_symbol: None,
7092            caller_file: rel_path.to_string(),
7093            kind: "import".to_string(),
7094            short_name: None,
7095            full_ref: Some(import.raw_text.clone()),
7096            module_path: Some(import.module_path.clone()),
7097            import_kind: Some(import_kind),
7098            local_name: empty_to_none(local_name),
7099            requested_name: empty_to_none(requested_name),
7100            namespace_alias: import.namespace_import.clone(),
7101            wildcard: import_is_wildcard(import),
7102            line: line_index.byte_to_line(import.byte_range.start),
7103            byte_start: import.byte_range.start,
7104            byte_end: import.byte_range.end,
7105            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7106        });
7107    }
7108    refs
7109}
7110
7111fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7112    let grammar = grammar_for(LangId::Rust);
7113    let mut parser = Parser::new();
7114    if parser.set_language(&grammar).is_err() {
7115        return;
7116    }
7117    let Some(tree) = parser.parse(source, None) else {
7118        return;
7119    };
7120
7121    let mut seen = data
7122        .import_block
7123        .imports
7124        .iter()
7125        .map(|import| (import.byte_range.start, import.byte_range.end))
7126        .collect::<HashSet<_>>();
7127    let mut nested_imports = Vec::new();
7128    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7129    if nested_imports.is_empty() {
7130        return;
7131    }
7132
7133    data.import_block.imports.extend(nested_imports);
7134    data.import_block
7135        .imports
7136        .sort_by_key(|import| import.byte_range.start);
7137    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7138}
7139
7140fn collect_rust_use_imports(
7141    source: &str,
7142    node: Node<'_>,
7143    seen: &mut HashSet<(usize, usize)>,
7144    imports: &mut Vec<ImportStatement>,
7145) {
7146    if node.kind() == "use_declaration" {
7147        let range = node.byte_range();
7148        if seen.insert((range.start, range.end)) {
7149            if let Some(import) = rust_import_from_use_node(source, node) {
7150                imports.push(import);
7151            }
7152        }
7153    }
7154
7155    let mut cursor = node.walk();
7156    if !cursor.goto_first_child() {
7157        return;
7158    }
7159    loop {
7160        collect_rust_use_imports(source, cursor.node(), seen, imports);
7161        if !cursor.goto_next_sibling() {
7162            break;
7163        }
7164    }
7165}
7166
7167fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7168    let raw_text = source[node.byte_range()].to_string();
7169    let body = rust_use_body(&raw_text)?.to_string();
7170    let visibility = rust_use_visibility(&raw_text);
7171    let names = rust_use_list_names(&body);
7172    let group = classify_rust_import_group(&body);
7173    let byte_range = node.byte_range();
7174
7175    Some(ImportStatement {
7176        module_path: body,
7177        names: names.clone(),
7178        default_import: visibility.clone(),
7179        namespace_import: None,
7180        kind: ImportKind::Value,
7181        group,
7182        byte_range,
7183        raw_text,
7184        form: ImportForm::RustUse {
7185            visibility,
7186            named: names,
7187        },
7188    })
7189}
7190
7191fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7192    let start = imports.iter().map(|import| import.byte_range.start).min()?;
7193    let end = imports.iter().map(|import| import.byte_range.end).max()?;
7194    Some(start..end)
7195}
7196
7197fn rust_use_visibility(raw_text: &str) -> Option<String> {
7198    let use_pos = raw_text.find("use ")?;
7199    let prefix = raw_text[..use_pos].trim();
7200    if prefix.is_empty() {
7201        None
7202    } else {
7203        Some(prefix.to_string())
7204    }
7205}
7206
7207fn rust_use_body(raw_text: &str) -> Option<&str> {
7208    let use_pos = raw_text.find("use ")?;
7209    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7210}
7211
7212fn rust_use_list_names(body: &str) -> Vec<String> {
7213    let Some(open) = body.find("::{") else {
7214        return Vec::new();
7215    };
7216    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7217        return Vec::new();
7218    };
7219    body[open + 3..close]
7220        .split(',')
7221        .filter_map(|spec| {
7222            let spec = spec.trim();
7223            if spec.is_empty() {
7224                None
7225            } else {
7226                Some(spec.to_string())
7227            }
7228        })
7229        .collect()
7230}
7231
7232fn classify_rust_import_group(body: &str) -> ImportGroup {
7233    let first = body
7234        .split("::")
7235        .next()
7236        .unwrap_or(body)
7237        .split_whitespace()
7238        .next()
7239        .unwrap_or(body);
7240    match first.trim() {
7241        "std" | "core" | "alloc" => ImportGroup::Stdlib,
7242        "crate" | "self" | "super" => ImportGroup::Internal,
7243        _ => ImportGroup::External,
7244    }
7245}
7246
7247#[derive(Debug, Clone)]
7248struct ReexportRefs {
7249    raw_refs: Vec<RawRef>,
7250    surface_parts: Vec<String>,
7251}
7252
7253fn collect_reexport_refs(
7254    project_root: &Path,
7255    abs_path: &Path,
7256    rel_path: &str,
7257    source: &str,
7258) -> ReexportRefs {
7259    let mut raw_refs = Vec::new();
7260    let mut surface_parts = Vec::new();
7261    let mut search_start = 0usize;
7262    let mut ordinal = 0usize;
7263    while let Some(export_offset) = source[search_start..].find("export") {
7264        let start = search_start + export_offset;
7265        let Some(statement_end_offset) = source[start..].find(';') else {
7266            break;
7267        };
7268        let end = start + statement_end_offset + 1;
7269        let statement = &source[start..end];
7270        search_start = end;
7271        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7272            continue;
7273        }
7274        let Some(module_path) = quoted_module_path(statement) else {
7275            continue;
7276        };
7277        ordinal += 1;
7278        let wildcard = statement.contains('*');
7279        let line = source[..start]
7280            .bytes()
7281            .filter(|byte| *byte == b'\n')
7282            .count() as u32
7283            + 1;
7284        let ref_id = ref_id(&[
7285            rel_path,
7286            "reexport",
7287            &start.to_string(),
7288            &end.to_string(),
7289            &module_path,
7290            &ordinal.to_string(),
7291        ]);
7292        surface_parts.push(format!("reexport\t{statement}"));
7293        raw_refs.push(RawRef {
7294            ref_id,
7295            caller_node: None,
7296            caller_symbol: None,
7297            caller_file: rel_path.to_string(),
7298            kind: "reexport".to_string(),
7299            short_name: None,
7300            full_ref: Some(statement.to_string()),
7301            module_path: Some(module_path.clone()),
7302            import_kind: Some("reexport".to_string()),
7303            local_name: None,
7304            requested_name: None,
7305            namespace_alias: None,
7306            wildcard,
7307            line,
7308            byte_start: start,
7309            byte_end: end,
7310            dependencies: module_dependencies(project_root, abs_path, &module_path),
7311        });
7312    }
7313    ReexportRefs {
7314        raw_refs,
7315        surface_parts,
7316    }
7317}
7318
7319fn collect_rust_pub_use_reexport_refs(
7320    project_root: &Path,
7321    abs_path: &Path,
7322    rel_path: &str,
7323    imports: &[ImportStatement],
7324    line_index: &LineIndex,
7325) -> ReexportRefs {
7326    let mut raw_refs = Vec::new();
7327    let mut surface_parts = Vec::new();
7328    let mut ordinal = 0usize;
7329
7330    for import in imports {
7331        let Some(visibility) = &import.default_import else {
7332            continue;
7333        };
7334        if !visibility.starts_with("pub") {
7335            continue;
7336        }
7337        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7338            continue;
7339        };
7340        ordinal += 1;
7341        let ref_id = ref_id(&[
7342            rel_path,
7343            "rust_reexport",
7344            &import.byte_range.start.to_string(),
7345            &import.byte_range.end.to_string(),
7346            &module_path,
7347            &ordinal.to_string(),
7348        ]);
7349        surface_parts.push(format!("reexport\t{}", import.raw_text));
7350        raw_refs.push(RawRef {
7351            ref_id,
7352            caller_node: None,
7353            caller_symbol: None,
7354            caller_file: rel_path.to_string(),
7355            kind: "reexport".to_string(),
7356            short_name: None,
7357            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7358            module_path: Some(module_path.clone()),
7359            import_kind: Some("reexport".to_string()),
7360            local_name: None,
7361            requested_name: None,
7362            namespace_alias: None,
7363            wildcard,
7364            line: line_index.byte_to_line(import.byte_range.start),
7365            byte_start: import.byte_range.start,
7366            byte_end: import.byte_range.end,
7367            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7368        });
7369    }
7370
7371    ReexportRefs {
7372        raw_refs,
7373        surface_parts,
7374    }
7375}
7376
7377fn rust_pub_use_reexport_parts(
7378    import: &ImportStatement,
7379) -> Option<(String, HashMap<String, String>, bool)> {
7380    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7381    let body = body.trim();
7382    if let Some(module_path) = body.strip_suffix("::*") {
7383        return Some((module_path.trim().to_string(), HashMap::new(), true));
7384    }
7385
7386    if let Some(brace_start) = body.find("::{") {
7387        let module_path = body[..brace_start].trim().to_string();
7388        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7389        if names.is_empty() {
7390            return None;
7391        }
7392        return Some((module_path, names, false));
7393    }
7394
7395    let (module_path, spec) = body.rsplit_once("::")?;
7396    let names = rust_reexport_names_from_specs(spec);
7397    if names.is_empty() {
7398        return None;
7399    }
7400    Some((module_path.trim().to_string(), names, false))
7401}
7402
7403fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7404    let mut names = HashMap::new();
7405    for spec in specs.split(',') {
7406        let spec = spec.trim();
7407        if spec.is_empty() || spec == "self" {
7408            continue;
7409        }
7410        if let Some((source, local)) = spec.split_once(" as ") {
7411            let source = source.trim();
7412            let local = local.trim();
7413            if !source.is_empty() && !local.is_empty() && source != "self" {
7414                names.insert(local.to_string(), source.to_string());
7415            }
7416        } else {
7417            names.insert(spec.to_string(), spec.to_string());
7418        }
7419    }
7420    names
7421}
7422
7423fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7424    if named.is_empty() {
7425        return fallback.to_string();
7426    }
7427    let mut specs = named
7428        .iter()
7429        .map(|(local, source)| {
7430            if local == source {
7431                source.clone()
7432            } else {
7433                format!("{source} as {local}")
7434            }
7435        })
7436        .collect::<Vec<_>>();
7437    specs.sort();
7438    format!("pub use {{{}}};", specs.join(", "))
7439}
7440
7441fn quoted_module_path(statement: &str) -> Option<String> {
7442    let quote = match (statement.find('\''), statement.find('"')) {
7443        (Some(single), Some(double)) if single < double => '\'',
7444        (Some(_), Some(_)) => '"',
7445        (Some(_), None) => '\'',
7446        (None, Some(_)) => '"',
7447        (None, None) => return None,
7448    };
7449    let start = statement.find(quote)? + 1;
7450    let end = statement[start..].find(quote)? + start;
7451    Some(statement[start..end].to_string())
7452}
7453
7454#[derive(Debug, Clone)]
7455struct SourceLessExportRefs {
7456    raw_refs: Vec<RawRef>,
7457    surface_parts: Vec<String>,
7458}
7459
7460fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7461    let mut raw_refs = Vec::new();
7462    let mut surface_parts = Vec::new();
7463    let mut search_start = 0usize;
7464    let mut ordinal = 0usize;
7465    while let Some(export_offset) = source[search_start..].find("export") {
7466        let start = search_start + export_offset;
7467        let Some(statement_end_offset) = source[start..].find(';') else {
7468            break;
7469        };
7470        let end = start + statement_end_offset + 1;
7471        let statement = &source[start..end];
7472        search_start = end;
7473        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7474            continue;
7475        }
7476        let aliases = parse_reexport_names(statement);
7477        if aliases.is_empty() {
7478            continue;
7479        }
7480        let line = source[..start]
7481            .bytes()
7482            .filter(|byte| *byte == b'\n')
7483            .count() as u32
7484            + 1;
7485        for (exported, source_symbol) in aliases {
7486            ordinal += 1;
7487            let ref_id = ref_id(&[
7488                rel_path,
7489                "export_alias",
7490                &start.to_string(),
7491                &end.to_string(),
7492                &exported,
7493                &source_symbol,
7494                &ordinal.to_string(),
7495            ]);
7496            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7497            raw_refs.push(RawRef {
7498                ref_id,
7499                caller_node: None,
7500                caller_symbol: None,
7501                caller_file: rel_path.to_string(),
7502                kind: "export_alias".to_string(),
7503                short_name: None,
7504                full_ref: Some(statement.to_string()),
7505                module_path: None,
7506                import_kind: Some("export_alias".to_string()),
7507                local_name: Some(exported),
7508                requested_name: Some(source_symbol),
7509                namespace_alias: None,
7510                wildcard: false,
7511                line,
7512                byte_start: start,
7513                byte_end: end,
7514                dependencies: BTreeSet::new(),
7515            });
7516        }
7517    }
7518    SourceLessExportRefs {
7519        raw_refs,
7520        surface_parts,
7521    }
7522}
7523
7524fn build_dispatch_hints(
7525    rel_path: &str,
7526    data: &FileCallData,
7527    node_by_scoped: &HashMap<String, String>,
7528) -> Vec<DispatchHint> {
7529    let mut hints = Vec::new();
7530    let mut ordinal = 0usize;
7531    for (caller_symbol, call_sites) in &data.calls_by_symbol {
7532        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7533            continue;
7534        };
7535        for call_site in call_sites {
7536            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7537                continue;
7538            }
7539            ordinal += 1;
7540            hints.push(DispatchHint {
7541                id: ref_id(&[
7542                    rel_path,
7543                    "dispatch",
7544                    caller_symbol,
7545                    &call_site.line.to_string(),
7546                    &call_site.byte_start.to_string(),
7547                    &call_site.byte_end.to_string(),
7548                    &ordinal.to_string(),
7549                ]),
7550                method_name: call_site.callee_name.clone(),
7551                caller_node: caller_node.clone(),
7552                file: rel_path.to_string(),
7553                line: call_site.line,
7554                byte_start: call_site.byte_start,
7555                byte_end: call_site.byte_end,
7556            });
7557        }
7558    }
7559    hints
7560}
7561
7562fn surface_fingerprint(
7563    nodes: &mut [NodeRecord],
7564    data: &FileCallData,
7565    reexport_parts: &[String],
7566) -> String {
7567    nodes.sort_by(|left, right| {
7568        (left.file_path.as_str(), left.scoped_name.as_str())
7569            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7570    });
7571    let mut parts = Vec::new();
7572    for node in nodes.iter() {
7573        parts.push(format!(
7574            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7575            node.scoped_name,
7576            node.name,
7577            node.kind,
7578            node.exported,
7579            node.range.start_line,
7580            node.range.start_col,
7581            node.range.end_line,
7582            node.range.end_col,
7583            node.range_ordinal,
7584            node.signature.as_deref().unwrap_or("")
7585        ));
7586    }
7587    let mut exports = data.exported_symbols.clone();
7588    exports.sort();
7589    for export in exports {
7590        parts.push(format!("export\t{export}"));
7591    }
7592    if let Some(default_export) = &data.default_export_symbol {
7593        parts.push(format!("default\t{default_export}"));
7594    }
7595    let mut imports: Vec<String> = data
7596        .import_block
7597        .imports
7598        .iter()
7599        .map(|import| {
7600            format!(
7601                "import\t{}\t{:?}\t{}",
7602                import.module_path, import.form, import.raw_text
7603            )
7604        })
7605        .collect();
7606    imports.sort();
7607    parts.extend(imports);
7608    parts.extend(reexport_parts.iter().cloned());
7609    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7610}
7611
7612fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7613    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
7614        return Ok(ResolvedRef {
7615            dependencies: raw.dependencies.clone(),
7616            raw,
7617            status: "unresolved".to_string(),
7618            target_node: None,
7619            target_file: None,
7620            target_symbol: None,
7621            edge: None,
7622        });
7623    }
7624
7625    let caller_file = raw.caller_file.clone();
7626    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7627        CallGraphStoreError::MissingCallerData {
7628            file: caller_file.clone(),
7629        }
7630    })?;
7631    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7632    let short_name = raw.short_name.as_deref().unwrap_or_default();
7633    let mut dependencies = raw.dependencies.clone();
7634
7635    let resolved = match index.lang_for(&caller_file) {
7636        Some(LangId::Rust) => {
7637            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7638        }
7639        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7640            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7641        }
7642        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7643    };
7644
7645    let Some((status, target_file, target_symbol)) = resolved else {
7646        return Ok(ResolvedRef {
7647            raw,
7648            status: "unresolved".to_string(),
7649            target_node: None,
7650            target_file: None,
7651            target_symbol: None,
7652            dependencies,
7653            edge: None,
7654        });
7655    };
7656
7657    dependencies.insert(target_file.clone());
7658    let target_node = index.node_for_symbol(&target_file, &target_symbol);
7659    if raw.kind == "value_ref"
7660        && !target_node
7661            .as_deref()
7662            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
7663    {
7664        return Ok(ResolvedRef {
7665            raw,
7666            status: "unresolved".to_string(),
7667            target_node: None,
7668            target_file: None,
7669            target_symbol: None,
7670            dependencies,
7671            edge: None,
7672        });
7673    }
7674    let source_node = raw.caller_node.clone();
7675    let edge = if let Some(source_node) = source_node {
7676        if target_file == caller_file
7677            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7678        {
7679            None
7680        } else {
7681            Some(EdgeRecord {
7682                edge_id: ref_id(&[&raw.ref_id, "edge"]),
7683                source_node,
7684                target_node: target_node.clone(),
7685                target_file: target_file.clone(),
7686                target_symbol: target_symbol.clone(),
7687                kind: raw.kind.clone(),
7688                line: raw.line,
7689            })
7690        }
7691    } else {
7692        None
7693    };
7694
7695    Ok(ResolvedRef {
7696        raw,
7697        status,
7698        target_node,
7699        target_file: Some(target_file),
7700        target_symbol: Some(target_symbol),
7701        dependencies,
7702        edge,
7703    })
7704}
7705
7706fn resolve_js_ts_target(
7707    index: &ProjectIndex<'_>,
7708    caller_file: &str,
7709    full_ref: &str,
7710    short_name: &str,
7711    caller_data: &FileCallData,
7712) -> Option<(String, String, String)> {
7713    if let Some((namespace, member)) = full_ref.split_once('.') {
7714        for import in &caller_data.import_block.imports {
7715            if import.namespace_import.as_deref() == Some(namespace) {
7716                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7717                    if let Some((file, symbol)) =
7718                        resolve_exported_symbol(index, &target_file, member, 0)
7719                    {
7720                        return Some(("resolved".to_string(), file, symbol));
7721                    }
7722                }
7723            }
7724        }
7725    }
7726
7727    for import in &caller_data.import_block.imports {
7728        for spec in &import.names {
7729            if crate::imports::specifier_local_name(spec) == short_name {
7730                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7731                    let requested = crate::imports::specifier_imported_name(spec);
7732                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7733                        .unwrap_or_else(|| (target_file, requested.to_string()));
7734                    return Some(("resolved".to_string(), file, symbol));
7735                }
7736            }
7737        }
7738
7739        if import.default_import.as_deref() == Some(short_name) {
7740            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7741                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7742                    .or_else(|| {
7743                        index
7744                            .files
7745                            .get(&target_file)
7746                            .and_then(|file| file.default_export.clone())
7747                            .map(|symbol| (target_file.clone(), symbol))
7748                    })
7749                    .unwrap_or_else(|| {
7750                        let file_name = Path::new(&target_file)
7751                            .file_name()
7752                            .and_then(|name| name.to_str())
7753                            .unwrap_or("unknown")
7754                            .to_string();
7755                        (target_file, format!("<default:{file_name}>"))
7756                    });
7757                return Some(("resolved".to_string(), file, symbol));
7758            }
7759        }
7760    }
7761
7762    for import in &caller_data.import_block.imports {
7763        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7764            if index
7765                .files
7766                .get(&target_file)
7767                .map(|file| file.exports.contains(short_name))
7768                .unwrap_or(false)
7769            {
7770                return Some(("resolved".to_string(), target_file, short_name.to_string()));
7771            }
7772        }
7773    }
7774
7775    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7776}
7777
7778fn resolve_exported_symbol(
7779    index: &ProjectIndex<'_>,
7780    file: &str,
7781    requested: &str,
7782    depth: usize,
7783) -> Option<(String, String)> {
7784    let mut visited = std::collections::HashMap::new();
7785    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7786}
7787
7788/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7789/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7790/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7791/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7792/// (file, symbol) pair — but only when the earlier visit had at least as much
7793/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7794/// first visit had to cut off at the cap, so plain visited-set pruning would
7795/// lose resolutions the capped walk finds).
7796fn resolve_exported_symbol_inner(
7797    index: &ProjectIndex<'_>,
7798    file: &str,
7799    requested: &str,
7800    depth: usize,
7801    visited: &mut std::collections::HashMap<(String, String), usize>,
7802) -> Option<(String, String)> {
7803    if depth > 16 {
7804        return None;
7805    }
7806    if requested != "default" {
7807        if let Some(source_symbol) = index
7808            .files
7809            .get(file)
7810            .and_then(|item| item.export_aliases.get(requested))
7811        {
7812            return Some((file.to_string(), source_symbol.clone()));
7813        }
7814        if index
7815            .files
7816            .get(file)
7817            .map(|item| item.exports.contains(requested))
7818            .unwrap_or(false)
7819        {
7820            return Some((file.to_string(), requested.to_string()));
7821        }
7822    } else if let Some(default) = index
7823        .files
7824        .get(file)
7825        .and_then(|item| item.default_export.clone())
7826    {
7827        return Some((file.to_string(), default));
7828    }
7829
7830    // Memo check sits after the local-export fast paths: the common direct
7831    // hit never allocates the key, and a hit through the memo would have
7832    // returned above anyway.
7833    match visited.entry((file.to_string(), requested.to_string())) {
7834        std::collections::hash_map::Entry::Occupied(mut seen) => {
7835            if *seen.get() <= depth {
7836                return None;
7837            }
7838            seen.insert(depth);
7839        }
7840        std::collections::hash_map::Entry::Vacant(slot) => {
7841            slot.insert(depth);
7842        }
7843    }
7844
7845    for reexport in index.reexports_for(file) {
7846        let mut next_requested = requested.to_string();
7847        let matches = if reexport.wildcard {
7848            true
7849        } else if let Some(source_name) = reexport.named.get(requested) {
7850            next_requested = source_name.clone();
7851            true
7852        } else {
7853            false
7854        };
7855        if !matches {
7856            continue;
7857        }
7858        if let Some(target_file) = &reexport.target_file {
7859            if let Some(target) = resolve_exported_symbol_inner(
7860                index,
7861                target_file,
7862                &next_requested,
7863                depth + 1,
7864                visited,
7865            ) {
7866                return Some(target);
7867            }
7868        }
7869    }
7870    None
7871}
7872
7873fn resolve_rust_target(
7874    index: &ProjectIndex<'_>,
7875    caller_file: &str,
7876    full_ref: &str,
7877    short_name: &str,
7878    caller_data: &FileCallData,
7879    raw: &RawRef,
7880) -> Option<(String, String, String)> {
7881    if full_ref.contains("::") {
7882        if let Some((target_file, target_symbol)) =
7883            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7884        {
7885            return Some(("resolved".to_string(), target_file, target_symbol));
7886        }
7887    }
7888
7889    for import in &caller_data.import_block.imports {
7890        if let Some((target_file, target_symbol)) =
7891            rust_target_for_use(index, caller_file, import, short_name)
7892        {
7893            return Some(("resolved".to_string(), target_file, target_symbol));
7894        }
7895    }
7896
7897    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7898}
7899
7900fn rust_target_for_qualified(
7901    index: &ProjectIndex<'_>,
7902    caller_file: &str,
7903    full_ref: &str,
7904    short_name: &str,
7905    caller_data: &FileCallData,
7906    raw: &RawRef,
7907) -> Option<(String, String)> {
7908    let mut segments: Vec<&str> = full_ref.split("::").collect();
7909    if segments.len() < 2 {
7910        return None;
7911    }
7912    segments.pop();
7913    let requested_symbol = rust_target_symbol(full_ref, short_name);
7914
7915    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7916        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7917        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7918            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7919                return Some(rust_resolve_reexport_if_symbol_missing(
7920                    index,
7921                    target_file,
7922                    requested_symbol.clone(),
7923                ));
7924            }
7925        }
7926
7927        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7928        if let Some(target) =
7929            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7930        {
7931            return Some(target);
7932        }
7933        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7934            return Some(rust_resolve_reexport_if_symbol_missing(
7935                index,
7936                target_file,
7937                requested_symbol.clone(),
7938            ));
7939        }
7940    }
7941    None
7942}
7943
7944fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7945    full_ref
7946        .rsplit("::")
7947        .next()
7948        .filter(|name| !name.is_empty())
7949        .unwrap_or(short_name)
7950        .to_string()
7951}
7952
7953fn rust_resolve_reexport_if_symbol_missing(
7954    index: &ProjectIndex<'_>,
7955    target_file: String,
7956    target_symbol: String,
7957) -> (String, String) {
7958    if index
7959        .node_for_symbol(&target_file, &target_symbol)
7960        .is_some()
7961    {
7962        return (target_file, target_symbol);
7963    }
7964    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7965        resolved
7966    } else {
7967        (target_file, target_symbol)
7968    }
7969}
7970
7971fn rust_module_path_candidates(
7972    segments: &[&str],
7973    caller_data: &FileCallData,
7974    raw: &RawRef,
7975) -> Vec<Vec<String>> {
7976    let mut candidates = Vec::new();
7977    if let Some(first) = segments.first().copied() {
7978        for import in &caller_data.import_block.imports {
7979            if !rust_import_is_visible_to_call(import, raw) {
7980                continue;
7981            }
7982            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7983                continue;
7984            };
7985            if local_name == first {
7986                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7987                rust_push_unique_path_candidate(&mut candidates, path_segments);
7988            }
7989        }
7990    }
7991    rust_push_unique_path_candidate(
7992        &mut candidates,
7993        segments
7994            .iter()
7995            .map(|segment| (*segment).to_string())
7996            .collect(),
7997    );
7998    candidates
7999}
8000
8001fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8002    if !candidates.iter().any(|existing| existing == &candidate) {
8003        candidates.push(candidate);
8004    }
8005}
8006
8007fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8008    import.byte_range.start <= raw.byte_start
8009}
8010
8011fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8012    let path = import.module_path.trim().trim_end_matches(';').trim();
8013    if path.contains("::{") || path.contains('{') || path.contains('*') {
8014        return None;
8015    }
8016    let (path_without_alias, alias) = path
8017        .split_once(" as ")
8018        .map(|(left, right)| (left.trim(), Some(right.trim())))
8019        .unwrap_or((path, None));
8020    let segments = path_without_alias
8021        .split("::")
8022        .map(str::trim)
8023        .filter(|segment| !segment.is_empty())
8024        .collect::<Vec<_>>();
8025    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8026    if local_name.chars().next().is_some_and(char::is_uppercase) {
8027        return None;
8028    }
8029    Some((
8030        local_name,
8031        segments
8032            .into_iter()
8033            .map(|segment| segment.to_string())
8034            .collect(),
8035    ))
8036}
8037
8038fn rust_inline_scoped_target(
8039    index: &ProjectIndex<'_>,
8040    caller_file: &str,
8041    module_segments: &[String],
8042    short_name: &str,
8043) -> Option<(String, String)> {
8044    let src_prefix = rust_src_prefix(caller_file);
8045    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
8046    file_paths.sort();
8047    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
8048        let caller = file_paths.remove(position);
8049        file_paths.insert(0, caller);
8050    }
8051
8052    for file_path in file_paths {
8053        if index.lang_for(&file_path) != Some(LangId::Rust)
8054            || rust_src_prefix(&file_path) != src_prefix
8055        {
8056            continue;
8057        }
8058        let file_module_segments = rust_module_segments_for_rel(&file_path);
8059        if !module_segments.starts_with(&file_module_segments) {
8060            continue;
8061        }
8062        let scoped_segments = &module_segments[file_module_segments.len()..];
8063        if scoped_segments.is_empty() {
8064            continue;
8065        }
8066        let mut scoped_symbol = scoped_segments.join("::");
8067        scoped_symbol.push_str("::");
8068        scoped_symbol.push_str(short_name);
8069        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8070            return Some((file_path, scoped_symbol));
8071        }
8072    }
8073    None
8074}
8075
8076fn rust_target_for_use(
8077    index: &ProjectIndex<'_>,
8078    caller_file: &str,
8079    import: &ImportStatement,
8080    short_name: &str,
8081) -> Option<(String, String)> {
8082    let path = import.module_path.trim().trim_end_matches(';');
8083    if let Some(brace_start) = path.find("::{") {
8084        let prefix = &path[..brace_start];
8085        if import.names.iter().any(|name| name == short_name) {
8086            let prefix_segments: Vec<&str> = prefix.split("::").collect();
8087            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8088            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8089            return Some((file, short_name.to_string()));
8090        }
8091        return None;
8092    }
8093
8094    let (path_without_alias, alias) = path
8095        .split_once(" as ")
8096        .map(|(left, right)| (left.trim(), Some(right.trim())))
8097        .unwrap_or((path, None));
8098    let segments: Vec<&str> = path_without_alias.split("::").collect();
8099    let imported = alias.or_else(|| segments.last().copied())?;
8100    if imported != short_name {
8101        return None;
8102    }
8103    if segments.len() < 2 {
8104        return None;
8105    }
8106    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8107    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8108    Some((file, segments.last().unwrap_or(&short_name).to_string()))
8109}
8110
8111fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8112    let crate_name = segments.first().copied()?;
8113    let src_prefix = index.crate_src_prefix(crate_name)?;
8114    let module_segments = segments[1..]
8115        .iter()
8116        .map(|segment| segment.to_string())
8117        .collect::<Vec<_>>();
8118    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8119}
8120
8121#[cfg(test)]
8122static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8123    OnceLock::new();
8124
8125#[cfg(test)]
8126fn note_workspace_crate_prefix_build(project_root: &Path) {
8127    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8128        .get_or_init(|| Mutex::new(HashMap::new()))
8129        .lock()
8130        .expect("workspace crate prefix build counts mutex poisoned");
8131    *counts.entry(project_root.to_path_buf()).or_default() += 1;
8132}
8133
8134#[cfg(not(test))]
8135fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8136
8137#[cfg(test)]
8138fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8139    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8140        .get_or_init(|| Mutex::new(HashMap::new()))
8141        .lock()
8142        .expect("workspace crate prefix build counts mutex poisoned")
8143        .remove(project_root);
8144}
8145
8146#[cfg(test)]
8147fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8148    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8149        .get_or_init(|| Mutex::new(HashMap::new()))
8150        .lock()
8151        .expect("workspace crate prefix build counts mutex poisoned")
8152        .get(project_root)
8153        .copied()
8154        .unwrap_or(0)
8155}
8156
8157/// Walk the project tree once and map every Rust crate name (package name with
8158/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
8159/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
8160/// longer re-walks the filesystem once per ref.
8161fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8162    note_workspace_crate_prefix_build(project_root);
8163    let mut prefixes = HashMap::new();
8164    let mut stack = vec![project_root.to_path_buf()];
8165    while let Some(dir) = stack.pop() {
8166        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8167        if matches!(name, "target" | "node_modules" | ".git") {
8168            continue;
8169        }
8170        let manifest = dir.join("Cargo.toml");
8171        if manifest.is_file() {
8172            let crate_names = rust_manifest_crate_names(&manifest);
8173            if !crate_names.is_empty() {
8174                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8175                for crate_name in crate_names {
8176                    prefixes
8177                        .entry(crate_name)
8178                        .or_insert_with(|| src_prefix.clone());
8179                }
8180            }
8181        }
8182        let Ok(entries) = std::fs::read_dir(&dir) else {
8183            continue;
8184        };
8185        for entry in entries.flatten() {
8186            let path = entry.path();
8187            if path.is_dir() {
8188                stack.push(path);
8189            }
8190        }
8191    }
8192    prefixes
8193}
8194
8195/// Extract the crate names a manifest defines: the normalized package name
8196/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
8197/// reachable by either spelling, matching the previous match semantics.
8198fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8199    let Ok(source) = std::fs::read_to_string(manifest) else {
8200        return Vec::new();
8201    };
8202    let mut in_lib = false;
8203    let mut package_name = None;
8204    let mut lib_name = None;
8205    for line in source.lines() {
8206        let trimmed = line.trim();
8207        if trimmed.starts_with('[') {
8208            in_lib = trimmed == "[lib]";
8209            continue;
8210        }
8211        let Some((key, value)) = trimmed.split_once('=') else {
8212            continue;
8213        };
8214        let key = key.trim();
8215        let value = value.trim().trim_matches('"');
8216        if in_lib && key == "name" {
8217            lib_name = Some(value.to_string());
8218        } else if !in_lib && key == "name" && package_name.is_none() {
8219            package_name = Some(value.to_string());
8220        }
8221    }
8222    let mut names = Vec::new();
8223    if let Some(lib) = lib_name {
8224        names.push(lib);
8225    }
8226    if let Some(package) = package_name {
8227        let normalized = package.replace('-', "_");
8228        if !names.contains(&normalized) {
8229            names.push(normalized);
8230        }
8231    }
8232    names
8233}
8234
8235fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8236    if segments.is_empty() {
8237        return Some(Vec::new());
8238    }
8239    let caller_segments = rust_module_segments_for_rel(caller_file);
8240    match segments[0] {
8241        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8242        "self" => {
8243            let mut resolved = caller_segments;
8244            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8245            Some(resolved)
8246        }
8247        "super" => {
8248            let mut resolved = caller_segments;
8249            resolved.pop();
8250            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8251            Some(resolved)
8252        }
8253        _ => {
8254            let mut resolved = caller_segments;
8255            resolved.pop();
8256            resolved.extend(segments.iter().map(|item| item.to_string()));
8257            Some(resolved)
8258        }
8259    }
8260}
8261
8262fn rust_file_for_segments(
8263    index: &ProjectIndex<'_>,
8264    caller_file: &str,
8265    segments: &[String],
8266) -> Option<String> {
8267    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8268}
8269
8270fn rust_file_for_src_prefix(
8271    index: &ProjectIndex<'_>,
8272    src_prefix: &str,
8273    segments: &[String],
8274) -> Option<String> {
8275    let candidate = if segments.is_empty() {
8276        [src_prefix, "lib.rs"].join("/")
8277    } else {
8278        format!("{}/{}.rs", src_prefix, segments.join("/"))
8279    };
8280    if index.files.contains_key(&candidate) {
8281        return Some(candidate);
8282    }
8283    if !segments.is_empty() {
8284        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8285        if index.files.contains_key(&mod_candidate) {
8286            return Some(mod_candidate);
8287        }
8288    }
8289    None
8290}
8291
8292fn rust_src_prefix(rel_path: &str) -> String {
8293    rel_path
8294        .split_once("/src/")
8295        .map(|(prefix, _)| format!("{prefix}/src"))
8296        .unwrap_or_else(|| "src".to_string())
8297}
8298
8299fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8300    let after_src = rel_path
8301        .split_once("/src/")
8302        .map(|(_, rest)| rest)
8303        .or_else(|| rel_path.strip_prefix("src/"))
8304        .unwrap_or(rel_path);
8305    if matches!(after_src, "lib.rs" | "main.rs") {
8306        return Vec::new();
8307    }
8308    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8309        return prefix.split('/').map(|item| item.to_string()).collect();
8310    }
8311    after_src
8312        .strip_suffix(".rs")
8313        .unwrap_or(after_src)
8314        .split('/')
8315        .map(|item| item.to_string())
8316        .collect()
8317}
8318
8319fn resolve_local_target(
8320    _index: &ProjectIndex<'_>,
8321    caller_file: &str,
8322    full_ref: &str,
8323    short_name: &str,
8324    caller_data: &FileCallData,
8325) -> Option<(String, String, String)> {
8326    if !callgraph::is_bare_callee(full_ref, short_name) {
8327        return None;
8328    }
8329    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8330        .ok()
8331        .map(|symbol| {
8332            (
8333                "resolved_local".to_string(),
8334                caller_file.to_string(),
8335                symbol,
8336            )
8337        })
8338}
8339
8340impl<'a> ProjectIndex<'a> {
8341    fn from_parts(
8342        project_root: &Path,
8343        files: HashMap<String, DbFileIndex>,
8344        caller_data: HashMap<String, &'a FileCallData>,
8345        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8346    ) -> Self {
8347        Self {
8348            project_root: project_root.to_path_buf(),
8349            files,
8350            caller_data,
8351            workspace_crate_prefixes,
8352        }
8353    }
8354
8355    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8356        let mut files = HashMap::new();
8357        let mut caller_data = HashMap::new();
8358        for extract in extracts {
8359            let index = DbFileIndex::from_extract(project_root, extract);
8360            caller_data.insert(extract.rel_path.clone(), &extract.data);
8361            files.insert(extract.rel_path.clone(), index);
8362        }
8363        Self::from_parts(
8364            project_root,
8365            files,
8366            caller_data,
8367            WorkspaceCratePrefixCache::default(),
8368        )
8369    }
8370
8371    fn from_db_and_callers(
8372        tx: &Transaction<'_>,
8373        project_root: &Path,
8374        caller_extracts: &'a HashMap<String, FileExtract>,
8375        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8376    ) -> Result<Self> {
8377        let mut files = load_db_file_indexes(tx, project_root)?;
8378        let mut caller_data = HashMap::new();
8379        for (rel_path, extract) in caller_extracts {
8380            files.insert(
8381                rel_path.clone(),
8382                DbFileIndex::from_extract(project_root, extract),
8383            );
8384            caller_data.insert(rel_path.clone(), &extract.data);
8385        }
8386        Ok(Self::from_parts(
8387            project_root,
8388            files,
8389            caller_data,
8390            workspace_crate_prefixes,
8391        ))
8392    }
8393
8394    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8395        self.files.get(rel_path).and_then(|file| file.lang)
8396    }
8397
8398    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8399        self.files
8400            .get(caller_file)
8401            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8402    }
8403
8404    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8405        self.files
8406            .get(rel_path)
8407            .map(|file| file.reexports.as_slice())
8408            .unwrap_or(&[])
8409    }
8410
8411    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8412        self.files.get(rel_path).and_then(|file| {
8413            file.node_by_scoped
8414                .get(symbol)
8415                .cloned()
8416                .or_else(|| file.node_by_bare.get(symbol).cloned())
8417        })
8418    }
8419
8420    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
8421        self.files
8422            .get(rel_path)
8423            .and_then(|file| file.node_kind_by_id.get(node_id))
8424            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
8425    }
8426}
8427
8428impl DbFileIndex {
8429    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8430        let mut node_by_scoped = HashMap::new();
8431        let mut node_by_bare = HashMap::new();
8432        for node in &extract.nodes {
8433            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8434            node_by_bare
8435                .entry(node.name.clone())
8436                .or_insert(node.id.clone());
8437        }
8438        let node_kind_by_id = extract
8439            .nodes
8440            .iter()
8441            .map(|node| (node.id.clone(), node.kind.clone()))
8442            .collect();
8443        let mut export_aliases = HashMap::new();
8444        for raw_ref in &extract.raw_refs {
8445            if raw_ref.kind == "export_alias" {
8446                if let (Some(exported), Some(source_symbol)) =
8447                    (&raw_ref.local_name, &raw_ref.requested_name)
8448                {
8449                    export_aliases.insert(exported.clone(), source_symbol.clone());
8450                }
8451            }
8452        }
8453        let mut module_targets = HashMap::new();
8454        let mut reexports = Vec::new();
8455        for raw_ref in &extract.raw_refs {
8456            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8457                continue;
8458            }
8459            let Some(module_path) = &raw_ref.module_path else {
8460                continue;
8461            };
8462            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8463            module_targets
8464                .entry(module_path.clone())
8465                .or_insert_with(|| target_file.clone());
8466            if raw_ref.kind == "reexport" {
8467                reexports.push(reexport_index_from_raw(raw_ref, target_file));
8468            }
8469        }
8470        Self {
8471            lang: Some(extract.lang),
8472            exports: extract.data.exported_symbols.iter().cloned().collect(),
8473            default_export: extract.data.default_export_symbol.clone(),
8474            export_aliases,
8475            node_by_scoped,
8476            node_by_bare,
8477            node_kind_by_id,
8478            module_targets,
8479            reexports,
8480        }
8481    }
8482}
8483
8484fn load_db_file_indexes(
8485    tx: &Transaction<'_>,
8486    project_root: &Path,
8487) -> Result<HashMap<String, DbFileIndex>> {
8488    let mut files = HashMap::new();
8489    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8490    let rows = stmt.query_map([], |row| {
8491        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8492    })?;
8493    for row in rows {
8494        let (rel_path, lang) = row?;
8495        files.insert(
8496            rel_path.clone(),
8497            DbFileIndex {
8498                lang: lang_from_label(&lang),
8499                exports: HashSet::new(),
8500                default_export: None,
8501                export_aliases: HashMap::new(),
8502                node_by_scoped: HashMap::new(),
8503                node_by_bare: HashMap::new(),
8504                node_kind_by_id: HashMap::new(),
8505                module_targets: HashMap::new(),
8506                reexports: Vec::new(),
8507            },
8508        );
8509    }
8510
8511    let mut node_stmt = tx.prepare(
8512        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
8513    )?;
8514    let nodes = node_stmt.query_map([], |row| {
8515        Ok((
8516            row.get::<_, String>(0)?,
8517            row.get::<_, String>(1)?,
8518            row.get::<_, String>(2)?,
8519            row.get::<_, String>(3)?,
8520            row.get::<_, String>(4)?,
8521            row.get::<_, i64>(5)? != 0,
8522            row.get::<_, i64>(6)? != 0,
8523        ))
8524    })?;
8525    for row in nodes {
8526        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
8527        let file = files
8528            .entry(file_path.clone())
8529            .or_insert_with(|| DbFileIndex {
8530                lang: None,
8531                exports: HashSet::new(),
8532                default_export: None,
8533                export_aliases: HashMap::new(),
8534                node_by_scoped: HashMap::new(),
8535                node_by_bare: HashMap::new(),
8536                node_kind_by_id: HashMap::new(),
8537                module_targets: HashMap::new(),
8538                reexports: Vec::new(),
8539            });
8540        if exported {
8541            file.exports.insert(name.clone());
8542            file.exports.insert(scoped_name.clone());
8543        }
8544        if is_default_export {
8545            file.default_export = Some(scoped_name.clone());
8546        }
8547        file.node_by_scoped.insert(scoped_name, id.clone());
8548        file.node_by_bare.entry(name).or_insert(id.clone());
8549        file.node_kind_by_id.insert(id, kind);
8550    }
8551    let file_keys: HashSet<String> = files.keys().cloned().collect();
8552    // Persisted caller extracts supply import targets. Only reexports from other
8553    // files need dependency reconstruction, and their caller dependencies are
8554    // loaded once instead of issuing repeated SQLite queries per reference.
8555    let dependencies_by_file = load_file_dependencies_index(tx)?;
8556    let mut ref_stmt = tx.prepare(
8557        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8558         FROM refs WHERE kind IN ('reexport', 'export_alias')",
8559    )?;
8560    let ref_rows = ref_stmt.query_map([], |row| {
8561        Ok((
8562            row.get::<_, String>(0)?,
8563            row.get::<_, String>(1)?,
8564            row.get::<_, String>(2)?,
8565            row.get::<_, Option<String>>(3)?,
8566            row.get::<_, Option<String>>(4)?,
8567            row.get::<_, i64>(5)? != 0,
8568            row.get::<_, Option<String>>(6)?,
8569            row.get::<_, Option<String>>(7)?,
8570        ))
8571    })?;
8572    for row in ref_rows {
8573        let (
8574            ref_id,
8575            caller_file,
8576            kind,
8577            module_path,
8578            full_ref,
8579            wildcard,
8580            local_name,
8581            requested_name,
8582        ) = row?;
8583        if kind == "export_alias" {
8584            if let (Some(exported), Some(source_symbol), Some(file)) =
8585                (local_name, requested_name, files.get_mut(&caller_file))
8586            {
8587                file.export_aliases.insert(exported, source_symbol);
8588            }
8589            continue;
8590        }
8591        let Some(module_path) = module_path else {
8592            continue;
8593        };
8594        let file_deps = dependencies_by_file
8595            .get(&caller_file)
8596            .cloned()
8597            .unwrap_or_default();
8598        let deps = stored_dependencies_for_module(
8599            project_root,
8600            &caller_file,
8601            &module_path,
8602            &file_deps,
8603            &file_keys,
8604        );
8605        let target_file = deps
8606            .iter()
8607            .find(|dep| file_keys.contains(*dep))
8608            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8609        if let Some(file) = files.get_mut(&caller_file) {
8610            file.module_targets
8611                .entry(module_path.clone())
8612                .or_insert_with(|| target_file.clone());
8613            if kind == "reexport" {
8614                let raw = RawRef {
8615                    ref_id,
8616                    caller_node: None,
8617                    caller_symbol: None,
8618                    caller_file,
8619                    kind,
8620                    short_name: None,
8621                    full_ref,
8622                    module_path: Some(module_path),
8623                    import_kind: Some("reexport".to_string()),
8624                    local_name: None,
8625                    requested_name: None,
8626                    namespace_alias: None,
8627                    wildcard,
8628                    line: 0,
8629                    byte_start: 0,
8630                    byte_end: 0,
8631                    dependencies: deps,
8632                };
8633                file.reexports
8634                    .push(reexport_index_from_raw(&raw, target_file));
8635            }
8636        }
8637    }
8638
8639    Ok(files)
8640}
8641
8642fn stored_dependencies_for_module(
8643    project_root: &Path,
8644    caller_file: &str,
8645    module_path: &str,
8646    caller_dependencies: &BTreeSet<String>,
8647    indexed_files: &HashSet<String>,
8648) -> BTreeSet<String> {
8649    let caller_path = project_root.join(caller_file);
8650    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8651    if module_path.starts_with('.') {
8652        let caller_dir = caller_path.parent().unwrap_or(project_root);
8653        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8654            let normalized = if candidate.is_file() {
8655                canonicalize_path(&candidate)
8656            } else {
8657                candidate
8658            };
8659            candidates.insert(relative_path(project_root, &normalized));
8660        }
8661    }
8662    let exact = candidates
8663        .intersection(caller_dependencies)
8664        .filter(|dependency| indexed_files.contains(*dependency))
8665        .cloned()
8666        .collect::<BTreeSet<_>>();
8667    if !exact.is_empty() || module_path.starts_with('.') {
8668        return exact;
8669    }
8670
8671    let module_path = rust_module_path_without_alias_or_use_list(module_path)
8672        .trim_matches(|character| matches!(character, '\'' | '"'));
8673    let package_name = module_path
8674        .split('/')
8675        .next_back()
8676        .unwrap_or(module_path)
8677        .replace('_', "-");
8678    let matched = caller_dependencies
8679        .iter()
8680        .filter(|dependency| indexed_files.contains(*dependency))
8681        .filter(|dependency| {
8682            dependency.as_str() == module_path
8683                || dependency.ends_with(&format!("/{module_path}"))
8684                || Path::new(dependency).components().any(|component| {
8685                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8686                })
8687        })
8688        .cloned()
8689        .collect::<BTreeSet<_>>();
8690    if matched.len() == 1 {
8691        matched
8692    } else {
8693        BTreeSet::new()
8694    }
8695}
8696
8697fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8698    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8699    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8700    let rows = stmt.query_map([], |row| {
8701        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8702    })?;
8703    for row in rows {
8704        let (file_path, dependency) = row?;
8705        by_file.entry(file_path).or_default().insert(dependency);
8706    }
8707    Ok(by_file)
8708}
8709
8710struct ColdBuildInsertStatements<'stmt> {
8711    file: Statement<'stmt>,
8712    node: Statement<'stmt>,
8713    file_dependency: Statement<'stmt>,
8714    dispatch_hint: Statement<'stmt>,
8715    backend_state: Statement<'stmt>,
8716    reference: Statement<'stmt>,
8717    edge: Statement<'stmt>,
8718}
8719
8720impl<'stmt> ColdBuildInsertStatements<'stmt> {
8721    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8722        Ok(Self {
8723            file: tx.prepare(
8724                "INSERT OR REPLACE INTO files(
8725                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8726                    is_public_api, surface_fingerprint, indexed_at
8727                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8728            )?,
8729            node: tx.prepare(
8730                "INSERT OR REPLACE INTO nodes(
8731                    id, file_path, name, scoped_name, kind, start_line, start_col,
8732                    end_line, end_col, range_ordinal, signature, exported,
8733                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
8734                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8735            )?,
8736            file_dependency: tx.prepare(
8737                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8738            )?,
8739            dispatch_hint: tx.prepare(
8740                "INSERT OR REPLACE INTO dispatch_hints(
8741                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8742                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8743            )?,
8744            backend_state: tx.prepare(
8745                "INSERT OR REPLACE INTO backend_file_state(
8746                    backend, workspace_root, file_path, content_hash, status, updated_at
8747                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8748            )?,
8749            reference: tx.prepare(
8750                "INSERT OR REPLACE INTO refs(
8751                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8752                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8753                    byte_start, byte_end, status, target_node, target_file, target_symbol,
8754                    provenance
8755                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8756            )?,
8757            edge: tx.prepare(
8758                "INSERT OR REPLACE INTO edges(
8759                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8760                    kind, line, provenance
8761                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8762            )?,
8763        })
8764    }
8765}
8766
8767fn insert_file_extract_prepared(
8768    statements: &mut ColdBuildInsertStatements<'_>,
8769    workspace_root: &str,
8770    extract: &FileExtract,
8771) -> Result<()> {
8772    statements.file.execute(params![
8773        extract.rel_path,
8774        hash_to_hex(extract.freshness.content_hash),
8775        system_time_to_ns(extract.freshness.mtime),
8776        extract.freshness.size as i64,
8777        lang_label(extract.lang),
8778        extract.surface_fingerprint,
8779        unix_seconds_now(),
8780    ])?;
8781    for node in &extract.nodes {
8782        statements.node.execute(params![
8783            node.id,
8784            node.file_path,
8785            node.name,
8786            node.scoped_name,
8787            node.kind,
8788            node.range.start_line as i64,
8789            node.range.start_col as i64,
8790            node.range.end_line as i64,
8791            node.range.end_col as i64,
8792            node.range_ordinal as i64,
8793            node.signature,
8794            bool_int(node.exported),
8795            bool_int(node.is_default_export),
8796            bool_int(node.is_type_like),
8797            bool_int(node.is_callgraph_entry_point),
8798            PROVENANCE_TREESITTER,
8799        ])?;
8800    }
8801
8802    let mut dependencies = BTreeSet::new();
8803    for raw_ref in &extract.raw_refs {
8804        dependencies.extend(raw_ref.dependencies.iter().cloned());
8805    }
8806    for dep_file in &dependencies {
8807        statements
8808            .file_dependency
8809            .execute(params![extract.rel_path, dep_file])?;
8810    }
8811
8812    for hint in &extract.dispatch_hints {
8813        statements.dispatch_hint.execute(params![
8814            hint.id,
8815            hint.method_name,
8816            hint.caller_node,
8817            hint.file,
8818            hint.line as i64,
8819            hint.byte_start as i64,
8820            hint.byte_end as i64,
8821            PROVENANCE_TREESITTER,
8822        ])?;
8823    }
8824    insert_backend_state_prepared(
8825        &mut statements.backend_state,
8826        workspace_root,
8827        &extract.rel_path,
8828        Some(&extract.freshness.content_hash),
8829        "fresh",
8830    )?;
8831    Ok(())
8832}
8833
8834fn insert_backend_state_prepared(
8835    stmt: &mut Statement<'_>,
8836    workspace_root: &str,
8837    rel_path: &str,
8838    content_hash: Option<&blake3::Hash>,
8839    status: &str,
8840) -> Result<()> {
8841    let hash = content_hash
8842        .map(|hash| hash_to_hex(*hash))
8843        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8844    stmt.execute(params![
8845        BACKEND_TREESITTER,
8846        workspace_root,
8847        rel_path,
8848        hash,
8849        status,
8850        unix_seconds_now(),
8851    ])?;
8852    Ok(())
8853}
8854
8855fn insert_resolved_ref_prepared(
8856    statements: &mut ColdBuildInsertStatements<'_>,
8857    resolved: &ResolvedRef,
8858) -> Result<()> {
8859    let raw = &resolved.raw;
8860    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8861    statements.reference.execute(params![
8862        raw.ref_id,
8863        raw.caller_node,
8864        raw.caller_file,
8865        raw.kind,
8866        raw.short_name,
8867        raw.full_ref,
8868        raw.module_path,
8869        raw.import_kind,
8870        raw.local_name,
8871        raw.requested_name,
8872        raw.namespace_alias,
8873        bool_int(raw.wildcard),
8874        raw.line as i64,
8875        raw.byte_start as i64,
8876        raw.byte_end as i64,
8877        resolved.status,
8878        resolved.target_node,
8879        resolved.target_file,
8880        resolved.target_symbol,
8881        ref_provenance(raw),
8882    ])?;
8883    if let Some(edge) = &resolved.edge {
8884        statements.edge.execute(params![
8885            edge.edge_id,
8886            raw.ref_id,
8887            edge.source_node,
8888            edge.target_node,
8889            edge.target_file,
8890            edge.target_symbol,
8891            edge.kind,
8892            edge.line as i64,
8893            ref_provenance(raw),
8894        ])?;
8895    }
8896    Ok(())
8897}
8898
8899fn insert_file_extract(
8900    tx: &Transaction<'_>,
8901    project_root: &Path,
8902    extract: &FileExtract,
8903) -> Result<()> {
8904    tx.execute(
8905        "INSERT OR REPLACE INTO files(
8906            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8907            is_public_api, surface_fingerprint, indexed_at
8908        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8909        params![
8910            extract.rel_path,
8911            hash_to_hex(extract.freshness.content_hash),
8912            system_time_to_ns(extract.freshness.mtime),
8913            extract.freshness.size as i64,
8914            lang_label(extract.lang),
8915            extract.surface_fingerprint,
8916            unix_seconds_now(),
8917        ],
8918    )?;
8919    for node in &extract.nodes {
8920        tx.execute(
8921            "INSERT OR REPLACE INTO nodes(
8922                id, file_path, name, scoped_name, kind, start_line, start_col,
8923                end_line, end_col, range_ordinal, signature, exported,
8924                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8925            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8926            params![
8927                node.id,
8928                node.file_path,
8929                node.name,
8930                node.scoped_name,
8931                node.kind,
8932                node.range.start_line as i64,
8933                node.range.start_col as i64,
8934                node.range.end_line as i64,
8935                node.range.end_col as i64,
8936                node.range_ordinal as i64,
8937                node.signature,
8938                bool_int(node.exported),
8939                bool_int(node.is_default_export),
8940                bool_int(node.is_type_like),
8941                bool_int(node.is_callgraph_entry_point),
8942                PROVENANCE_TREESITTER,
8943            ],
8944        )?;
8945    }
8946    let mut dependencies = BTreeSet::new();
8947    for raw_ref in &extract.raw_refs {
8948        dependencies.extend(raw_ref.dependencies.iter().cloned());
8949    }
8950    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8951
8952    for hint in &extract.dispatch_hints {
8953        tx.execute(
8954            "INSERT OR REPLACE INTO dispatch_hints(
8955                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8956            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8957            params![
8958                hint.id,
8959                hint.method_name,
8960                hint.caller_node,
8961                hint.file,
8962                hint.line as i64,
8963                hint.byte_start as i64,
8964                hint.byte_end as i64,
8965                PROVENANCE_TREESITTER,
8966            ],
8967        )?;
8968    }
8969    mark_backend_state(
8970        tx,
8971        project_root,
8972        &extract.rel_path,
8973        Some(&extract.freshness.content_hash),
8974        "fresh",
8975    )?;
8976    Ok(())
8977}
8978
8979fn insert_file_dependencies(
8980    tx: &Transaction<'_>,
8981    file_path: &str,
8982    dependencies: &BTreeSet<String>,
8983) -> Result<()> {
8984    for dep_file in dependencies {
8985        tx.execute(
8986            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8987            params![file_path, dep_file],
8988        )?;
8989    }
8990    Ok(())
8991}
8992
8993fn ref_provenance(raw: &RawRef) -> &'static str {
8994    if raw.kind == "value_ref" {
8995        PROVENANCE_VALUE_REF
8996    } else {
8997        PROVENANCE_TREESITTER
8998    }
8999}
9000
9001fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9002    let raw = &resolved.raw;
9003    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9004    tx.execute(
9005        "INSERT OR REPLACE INTO refs(
9006            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9007            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9008            byte_start, byte_end, status, target_node, target_file, target_symbol,
9009            provenance
9010        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9011        params![
9012            raw.ref_id,
9013            raw.caller_node,
9014            raw.caller_file,
9015            raw.kind,
9016            raw.short_name,
9017            raw.full_ref,
9018            raw.module_path,
9019            raw.import_kind,
9020            raw.local_name,
9021            raw.requested_name,
9022            raw.namespace_alias,
9023            bool_int(raw.wildcard),
9024            raw.line as i64,
9025            raw.byte_start as i64,
9026            raw.byte_end as i64,
9027            resolved.status,
9028            resolved.target_node,
9029            resolved.target_file,
9030            resolved.target_symbol,
9031            ref_provenance(raw),
9032        ],
9033    )?;
9034    if let Some(edge) = &resolved.edge {
9035        tx.execute(
9036            "INSERT OR REPLACE INTO edges(
9037                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9038                kind, line, provenance
9039            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9040            params![
9041                edge.edge_id,
9042                raw.ref_id,
9043                edge.source_node,
9044                edge.target_node,
9045                edge.target_file,
9046                edge.target_symbol,
9047                edge.kind,
9048                edge.line as i64,
9049                ref_provenance(raw),
9050            ],
9051        )?;
9052    }
9053    Ok(())
9054}
9055
9056fn insert_method_dispatch_edges(
9057    tx: &Transaction<'_>,
9058    project_root: &Path,
9059    caller_files: Option<&BTreeSet<String>>,
9060) -> Result<usize> {
9061    let references = load_name_match_refs(tx, caller_files)?;
9062    if references.is_empty() {
9063        return Ok(0);
9064    }
9065
9066    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9067    let mut source_cache: DispatchSourceCache = HashMap::new();
9068    let mut inserted = 0usize;
9069    for reference in references {
9070        let key = (reference.method_name.clone(), reference.lang.clone());
9071        let candidates = match candidates_by_name.entry(key) {
9072            Entry::Occupied(entry) => entry.into_mut(),
9073            Entry::Vacant(entry) => {
9074                let candidates =
9075                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9076                entry.insert(candidates)
9077            }
9078        };
9079
9080        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9081            ReceiverTypeInference::Known(receiver_type) => {
9082                let Some(candidate) =
9083                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9084                else {
9085                    continue;
9086                };
9087                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9088                inserted += 1;
9089                continue;
9090            }
9091            ReceiverTypeInference::RustDirectSelfField {
9092                receiver_type,
9093                declaration_file,
9094                module_scope,
9095            } => {
9096                let Some(candidate) = select_rust_direct_self_field_candidate(
9097                    project_root,
9098                    &reference,
9099                    candidates.as_slice(),
9100                    &receiver_type,
9101                    &declaration_file,
9102                    &module_scope,
9103                    &mut source_cache,
9104                ) else {
9105                    continue;
9106                };
9107                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9108                inserted += 1;
9109                continue;
9110            }
9111            ReceiverTypeInference::KnownButUnresolved => continue,
9112            ReceiverTypeInference::Unknown => {}
9113        }
9114
9115        if method_name_match_denylisted(&reference.method_name) {
9116            continue;
9117        }
9118
9119        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9120            continue;
9121        };
9122        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9123        inserted += 1;
9124    }
9125    Ok(inserted)
9126}
9127
9128fn insert_method_dispatch_edges_chunked(
9129    tx: &Transaction<'_>,
9130    project_root: &Path,
9131    caller_files: &BTreeSet<String>,
9132    chunk_size: usize,
9133) -> Result<usize> {
9134    if caller_files.is_empty() {
9135        return Ok(0);
9136    }
9137    if chunk_size == 0 || caller_files.len() <= chunk_size {
9138        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9139    }
9140
9141    let mut inserted = 0usize;
9142    let mut batch = BTreeSet::new();
9143    for caller_file in caller_files {
9144        batch.insert(caller_file.clone());
9145        if batch.len() == chunk_size {
9146            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9147            batch.clear();
9148        }
9149    }
9150    if !batch.is_empty() {
9151        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9152    }
9153    Ok(inserted)
9154}
9155
9156fn insert_method_dispatch_edge(
9157    tx: &Transaction<'_>,
9158    reference: &NameMatchRef,
9159    candidate: &NameMatchCandidate,
9160    provenance: &str,
9161) -> Result<()> {
9162    tx.execute(
9163        "INSERT OR REPLACE INTO edges(
9164            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9165            kind, line, provenance
9166        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9167        params![
9168            ref_id(&[&reference.ref_id, provenance, "edge"]),
9169            &reference.ref_id,
9170            &reference.caller_node,
9171            &candidate.node_id,
9172            &candidate.file_path,
9173            &candidate.scoped_name,
9174            reference.line as i64,
9175            provenance,
9176        ],
9177    )?;
9178    Ok(())
9179}
9180
9181fn delete_method_dispatch_edges_for_callers(
9182    tx: &Transaction<'_>,
9183    caller_files: &BTreeSet<String>,
9184) -> Result<()> {
9185    if caller_files.is_empty() {
9186        return Ok(());
9187    }
9188
9189    let mut stmt = tx.prepare(
9190        "DELETE FROM edges
9191         WHERE provenance IN (?1, ?2)
9192           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9193    )?;
9194    for caller_file in caller_files {
9195        stmt.execute(params![
9196            PROVENANCE_NAME_MATCH,
9197            PROVENANCE_TYPE_MATCH,
9198            caller_file
9199        ])?;
9200    }
9201    Ok(())
9202}
9203
9204fn load_name_match_refs(
9205    tx: &Transaction<'_>,
9206    caller_files: Option<&BTreeSet<String>>,
9207) -> Result<Vec<NameMatchRef>> {
9208    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9209                           n.signature, r.short_name, r.full_ref, r.line, f.lang
9210                    FROM refs r
9211                    JOIN files f ON f.path = r.caller_file
9212                    JOIN nodes n ON n.id = r.caller_node
9213                    WHERE r.kind = 'call'
9214                      AND r.status = 'unresolved'
9215                      AND r.caller_node IS NOT NULL
9216                      AND r.full_ref IS NOT NULL
9217                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9218                      AND NOT EXISTS (
9219                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9220                      )";
9221    let mut references = Vec::new();
9222
9223    if let Some(caller_files) = caller_files {
9224        if caller_files.is_empty() {
9225            return Ok(references);
9226        }
9227        let sql = format!(
9228            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9229        );
9230        let mut stmt = tx.prepare(&sql)?;
9231        for caller_file in caller_files {
9232            let rows = stmt.query_map(params![caller_file], |row| {
9233                Ok((
9234                    row.get::<_, String>(0)?,
9235                    row.get::<_, Option<String>>(1)?,
9236                    row.get::<_, String>(2)?,
9237                    row.get::<_, String>(3)?,
9238                    row.get::<_, Option<String>>(4)?,
9239                    row.get::<_, Option<String>>(5)?,
9240                    row.get::<_, Option<String>>(6)?,
9241                    row.get::<_, i64>(7)?,
9242                    row.get::<_, String>(8)?,
9243                ))
9244            })?;
9245            for row in rows {
9246                let (
9247                    ref_id,
9248                    caller_node,
9249                    caller_file,
9250                    caller_symbol,
9251                    caller_signature,
9252                    short_name,
9253                    full_ref,
9254                    line,
9255                    lang,
9256                ) = row?;
9257                if let Some(reference) = name_match_ref_from_parts(
9258                    ref_id,
9259                    caller_node,
9260                    caller_file,
9261                    caller_symbol,
9262                    caller_signature,
9263                    short_name,
9264                    full_ref,
9265                    line,
9266                    lang,
9267                ) {
9268                    references.push(reference);
9269                }
9270            }
9271        }
9272        return Ok(references);
9273    }
9274
9275    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9276    let mut stmt = tx.prepare(&sql)?;
9277    let rows = stmt.query_map([], |row| {
9278        Ok((
9279            row.get::<_, String>(0)?,
9280            row.get::<_, Option<String>>(1)?,
9281            row.get::<_, String>(2)?,
9282            row.get::<_, String>(3)?,
9283            row.get::<_, Option<String>>(4)?,
9284            row.get::<_, Option<String>>(5)?,
9285            row.get::<_, Option<String>>(6)?,
9286            row.get::<_, i64>(7)?,
9287            row.get::<_, String>(8)?,
9288        ))
9289    })?;
9290    for row in rows {
9291        let (
9292            ref_id,
9293            caller_node,
9294            caller_file,
9295            caller_symbol,
9296            caller_signature,
9297            short_name,
9298            full_ref,
9299            line,
9300            lang,
9301        ) = row?;
9302        if let Some(reference) = name_match_ref_from_parts(
9303            ref_id,
9304            caller_node,
9305            caller_file,
9306            caller_symbol,
9307            caller_signature,
9308            short_name,
9309            full_ref,
9310            line,
9311            lang,
9312        ) {
9313            references.push(reference);
9314        }
9315    }
9316    Ok(references)
9317}
9318
9319#[allow(clippy::too_many_arguments)]
9320fn name_match_ref_from_parts(
9321    ref_id: String,
9322    caller_node: Option<String>,
9323    caller_file: String,
9324    caller_symbol: String,
9325    caller_signature: Option<String>,
9326    short_name: Option<String>,
9327    full_ref: Option<String>,
9328    line: i64,
9329    lang: String,
9330) -> Option<NameMatchRef> {
9331    let caller_node = caller_node?;
9332    let full_ref = full_ref?;
9333    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9334    let method_name = if member.is_empty() {
9335        short_name.as_deref()?.to_string()
9336    } else {
9337        member
9338    };
9339    Some(NameMatchRef {
9340        ref_id,
9341        caller_node,
9342        caller_file,
9343        caller_symbol,
9344        caller_signature,
9345        receiver_expression,
9346        receiver,
9347        method_name,
9348        colon_dispatch,
9349        line: line.max(0) as u32,
9350        lang,
9351    })
9352}
9353
9354fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9355    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9356    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9357    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9358    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9359        .into_iter()
9360        .flatten()
9361        .max_by_key(|(index, _, _)| *index)?;
9362    if delimiter == 0 {
9363        return None;
9364    }
9365    let member_start = delimiter + delimiter_len;
9366    if member_start >= full_ref.len() {
9367        return None;
9368    }
9369    let receiver_expression = full_ref[..delimiter].trim();
9370    let receiver = last_name_segment(receiver_expression).trim();
9371    let member = &full_ref[member_start..];
9372    if receiver.is_empty() || member.is_empty() {
9373        return None;
9374    }
9375    Some((
9376        receiver_expression.to_string(),
9377        receiver.to_string(),
9378        member.to_string(),
9379        colon_dispatch,
9380    ))
9381}
9382
9383fn last_name_segment(value: &str) -> &str {
9384    value
9385        .rsplit(['.', ':', '/', '\\', '-', '>'])
9386        .find(|segment| !segment.is_empty())
9387        .unwrap_or(value)
9388}
9389
9390fn load_name_match_candidates(
9391    tx: &Transaction<'_>,
9392    method_name: &str,
9393    lang: &str,
9394) -> Result<Vec<NameMatchCandidate>> {
9395    let mut stmt = tx.prepare(
9396        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9397         FROM nodes n JOIN files f ON f.path = n.file_path
9398         WHERE n.name = ?1
9399           AND f.lang = ?2
9400           AND n.kind IN ('method', 'function')
9401         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9402    )?;
9403    let rows = stmt.query_map(params![method_name, lang], |row| {
9404        Ok(NameMatchCandidate {
9405            node_id: row.get(0)?,
9406            file_path: row.get(1)?,
9407            scoped_name: row.get(2)?,
9408            kind: row.get(3)?,
9409            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9410        })
9411    })?;
9412    rows.collect::<std::result::Result<Vec<_>, _>>()
9413        .map_err(Into::into)
9414}
9415
9416struct ParsedDispatchSource {
9417    source: String,
9418    tree: tree_sitter::Tree,
9419}
9420
9421type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9422
9423#[derive(Debug, Clone, PartialEq, Eq)]
9424enum ReceiverTypeInference {
9425    Unknown,
9426    Known(String),
9427    RustDirectSelfField {
9428        receiver_type: String,
9429        declaration_file: String,
9430        module_scope: Vec<(usize, usize)>,
9431    },
9432    KnownButUnresolved,
9433}
9434
9435#[cfg(test)]
9436fn infer_receiver_type(
9437    project_root: &Path,
9438    reference: &NameMatchRef,
9439    source_cache: &mut DispatchSourceCache,
9440) -> Option<String> {
9441    match infer_receiver_type_state(project_root, reference, source_cache) {
9442        ReceiverTypeInference::Known(receiver_type)
9443        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9444        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9445    }
9446}
9447
9448fn infer_receiver_type_state(
9449    project_root: &Path,
9450    reference: &NameMatchRef,
9451    source_cache: &mut DispatchSourceCache,
9452) -> ReceiverTypeInference {
9453    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9454    match reference.lang.as_str() {
9455        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9456        "java" => {
9457            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9458                .map(known)
9459                .unwrap_or(ReceiverTypeInference::Unknown)
9460        }
9461        "kotlin" => {
9462            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9463                .map(known)
9464                .unwrap_or(ReceiverTypeInference::Unknown)
9465        }
9466        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9467            .map(known)
9468            .unwrap_or(ReceiverTypeInference::Unknown),
9469        _ => ReceiverTypeInference::Unknown,
9470    }
9471}
9472
9473fn parse_dispatch_source(
9474    project_root: &Path,
9475    caller_file: &str,
9476    lang: LangId,
9477) -> Option<ParsedDispatchSource> {
9478    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9479    let grammar = crate::parser::grammar_for(lang);
9480    let mut parser = tree_sitter::Parser::new();
9481    parser.set_language(&grammar).ok()?;
9482    let tree = parser.parse(&source, None)?;
9483    Some(ParsedDispatchSource { source, tree })
9484}
9485
9486fn parsed_dispatch_source<'a>(
9487    project_root: &Path,
9488    reference: &NameMatchRef,
9489    lang: LangId,
9490    source_cache: &'a mut DispatchSourceCache,
9491) -> Option<&'a ParsedDispatchSource> {
9492    parsed_dispatch_source_for_file(
9493        project_root,
9494        &reference.caller_file,
9495        &reference.lang,
9496        lang,
9497        source_cache,
9498    )
9499}
9500
9501fn parsed_dispatch_source_for_file<'a>(
9502    project_root: &Path,
9503    file_path: &str,
9504    lang_label: &str,
9505    lang: LangId,
9506    source_cache: &'a mut DispatchSourceCache,
9507) -> Option<&'a ParsedDispatchSource> {
9508    let key = (file_path.to_string(), lang_label.to_string());
9509    source_cache
9510        .entry(key)
9511        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9512        .as_ref()
9513}
9514
9515fn infer_java_like_receiver_type(
9516    project_root: &Path,
9517    reference: &NameMatchRef,
9518    lang: LangId,
9519    source_cache: &mut DispatchSourceCache,
9520) -> Option<String> {
9521    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9522        return None;
9523    }
9524
9525    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9526    let root = parsed.tree.root_node();
9527    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9528
9529    let callable_scope = type_node
9530        .and_then(|node| {
9531            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9532        })
9533        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9534
9535    if let Some(callable_scope) = callable_scope {
9536        if let Some(receiver_type) = infer_java_like_local_receiver_type(
9537            callable_scope,
9538            &parsed.source,
9539            &reference.receiver,
9540            reference.line.max(1),
9541            lang,
9542        ) {
9543            return Some(receiver_type);
9544        }
9545    }
9546
9547    type_node.and_then(|node| {
9548        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9549    })
9550}
9551
9552fn infer_cpp_receiver_type(
9553    project_root: &Path,
9554    reference: &NameMatchRef,
9555    source_cache: &mut DispatchSourceCache,
9556) -> Option<String> {
9557    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9558        return None;
9559    }
9560
9561    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9562    let root = parsed.tree.root_node();
9563    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9564    infer_cpp_receiver_type_from_scope(
9565        scope,
9566        &parsed.source,
9567        &reference.receiver,
9568        reference.line.max(1),
9569    )
9570}
9571
9572fn find_enclosing_java_like_type_node<'tree>(
9573    root: tree_sitter::Node<'tree>,
9574    source: &str,
9575    reference: &NameMatchRef,
9576    lang: LangId,
9577) -> Option<tree_sitter::Node<'tree>> {
9578    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9579        .and_then(|name| simple_type_name(&name));
9580    let line = reference.line.max(1);
9581    let mut best = None;
9582    let mut stack = vec![root];
9583    while let Some(node) = stack.pop() {
9584        if !node_contains_line(node, line) {
9585            continue;
9586        }
9587        if is_java_like_type_kind(node.kind(), lang) {
9588            let name = declaration_name(node, source);
9589            if expected_type
9590                .as_deref()
9591                .is_none_or(|expected| name == Some(expected))
9592            {
9593                best = tighter_node(best, node);
9594            }
9595        }
9596        push_named_children(node, &mut stack);
9597    }
9598    best
9599}
9600
9601fn find_enclosing_java_like_callable_node<'tree>(
9602    root: tree_sitter::Node<'tree>,
9603    source: &str,
9604    reference: &NameMatchRef,
9605    lang: LangId,
9606) -> Option<tree_sitter::Node<'tree>> {
9607    let expected_name = reference.caller_symbol.rsplit("::").next();
9608    let line = reference.line.max(1);
9609    let mut best = None;
9610    let mut stack = vec![root];
9611    while let Some(node) = stack.pop() {
9612        if !node_contains_line(node, line) {
9613            continue;
9614        }
9615        if is_java_like_callable_kind(node.kind(), lang) {
9616            let name = declaration_name(node, source);
9617            if expected_name.is_none_or(|expected| name == Some(expected)) {
9618                best = tighter_node(best, node);
9619            }
9620        }
9621        push_named_children(node, &mut stack);
9622    }
9623    best
9624}
9625
9626fn find_enclosing_cpp_callable_node<'tree>(
9627    root: tree_sitter::Node<'tree>,
9628    _source: &str,
9629    reference: &NameMatchRef,
9630) -> Option<tree_sitter::Node<'tree>> {
9631    let line = reference.line.max(1);
9632    let mut best = None;
9633    let mut stack = vec![root];
9634    while let Some(node) = stack.pop() {
9635        if !node_contains_line(node, line) {
9636            continue;
9637        }
9638        if node.kind() == "function_definition" {
9639            best = tighter_node(best, node);
9640        }
9641        push_named_children(node, &mut stack);
9642    }
9643    best
9644}
9645
9646fn tighter_node<'tree>(
9647    current: Option<tree_sitter::Node<'tree>>,
9648    candidate: tree_sitter::Node<'tree>,
9649) -> Option<tree_sitter::Node<'tree>> {
9650    match current {
9651        Some(current)
9652            if current.start_byte() > candidate.start_byte()
9653                || (current.start_byte() == candidate.start_byte()
9654                    && current.end_byte() <= candidate.end_byte()) =>
9655        {
9656            Some(current)
9657        }
9658        _ => Some(candidate),
9659    }
9660}
9661
9662fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9663    let start = node.start_position().row as u32 + 1;
9664    let end = node.end_position().row as u32 + 1;
9665    start <= line && line <= end
9666}
9667
9668fn push_named_children<'tree>(
9669    node: tree_sitter::Node<'tree>,
9670    stack: &mut Vec<tree_sitter::Node<'tree>>,
9671) {
9672    for index in 0..node.named_child_count() {
9673        if let Some(child) = node.named_child(index as u32) {
9674            stack.push(child);
9675        }
9676    }
9677}
9678
9679fn declaration_name<'source>(
9680    node: tree_sitter::Node<'_>,
9681    source: &'source str,
9682) -> Option<&'source str> {
9683    node.child_by_field_name("name")
9684        .map(|name| node_text(name, source))
9685        .or_else(|| {
9686            first_named_child_text(
9687                node,
9688                source,
9689                &["identifier", "type_identifier", "simple_identifier"],
9690            )
9691        })
9692}
9693
9694fn first_named_child_text<'source>(
9695    node: tree_sitter::Node<'_>,
9696    source: &'source str,
9697    kinds: &[&str],
9698) -> Option<&'source str> {
9699    for index in 0..node.named_child_count() {
9700        let child = node.named_child(index as u32)?;
9701        if kinds.contains(&child.kind()) {
9702            return Some(node_text(child, source));
9703        }
9704    }
9705    None
9706}
9707
9708fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9709    &source[node.byte_range()]
9710}
9711
9712fn infer_java_like_field_receiver_type(
9713    type_node: tree_sitter::Node<'_>,
9714    source: &str,
9715    receiver: &str,
9716    lang: LangId,
9717) -> Option<String> {
9718    let mut stack = Vec::new();
9719    push_named_children(type_node, &mut stack);
9720    while let Some(node) = stack.pop() {
9721        if is_java_like_field_kind(node.kind(), lang) {
9722            if let Some(receiver_type) =
9723                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9724            {
9725                return Some(receiver_type);
9726            }
9727        }
9728        if is_java_like_type_kind(node.kind(), lang)
9729            || is_java_like_callable_kind(node.kind(), lang)
9730        {
9731            continue;
9732        }
9733        push_named_children(node, &mut stack);
9734    }
9735    None
9736}
9737
9738fn infer_java_like_local_receiver_type(
9739    callable_node: tree_sitter::Node<'_>,
9740    source: &str,
9741    receiver: &str,
9742    call_line: u32,
9743    lang: LangId,
9744) -> Option<String> {
9745    let mut best: Option<(u32, String)> = None;
9746    let mut stack = Vec::new();
9747    push_named_children(callable_node, &mut stack);
9748    while let Some(node) = stack.pop() {
9749        let start_line = node.start_position().row as u32 + 1;
9750        if start_line > call_line {
9751            continue;
9752        }
9753        if is_java_like_local_kind(node.kind(), lang) {
9754            if let Some(receiver_type) =
9755                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9756            {
9757                if best
9758                    .as_ref()
9759                    .is_none_or(|(best_line, _)| start_line >= *best_line)
9760                {
9761                    best = Some((start_line, receiver_type));
9762                }
9763            }
9764        }
9765        if is_java_like_type_kind(node.kind(), lang)
9766            || is_java_like_callable_kind(node.kind(), lang)
9767        {
9768            continue;
9769        }
9770        push_named_children(node, &mut stack);
9771    }
9772    best.map(|(_, receiver_type)| receiver_type)
9773}
9774
9775fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9776    match lang {
9777        LangId::Java => matches!(
9778            kind,
9779            "class_declaration"
9780                | "interface_declaration"
9781                | "enum_declaration"
9782                | "record_declaration"
9783                | "annotation_type_declaration"
9784        ),
9785        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9786        _ => false,
9787    }
9788}
9789
9790fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9791    match lang {
9792        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9793        LangId::Kotlin => kind == "function_declaration",
9794        _ => false,
9795    }
9796}
9797
9798fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9799    match lang {
9800        LangId::Java => kind == "field_declaration",
9801        LangId::Kotlin => kind == "property_declaration",
9802        _ => false,
9803    }
9804}
9805
9806fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9807    match lang {
9808        LangId::Java => kind == "local_variable_declaration",
9809        LangId::Kotlin => kind == "property_declaration",
9810        _ => false,
9811    }
9812}
9813
9814fn extract_java_like_declared_type(
9815    declaration: &str,
9816    receiver: &str,
9817    lang: LangId,
9818) -> Option<String> {
9819    match lang {
9820        LangId::Java => extract_java_declared_type(declaration, receiver),
9821        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9822        _ => None,
9823    }
9824}
9825
9826fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9827    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9828    let after = declaration[receiver_start + receiver.len()..].trim_start();
9829    if after
9830        .chars()
9831        .next()
9832        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9833    {
9834        return None;
9835    }
9836
9837    let before = declaration[..receiver_start].trim_end();
9838    if before.contains(',') {
9839        return None;
9840    }
9841    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9842}
9843
9844fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9845    loop {
9846        value = value.trim_start();
9847        if let Some(stripped) = strip_leading_java_annotation(value) {
9848            value = stripped;
9849            continue;
9850        }
9851        if let Some(stripped) = strip_leading_java_modifier(value) {
9852            value = stripped;
9853            continue;
9854        }
9855        return value.trim();
9856    }
9857}
9858
9859fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9860    let value = value.trim_start();
9861    let mut chars = value.char_indices();
9862    let (_, first) = chars.next()?;
9863    if first != '@' {
9864        return None;
9865    }
9866    let mut end = first.len_utf8();
9867    for (index, ch) in chars {
9868        if !(is_code_ident_char(ch) || ch == '.') {
9869            end = index;
9870            break;
9871        }
9872        end = index + ch.len_utf8();
9873    }
9874    let rest = value[end..].trim_start();
9875    if let Some(stripped) = rest.strip_prefix('(') {
9876        let mut depth = 1usize;
9877        for (index, ch) in stripped.char_indices() {
9878            match ch {
9879                '(' => depth += 1,
9880                ')' => {
9881                    depth = depth.saturating_sub(1);
9882                    if depth == 0 {
9883                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9884                    }
9885                }
9886                _ => {}
9887            }
9888        }
9889        return Some("");
9890    }
9891    Some(rest)
9892}
9893
9894fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9895    const MODIFIERS: &[&str] = &[
9896        "public",
9897        "protected",
9898        "private",
9899        "abstract",
9900        "static",
9901        "final",
9902        "transient",
9903        "volatile",
9904        "synchronized",
9905        "native",
9906        "strictfp",
9907    ];
9908    MODIFIERS
9909        .iter()
9910        .find_map(|modifier| strip_leading_word(value, modifier))
9911}
9912
9913fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9914    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9915    let before = &declaration[..receiver_start];
9916    if find_identifier_occurrence(before, "val").is_none()
9917        && find_identifier_occurrence(before, "var").is_none()
9918    {
9919        return None;
9920    }
9921
9922    let after = declaration[receiver_start + receiver.len()..].trim_start();
9923    if let Some(type_text) = after.strip_prefix(':') {
9924        return normalize_receiver_type_name(read_type_prefix(type_text));
9925    }
9926    after
9927        .strip_prefix('=')
9928        .and_then(infer_kotlin_constructor_type)
9929}
9930
9931fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9932    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9933    if rest.trim_start().starts_with('(') {
9934        normalize_receiver_type_name(head)
9935    } else {
9936        None
9937    }
9938}
9939
9940fn read_type_prefix(value: &str) -> &str {
9941    let mut angle_depth = 0usize;
9942    for (index, ch) in value.char_indices() {
9943        match ch {
9944            '<' => angle_depth += 1,
9945            '>' => angle_depth = angle_depth.saturating_sub(1),
9946            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9947                return value[..index].trim();
9948            }
9949            _ => {}
9950        }
9951    }
9952    value.trim()
9953}
9954
9955fn infer_cpp_receiver_type_from_scope(
9956    scope: tree_sitter::Node<'_>,
9957    source: &str,
9958    receiver: &str,
9959    call_line: u32,
9960) -> Option<String> {
9961    let lines = source.lines().collect::<Vec<_>>();
9962    if lines.is_empty() {
9963        return None;
9964    }
9965    let scope_start = scope.start_position().row as usize;
9966    let call_index = (call_line as usize)
9967        .saturating_sub(1)
9968        .min(lines.len().saturating_sub(1));
9969    for index in (scope_start..=call_index).rev() {
9970        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9971            return Some(receiver_type);
9972        }
9973    }
9974    None
9975}
9976
9977fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9978    for receiver_start in identifier_occurrences(line, receiver) {
9979        let after = line[receiver_start + receiver.len()..].trim_start();
9980        if after
9981            .chars()
9982            .next()
9983            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9984        {
9985            continue;
9986        }
9987        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9988        let normalized = normalize_cpp_type_name(type_text)?;
9989        if normalized == "auto" {
9990            if let Some(rhs) = after.strip_prefix('=') {
9991                return infer_cpp_auto_receiver_type(rhs);
9992            }
9993            continue;
9994        }
9995        return Some(normalized);
9996    }
9997    None
9998}
9999
10000fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10001    let candidate = prefix
10002        .rsplit([';', '{', '}', '('])
10003        .next()
10004        .unwrap_or(prefix)
10005        .trim();
10006    if candidate.is_empty() || candidate.ends_with(',') {
10007        None
10008    } else {
10009        Some(candidate)
10010    }
10011}
10012
10013fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10014    let without_templates = strip_angle_groups(type_text);
10015    let mut cleaned = String::with_capacity(without_templates.len());
10016    for token in without_templates.split_whitespace() {
10017        if matches!(
10018            token,
10019            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10020        ) {
10021            continue;
10022        }
10023        if !cleaned.is_empty() {
10024            cleaned.push(' ');
10025        }
10026        cleaned.push_str(token);
10027    }
10028    let token = cleaned
10029        .split_whitespace()
10030        .last()
10031        .unwrap_or(cleaned.trim())
10032        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10033        .trim_matches(['*', '&']);
10034    let simple = token.rsplit("::").next().unwrap_or(token).trim();
10035    if simple.is_empty() || cpp_non_type_token(simple) {
10036        None
10037    } else {
10038        Some(simple.to_string())
10039    }
10040}
10041
10042fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10043    let rhs = rhs.trim_start();
10044    if let Some(after_new) = rhs.strip_prefix("new ") {
10045        return infer_cpp_constructor_type(after_new);
10046    }
10047    infer_cpp_make_template_type(rhs)
10048        .or_else(|| infer_cpp_constructor_type(rhs))
10049        .or_else(|| infer_cpp_factory_type(rhs))
10050}
10051
10052fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10053    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10054    let normalized = normalize_cpp_type_name(head)?;
10055    if !normalized
10056        .chars()
10057        .next()
10058        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10059    {
10060        return None;
10061    }
10062    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10063        Some(normalized)
10064    } else {
10065        None
10066    }
10067}
10068
10069fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10070    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10071    if !rest.trim_start().starts_with('(') {
10072        return None;
10073    }
10074    let base = head.split('<').next().unwrap_or(head);
10075    let base_simple = base.rsplit("::").next().unwrap_or(base);
10076    if !matches!(base_simple, "make_unique" | "make_shared") {
10077        return None;
10078    }
10079    first_angle_arg(head).and_then(normalize_cpp_type_name)
10080}
10081
10082fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10083    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10084    if !rest.trim_start().starts_with('(') {
10085        return None;
10086    }
10087    let simple = head
10088        .split('<')
10089        .next()
10090        .unwrap_or(head)
10091        .rsplit("::")
10092        .next()
10093        .unwrap_or(head);
10094    for prefix in ["make", "create", "build"] {
10095        if let Some(suffix) = simple.strip_prefix(prefix) {
10096            if suffix
10097                .chars()
10098                .next()
10099                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10100            {
10101                return normalize_cpp_type_name(suffix);
10102            }
10103        }
10104    }
10105    None
10106}
10107
10108#[derive(Debug, Clone, Copy)]
10109enum JavaLikeInvocation {
10110    Kotlin,
10111    Cpp,
10112}
10113
10114fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10115    let value = value.trim_start();
10116    let mut end = 0usize;
10117    for (index, ch) in value.char_indices() {
10118        let allowed_separator = match flavor {
10119            JavaLikeInvocation::Kotlin => ch == '.',
10120            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10121        };
10122        if is_code_ident_char(ch) || allowed_separator {
10123            end = index + ch.len_utf8();
10124            continue;
10125        }
10126        break;
10127    }
10128    if end == 0 {
10129        return None;
10130    }
10131    let mut rest = &value[end..];
10132    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10133        let skipped = skip_balanced_angle(stripped)?;
10134        let rest_start = rest.len() - rest.trim_start().len();
10135        let angle_len = 1 + skipped;
10136        end += rest_start + angle_len;
10137        rest = &value[end..];
10138    }
10139    Some((value[..end].trim(), rest))
10140}
10141
10142fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10143    let mut depth = 1usize;
10144    for (index, ch) in value_after_open.char_indices() {
10145        match ch {
10146            '<' => depth += 1,
10147            '>' => {
10148                depth = depth.saturating_sub(1);
10149                if depth == 0 {
10150                    return Some(index + ch.len_utf8());
10151                }
10152            }
10153            _ => {}
10154        }
10155    }
10156    None
10157}
10158
10159fn first_angle_arg(value: &str) -> Option<&str> {
10160    let open = value.find('<')?;
10161    let inner_len = skip_balanced_angle(&value[open + 1..])?;
10162    let inner = &value[open + 1..open + inner_len];
10163    split_top_level_commas(inner).into_iter().next()
10164}
10165
10166fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10167    let without_generics = strip_angle_groups(type_text);
10168    let cleaned = without_generics
10169        .replace("[]", " ")
10170        .replace("...", " ")
10171        .replace(['?', '&', '*'], " ");
10172    let token = cleaned
10173        .split_whitespace()
10174        .last()
10175        .unwrap_or(cleaned.trim())
10176        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10177    let token = token.rsplit("::").next().unwrap_or(token);
10178    let simple = token.rsplit('.').next().unwrap_or(token).trim();
10179    if simple.is_empty()
10180        || java_like_primitive_type(simple)
10181        || !simple
10182            .chars()
10183            .next()
10184            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10185    {
10186        None
10187    } else {
10188        Some(simple.to_string())
10189    }
10190}
10191
10192fn simple_type_name(scoped_name: &str) -> Option<String> {
10193    scoped_name
10194        .rsplit("::")
10195        .find(|segment| !segment.is_empty())
10196        .and_then(normalize_receiver_type_name)
10197}
10198
10199fn strip_angle_groups(value: &str) -> String {
10200    let mut output = String::with_capacity(value.len());
10201    let mut depth = 0usize;
10202    for ch in value.chars() {
10203        match ch {
10204            '<' => {
10205                if depth == 0 {
10206                    output.push(' ');
10207                }
10208                depth += 1;
10209            }
10210            '>' => depth = depth.saturating_sub(1),
10211            _ if depth == 0 => output.push(ch),
10212            _ => {}
10213        }
10214    }
10215    output
10216}
10217
10218fn java_like_primitive_type(value: &str) -> bool {
10219    matches!(
10220        value,
10221        "boolean"
10222            | "byte"
10223            | "char"
10224            | "double"
10225            | "float"
10226            | "int"
10227            | "long"
10228            | "short"
10229            | "void"
10230            | "Boolean"
10231            | "Byte"
10232            | "Char"
10233            | "Double"
10234            | "Float"
10235            | "Int"
10236            | "Long"
10237            | "Short"
10238            | "Unit"
10239    )
10240}
10241
10242fn cpp_non_type_token(value: &str) -> bool {
10243    matches!(
10244        value,
10245        "return"
10246            | "if"
10247            | "else"
10248            | "for"
10249            | "while"
10250            | "do"
10251            | "switch"
10252            | "case"
10253            | "default"
10254            | "break"
10255            | "continue"
10256            | "goto"
10257            | "throw"
10258            | "new"
10259            | "delete"
10260            | "co_await"
10261            | "co_yield"
10262            | "co_return"
10263            | "static_cast"
10264            | "const_cast"
10265            | "dynamic_cast"
10266            | "reinterpret_cast"
10267            | "sizeof"
10268            | "alignof"
10269            | "typeid"
10270            | "and"
10271            | "or"
10272            | "not"
10273            | "xor"
10274    )
10275}
10276
10277fn receiver_is_bare_identifier(value: &str) -> bool {
10278    let mut chars = value.chars();
10279    let Some(first) = chars.next() else {
10280        return false;
10281    };
10282    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10283}
10284
10285fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10286    identifier_occurrences(value, needle).into_iter().next()
10287}
10288
10289fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10290    value
10291        .match_indices(needle)
10292        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10293        .collect()
10294}
10295
10296fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10297    let before = value[..start].chars().next_back();
10298    let after = value[start + len..].chars().next();
10299    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10300}
10301
10302fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10303    let stripped = value.strip_prefix(word)?;
10304    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10305        Some(stripped.trim_start())
10306    } else {
10307        None
10308    }
10309}
10310
10311fn is_code_ident_char(ch: char) -> bool {
10312    ch == '_' || ch.is_ascii_alphanumeric()
10313}
10314
10315fn infer_rust_receiver_type(
10316    project_root: &Path,
10317    reference: &NameMatchRef,
10318    source_cache: &mut DispatchSourceCache,
10319) -> ReceiverTypeInference {
10320    if matches!(reference.receiver.as_str(), "self" | "Self") {
10321        return enclosing_type_from_scoped_name(&reference.caller_symbol)
10322            .map(ReceiverTypeInference::Known)
10323            .unwrap_or(ReceiverTypeInference::Unknown);
10324    }
10325
10326    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10327        return ReceiverTypeInference::Known(reference.receiver.clone());
10328    }
10329
10330    if let Some(receiver_type) = reference
10331        .caller_signature
10332        .as_deref()
10333        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10334    {
10335        return ReceiverTypeInference::Known(receiver_type);
10336    }
10337
10338    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10339}
10340
10341fn infer_rust_direct_self_field_receiver_type(
10342    project_root: &Path,
10343    reference: &NameMatchRef,
10344    source_cache: &mut DispatchSourceCache,
10345) -> ReceiverTypeInference {
10346    if reference.colon_dispatch {
10347        return ReceiverTypeInference::Unknown;
10348    }
10349    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10350        return ReceiverTypeInference::Unknown;
10351    };
10352    if field_name != reference.receiver {
10353        return ReceiverTypeInference::Unknown;
10354    }
10355
10356    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10357        return ReceiverTypeInference::Unknown;
10358    };
10359    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10360        return ReceiverTypeInference::KnownButUnresolved;
10361    };
10362    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10363    else {
10364        return ReceiverTypeInference::Unknown;
10365    };
10366    let Some(impl_node) =
10367        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10368    else {
10369        return ReceiverTypeInference::Unknown;
10370    };
10371    if impl_node.child_by_field_name("trait").is_some()
10372        || impl_node.child_by_field_name("type_parameters").is_some()
10373    {
10374        return ReceiverTypeInference::KnownButUnresolved;
10375    }
10376    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10377        return ReceiverTypeInference::KnownButUnresolved;
10378    };
10379    if impl_target.kind() != "type_identifier"
10380        || node_text(impl_target, &parsed.source) != impl_type
10381    {
10382        return ReceiverTypeInference::KnownButUnresolved;
10383    }
10384
10385    let module_scope = rust_module_scope(impl_node);
10386    let Some(struct_node) = find_unique_rust_struct(
10387        parsed.tree.root_node(),
10388        &parsed.source,
10389        struct_name,
10390        &module_scope,
10391    ) else {
10392        return ReceiverTypeInference::KnownButUnresolved;
10393    };
10394    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10395    else {
10396        return ReceiverTypeInference::KnownButUnresolved;
10397    };
10398    if field_type.kind() != "type_identifier" {
10399        return ReceiverTypeInference::KnownButUnresolved;
10400    }
10401    let field_type_name = node_text(field_type, &parsed.source);
10402    if find_unique_rust_struct(
10403        parsed.tree.root_node(),
10404        &parsed.source,
10405        field_type_name,
10406        &module_scope,
10407    )
10408    .is_none()
10409    {
10410        return ReceiverTypeInference::KnownButUnresolved;
10411    }
10412
10413    ReceiverTypeInference::RustDirectSelfField {
10414        receiver_type: field_type_name.to_string(),
10415        declaration_file: reference.caller_file.clone(),
10416        module_scope,
10417    }
10418}
10419
10420fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10421    let (base, field) = receiver_expression.split_once('.')?;
10422    let base = base.trim();
10423    let field = field.trim();
10424    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10425}
10426
10427fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10428    let name = value.rsplit("::").next()?.trim();
10429    (!name.is_empty()
10430        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10431        && name.chars().all(is_rust_ident_char))
10432    .then_some(name)
10433}
10434
10435fn find_enclosing_rust_impl_node<'tree>(
10436    root: tree_sitter::Node<'tree>,
10437    line: u32,
10438) -> Option<tree_sitter::Node<'tree>> {
10439    let mut best = None;
10440    let mut stack = vec![root];
10441    while let Some(node) = stack.pop() {
10442        if !node_contains_line(node, line) {
10443            continue;
10444        }
10445        if node.kind() == "impl_item" {
10446            best = tighter_node(best, node);
10447        }
10448        push_named_children(node, &mut stack);
10449    }
10450    best
10451}
10452
10453fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10454    let mut scope = Vec::new();
10455    let mut current = node.parent();
10456    while let Some(parent) = current {
10457        if parent.kind() == "mod_item" {
10458            scope.push((parent.start_byte(), parent.end_byte()));
10459        }
10460        current = parent.parent();
10461    }
10462    scope.reverse();
10463    scope
10464}
10465
10466fn find_unique_rust_struct<'tree>(
10467    root: tree_sitter::Node<'tree>,
10468    source: &str,
10469    expected_name: &str,
10470    module_scope: &[(usize, usize)],
10471) -> Option<tree_sitter::Node<'tree>> {
10472    let mut found = None;
10473    let mut stack = vec![root];
10474    while let Some(node) = stack.pop() {
10475        if node.kind() == "struct_item"
10476            && rust_module_scope(node) == module_scope
10477            && node.child_by_field_name("type_parameters").is_none()
10478            && declaration_name(node, source) == Some(expected_name)
10479        {
10480            if found.is_some() {
10481                return None;
10482            }
10483            found = Some(node);
10484        }
10485        push_named_children(node, &mut stack);
10486    }
10487    found
10488}
10489
10490fn rust_struct_field_type_node<'tree>(
10491    struct_node: tree_sitter::Node<'tree>,
10492    source: &str,
10493    field_name: &str,
10494) -> Option<tree_sitter::Node<'tree>> {
10495    let fields = struct_node.child_by_field_name("body")?;
10496    if fields.kind() != "field_declaration_list" {
10497        return None;
10498    }
10499    for index in 0..fields.named_child_count() {
10500        let field = fields.named_child(index as u32)?;
10501        if field.kind() != "field_declaration"
10502            || declaration_name(field, source) != Some(field_name)
10503        {
10504            continue;
10505        }
10506        return field.child_by_field_name("type");
10507    }
10508    None
10509}
10510
10511fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10512    receiver
10513        .chars()
10514        .next()
10515        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10516}
10517
10518fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10519    scoped_name
10520        .rsplit_once("::")
10521        .map(|(enclosing, _)| enclosing)
10522        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10523        .map(ToString::to_string)
10524}
10525
10526fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10527    let params = signature_parameter_text(signature)?;
10528    for param in split_top_level_commas(params) {
10529        let Some((pattern, type_text)) = param.split_once(':') else {
10530            continue;
10531        };
10532        let Some(name) = rust_parameter_name(pattern) else {
10533            continue;
10534        };
10535        if name == receiver {
10536            return normalize_rust_receiver_type(type_text);
10537        }
10538    }
10539    None
10540}
10541
10542fn signature_parameter_text(signature: &str) -> Option<&str> {
10543    let open = signature.find('(')?;
10544    let mut depth = 0usize;
10545    for (offset, ch) in signature[open..].char_indices() {
10546        match ch {
10547            '(' => depth += 1,
10548            ')' => {
10549                depth = depth.saturating_sub(1);
10550                if depth == 0 {
10551                    return Some(&signature[open + 1..open + offset]);
10552                }
10553            }
10554            _ => {}
10555        }
10556    }
10557    None
10558}
10559
10560fn split_top_level_commas(value: &str) -> Vec<&str> {
10561    let mut parts = Vec::new();
10562    let mut start = 0usize;
10563    let mut angle_depth = 0usize;
10564    let mut paren_depth = 0usize;
10565    let mut bracket_depth = 0usize;
10566    for (index, ch) in value.char_indices() {
10567        match ch {
10568            '<' => angle_depth += 1,
10569            '>' => angle_depth = angle_depth.saturating_sub(1),
10570            '(' => paren_depth += 1,
10571            ')' => paren_depth = paren_depth.saturating_sub(1),
10572            '[' => bracket_depth += 1,
10573            ']' => bracket_depth = bracket_depth.saturating_sub(1),
10574            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10575                let part = value[start..index].trim();
10576                if !part.is_empty() {
10577                    parts.push(part);
10578                }
10579                start = index + ch.len_utf8();
10580            }
10581            _ => {}
10582        }
10583    }
10584    let part = value[start..].trim();
10585    if !part.is_empty() {
10586        parts.push(part);
10587    }
10588    parts
10589}
10590
10591fn rust_parameter_name(pattern: &str) -> Option<&str> {
10592    let mut pattern = pattern.trim();
10593    if let Some(stripped) = pattern.strip_prefix("mut ") {
10594        pattern = stripped.trim_start();
10595    }
10596    pattern
10597        .rsplit(|ch: char| !is_rust_ident_char(ch))
10598        .find(|part| !part.is_empty())
10599}
10600
10601fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10602    let mut ty = strip_leading_rust_type_modifiers(type_text);
10603    let owned_inner;
10604    if let Some(inner) = single_outer_generic_arg(ty) {
10605        owned_inner = inner.trim().to_string();
10606        ty = strip_leading_rust_type_modifiers(&owned_inner);
10607    }
10608    rust_base_type_ident(ty)
10609}
10610
10611fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10612    loop {
10613        ty = ty.trim_start();
10614        if let Some(stripped) = ty.strip_prefix('&') {
10615            ty = stripped.trim_start();
10616            if let Some(stripped) = strip_leading_lifetime(ty) {
10617                ty = stripped.trim_start();
10618            }
10619            if let Some(stripped) = ty.strip_prefix("mut ") {
10620                ty = stripped.trim_start();
10621            }
10622            continue;
10623        }
10624        if let Some(stripped) = ty.strip_prefix("mut ") {
10625            ty = stripped.trim_start();
10626            continue;
10627        }
10628        if let Some(stripped) = ty.strip_prefix("dyn ") {
10629            ty = stripped.trim_start();
10630            continue;
10631        }
10632        if let Some(stripped) = ty.strip_prefix("impl ") {
10633            ty = stripped.trim_start();
10634            continue;
10635        }
10636        break ty.trim();
10637    }
10638}
10639
10640fn strip_leading_lifetime(value: &str) -> Option<&str> {
10641    let mut chars = value.char_indices();
10642    let (_, first) = chars.next()?;
10643    if first != '\'' {
10644        return None;
10645    }
10646    for (index, ch) in chars {
10647        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10648            return Some(&value[index..]);
10649        }
10650    }
10651    Some("")
10652}
10653
10654fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10655    let ty = ty.trim();
10656    let open = ty.find('<')?;
10657    let mut depth = 0usize;
10658    let mut close = None;
10659    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10660        match ch {
10661            '<' => depth += 1,
10662            '>' => {
10663                depth = depth.saturating_sub(1);
10664                if depth == 0 {
10665                    close = Some(index);
10666                    break;
10667                }
10668            }
10669            _ => {}
10670        }
10671    }
10672    let close = close?;
10673    if !ty[close + 1..].trim().is_empty() {
10674        return None;
10675    }
10676    let inner = &ty[open + 1..close];
10677    let args = split_top_level_commas(inner);
10678    match args.as_slice() {
10679        [arg] => Some(*arg),
10680        _ => None,
10681    }
10682}
10683
10684fn rust_base_type_ident(ty: &str) -> Option<String> {
10685    let ty = ty.trim();
10686    let head = ty
10687        .split([' ', '+', '='])
10688        .find(|part| !part.is_empty())
10689        .unwrap_or(ty);
10690    let head = head.split('<').next().unwrap_or(head).trim();
10691    let ident = head
10692        .rsplit("::")
10693        .next()
10694        .unwrap_or(head)
10695        .trim_matches(|ch: char| !is_rust_ident_char(ch));
10696    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10697        None
10698    } else {
10699        Some(ident.to_string())
10700    }
10701}
10702
10703fn is_rust_ident_char(ch: char) -> bool {
10704    ch == '_' || ch.is_ascii_alphanumeric()
10705}
10706
10707fn select_rust_direct_self_field_candidate(
10708    project_root: &Path,
10709    reference: &NameMatchRef,
10710    candidates: &[NameMatchCandidate],
10711    receiver_type: &str,
10712    declaration_file: &str,
10713    declaration_scope: &[(usize, usize)],
10714    source_cache: &mut DispatchSourceCache,
10715) -> Option<NameMatchCandidate> {
10716    let eligible = candidates
10717        .iter()
10718        .filter(|candidate| candidate.node_id != reference.caller_node)
10719        .filter(|candidate| {
10720            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10721        })
10722        .filter(|candidate| {
10723            rust_direct_self_field_candidate_matches_scope(
10724                project_root,
10725                candidate,
10726                receiver_type,
10727                declaration_file,
10728                declaration_scope,
10729                source_cache,
10730            )
10731        })
10732        .collect::<Vec<_>>();
10733    match eligible.as_slice() {
10734        [candidate] => Some((**candidate).clone()),
10735        _ => None,
10736    }
10737}
10738
10739fn rust_direct_self_field_candidate_matches_scope(
10740    project_root: &Path,
10741    candidate: &NameMatchCandidate,
10742    receiver_type: &str,
10743    declaration_file: &str,
10744    declaration_scope: &[(usize, usize)],
10745    source_cache: &mut DispatchSourceCache,
10746) -> bool {
10747    if candidate.file_path != declaration_file {
10748        return false;
10749    }
10750    let Some(parsed) = parsed_dispatch_source_for_file(
10751        project_root,
10752        &candidate.file_path,
10753        "rust",
10754        LangId::Rust,
10755        source_cache,
10756    ) else {
10757        return false;
10758    };
10759    let Some(impl_node) =
10760        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10761    else {
10762        return false;
10763    };
10764    if impl_node.child_by_field_name("trait").is_some()
10765        || impl_node.child_by_field_name("type_parameters").is_some()
10766    {
10767        return false;
10768    }
10769    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10770        return false;
10771    };
10772    impl_target.kind() == "type_identifier"
10773        && node_text(impl_target, &parsed.source) == receiver_type
10774        && rust_module_scope(impl_node) == declaration_scope
10775}
10776
10777fn select_type_match_candidate(
10778    reference: &NameMatchRef,
10779    candidates: &[NameMatchCandidate],
10780    receiver_type: &str,
10781) -> Option<NameMatchCandidate> {
10782    let candidates = candidates
10783        .iter()
10784        .filter(|candidate| candidate.node_id != reference.caller_node)
10785        .filter(|candidate| {
10786            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10787        })
10788        .collect::<Vec<_>>();
10789    match candidates.as_slice() {
10790        [candidate] => Some((**candidate).clone()),
10791        _ => None,
10792    }
10793}
10794
10795fn type_candidate_matches(
10796    candidate: &NameMatchCandidate,
10797    receiver_type: &str,
10798    method_name: &str,
10799) -> bool {
10800    let normalized_type = receiver_type.replace('.', "::");
10801    let suffix = format!("{normalized_type}::{method_name}");
10802    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10803}
10804
10805fn select_name_match_candidate(
10806    reference: &NameMatchRef,
10807    candidates: &[NameMatchCandidate],
10808) -> Option<NameMatchCandidate> {
10809    let candidates = candidates
10810        .iter()
10811        .filter(|candidate| candidate.node_id != reference.caller_node)
10812        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10813        .collect::<Vec<_>>();
10814    match candidates.as_slice() {
10815        [] => None,
10816        [candidate] => Some((**candidate).clone()),
10817        _ => select_scored_name_match_candidate(reference, &candidates),
10818    }
10819}
10820
10821fn candidate_allowed_for_reference(
10822    reference: &NameMatchRef,
10823    candidate: &NameMatchCandidate,
10824) -> bool {
10825    if !reference.colon_dispatch {
10826        return true;
10827    }
10828
10829    candidate.kind == "method"
10830        && candidate
10831            .scoped_name
10832            .split("::")
10833            .any(|segment| segment == reference.receiver)
10834}
10835
10836fn select_scored_name_match_candidate(
10837    reference: &NameMatchRef,
10838    candidates: &[&NameMatchCandidate],
10839) -> Option<NameMatchCandidate> {
10840    let receiver_words = split_camel_case(&reference.receiver);
10841    if receiver_words.is_empty() {
10842        return None;
10843    }
10844
10845    let mut best: Option<(&NameMatchCandidate, f64)> = None;
10846    let mut tied_best = false;
10847    for candidate in candidates {
10848        let candidate_words = split_camel_case(&candidate.scoped_name);
10849        let overlap = receiver_words
10850            .iter()
10851            .filter(|receiver_word| {
10852                candidate_words
10853                    .iter()
10854                    .any(|candidate_word| candidate_word == *receiver_word)
10855            })
10856            .count() as f64;
10857        let score =
10858            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10859        match best {
10860            None => {
10861                best = Some((*candidate, score));
10862                tied_best = false;
10863            }
10864            Some((_, best_score)) if score > best_score => {
10865                best = Some((*candidate, score));
10866                tied_best = false;
10867            }
10868            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10869                tied_best = true;
10870            }
10871            _ => {}
10872        }
10873    }
10874
10875    let (candidate, score) = best?;
10876    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10877        Some(candidate.clone())
10878    } else {
10879        None
10880    }
10881}
10882
10883fn method_name_match_denylisted(method_name: &str) -> bool {
10884    matches!(
10885        method_name,
10886        "and_then"
10887            | "as_bytes"
10888            | "as_deref"
10889            | "as_mut"
10890            | "as_ref"
10891            | "as_str"
10892            | "borrow"
10893            | "borrow_mut"
10894            | "clear"
10895            | "clone"
10896            | "collect"
10897            | "contains"
10898            | "contains_key"
10899            | "count"
10900            | "dedup"
10901            | "default"
10902            | "drain"
10903            | "ends_with"
10904            | "entry"
10905            | "err"
10906            | "expect"
10907            | "extend"
10908            | "filter"
10909            | "filter_map"
10910            | "find"
10911            | "from"
10912            | "get"
10913            | "get_mut"
10914            | "insert"
10915            | "into"
10916            | "into_iter"
10917            | "is_empty"
10918            | "is_err"
10919            | "is_none"
10920            | "is_ok"
10921            | "is_some"
10922            | "iter"
10923            | "iter_mut"
10924            | "join"
10925            | "len"
10926            | "lock"
10927            | "map"
10928            | "map_err"
10929            | "max"
10930            | "min"
10931            | "new"
10932            | "next"
10933            | "ok"
10934            | "or_default"
10935            | "or_else"
10936            | "or_insert"
10937            | "or_insert_with"
10938            | "parse"
10939            | "pop"
10940            | "position"
10941            | "push"
10942            | "read"
10943            | "recv"
10944            | "remove"
10945            | "replace"
10946            | "retain"
10947            | "send"
10948            | "sort"
10949            | "sort_by"
10950            | "split"
10951            | "starts_with"
10952            | "sum"
10953            | "take"
10954            | "to_owned"
10955            | "to_string"
10956            | "trim"
10957            | "try_from"
10958            | "try_into"
10959            | "unwrap"
10960            | "unwrap_or"
10961            | "unwrap_or_default"
10962            | "unwrap_or_else"
10963            | "with_capacity"
10964            | "write"
10965    )
10966}
10967
10968fn split_camel_case(value: &str) -> Vec<String> {
10969    let chars = value.chars().collect::<Vec<_>>();
10970    let mut normalized = String::with_capacity(value.len() + 8);
10971    for (index, ch) in chars.iter().enumerate() {
10972        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10973        let next = chars.get(index + 1);
10974        let is_separator = ch.is_whitespace()
10975            || matches!(
10976                ch,
10977                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10978            );
10979        if is_separator {
10980            normalized.push(' ');
10981            continue;
10982        }
10983        let camel_boundary = previous.is_some_and(|prev| {
10984            (prev.is_lowercase() && ch.is_uppercase())
10985                || (prev.is_ascii_digit() && ch.is_alphabetic())
10986                || (prev.is_uppercase()
10987                    && ch.is_uppercase()
10988                    && next.is_some_and(|next| next.is_lowercase()))
10989        });
10990        if camel_boundary {
10991            normalized.push(' ');
10992        }
10993        normalized.push(*ch);
10994    }
10995
10996    normalized
10997        .split_whitespace()
10998        .filter(|word| word.len() > 1)
10999        .map(|word| word.to_ascii_lowercase())
11000        .collect()
11001}
11002
11003fn compute_path_proximity(left: &str, right: &str) -> f64 {
11004    let left_dirs = left
11005        .rsplit_once('/')
11006        .map(|(dir, _)| dir)
11007        .unwrap_or_default()
11008        .split('/')
11009        .filter(|part| !part.is_empty());
11010    let right_dirs = right
11011        .rsplit_once('/')
11012        .map(|(dir, _)| dir)
11013        .unwrap_or_default()
11014        .split('/')
11015        .filter(|part| !part.is_empty());
11016
11017    let shared = left_dirs
11018        .zip(right_dirs)
11019        .take_while(|(left, right)| left == right)
11020        .count();
11021    ((shared as f64) * 0.05).min(0.5)
11022}
11023
11024fn mark_backend_state(
11025    tx: &Transaction<'_>,
11026    project_root: &Path,
11027    rel_path: &str,
11028    content_hash: Option<&blake3::Hash>,
11029    status: &str,
11030) -> Result<()> {
11031    clear_backend_state_for_file(tx, project_root, rel_path)?;
11032    let hash = content_hash
11033        .map(|hash| hash_to_hex(*hash))
11034        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11035    tx.execute(
11036        "INSERT OR REPLACE INTO backend_file_state(
11037            backend, workspace_root, file_path, content_hash, status, updated_at
11038        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11039        params![
11040            BACKEND_TREESITTER,
11041            project_root.display().to_string(),
11042            rel_path,
11043            hash,
11044            status,
11045            unix_seconds_now(),
11046        ],
11047    )?;
11048    Ok(())
11049}
11050
11051fn clear_backend_state_for_file(
11052    tx: &Transaction<'_>,
11053    project_root: &Path,
11054    rel_path: &str,
11055) -> Result<()> {
11056    tx.execute(
11057        "DELETE FROM backend_file_state
11058         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11059        params![
11060            BACKEND_TREESITTER,
11061            project_root.display().to_string(),
11062            rel_path
11063        ],
11064    )?;
11065    Ok(())
11066}
11067
11068fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
11069    tx.query_row(
11070        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11071        params![rel_path],
11072        |row| {
11073            let hash_text: String = row.get(1)?;
11074            Ok(FileRow {
11075                surface_fingerprint: row.get(0)?,
11076                freshness: FileFreshness {
11077                    content_hash: hash_from_hex(&hash_text)
11078                        .unwrap_or_else(cache_freshness::zero_hash),
11079                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11080                    size: row.get::<_, i64>(3)? as u64,
11081                },
11082            })
11083        },
11084    )
11085    .optional()
11086    .map_err(CallGraphStoreError::from)
11087}
11088
11089fn stored_node_ids_match_extract(
11090    tx: &Transaction<'_>,
11091    rel_path: &str,
11092    extract: &FileExtract,
11093) -> Result<bool> {
11094    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11095    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11096    let mut stored = BTreeSet::new();
11097    for row in rows {
11098        stored.insert(row?);
11099    }
11100    let extracted = extract
11101        .nodes
11102        .iter()
11103        .map(|node| node.id.clone())
11104        .collect::<BTreeSet<_>>();
11105    Ok(stored == extracted)
11106}
11107
11108/// Compare every persisted graph row that comes from this file before rewriting it.
11109/// Ranges and reference byte offsets are part of the key because queries expose
11110/// source locations; equal names and edges are not enough after a body shift.
11111fn stored_extract_matches(
11112    tx: &Transaction<'_>,
11113    rel_path: &str,
11114    extract: &FileExtract,
11115    index: &ProjectIndex<'_>,
11116) -> Result<bool> {
11117    let stored_file = tx
11118        .query_row(
11119            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11120            params![rel_path],
11121            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11122        )
11123        .optional()?;
11124    if stored_file
11125        != Some((
11126            lang_label(extract.lang).to_string(),
11127            extract.surface_fingerprint.clone(),
11128        ))
11129    {
11130        return Ok(false);
11131    }
11132
11133    let mut stored_nodes_stmt = tx.prepare(
11134        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11135                end_line, end_col, range_ordinal, signature, exported,
11136                is_default_export, is_type_like, is_callgraph_entry_point, provenance
11137         FROM nodes WHERE file_path = ?1",
11138    )?;
11139    let stored_nodes = stored_nodes_stmt
11140        .query_map(params![rel_path], |row| {
11141            Ok(serde_json::json!([
11142                row.get::<_, String>(0)?,
11143                row.get::<_, String>(1)?,
11144                row.get::<_, String>(2)?,
11145                row.get::<_, String>(3)?,
11146                row.get::<_, String>(4)?,
11147                row.get::<_, i64>(5)?,
11148                row.get::<_, i64>(6)?,
11149                row.get::<_, i64>(7)?,
11150                row.get::<_, i64>(8)?,
11151                row.get::<_, i64>(9)?,
11152                row.get::<_, Option<String>>(10)?,
11153                row.get::<_, i64>(11)?,
11154                row.get::<_, i64>(12)?,
11155                row.get::<_, i64>(13)?,
11156                row.get::<_, i64>(14)?,
11157                row.get::<_, String>(15)?,
11158            ])
11159            .to_string())
11160        })?
11161        .collect::<rusqlite::Result<Vec<_>>>()?;
11162    let expected_nodes = extract
11163        .nodes
11164        .iter()
11165        .map(|node| {
11166            serde_json::json!([
11167                node.id,
11168                node.file_path,
11169                node.name,
11170                node.scoped_name,
11171                node.kind,
11172                node.range.start_line,
11173                node.range.start_col,
11174                node.range.end_line,
11175                node.range.end_col,
11176                node.range_ordinal,
11177                node.signature,
11178                bool_int(node.exported),
11179                bool_int(node.is_default_export),
11180                bool_int(node.is_type_like),
11181                bool_int(node.is_callgraph_entry_point),
11182                PROVENANCE_TREESITTER,
11183            ])
11184            .to_string()
11185        })
11186        .collect::<Vec<_>>();
11187    let mut stored_nodes = stored_nodes;
11188    let mut expected_nodes = expected_nodes;
11189    stored_nodes.sort();
11190    expected_nodes.sort();
11191    if stored_nodes != expected_nodes {
11192        return Ok(false);
11193    }
11194
11195    let resolved_refs = extract
11196        .raw_refs
11197        .iter()
11198        .cloned()
11199        .map(|raw| resolve_ref(raw, index))
11200        .collect::<Result<Vec<_>>>()?;
11201    let mut stored_refs_stmt = tx.prepare(
11202        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11203                module_path, import_kind, local_name, requested_name, namespace_alias,
11204                wildcard, line, byte_start, byte_end, status, target_node,
11205                target_file, target_symbol, provenance
11206         FROM refs WHERE caller_file = ?1",
11207    )?;
11208    let stored_refs = stored_refs_stmt
11209        .query_map(params![rel_path], |row| {
11210            Ok(serde_json::json!([
11211                row.get::<_, String>(0)?,
11212                row.get::<_, Option<String>>(1)?,
11213                row.get::<_, String>(2)?,
11214                row.get::<_, String>(3)?,
11215                row.get::<_, Option<String>>(4)?,
11216                row.get::<_, Option<String>>(5)?,
11217                row.get::<_, Option<String>>(6)?,
11218                row.get::<_, Option<String>>(7)?,
11219                row.get::<_, Option<String>>(8)?,
11220                row.get::<_, Option<String>>(9)?,
11221                row.get::<_, Option<String>>(10)?,
11222                row.get::<_, i64>(11)?,
11223                row.get::<_, i64>(12)?,
11224                row.get::<_, i64>(13)?,
11225                row.get::<_, i64>(14)?,
11226                row.get::<_, String>(15)?,
11227                row.get::<_, Option<String>>(16)?,
11228                row.get::<_, Option<String>>(17)?,
11229                row.get::<_, Option<String>>(18)?,
11230                row.get::<_, String>(19)?,
11231            ])
11232            .to_string())
11233        })?
11234        .collect::<rusqlite::Result<Vec<_>>>()?;
11235    let expected_refs = resolved_refs
11236        .iter()
11237        .map(|resolved| {
11238            let raw = &resolved.raw;
11239            serde_json::json!([
11240                raw.ref_id,
11241                raw.caller_node,
11242                raw.caller_file,
11243                raw.kind,
11244                raw.short_name,
11245                raw.full_ref,
11246                raw.module_path,
11247                raw.import_kind,
11248                raw.local_name,
11249                raw.requested_name,
11250                raw.namespace_alias,
11251                bool_int(raw.wildcard),
11252                raw.line,
11253                raw.byte_start,
11254                raw.byte_end,
11255                resolved.status,
11256                resolved.target_node,
11257                resolved.target_file,
11258                resolved.target_symbol,
11259                PROVENANCE_TREESITTER,
11260            ])
11261            .to_string()
11262        })
11263        .collect::<Vec<_>>();
11264    let mut stored_refs = stored_refs;
11265    let mut expected_refs = expected_refs;
11266    stored_refs.sort();
11267    expected_refs.sort();
11268    if stored_refs != expected_refs {
11269        return Ok(false);
11270    }
11271
11272    let mut stored_edges_stmt = tx.prepare(
11273        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11274                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11275         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11276         WHERE r.caller_file = ?1 AND e.provenance = ?2",
11277    )?;
11278    let stored_edges = stored_edges_stmt
11279        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11280            Ok(serde_json::json!([
11281                row.get::<_, String>(0)?,
11282                row.get::<_, String>(1)?,
11283                row.get::<_, String>(2)?,
11284                row.get::<_, Option<String>>(3)?,
11285                row.get::<_, String>(4)?,
11286                row.get::<_, String>(5)?,
11287                row.get::<_, String>(6)?,
11288                row.get::<_, i64>(7)?,
11289                row.get::<_, String>(8)?,
11290            ])
11291            .to_string())
11292        })?
11293        .collect::<rusqlite::Result<Vec<_>>>()?;
11294    let expected_edges = resolved_refs
11295        .iter()
11296        .filter_map(|resolved| {
11297            resolved.edge.as_ref().map(|edge| {
11298                serde_json::json!([
11299                    edge.edge_id,
11300                    resolved.raw.ref_id,
11301                    edge.source_node,
11302                    edge.target_node,
11303                    edge.target_file,
11304                    edge.target_symbol,
11305                    edge.kind,
11306                    edge.line,
11307                    PROVENANCE_TREESITTER,
11308                ])
11309                .to_string()
11310            })
11311        })
11312        .collect::<Vec<_>>();
11313    let mut stored_edges = stored_edges;
11314    let mut expected_edges = expected_edges;
11315    stored_edges.sort();
11316    expected_edges.sort();
11317    if stored_edges != expected_edges {
11318        return Ok(false);
11319    }
11320
11321    let mut stored_dependencies_stmt =
11322        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11323    let stored_dependencies = stored_dependencies_stmt
11324        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11325        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11326    let expected_dependencies = extract
11327        .raw_refs
11328        .iter()
11329        .flat_map(|raw| raw.dependencies.iter().cloned())
11330        .collect::<BTreeSet<_>>();
11331    if stored_dependencies != expected_dependencies {
11332        return Ok(false);
11333    }
11334
11335    let mut stored_hints_stmt = tx.prepare(
11336        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11337         FROM dispatch_hints WHERE file = ?1",
11338    )?;
11339    let stored_hints = stored_hints_stmt
11340        .query_map(params![rel_path], |row| {
11341            Ok(serde_json::json!([
11342                row.get::<_, String>(0)?,
11343                row.get::<_, String>(1)?,
11344                row.get::<_, String>(2)?,
11345                row.get::<_, String>(3)?,
11346                row.get::<_, i64>(4)?,
11347                row.get::<_, i64>(5)?,
11348                row.get::<_, i64>(6)?,
11349                row.get::<_, String>(7)?,
11350            ])
11351            .to_string())
11352        })?
11353        .collect::<rusqlite::Result<Vec<_>>>()?;
11354    let expected_hints = extract
11355        .dispatch_hints
11356        .iter()
11357        .map(|hint| {
11358            serde_json::json!([
11359                hint.id,
11360                hint.method_name,
11361                hint.caller_node,
11362                hint.file,
11363                hint.line,
11364                hint.byte_start,
11365                hint.byte_end,
11366                PROVENANCE_TREESITTER,
11367            ])
11368            .to_string()
11369        })
11370        .collect::<Vec<_>>();
11371    let mut stored_hints = stored_hints;
11372    let mut expected_hints = expected_hints;
11373    stored_hints.sort();
11374    expected_hints.sort();
11375    Ok(stored_hints == expected_hints)
11376}
11377
11378fn update_file_fresh_metadata(
11379    tx: &Transaction<'_>,
11380    project_root: &Path,
11381    rel_path: &str,
11382    hash: &blake3::Hash,
11383    mtime: SystemTime,
11384    size: u64,
11385) -> Result<()> {
11386    tx.execute(
11387        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11388         WHERE path = ?1",
11389        params![
11390            rel_path,
11391            hash_to_hex(*hash),
11392            system_time_to_ns(mtime),
11393            size as i64,
11394            unix_seconds_now()
11395        ],
11396    )?;
11397    tx.execute(
11398        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11399         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11400        params![
11401            BACKEND_TREESITTER,
11402            rel_path,
11403            hash_to_hex(*hash),
11404            project_root.display().to_string(),
11405            unix_seconds_now(),
11406        ],
11407    )?;
11408    Ok(())
11409}
11410
11411#[derive(Debug, Clone, PartialEq, Eq)]
11412struct DependentRefSelection {
11413    ref_id: String,
11414    caller_file: String,
11415}
11416
11417fn ref_ids_depending_on(
11418    tx: &Transaction<'_>,
11419    project_root: &Path,
11420    rel_path: &str,
11421) -> Result<Vec<DependentRefSelection>> {
11422    let mut stmt = tx.prepare(
11423        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11424         FROM refs r
11425         WHERE r.caller_file IN (
11426             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11427         )
11428            OR r.target_file = ?1
11429         ORDER BY r.ref_id",
11430    )?;
11431    let rows = stmt.query_map(params![rel_path], |row| {
11432        Ok(RefDependencyRow {
11433            ref_id: row.get(0)?,
11434            kind: row.get(1)?,
11435            caller_file: row.get(2)?,
11436            module_path: row.get(3)?,
11437            target_file: row.get(4)?,
11438        })
11439    })?;
11440    let mut ids = Vec::new();
11441    for row in rows {
11442        let row = row?;
11443        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11444            ids.push(DependentRefSelection {
11445                ref_id: row.ref_id,
11446                caller_file: row.caller_file,
11447            });
11448        }
11449    }
11450    Ok(ids)
11451}
11452
11453fn record_dependent_refs(
11454    selected_ref_ids: &mut BTreeSet<String>,
11455    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11456    dependent_refs: Vec<DependentRefSelection>,
11457) {
11458    for dependent_ref in dependent_refs {
11459        let DependentRefSelection {
11460            ref_id,
11461            caller_file,
11462        } = dependent_ref;
11463        selected_ref_ids.insert(ref_id.clone());
11464        selected_refs_by_caller
11465            .entry(caller_file)
11466            .or_default()
11467            .insert(ref_id);
11468    }
11469}
11470
11471#[cfg(test)]
11472fn refs_by_caller_for_ref_ids(
11473    tx: &Transaction<'_>,
11474    ref_ids: &BTreeSet<String>,
11475) -> Result<BTreeMap<String, BTreeSet<String>>> {
11476    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11477    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11478    for ref_id in ref_ids {
11479        if let Some(caller) = stmt
11480            .query_row(params![ref_id], |row| row.get::<_, String>(0))
11481            .optional()?
11482        {
11483            by_caller.entry(caller).or_default().insert(ref_id.clone());
11484        }
11485    }
11486    Ok(by_caller)
11487}
11488
11489fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11490    tx.execute(
11491        "DELETE FROM file_dependencies WHERE file_path = ?1",
11492        params![rel_path],
11493    )?;
11494    delete_refs_for_caller(tx, rel_path)?;
11495    tx.execute(
11496        "DELETE FROM dispatch_hints WHERE file = ?1",
11497        params![rel_path],
11498    )?;
11499    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11500    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11501    Ok(())
11502}
11503
11504fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11505    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11506    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11507    let mut ids = BTreeSet::new();
11508    for row in rows {
11509        ids.insert(row?);
11510    }
11511    delete_ref_ids(tx, &ids)
11512}
11513
11514fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11515    for ref_id in ref_ids {
11516        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11517        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11518    }
11519    Ok(())
11520}
11521
11522fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11523    let mut stmt = conn.prepare(
11524        "SELECT source.file_path, source.scoped_name, edges.target_file,
11525                edges.target_symbol, edges.kind, edges.line
11526         FROM edges
11527         JOIN nodes AS source ON source.id = edges.source_node
11528         ORDER BY source.file_path, source.scoped_name, edges.target_file,
11529                  edges.target_symbol, edges.kind, edges.line",
11530    )?;
11531    let rows = stmt.query_map([], |row| {
11532        Ok(StoredEdge {
11533            source_file: row.get(0)?,
11534            source_symbol: row.get(1)?,
11535            target_file: row.get(2)?,
11536            target_symbol: row.get(3)?,
11537            kind: row.get(4)?,
11538            line: row.get::<_, i64>(5)? as u32,
11539        })
11540    })?;
11541    let mut edges = BTreeSet::new();
11542    for row in rows {
11543        edges.insert(row?);
11544    }
11545    Ok(edges)
11546}
11547
11548fn module_target_from_dependencies(
11549    project_root: &Path,
11550    dependencies: &BTreeSet<String>,
11551) -> Option<String> {
11552    dependencies.iter().find_map(|dep| {
11553        let path = project_root.join(dep);
11554        if path.is_file() {
11555            Some(relative_path(project_root, &canonicalize_path(&path)))
11556        } else {
11557            None
11558        }
11559    })
11560}
11561
11562fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11563    let mut named = HashMap::new();
11564    if let Some(full_ref) = &raw_ref.full_ref {
11565        named = parse_reexport_names(full_ref);
11566    }
11567    ReexportIndex {
11568        target_file,
11569        named,
11570        wildcard: raw_ref.wildcard,
11571    }
11572}
11573
11574fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11575    let mut names = HashMap::new();
11576    let Some(open) = statement.find('{') else {
11577        return names;
11578    };
11579    let Some(close) = statement[open + 1..]
11580        .find('}')
11581        .map(|offset| open + 1 + offset)
11582    else {
11583        return names;
11584    };
11585    for spec in statement[open + 1..close].split(',') {
11586        let spec = spec.trim();
11587        if spec.is_empty() {
11588            continue;
11589        }
11590        if let Some((source, local)) = spec.split_once(" as ") {
11591            names.insert(local.trim().to_string(), source.trim().to_string());
11592        } else {
11593            names.insert(spec.to_string(), spec.to_string());
11594        }
11595    }
11596    names
11597}
11598
11599#[derive(Debug)]
11600struct RefDependencyRow {
11601    ref_id: String,
11602    kind: String,
11603    caller_file: String,
11604    module_path: Option<String>,
11605    target_file: Option<String>,
11606}
11607
11608fn ref_dependency_row_depends_on(
11609    project_root: &Path,
11610    row: &RefDependencyRow,
11611    rel_path: &str,
11612) -> bool {
11613    if row.target_file.as_deref() == Some(rel_path) {
11614        return true;
11615    }
11616
11617    match row.kind.as_str() {
11618        "call" => true,
11619        "import" | "reexport" => row
11620            .module_path
11621            .as_deref()
11622            .map(|module_path| {
11623                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11624                    .contains(rel_path)
11625            })
11626            .unwrap_or(false),
11627        "export_alias" => false,
11628        _ => false,
11629    }
11630}
11631
11632fn module_dependencies_for_ref(
11633    project_root: &Path,
11634    caller_file: &str,
11635    module_path: &str,
11636) -> BTreeSet<String> {
11637    module_dependencies(project_root, &project_root.join(caller_file), module_path)
11638}
11639
11640fn import_dependencies(
11641    project_root: &Path,
11642    abs_path: &Path,
11643    imports: &[ImportStatement],
11644) -> BTreeSet<String> {
11645    let mut deps = BTreeSet::new();
11646    for import in imports {
11647        deps.extend(module_dependencies(
11648            project_root,
11649            abs_path,
11650            &import.module_path,
11651        ));
11652    }
11653    deps
11654}
11655
11656fn module_dependencies(
11657    project_root: &Path,
11658    abs_path: &Path,
11659    module_path: &str,
11660) -> BTreeSet<String> {
11661    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11662    let caller_dir = abs_path.parent().unwrap_or(project_root);
11663    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11664        deps.insert(relative_path(project_root, &resolved));
11665    }
11666    if module_path.starts_with('.') {
11667        let base = caller_dir.join(module_path);
11668        for candidate in relative_module_candidates(&base) {
11669            deps.insert(relative_path(project_root, &candidate));
11670        }
11671    }
11672    deps
11673}
11674
11675fn rust_module_dependencies(
11676    project_root: &Path,
11677    abs_path: &Path,
11678    module_path: &str,
11679) -> BTreeSet<String> {
11680    let mut deps = BTreeSet::new();
11681    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11682    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11683        return deps;
11684    };
11685    let src_prefix = rust_src_prefix(&rel_path);
11686    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11687    if !path_segments.is_empty() {
11688        rust_push_module_dependency_candidate(
11689            project_root,
11690            &mut deps,
11691            &src_prefix,
11692            &path_segments[..path_segments.len() - 1],
11693        );
11694    }
11695    deps
11696}
11697
11698fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11699    let path = rust_module_path_without_alias_or_use_list(module_path);
11700    let segments = path
11701        .split("::")
11702        .map(str::trim)
11703        .filter(|segment| !segment.is_empty())
11704        .collect::<Vec<_>>();
11705    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11706        return None;
11707    }
11708    rust_resolve_segments(rel_path, &segments)
11709}
11710
11711fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11712    let path = module_path
11713        .trim()
11714        .trim_end_matches(';')
11715        .split_once(" as ")
11716        .map(|(left, _)| left.trim())
11717        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11718    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11719}
11720
11721fn rust_push_module_dependency_candidate(
11722    project_root: &Path,
11723    deps: &mut BTreeSet<String>,
11724    src_prefix: &str,
11725    segments: &[String],
11726) {
11727    let candidates = if segments.is_empty() {
11728        vec![
11729            format!("{src_prefix}/lib.rs"),
11730            format!("{src_prefix}/main.rs"),
11731        ]
11732    } else {
11733        vec![
11734            format!("{}/{}.rs", src_prefix, segments.join("/")),
11735            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11736        ]
11737    };
11738    for candidate in candidates {
11739        if project_root.join(&candidate).is_file() {
11740            deps.insert(candidate);
11741        }
11742    }
11743}
11744
11745fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11746    let mut candidates = Vec::new();
11747    if base.extension().is_some() {
11748        candidates.push(base.to_path_buf());
11749        return candidates;
11750    }
11751    for ext in JS_TS_EXTENSIONS {
11752        candidates.push(base.with_extension(ext));
11753    }
11754    for ext in JS_TS_EXTENSIONS {
11755        candidates.push(base.join(format!("index.{ext}")));
11756    }
11757    candidates
11758}
11759
11760fn import_local_names(import: &ImportStatement) -> Vec<String> {
11761    let mut names = Vec::new();
11762    if let Some(default) = &import.default_import {
11763        names.push(default.clone());
11764    }
11765    if let Some(namespace) = &import.namespace_import {
11766        names.push(namespace.clone());
11767    }
11768    for name in &import.names {
11769        names.push(crate::imports::specifier_local_name(name).to_string());
11770    }
11771    names
11772}
11773
11774fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11775    import
11776        .names
11777        .iter()
11778        .map(|name| crate::imports::specifier_imported_name(name).to_string())
11779        .collect()
11780}
11781
11782fn import_is_wildcard(import: &ImportStatement) -> bool {
11783    import.namespace_import.is_some() || import.raw_text.contains('*')
11784}
11785
11786fn namespace_alias(full_ref: &str) -> Option<String> {
11787    full_ref
11788        .split_once('.')
11789        .map(|(namespace, _)| namespace.to_string())
11790}
11791
11792fn import_kind_label(kind: ImportKind) -> &'static str {
11793    match kind {
11794        ImportKind::Value => "value",
11795        ImportKind::Type => "type",
11796        ImportKind::SideEffect => "side_effect",
11797    }
11798}
11799
11800fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11801    match kind {
11802        SymbolKind::Function => "function",
11803        SymbolKind::Class => "class",
11804        SymbolKind::Method => "method",
11805        SymbolKind::Struct => "struct",
11806        SymbolKind::Interface => "interface",
11807        SymbolKind::Enum => "enum",
11808        SymbolKind::TypeAlias => "type_alias",
11809        SymbolKind::Variable => "variable",
11810        SymbolKind::Heading => "heading",
11811        SymbolKind::FileSummary => "file_summary",
11812    }
11813}
11814
11815fn is_type_like(kind: &SymbolKind) -> bool {
11816    matches!(
11817        kind,
11818        SymbolKind::Class
11819            | SymbolKind::Struct
11820            | SymbolKind::Interface
11821            | SymbolKind::Enum
11822            | SymbolKind::TypeAlias
11823    )
11824}
11825
11826fn lang_label(lang: LangId) -> &'static str {
11827    match lang {
11828        LangId::TypeScript => "typescript",
11829        LangId::Tsx => "tsx",
11830        LangId::JavaScript => "javascript",
11831        LangId::Python => "python",
11832        LangId::Rust => "rust",
11833        LangId::Go => "go",
11834        LangId::C => "c",
11835        LangId::Cpp => "cpp",
11836        LangId::Zig => "zig",
11837        LangId::CSharp => "csharp",
11838        LangId::Bash => "bash",
11839        LangId::Html => "html",
11840        LangId::Markdown => "markdown",
11841        LangId::Solidity => "solidity",
11842        LangId::Scss => "scss",
11843        LangId::Vue => "vue",
11844        LangId::Json => "json",
11845        LangId::Scala => "scala",
11846        LangId::Java => "java",
11847        LangId::Ruby => "ruby",
11848        LangId::Kotlin => "kotlin",
11849        LangId::Swift => "swift",
11850        LangId::Php => "php",
11851        LangId::Lua => "lua",
11852        LangId::Perl => "perl",
11853        LangId::Yaml => "yaml",
11854        LangId::Pascal => "pascal",
11855        LangId::R => "r",
11856        LangId::Groovy => "groovy",
11857        LangId::ObjC => "objc",
11858    }
11859}
11860
11861fn lang_from_label(label: &str) -> Option<LangId> {
11862    match label {
11863        "typescript" => Some(LangId::TypeScript),
11864        "tsx" => Some(LangId::Tsx),
11865        "javascript" => Some(LangId::JavaScript),
11866        "python" => Some(LangId::Python),
11867        "rust" => Some(LangId::Rust),
11868        "go" => Some(LangId::Go),
11869        "c" => Some(LangId::C),
11870        "cpp" => Some(LangId::Cpp),
11871        "zig" => Some(LangId::Zig),
11872        "csharp" => Some(LangId::CSharp),
11873        "bash" => Some(LangId::Bash),
11874        "html" => Some(LangId::Html),
11875        "markdown" => Some(LangId::Markdown),
11876        "solidity" => Some(LangId::Solidity),
11877        "scss" => Some(LangId::Scss),
11878        "vue" => Some(LangId::Vue),
11879        "json" => Some(LangId::Json),
11880        "scala" => Some(LangId::Scala),
11881        "java" => Some(LangId::Java),
11882        "ruby" => Some(LangId::Ruby),
11883        "kotlin" => Some(LangId::Kotlin),
11884        "swift" => Some(LangId::Swift),
11885        "php" => Some(LangId::Php),
11886        "lua" => Some(LangId::Lua),
11887        "perl" => Some(LangId::Perl),
11888        "yaml" => Some(LangId::Yaml),
11889        "pascal" => Some(LangId::Pascal),
11890        "r" => Some(LangId::R),
11891        "groovy" => Some(LangId::Groovy),
11892        "objc" => Some(LangId::ObjC),
11893        _ => None,
11894    }
11895}
11896
11897fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11898    let mut normalized = if files.is_empty() {
11899        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11900    } else {
11901        files
11902            .iter()
11903            .map(|path| normalize_file_path(project_root, path))
11904            .collect::<Result<Vec<_>>>()?
11905    };
11906    normalized.sort();
11907    normalized.dedup();
11908    Ok(normalized)
11909}
11910
11911fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11912    let full_path = if path.is_relative() {
11913        project_root.join(path)
11914    } else {
11915        path.to_path_buf()
11916    };
11917    Ok(canonicalize_path(&full_path))
11918}
11919
11920fn canonicalize_path(path: &Path) -> PathBuf {
11921    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11922}
11923
11924fn relative_path(project_root: &Path, path: &Path) -> String {
11925    if let Ok(stripped) = path.strip_prefix(project_root) {
11926        return stripped.to_string_lossy().replace('\\', "/");
11927    }
11928    let canon_root = canonicalize_path(project_root);
11929    let canon_path = canonicalize_path(path);
11930    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11931        return stripped.to_string_lossy().replace('\\', "/");
11932    }
11933    canon_path.to_string_lossy().replace('\\', "/")
11934}
11935
11936fn unqualified_name(scoped: &str) -> &str {
11937    if scoped == TOP_LEVEL_SYMBOL {
11938        return scoped;
11939    }
11940    scoped
11941        .rsplit("::")
11942        .next()
11943        .unwrap_or(scoped)
11944        .rsplit('.')
11945        .next()
11946        .unwrap_or(scoped)
11947        .rsplit('#')
11948        .next()
11949        .unwrap_or(scoped)
11950}
11951
11952fn ref_id(parts: &[&str]) -> String {
11953    let joined = parts.join("\0");
11954    hash_to_hex(blake3::hash(joined.as_bytes()))
11955}
11956
11957fn hash_to_hex(hash: blake3::Hash) -> String {
11958    hash.to_hex().to_string()
11959}
11960
11961fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11962    let bytes = hex_to_bytes(value)?;
11963    Some(blake3::Hash::from_bytes(bytes))
11964}
11965
11966fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11967    if value.len() != 64 {
11968        return None;
11969    }
11970    let mut bytes = [0u8; 32];
11971    for (index, slot) in bytes.iter_mut().enumerate() {
11972        let start = index * 2;
11973        let end = start + 2;
11974        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11975    }
11976    Some(bytes)
11977}
11978
11979#[derive(Debug, Clone)]
11980struct LineIndex {
11981    newline_offsets: Vec<usize>,
11982    source_len: usize,
11983}
11984
11985impl LineIndex {
11986    fn new(source: &str) -> Self {
11987        Self {
11988            newline_offsets: source
11989                .bytes()
11990                .enumerate()
11991                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11992                .collect(),
11993            source_len: source.len(),
11994        }
11995    }
11996
11997    fn byte_to_line(&self, byte_offset: usize) -> u32 {
11998        let byte_offset = byte_offset.min(self.source_len);
11999        self.newline_offsets
12000            .partition_point(|offset| *offset < byte_offset) as u32
12001            + 1
12002    }
12003}
12004
12005fn empty_to_none(value: String) -> Option<String> {
12006    if value.is_empty() {
12007        None
12008    } else {
12009        Some(value)
12010    }
12011}
12012
12013fn bool_int(value: bool) -> i64 {
12014    if value {
12015        1
12016    } else {
12017        0
12018    }
12019}
12020
12021fn system_time_to_ns(time: SystemTime) -> i64 {
12022    time.duration_since(UNIX_EPOCH)
12023        .unwrap_or_default()
12024        .as_nanos()
12025        .min(i64::MAX as u128) as i64
12026}
12027
12028fn ns_to_system_time(value: i64) -> SystemTime {
12029    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
12030}
12031
12032fn unix_millis_now() -> u64 {
12033    SystemTime::now()
12034        .duration_since(UNIX_EPOCH)
12035        .unwrap_or_default()
12036        .as_millis()
12037        .min(u128::from(u64::MAX)) as u64
12038}
12039
12040fn unix_seconds_now() -> i64 {
12041    SystemTime::now()
12042        .duration_since(UNIX_EPOCH)
12043        .unwrap_or_default()
12044        .as_secs() as i64
12045}
12046
12047/// Serializes every test that drives the process-wide refresh worker
12048/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
12049/// worker down between another test's enqueue and its flush, deferring the
12050/// batch and zeroing that test's seam counts).
12051#[cfg(test)]
12052pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12053
12054#[cfg(test)]
12055mod refresh_worker_tests {
12056    use super::*;
12057    use std::fs;
12058    use tempfile::tempdir;
12059
12060    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12061        let temp = tempdir().unwrap();
12062        let root = temp.path().join("root");
12063        fs::create_dir_all(&root).unwrap();
12064        let artifact_key = crate::search_index::artifact_cache_key(&root);
12065        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12066        let callgraph_dir = temp
12067            .path()
12068            .join("storage")
12069            .join("callgraph")
12070            .join(artifact_key);
12071        let source = root.join("main.rs");
12072        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
12073        let (store, _) = CallGraphStore::cold_build_with_lease(
12074            callgraph_dir.clone(),
12075            root.clone(),
12076            std::slice::from_ref(&source),
12077        )
12078        .unwrap();
12079        drop(store);
12080        (temp, root, callgraph_dir, source)
12081    }
12082
12083    fn pending_paths() -> PendingCallGraphStorePaths {
12084        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12085    }
12086
12087    fn wait_for_refresh_calls(root: &Path, expected: usize) {
12088        let deadline = Instant::now() + Duration::from_secs(12);
12089        while callgraph_refresh_worker_test_counts(root).0 < expected {
12090            assert!(
12091                Instant::now() < deadline,
12092                "timed out waiting for {expected} callgraph refresh worker call(s)"
12093            );
12094            std::thread::sleep(Duration::from_millis(5));
12095        }
12096    }
12097
12098    fn wait_for_refresh_worker_idle() {
12099        let deadline = Instant::now() + Duration::from_secs(12);
12100        loop {
12101            let worker = CALLGRAPH_REFRESH_WORKER
12102                .get_or_init(|| Mutex::new(None))
12103                .lock()
12104                .expect("callgraph refresh worker mutex poisoned")
12105                .clone();
12106            let idle = worker.is_none_or(|worker| {
12107                let queue = worker
12108                    .shared
12109                    .queue
12110                    .lock()
12111                    .expect("callgraph refresh queue mutex poisoned");
12112                queue.active.is_none() && queue.order.is_empty()
12113            });
12114            if idle {
12115                return;
12116            }
12117            assert!(
12118                Instant::now() < deadline,
12119                "timed out waiting for callgraph refresh worker to become idle"
12120            );
12121            std::thread::sleep(Duration::from_millis(5));
12122        }
12123    }
12124
12125    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12126        let temp = tempdir().unwrap();
12127        let root = temp.path().join("workspace");
12128        fs::create_dir_all(root.join("app/src")).unwrap();
12129        let artifact_key = crate::search_index::artifact_cache_key(&root);
12130        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12131        let callgraph_dir = temp
12132            .path()
12133            .join("storage")
12134            .join("callgraph")
12135            .join(artifact_key);
12136        fs::write(
12137            root.join("Cargo.toml"),
12138            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12139        )
12140        .unwrap();
12141        fs::write(
12142            root.join("app/Cargo.toml"),
12143            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12144        )
12145        .unwrap();
12146        let caller = root.join("app/src/lib.rs");
12147        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12148        let (store, _) = CallGraphStore::cold_build_with_lease(
12149            callgraph_dir.clone(),
12150            root.clone(),
12151            std::slice::from_ref(&caller),
12152        )
12153        .unwrap();
12154        drop(store);
12155        (temp, root, callgraph_dir, caller)
12156    }
12157
12158    #[test]
12159    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12160        let _guard = REFRESH_WORKER_TEST_LOCK
12161            .lock()
12162            .unwrap_or_else(std::sync::PoisonError::into_inner);
12163        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12164        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12165        reset_workspace_crate_prefix_build_count(&root);
12166        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12167
12168        for revision in ["first", "second"] {
12169            fs::write(
12170                &caller,
12171                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12172            )
12173            .unwrap();
12174            enqueue_callgraph_store_refresh(
12175                callgraph_dir.clone(),
12176                root.clone(),
12177                vec![caller.clone()],
12178                pending_paths(),
12179            );
12180            wait_for_refresh_worker_idle();
12181        }
12182
12183        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12184        assert!(flush_callgraph_store_refreshes_with_budget(
12185            Duration::from_secs(5)
12186        ));
12187        clear_callgraph_refresh_worker_test_seam(&root);
12188    }
12189
12190    #[test]
12191    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12192        let _guard = REFRESH_WORKER_TEST_LOCK
12193            .lock()
12194            .unwrap_or_else(std::sync::PoisonError::into_inner);
12195        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12196        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12197        reset_workspace_crate_prefix_build_count(&root);
12198        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12199
12200        fs::write(
12201            &caller,
12202            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12203        )
12204        .unwrap();
12205        enqueue_callgraph_store_refresh(
12206            callgraph_dir.clone(),
12207            root.clone(),
12208            vec![caller.clone()],
12209            pending_paths(),
12210        );
12211        wait_for_refresh_worker_idle();
12212        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12213
12214        let added_manifest = root.join("added/Cargo.toml");
12215        let added_source = root.join("added/src/lib.rs");
12216        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12217        fs::write(
12218            root.join("Cargo.toml"),
12219            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12220        )
12221        .unwrap();
12222        fs::write(
12223            &added_manifest,
12224            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12225        )
12226        .unwrap();
12227        fs::write(&added_source, "pub fn target() {}\n").unwrap();
12228        fs::write(
12229            &caller,
12230            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12231        )
12232        .unwrap();
12233
12234        enqueue_callgraph_store_refresh(
12235            callgraph_dir.clone(),
12236            root.clone(),
12237            vec![
12238                root.join("Cargo.toml"),
12239                added_manifest,
12240                added_source,
12241                caller,
12242            ],
12243            pending_paths(),
12244        );
12245        assert!(flush_callgraph_store_refreshes_with_budget(
12246            Duration::from_secs(12)
12247        ));
12248
12249        // This is the negative control for a permanently-static cache: without
12250        // manifest invalidation the build count stays at one and the call remains
12251        // unresolved because `added_crate` was absent when the map was primed.
12252        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12253        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12254            .unwrap()
12255            .expect("refreshed workspace store");
12256        let tree = store
12257            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12258            .unwrap();
12259        assert_eq!(tree.children.len(), 1);
12260        assert_eq!(tree.children[0].file, "added/src/lib.rs");
12261        assert_eq!(tree.children[0].name, "target");
12262        assert!(tree.children[0].resolved);
12263        clear_callgraph_refresh_worker_test_seam(&root);
12264    }
12265
12266    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12267        let temp = tempdir().unwrap();
12268        let main = temp.path().join("main");
12269        let worktree = temp.path().join("worktree");
12270        fs::create_dir_all(&main).unwrap();
12271        let mut git = std::process::Command::new("git");
12272        assert!(
12273            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12274                .status()
12275                .unwrap()
12276                .success()
12277        );
12278        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12279        for args in [
12280            vec![
12281                "-C",
12282                main.to_str().unwrap(),
12283                "config",
12284                "user.email",
12285                "test@example.com",
12286            ],
12287            vec![
12288                "-C",
12289                main.to_str().unwrap(),
12290                "config",
12291                "user.name",
12292                "AFT Test",
12293            ],
12294            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12295            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12296        ] {
12297            let mut command = std::process::Command::new("git");
12298            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12299                .status()
12300                .unwrap()
12301                .success());
12302        }
12303        let mut add_worktree = std::process::Command::new("git");
12304        assert!(crate::test_env::apply_hermetic_git_env(
12305            add_worktree
12306                .arg("-C")
12307                .arg(&main)
12308                .args(["worktree", "add", "--detach"])
12309                .arg(&worktree),
12310        )
12311        .status()
12312        .unwrap()
12313        .success());
12314        let main = fs::canonicalize(main).unwrap();
12315        let worktree = fs::canonicalize(worktree).unwrap();
12316        let project_key = crate::search_index::artifact_cache_key(&main);
12317        assert_eq!(
12318            crate::search_index::artifact_cache_key(&worktree),
12319            project_key
12320        );
12321        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12322        (temp, main, worktree, project_key, callgraph_dir)
12323    }
12324
12325    #[test]
12326    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12327        let _git_env = crate::test_env::hermetic_git_env_guard();
12328        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12329        crate::root_cache::configure_artifact_access(&root, &project_key, true);
12330        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12331        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12332        let publications_for_observer = Arc::clone(&publications);
12333        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12334            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12335        })));
12336        let source = root.join("lib.rs");
12337
12338        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12339            .expect_err("borrow-only writable open must remain unavailable");
12340        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12341        assert!(
12342            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12343                .unwrap()
12344                .is_none()
12345        );
12346        assert!(
12347            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12348                .unwrap()
12349                .is_none()
12350        );
12351        assert!(matches!(
12352            CallGraphStore::cold_build_with_lease(
12353                callgraph_dir.clone(),
12354                root.clone(),
12355                std::slice::from_ref(&source),
12356            ),
12357            Err(CallGraphStoreError::Unavailable(_))
12358        ));
12359        assert!(matches!(
12360            CallGraphStore::ensure_built_with_lease(
12361                callgraph_dir.clone(),
12362                root.clone(),
12363                std::slice::from_ref(&source),
12364            ),
12365            Err(CallGraphStoreError::Unavailable(_))
12366        ));
12367        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12368            callgraph_dir.clone(),
12369            root.clone(),
12370            &[source],
12371            1,
12372        )
12373        .expect_err("borrow-only forced rebuild must remain unsatisfied");
12374        set_cold_build_swap_observer(None);
12375
12376        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12377        assert_eq!(
12378            crate::root_cache::writer_lease_acquisition_count_for_test(
12379                crate::root_cache::RootCacheDomain::Callgraph,
12380                &project_key,
12381                &root,
12382            ),
12383            0
12384        );
12385        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12386        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12387    }
12388
12389    #[test]
12390    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12391        let _git_env = crate::test_env::hermetic_git_env_guard();
12392        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12393        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12394        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12395        let source = owner.join("lib.rs");
12396        let (store, _) = CallGraphStore::cold_build_with_lease(
12397            callgraph_dir.clone(),
12398            owner.clone(),
12399            std::slice::from_ref(&source),
12400        )
12401        .unwrap();
12402        let sqlite_path = store.sqlite_path().to_path_buf();
12403        drop(store);
12404
12405        let conn = Connection::open(&sqlite_path).unwrap();
12406        conn.execute(
12407            "UPDATE backend_file_state SET workspace_root = ?1",
12408            [worktree.display().to_string()],
12409        )
12410        .unwrap();
12411        drop(conn);
12412
12413        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12414        let publications_for_observer = Arc::clone(&publications);
12415        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12416            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12417        })));
12418        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12419
12420        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12421            .unwrap()
12422            .expect("owner should purge the storm-era worktree root");
12423        drop(repaired);
12424        for _ in 0..3 {
12425            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12426                .unwrap()
12427                .expect("linked worktree should borrow the owner generation");
12428            drop(borrower);
12429            assert!(
12430                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12431                    .unwrap()
12432                    .is_none()
12433            );
12434            let owner_store =
12435                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12436                    .unwrap()
12437                    .expect("owner generation should remain ready");
12438            drop(owner_store);
12439        }
12440        set_cold_build_swap_observer(None);
12441
12442        assert_eq!(
12443            publications.load(AtomicOrdering::SeqCst),
12444            1,
12445            "the owner performs one expected post-storm purge and alternation stays read-only"
12446        );
12447        assert_eq!(
12448            crate::root_cache::writer_lease_acquisition_count_for_test(
12449                crate::root_cache::RootCacheDomain::Callgraph,
12450                &project_key,
12451                &worktree,
12452            ),
12453            0
12454        );
12455    }
12456
12457    #[test]
12458    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12459        let temp = tempdir().unwrap();
12460        let root = temp.path().join("owner");
12461        let other_root = temp.path().join("other");
12462        fs::create_dir_all(&root).unwrap();
12463        fs::create_dir_all(&other_root).unwrap();
12464        let source = root.join("lib.rs");
12465        fs::write(&source, "pub fn marker() {}\n").unwrap();
12466        let project_key = crate::search_index::artifact_cache_key(&root);
12467        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12468        crate::root_cache::configure_artifact_access(&root, &project_key, false);
12469        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12470        rebuild_cooldown_records()
12471            .lock()
12472            .unwrap_or_else(std::sync::PoisonError::into_inner)
12473            .remove(&cooldown_key);
12474        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12475        let stale_epoch = epoch.current();
12476        epoch.next();
12477
12478        let failed = with_publish_epoch(epoch, stale_epoch, || {
12479            CallGraphStore::cold_build_with_lease(
12480                callgraph_dir.clone(),
12481                root.clone(),
12482                std::slice::from_ref(&source),
12483            )
12484        });
12485        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12486        assert!(
12487            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12488                .is_none()
12489        );
12490
12491        let (store, _) = CallGraphStore::cold_build_with_lease(
12492            callgraph_dir.clone(),
12493            root.clone(),
12494            std::slice::from_ref(&source),
12495        )
12496        .unwrap();
12497        drop(store);
12498        assert!(
12499            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12500                .is_none()
12501        );
12502
12503        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12504        assert!(
12505            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12506        );
12507    }
12508
12509    #[test]
12510    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12511        let _guard = REFRESH_WORKER_TEST_LOCK
12512            .lock()
12513            .unwrap_or_else(std::sync::PoisonError::into_inner);
12514        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12515        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12516        let pending = pending_paths();
12517        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12518
12519        let lifecycle = SubcLifecycleAdmission::default();
12520        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12521        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12522        let ticket = CallgraphRefreshTicket::new(
12523            lifecycle,
12524            Arc::clone(&generation),
12525            7,
12526            publish_epoch.clone(),
12527            publish_epoch.current(),
12528        );
12529        // Supersede before the worker runs: the batch must defer, not commit.
12530        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12531        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12532            .unwrap()
12533            .expect("ready store snapshot");
12534        let refresh_state = CallgraphRefreshState::new(
12535            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12536            Arc::new(AtomicBool::new(true)),
12537        );
12538
12539        enqueue_callgraph_store_refresh_fenced_with_state(
12540            callgraph_dir,
12541            root.clone(),
12542            vec![source.clone()],
12543            Arc::clone(&pending),
12544            refresh_state,
12545            ticket,
12546        );
12547        assert!(flush_callgraph_store_refreshes_with_budget(
12548            Duration::from_secs(5)
12549        ));
12550        assert_eq!(
12551            callgraph_refresh_worker_test_counts(&root).0,
12552            0,
12553            "superseded batch must not reach refresh_files or self-replay"
12554        );
12555        assert!(
12556            pending.lock().contains(&source),
12557            "superseded batch must defer its paths to the pending sink"
12558        );
12559        clear_callgraph_refresh_worker_test_seam(&root);
12560    }
12561
12562    #[test]
12563    fn superseded_open_failure_defers_without_self_replay() {
12564        let _guard = REFRESH_WORKER_TEST_LOCK
12565            .lock()
12566            .unwrap_or_else(std::sync::PoisonError::into_inner);
12567        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12568        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12569        let pending = pending_paths();
12570        let installed = Arc::new(
12571            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12572                .unwrap()
12573                .expect("ready store snapshot"),
12574        );
12575        let refresh_state = CallgraphRefreshState::new(
12576            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12577            Arc::new(AtomicBool::new(true)),
12578        );
12579        assert!(!installed.is_legacy_fallback());
12580        assert!(installed.is_current());
12581        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12582        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12583        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12584        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12585
12586        let lifecycle = SubcLifecycleAdmission::default();
12587        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12588        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12589        let ticket = CallgraphRefreshTicket::new(
12590            lifecycle,
12591            Arc::clone(&generation),
12592            7,
12593            publish_epoch.clone(),
12594            publish_epoch.current(),
12595        );
12596        enqueue_callgraph_store_refresh_fenced_with_state(
12597            callgraph_dir,
12598            root.clone(),
12599            vec![source.clone()],
12600            Arc::clone(&pending),
12601            refresh_state,
12602            ticket,
12603        );
12604        held_rx
12605            .recv_timeout(Duration::from_secs(12))
12606            .expect("refresh worker must hold after injected open failure");
12607
12608        // Mark the refresh request obsolete after the injected open failure,
12609        // then unblock the worker before its deferred retry can run.
12610        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12611        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12612        release_tx
12613            .send(())
12614            .expect("release superseded refresh worker");
12615        wait_for_refresh_worker_idle();
12616
12617        assert_eq!(
12618            callgraph_refresh_worker_test_counts(&root).0,
12619            1,
12620            "superseded open-failure batch must not self-replay"
12621        );
12622        assert_eq!(
12623            callgraph_refresh_worker_test_worker_calls(&root),
12624            1,
12625            "superseded open-failure batch must not create another worker call"
12626        );
12627        assert!(
12628            pending.lock().contains(&source),
12629            "superseded open-failure paths must remain in the pending sink"
12630        );
12631        let tree = installed
12632            .call_tree(Path::new("main.rs"), "entry", 1)
12633            .unwrap();
12634        assert_eq!(
12635            tree.children[0].name, "old_leaf",
12636            "superseded open-failure batch must not converge the store"
12637        );
12638        clear_callgraph_refresh_worker_test_seam(&root);
12639    }
12640
12641    #[test]
12642    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12643        let _guard = REFRESH_WORKER_TEST_LOCK
12644            .lock()
12645            .unwrap_or_else(std::sync::PoisonError::into_inner);
12646        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12647        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12648        let pending = pending_paths();
12649        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12650
12651        let lifecycle = SubcLifecycleAdmission::default();
12652        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12653        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12654        let expected_epoch = publish_epoch.current();
12655        let ticket = CallgraphRefreshTicket::new(
12656            lifecycle,
12657            generation,
12658            3,
12659            publish_epoch.clone(),
12660            expected_epoch,
12661        );
12662        // A cold build published a replacement generation after enqueue.
12663        publish_epoch.next();
12664
12665        enqueue_callgraph_store_refresh_fenced(
12666            callgraph_dir,
12667            root.clone(),
12668            vec![source.clone()],
12669            Arc::clone(&pending),
12670            ticket,
12671        );
12672        assert!(flush_callgraph_store_refreshes_with_budget(
12673            Duration::from_secs(5)
12674        ));
12675        assert_eq!(
12676            callgraph_refresh_worker_test_counts(&root).0,
12677            0,
12678            "epoch-superseded batch must not reach refresh_files"
12679        );
12680        assert!(
12681            pending.lock().contains(&source),
12682            "epoch-superseded batch must defer its paths to the pending sink"
12683        );
12684        clear_callgraph_refresh_worker_test_seam(&root);
12685    }
12686
12687    #[test]
12688    fn fenced_refresh_with_current_ticket_commits_normally() {
12689        let _guard = REFRESH_WORKER_TEST_LOCK
12690            .lock()
12691            .unwrap_or_else(std::sync::PoisonError::into_inner);
12692        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12693        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12694        let pending = pending_paths();
12695        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12696
12697        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12698
12699        let lifecycle = SubcLifecycleAdmission::default();
12700        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12701        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12702        let ticket = CallgraphRefreshTicket::new(
12703            lifecycle,
12704            generation,
12705            5,
12706            publish_epoch.clone(),
12707            publish_epoch.current(),
12708        );
12709
12710        enqueue_callgraph_store_refresh_fenced(
12711            callgraph_dir.clone(),
12712            root.clone(),
12713            vec![source.clone()],
12714            Arc::clone(&pending),
12715            ticket,
12716        );
12717        assert!(flush_callgraph_store_refreshes_with_budget(
12718            Duration::from_secs(5)
12719        ));
12720        assert_eq!(
12721            callgraph_refresh_worker_test_counts(&root).0,
12722            1,
12723            "current ticket must run the refresh"
12724        );
12725        assert!(
12726            pending.lock().is_empty(),
12727            "committed batch must not defer paths"
12728        );
12729
12730        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12731            .unwrap()
12732            .expect("published generation must remain readable");
12733        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12734        assert_eq!(
12735            tree.children[0].name, "new_leaf",
12736            "fenced commit must actually persist the refreshed content"
12737        );
12738        clear_callgraph_refresh_worker_test_seam(&root);
12739    }
12740
12741    #[test]
12742    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12743        let _guard = REFRESH_WORKER_TEST_LOCK
12744            .lock()
12745            .unwrap_or_else(std::sync::PoisonError::into_inner);
12746        // Generous pre-drain: the refresh worker is process-wide, so a prior
12747        // test's still-running batch (slow Windows CI) must fully settle
12748        // before this test enqueues, or its wait deadline absorbs the
12749        // leftover work. Idle workers return immediately.
12750        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12751        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12752        let pending = pending_paths();
12753        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12754
12755        enqueue_callgraph_store_refresh(
12756            callgraph_dir.clone(),
12757            root.clone(),
12758            vec![source.clone()],
12759            Arc::clone(&pending),
12760        );
12761        wait_for_refresh_calls(&root, 1);
12762        for _ in 0..3 {
12763            enqueue_callgraph_store_refresh(
12764                callgraph_dir.clone(),
12765                root.clone(),
12766                vec![source.clone()],
12767                Arc::clone(&pending),
12768            );
12769        }
12770
12771        assert!(flush_callgraph_store_refreshes_with_budget(
12772            Duration::from_secs(2)
12773        ));
12774        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12775        assert!(pending.lock().is_empty());
12776        clear_callgraph_refresh_worker_test_seam(&root);
12777    }
12778
12779    #[test]
12780    fn queued_refresh_opens_generation_published_after_enqueue() {
12781        let _guard = REFRESH_WORKER_TEST_LOCK
12782            .lock()
12783            .unwrap_or_else(std::sync::PoisonError::into_inner);
12784        // Generous pre-drain: the refresh worker is process-wide, so a prior
12785        // test's still-running batch (slow Windows CI) must fully settle
12786        // before this test enqueues, or its wait deadline absorbs the
12787        // leftover work. Idle workers return immediately.
12788        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12789        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12790        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12791        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12792        let (active_held_rx, active_release_tx) =
12793            install_callgraph_refresh_worker_test_gate(active_root.clone());
12794        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12795        enqueue_callgraph_store_refresh(
12796            active_dir,
12797            active_root.clone(),
12798            vec![active_source],
12799            pending_paths(),
12800        );
12801        active_held_rx
12802            .recv_timeout(Duration::from_secs(12))
12803            .expect("active refresh worker holds the queue");
12804
12805        fs::write(
12806            &target_source,
12807            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12808        )
12809        .unwrap();
12810        enqueue_callgraph_store_refresh(
12811            target_dir.clone(),
12812            target_root.clone(),
12813            vec![target_source.clone()],
12814            pending_paths(),
12815        );
12816        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12817            target_dir.clone(),
12818            target_root.clone(),
12819            std::slice::from_ref(&target_source),
12820        )
12821        .unwrap();
12822        fs::write(
12823            &target_source,
12824            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12825        )
12826        .unwrap();
12827        drop(new_generation);
12828
12829        active_release_tx
12830            .send(())
12831            .expect("release active refresh worker");
12832        wait_for_refresh_calls(&target_root, 1);
12833        assert!(flush_callgraph_store_refreshes_with_budget(
12834            Duration::from_secs(12)
12835        ));
12836        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12837            .unwrap()
12838            .expect("current callgraph generation");
12839        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12840        assert_eq!(tree.children[0].name, "worker_leaf");
12841        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12842        clear_callgraph_refresh_worker_test_seam(&active_root);
12843        clear_callgraph_refresh_worker_test_seam(&target_root);
12844    }
12845
12846    #[test]
12847    fn refresh_failure_marks_files_stale() {
12848        let _guard = REFRESH_WORKER_TEST_LOCK
12849            .lock()
12850            .unwrap_or_else(std::sync::PoisonError::into_inner);
12851        // Generous pre-drain: the refresh worker is process-wide, so a prior
12852        // test's still-running batch (slow Windows CI) must fully settle
12853        // before this test enqueues, or its wait deadline absorbs the
12854        // leftover work. Idle workers return immediately.
12855        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12856        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12857        let pending = pending_paths();
12858        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12859
12860        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12861        assert!(flush_callgraph_store_refreshes_with_budget(
12862            Duration::from_secs(2)
12863        ));
12864
12865        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12866        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12867            .unwrap()
12868            .expect("ready callgraph store");
12869        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12870        clear_callgraph_refresh_worker_test_seam(&root);
12871    }
12872
12873    #[test]
12874    fn idle_refresh_truncates_wal() {
12875        let _guard = REFRESH_WORKER_TEST_LOCK
12876            .lock()
12877            .unwrap_or_else(std::sync::PoisonError::into_inner);
12878        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12879        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12880        let generation = read_pointer(
12881            &callgraph_dir,
12882            &crate::search_index::artifact_cache_key(&root),
12883        )
12884        .expect("fixture publishes a generation");
12885        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12886        let pending = pending_paths();
12887        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12888
12889        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12890        enqueue_callgraph_store_refresh(
12891            callgraph_dir.clone(),
12892            root.clone(),
12893            vec![source.clone()],
12894            Arc::clone(&pending),
12895        );
12896        wait_for_refresh_calls(&root, 1);
12897        wait_for_refresh_worker_idle();
12898        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12899        while fs::metadata(&wal_path)
12900            .map(|metadata| metadata.len())
12901            .unwrap_or(0)
12902            != 0
12903        {
12904            assert!(
12905                Instant::now() < checkpoint_deadline,
12906                "idle checkpoint did not truncate WAL"
12907            );
12908            std::thread::sleep(Duration::from_millis(5));
12909        }
12910        assert_eq!(
12911            fs::metadata(&wal_path)
12912                .map(|metadata| metadata.len())
12913                .unwrap_or(0),
12914            0,
12915            "idle transition truncates the refresh WAL"
12916        );
12917
12918        clear_callgraph_refresh_worker_test_seam(&root);
12919    }
12920
12921    #[test]
12922    fn bounded_shutdown_defers_unprocessed_batches() {
12923        let _guard = REFRESH_WORKER_TEST_LOCK
12924            .lock()
12925            .unwrap_or_else(std::sync::PoisonError::into_inner);
12926        // Generous pre-drain: the refresh worker is process-wide, so a prior
12927        // test's still-running batch (slow Windows CI) must fully settle
12928        // before this test enqueues, or its wait deadline absorbs the
12929        // leftover work. Idle workers return immediately.
12930        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12931        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12932        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12933        let active_pending = pending_paths();
12934        let queued_pending = pending_paths();
12935        set_callgraph_refresh_worker_test_seam(
12936            active_root.clone(),
12937            Duration::from_millis(300),
12938            false,
12939        );
12940
12941        enqueue_callgraph_store_refresh(
12942            active_dir,
12943            active_root.clone(),
12944            vec![active_source.clone()],
12945            Arc::clone(&active_pending),
12946        );
12947        wait_for_refresh_calls(&active_root, 1);
12948        enqueue_callgraph_store_refresh(
12949            queued_dir,
12950            queued_root.clone(),
12951            vec![queued_source.clone()],
12952            Arc::clone(&queued_pending),
12953        );
12954
12955        assert!(!flush_callgraph_store_refreshes_with_budget(
12956            Duration::from_millis(20)
12957        ));
12958        assert!(active_pending.lock().contains(&active_source));
12959        assert!(queued_pending.lock().contains(&queued_source));
12960        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12961        clear_callgraph_refresh_worker_test_seam(&active_root);
12962    }
12963}
12964
12965#[cfg(test)]
12966mod cold_build_insert_tests {
12967    use super::*;
12968    use crate::imports::ImportBlock;
12969    use std::cell::Cell;
12970    use std::fs;
12971    use std::path::{Path, PathBuf};
12972    use tempfile::tempdir;
12973
12974    thread_local! {
12975        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12976        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12977        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12978    }
12979
12980    fn count_caller_traversal_selects(sql: &str) {
12981        let sql = sql.trim_start();
12982        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12983            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12984        }
12985        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12986            && sql.contains("e.target_file =")
12987        {
12988            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12989        }
12990        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
12991            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12992        }
12993    }
12994
12995    #[test]
12996    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12997        let dir = tempdir().unwrap();
12998        let previous_root = dir.path().join("previous-root");
12999        let current_root = dir.path().join("current-root");
13000        fs::create_dir_all(&previous_root).unwrap();
13001        fs::create_dir_all(&current_root).unwrap();
13002        fs::remove_dir(&previous_root).unwrap();
13003        let mut conn = Connection::open_in_memory().unwrap();
13004        initialize_schema(&conn).unwrap();
13005        conn.execute(
13006            "INSERT INTO backend_file_state(
13007                backend, workspace_root, file_path, content_hash, status, updated_at
13008             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13009            params![previous_root.display().to_string()],
13010        )
13011        .unwrap();
13012
13013        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
13014
13015        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13016        assert_eq!(
13017            stored_workspace_roots(&conn).unwrap(),
13018            vec![previous_root.display().to_string()]
13019        );
13020    }
13021
13022    #[test]
13023    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
13024        assert_eq!(
13025            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
13026            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
13027        );
13028    }
13029
13030    #[test]
13031    fn legacy_migration_completion_log_has_operator_fields() {
13032        assert_eq!(
13033            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
13034            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
13035        );
13036    }
13037
13038    fn write_generation_with_age(
13039        dir: &Path,
13040        project_key: &str,
13041        ordinal: u64,
13042        age: Duration,
13043    ) -> String {
13044        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
13045        let path = dir.join(&generation);
13046        fs::write(&path, b"sqlite placeholder").unwrap();
13047        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13048        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13049        generation
13050    }
13051
13052    #[test]
13053    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
13054        let dir = tempfile::tempdir().unwrap();
13055        let project_key = "project";
13056        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13057        let previous =
13058            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13059        let pinned =
13060            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13061        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13062
13063        gc_old_generations(dir.path(), project_key, &current);
13064
13065        assert!(dir.path().join(&previous).is_file());
13066        assert!(dir.path().join(&pinned).is_file());
13067
13068        drop(marker);
13069        gc_old_generations(dir.path(), project_key, &current);
13070
13071        assert!(dir.path().join(&previous).is_file());
13072        assert!(!dir.path().join(&pinned).exists());
13073    }
13074
13075    #[test]
13076    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
13077        let dir = tempfile::tempdir().unwrap();
13078        let project_key = "project";
13079        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13080        let _previous =
13081            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13082        let pinned =
13083            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13084        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13085        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13086
13087        gc_old_generations(dir.path(), project_key, &current);
13088
13089        assert!(dir.path().join(&pinned).is_file());
13090    }
13091
13092    #[test]
13093    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13094        let dir = tempfile::tempdir().unwrap();
13095        let project_key = "project";
13096        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13097        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13098        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13099        let old = write_generation_with_age(
13100            dir.path(),
13101            project_key,
13102            200,
13103            expired + Duration::from_secs(60),
13104        );
13105        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13106
13107        gc_old_generations(dir.path(), project_key, &current);
13108
13109        assert!(dir.path().join(&current).is_file());
13110        assert!(dir.path().join(&previous).is_file());
13111        assert!(!dir.path().join(&old).exists());
13112    }
13113
13114    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13115        let path = dir.join(name);
13116        fs::write(&path, b"temp placeholder").unwrap();
13117        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13118        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13119        path
13120    }
13121
13122    #[test]
13123    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13124        let dir = tempdir().unwrap();
13125        // One directory holds both an aged orphan (with its journal sidecar) and a
13126        // fresh temporary, so this proves the sweep SELECTS by age rather than
13127        // deleting everything in the directory.
13128        let aged = "project.g100.1.sqlite.tmp.1.200";
13129        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13130        let fresh = "project.g300.1.sqlite.tmp.1.400";
13131        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13132        write_build_temp_with_age(dir.path(), aged, aged_age);
13133        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13134        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13135
13136        sweep_orphaned_build_temps(dir.path());
13137
13138        assert!(
13139            !dir.path().join(aged).exists(),
13140            "aged orphan must be removed"
13141        );
13142        assert!(
13143            !dir.path().join(aged_journal).exists(),
13144            "aged journal sidecar must be removed"
13145        );
13146        assert!(
13147            dir.path().join(fresh).is_file(),
13148            "fresh temporary must survive"
13149        );
13150    }
13151
13152    #[test]
13153    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13154        let storage = tempdir().unwrap();
13155        let storage_root = storage.path();
13156        // The production shape: a legacy per-harness store whose root no longer
13157        // builds there — no `.current` pointer, no running build — so the per-root
13158        // cleanup never fires for it. A sibling root still building in the
13159        // root-keyed store triggers the store-wide sweep, which must reach into the
13160        // legacy directory and reclaim the orphan.
13161        let legacy_dir = storage_root.join("opencode").join("callgraph");
13162        fs::create_dir_all(&legacy_dir).unwrap();
13163        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13164        write_build_temp_with_age(
13165            &legacy_dir,
13166            orphan,
13167            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13168        );
13169        assert!(
13170            !legacy_dir.join("deadbeef.current").exists(),
13171            "the dead root has no current pointer"
13172        );
13173
13174        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13175        fs::create_dir_all(&root_keyed_dir).unwrap();
13176
13177        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13178
13179        assert!(
13180            !legacy_dir.join(orphan).exists(),
13181            "legacy orphan must be reclaimed by the store-wide sweep"
13182        );
13183    }
13184
13185    #[test]
13186    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13187        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
13188        // everything (min_age = 0) removes the fresh temporary that the real 24h
13189        // threshold spares in the test above. If a mutation to the age check leaves
13190        // the fresh file in place here, the predicate is no longer doing the
13191        // selection work the fresh-survives assertion relies on.
13192        let dir = tempdir().unwrap();
13193        let fresh = "project.g300.1.sqlite.tmp.1.400";
13194        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13195
13196        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13197
13198        assert!(
13199            !dir.path().join(fresh).exists(),
13200            "with the age predicate forced open, the fresh temporary is removed"
13201        );
13202    }
13203
13204    #[test]
13205    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13206        let dir = tempdir().unwrap();
13207        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
13208        // to be swept, plus a live read marker, is generation GC's jurisdiction.
13209        // The orphan sweep must not intersect it.
13210        let generation = write_generation_with_age(
13211            dir.path(),
13212            "project",
13213            400,
13214            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13215        );
13216        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13217
13218        sweep_orphaned_build_temps(dir.path());
13219
13220        assert!(
13221            dir.path().join(&generation).is_file(),
13222            "completed generation must survive the orphan sweep"
13223        );
13224        assert!(
13225            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13226            "read marker must survive the orphan sweep"
13227        );
13228    }
13229
13230    #[test]
13231    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13232        let dir = tempfile::tempdir().unwrap();
13233        let project_key = "project".to_string();
13234        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13235        let sqlite_path = dir.path().join(&generation);
13236        fs::remove_file(&sqlite_path).unwrap();
13237        let conn = Connection::open(&sqlite_path).unwrap();
13238        let store = CallGraphStore::from_connection(
13239            dir.path().to_path_buf(),
13240            project_key,
13241            sqlite_path,
13242            dir.path().to_path_buf(),
13243            false,
13244            Some(generation.clone()),
13245            None,
13246            None,
13247            conn,
13248        );
13249
13250        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13251        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13252
13253        drop(marker);
13254        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13255    }
13256
13257    #[test]
13258    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13259        let dir = tempdir().expect("temp dir");
13260        let file = dir.path().join("main.ts");
13261        fs::write(&file, "export function main() {}\n").expect("write fixture");
13262        let store = CallGraphStore::open(
13263            dir.path().join(".store-readiness-cache"),
13264            dir.path().to_path_buf(),
13265        )
13266        .expect("open store");
13267        {
13268            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13269            conn.trace(Some(count_caller_traversal_selects));
13270        }
13271
13272        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13273        assert!(store.indexed_file_count().is_err());
13274        assert!(store.indexed_file_count().is_err());
13275        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13276
13277        store
13278            .cold_build(std::slice::from_ref(&file))
13279            .expect("cold build");
13280        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13281        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13282        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13283        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13284
13285        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13286        conn.trace(None);
13287    }
13288
13289    #[test]
13290    fn direct_caller_frontier_chunks_sqlite_selects() {
13291        let dir = tempdir().expect("temp dir");
13292        let file = dir.path().join("main.ts");
13293        fs::write(
13294            &file,
13295            "export function caller() { target(); }\nexport function target() {}\n",
13296        )
13297        .expect("write fixture");
13298        let store = CallGraphStore::open(
13299            dir.path().join(".store-caller-frontier-query"),
13300            dir.path().to_path_buf(),
13301        )
13302        .expect("open store");
13303        store
13304            .cold_build(std::slice::from_ref(&file))
13305            .expect("cold build");
13306        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13307        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13308
13309        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13310        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13311        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13312        {
13313            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13314            conn.trace(Some(count_caller_traversal_selects));
13315        }
13316        let callers = store
13317            .direct_callers_for_symbols(&targets)
13318            .expect("batched callers");
13319        {
13320            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13321            conn.trace(None);
13322        }
13323
13324        assert_eq!(callers.len(), 1_000);
13325        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13326        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13327        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13328        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13329    }
13330
13331    #[test]
13332    fn callers_depth_boundary_batches_sqlite_counts() {
13333        const CALLER_COUNT: usize = 1_000;
13334
13335        let dir = tempdir().expect("temp dir");
13336        let file = dir.path().join("main.ts");
13337        let mut source = String::from("export function sharedHotHelper() {}\n");
13338        for index in 0..CALLER_COUNT {
13339            source.push_str(&format!(
13340                "export function caller{index}() {{ sharedHotHelper(); }}\n"
13341            ));
13342        }
13343        fs::write(&file, source).expect("write fixture");
13344
13345        let store = CallGraphStore::open(
13346            dir.path().join(".store-callers-query-fanout"),
13347            dir.path().to_path_buf(),
13348        )
13349        .expect("open store");
13350        store
13351            .cold_build(std::slice::from_ref(&file))
13352            .expect("cold build");
13353
13354        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13355        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13356        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13357        {
13358            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13359            conn.trace(Some(count_caller_traversal_selects));
13360        }
13361
13362        let started = Instant::now();
13363        let result = crate::commands::callgraph_store_adapter::callers_result(
13364            &store,
13365            Path::new("main.ts"),
13366            "sharedHotHelper",
13367            1,
13368            true,
13369        )
13370        .expect("callers result");
13371        let elapsed = started.elapsed();
13372
13373        {
13374            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13375            conn.trace(None);
13376        }
13377        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13378        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13379        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13380        eprintln!(
13381            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13382            result.total_callers,
13383            caller_queries,
13384            boundary_queries,
13385            total_selects,
13386            elapsed.as_secs_f64() * 1_000.0
13387        );
13388
13389        assert_eq!(result.total_callers, CALLER_COUNT);
13390        assert_eq!(caller_queries, 1);
13391        assert_eq!(boundary_queries, 3);
13392        assert_eq!(total_selects, 9);
13393    }
13394
13395    #[test]
13396    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13397        let dir = tempdir().expect("temp dir");
13398        let file = dir.path().join("main.ts");
13399        fs::write(
13400            &file,
13401            r#"export function topA() {
13402  root();
13403}
13404
13405export function topB() {
13406  root();
13407}
13408
13409export function root() {
13410  leaf();
13411  missing();
13412}
13413
13414export function leaf() {}
13415"#,
13416        )
13417        .expect("write fixture");
13418
13419        let store = CallGraphStore::open(
13420            dir.path().join(".store-depth-boundary-counts"),
13421            dir.path().to_path_buf(),
13422        )
13423        .expect("open store");
13424        store
13425            .cold_build(std::slice::from_ref(&file))
13426            .expect("cold build");
13427
13428        let root = store
13429            .node_for(Path::new("main.ts"), "root")
13430            .expect("root node");
13431        let leaf = store
13432            .node_for(Path::new("main.ts"), "leaf")
13433            .expect("leaf node");
13434
13435        let (full_forward_len, full_direct_len) = {
13436            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13437            conn.execute(
13438                "INSERT INTO edges (
13439                    edge_id, ref_id, source_node, target_node, target_file,
13440                    target_symbol, kind, line, provenance
13441                 ) VALUES (
13442                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13443                    ?2, ?3, 'call', 98, ?4
13444                 )",
13445                rusqlite::params![
13446                    &root.node_id,
13447                    &leaf.file,
13448                    &leaf.symbol,
13449                    PROVENANCE_TREESITTER
13450                ],
13451            )
13452            .expect("insert dangling forward edge");
13453            conn.execute(
13454                "INSERT INTO edges (
13455                    edge_id, ref_id, source_node, target_node, target_file,
13456                    target_symbol, kind, line, provenance
13457                 ) VALUES (
13458                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13459                    ?1, ?2, ?3, 'call', 99, ?4
13460                 )",
13461                rusqlite::params![
13462                    &root.node_id,
13463                    &root.file,
13464                    &root.symbol,
13465                    PROVENANCE_TREESITTER
13466                ],
13467            )
13468            .expect("insert dangling direct-caller edge");
13469
13470            let full_forward_len = forward_calls_for_node(&conn, &root)
13471                .expect("full forward calls")
13472                .len();
13473            let counted_forward_len =
13474                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13475            assert_eq!(
13476                counted_forward_len, full_forward_len,
13477                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13478            );
13479
13480            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13481                .expect("full direct callers");
13482            let full_direct_len = full_direct.len();
13483            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13484                .expect("counted direct callers");
13485            assert_eq!(
13486                counted_direct_len, full_direct_len,
13487                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13488            );
13489
13490            let distinct_direct_len = full_direct
13491                .iter()
13492                .map(|site| {
13493                    (
13494                        site.caller.file.clone(),
13495                        site.line,
13496                        site.target_file.clone(),
13497                        site.target_symbol.clone(),
13498                    )
13499                })
13500                .collect::<BTreeSet<_>>()
13501                .len();
13502            let batch_counts = direct_caller_counts_for_tuples(
13503                &conn,
13504                &[
13505                    (root.file.clone(), root.symbol.clone()),
13506                    (root.file.clone(), root.symbol.clone()),
13507                    (leaf.file.clone(), leaf.symbol.clone()),
13508                ],
13509            )
13510            .expect("batched direct-caller counts");
13511            assert_eq!(batch_counts.len(), 2);
13512            assert_eq!(
13513                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13514                Some(&distinct_direct_len)
13515            );
13516
13517            (full_forward_len, full_direct_len)
13518        };
13519
13520        assert_eq!(
13521            full_forward_len, 2,
13522            "fixture root should have one resolved and one unresolved outgoing call"
13523        );
13524        assert_eq!(
13525            full_direct_len, 2,
13526            "fixture root should have two real direct callers"
13527        );
13528
13529        let tree = store
13530            .call_tree(Path::new("main.ts"), "root", 0)
13531            .expect("call tree");
13532        assert!(tree.depth_limited);
13533        assert_eq!(tree.children.len(), 0);
13534        assert_eq!(
13535            tree.truncated, full_forward_len,
13536            "call_tree depth boundary must report the full forward-call list length"
13537        );
13538
13539        let callers = store
13540            .callers_of(Path::new("main.ts"), "leaf", 0)
13541            .expect("callers");
13542        assert!(callers.depth_limited);
13543        assert_eq!(callers.callers.len(), 1);
13544        assert_eq!(callers.callers[0].caller.symbol, "root");
13545        assert_eq!(
13546            callers.truncated, full_direct_len,
13547            "callers depth boundary must report the full direct-caller list length"
13548        );
13549    }
13550
13551    #[test]
13552    fn source_freshness_matches_cache_collect_for_same_bytes() {
13553        let dir = tempdir().expect("temp dir");
13554        let path = dir.path().join("fixture.ts");
13555        let source = "export function main() { return helper(); }\n";
13556        fs::write(&path, source).expect("write fixture");
13557
13558        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13559        let actual =
13560            collect_source_freshness(&path, source).expect("collect freshness from source");
13561
13562        assert_eq!(actual, expected);
13563    }
13564
13565    #[test]
13566    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13567        let root = tempfile::tempdir().unwrap();
13568        let callgraph_dir = tempfile::tempdir().unwrap();
13569        let source_dir = root.path().join("src");
13570        std::fs::create_dir_all(&source_dir).unwrap();
13571        let source = source_dir.join("lib.rs");
13572        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13573        let files = vec![source.clone()];
13574        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13575        let old_epoch = epoch.next();
13576        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13577        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13578        let old_epoch_flag = epoch.clone();
13579        let old_dir = callgraph_dir.path().to_path_buf();
13580        let old_root = root.path().to_path_buf();
13581        let old_files = files.clone();
13582        let old = std::thread::spawn(move || {
13583            set_cold_build_before_publish_observer(Some(Arc::new(move || {
13584                reached_tx.send(()).unwrap();
13585                release_rx.recv().unwrap();
13586            })));
13587            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13588                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13589            });
13590            set_cold_build_before_publish_observer(None);
13591            result
13592        });
13593        // Positive wait: the older build runs a real cold build (git probe +
13594        // SQLite schema init) before the barrier, which can exceed 5s on a
13595        // contended Windows CI runner. Only negative waits stay short.
13596        reached_rx
13597            .recv_timeout(Duration::from_secs(30))
13598            .expect("older build did not reach its publication barrier");
13599
13600        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13601        let new_epoch = epoch.next();
13602        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13603            CallGraphStore::cold_build_with_lease(
13604                callgraph_dir.path().to_path_buf(),
13605                root.path().to_path_buf(),
13606                &files,
13607            )
13608        })
13609        .expect("newer build should publish");
13610        drop(new_store);
13611
13612        release_tx.send(()).unwrap();
13613        assert!(matches!(
13614            old.join().unwrap(),
13615            Err(CallGraphStoreError::Superseded)
13616        ));
13617
13618        let current = CallGraphStore::open_readonly(
13619            callgraph_dir.path().to_path_buf(),
13620            root.path().to_path_buf(),
13621        )
13622        .unwrap()
13623        .expect("current callgraph generation");
13624        assert_eq!(
13625            current
13626                .nodes_matching("new_generation_marker")
13627                .unwrap()
13628                .len(),
13629            1
13630        );
13631        assert!(current
13632            .nodes_matching("old_generation_marker")
13633            .unwrap()
13634            .is_empty());
13635    }
13636
13637    #[test]
13638    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13639        let dir = tempdir().expect("temp dir");
13640        let project_root = dir.path();
13641        let extract = fixture_extract(project_root);
13642        let resolved = fixture_resolved(&extract);
13643
13644        let reference = build_reference_connection(project_root, &extract, &resolved);
13645        let optimized = build_optimized_connection(project_root, &extract, &resolved);
13646
13647        for table in [
13648            "files",
13649            "nodes",
13650            "file_dependencies",
13651            "dispatch_hints",
13652            "refs",
13653            "edges",
13654        ] {
13655            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
13656            // the reference and optimized builds run sequentially and can straddle a
13657            // one-second tick under load, so it is legitimately allowed to differ.
13658            // This mirrors the existing exclusions of `backend_file_state.updated_at`
13659            // and the chunked-vs-unchunked sibling test. The check is for structural
13660            // row equivalence of the optimized bulk insert, not wall-clock equality.
13661            let excluded: &[&str] = if table == "files" {
13662                &["indexed_at"]
13663            } else {
13664                &[]
13665            };
13666            assert_eq!(
13667                table_rows_without(&reference, table, excluded),
13668                table_rows_without(&optimized, table, excluded),
13669                "table `{table}` rows must match apart from wall-clock columns"
13670            );
13671        }
13672        assert_eq!(
13673            backend_state_rows(&reference),
13674            backend_state_rows(&optimized),
13675            "backend freshness rows must match apart from updated_at"
13676        );
13677        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13678    }
13679
13680    #[test]
13681    fn cold_build_chunked_matches_unchunked_logical_rows() {
13682        let dir = tempdir().expect("temp dir");
13683        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13684        write_chunked_equivalence_fixture(&project_root);
13685        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13686        assert!(
13687            files.len() > 6,
13688            "fixture should be large enough to split into multiple chunks"
13689        );
13690
13691        let unchunked = CallGraphStore::open(
13692            project_root.join(".store-unchunked"),
13693            project_root.to_path_buf(),
13694        )
13695        .expect("open unchunked store");
13696        let unchunked_stats = unchunked
13697            .cold_build_chunked(&files, 0)
13698            .expect("unchunked cold build");
13699
13700        let chunked = CallGraphStore::open(
13701            project_root.join(".store-chunked"),
13702            project_root.to_path_buf(),
13703        )
13704        .expect("open chunked store");
13705        let chunked_stats = chunked
13706            .cold_build_chunked(&files, 3)
13707            .expect("chunked cold build");
13708
13709        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13710        assert_eq!(
13711            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13712            chunked.edge_snapshot().expect("chunked edge snapshot"),
13713            "public edge snapshots must match"
13714        );
13715
13716        let dispatch_edges = {
13717            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13718            conn.query_row(
13719                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13720                [],
13721                |row| row.get::<_, i64>(0),
13722            )
13723            .expect("count dispatch edges")
13724        };
13725        assert!(
13726            dispatch_edges > 0,
13727            "fixture must exercise method-dispatch edge insertion"
13728        );
13729
13730        for table in [
13731            "edges",
13732            "refs",
13733            "nodes",
13734            "file_dependencies",
13735            "dispatch_hints",
13736        ] {
13737            assert_eq!(
13738                graph_table_rows(&unchunked, table),
13739                graph_table_rows(&chunked, table),
13740                "chunked cold build must match unchunked rows for {table}"
13741            );
13742        }
13743        assert_eq!(
13744            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13745            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13746            "files rows must match apart from indexed_at"
13747        );
13748        assert_eq!(
13749            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13750            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13751            "backend freshness rows must match apart from updated_at"
13752        );
13753
13754        let published_dir = project_root.join(".store-published");
13755        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13756            published_dir.clone(),
13757            project_root.to_path_buf(),
13758            &files,
13759            0,
13760        )
13761        .expect("published unchunked cold build");
13762        assert!(
13763            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13764                .expect("needs_cold_build after publish"),
13765            "published store should be ready"
13766        );
13767        drop(_published);
13768        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13769            published_dir,
13770            project_root.to_path_buf(),
13771            &files,
13772            3,
13773        )
13774        .expect("ensure with a different chunk size");
13775        assert!(
13776            rebuild_stats.is_none(),
13777            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13778        );
13779    }
13780
13781    // Perf A/B bench (not a gate): measures cold_build wall time at a given
13782    // chunk size against a real repo. Driven by env so the same binary can A/B
13783    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
13784    // memory work. Run:
13785    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
13786    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
13787    #[test]
13788    #[ignore]
13789    fn bench_cold_build_chunk() {
13790        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13791        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13792            .expect("AFT_PERF_CHUNK")
13793            .parse()
13794            .expect("AFT_PERF_CHUNK must be a non-negative integer");
13795        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13796        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13797        let dir = tempdir().expect("temp dir");
13798        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13799            .expect("open store");
13800        let started = Instant::now();
13801        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13802        let ms = started.elapsed().as_millis();
13803        println!(
13804            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13805            stats.files, stats.nodes, stats.refs, stats.edges
13806        );
13807    }
13808
13809    #[test]
13810    fn persisted_workspace_reexport_selects_its_package_dependency() {
13811        let root = tempdir().expect("temp dir");
13812        let dependencies = BTreeSet::from([
13813            "packages/aft-bridge/src/index.ts".to_string(),
13814            "packages/opencode-plugin/src/types.ts".to_string(),
13815        ]);
13816        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13817
13818        assert_eq!(
13819            stored_dependencies_for_module(
13820                root.path(),
13821                "packages/opencode-plugin/src/shared/bash-hints.ts",
13822                "@cortexkit/aft-bridge",
13823                &dependencies,
13824                &indexed_files,
13825            ),
13826            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13827        );
13828    }
13829
13830    #[test]
13831    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13832        let dir = tempdir().expect("temp dir");
13833        let project_root = dir.path();
13834        let files =
13835            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13836        let index_path = project_root.join("src/index.ts");
13837
13838        let store = CallGraphStore::open(
13839            project_root.join(".store-incremental-barrel"),
13840            project_root.to_path_buf(),
13841        )
13842        .expect("open incremental store");
13843        store.cold_build(&files).expect("initial cold build");
13844
13845        {
13846            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13847            let tx = conn.transaction().expect("dependency transaction");
13848            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13849                .expect("dependent refs for barrel");
13850            let selected_ref_ids = dependent_refs
13851                .iter()
13852                .map(|dependent_ref| dependent_ref.ref_id.clone())
13853                .collect::<BTreeSet<_>>();
13854            let mut threaded_ref_ids = BTreeSet::new();
13855            let mut threaded_by_caller = BTreeMap::new();
13856            record_dependent_refs(
13857                &mut threaded_ref_ids,
13858                &mut threaded_by_caller,
13859                dependent_refs,
13860            );
13861            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13862                .expect("old per-ref caller lookup");
13863
13864            assert_eq!(threaded_ref_ids, selected_ref_ids);
13865            assert_eq!(threaded_by_caller, old_by_caller);
13866            for consumer in [
13867                "src/consumer_a.ts",
13868                "src/consumer_b.ts",
13869                "src/consumer_c.ts",
13870            ] {
13871                assert!(
13872                    threaded_by_caller.contains_key(consumer),
13873                    "barrel edit should select dependent refs from {consumer}"
13874                );
13875            }
13876        }
13877
13878        fs::write(
13879            &index_path,
13880            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13881        )
13882        .expect("edit barrel");
13883        let stats = store
13884            .refresh_files(std::slice::from_ref(&index_path))
13885            .expect("incremental refresh");
13886        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13887        assert!(
13888            stats.dependency_selected_refs > 0,
13889            "barrel surface edit should select dependent refs"
13890        );
13891
13892        let cold_store = CallGraphStore::open(
13893            project_root.join(".store-cold-barrel"),
13894            project_root.to_path_buf(),
13895        )
13896        .expect("open cold rebuild store");
13897        cold_store
13898            .cold_build(&files)
13899            .expect("comparison cold build");
13900
13901        for table in [
13902            "nodes",
13903            "refs",
13904            "file_dependencies",
13905            "edges",
13906            "dispatch_hints",
13907        ] {
13908            assert_eq!(
13909                graph_table_rows(&store, table),
13910                graph_table_rows(&cold_store, table),
13911                "incremental refresh {table} rows must match cold rebuild"
13912            );
13913        }
13914
13915        let consumer_path = project_root.join("src/consumer_a.ts");
13916        fs::write(
13917            &consumer_path,
13918            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13919        )
13920        .expect("edit barrel consumer");
13921        store
13922            .refresh_files(std::slice::from_ref(&consumer_path))
13923            .expect("refresh consumer through unchanged barrel");
13924        cold_store
13925            .cold_build(&files)
13926            .expect("comparison cold rebuild after consumer refresh");
13927        for table in [
13928            "nodes",
13929            "refs",
13930            "file_dependencies",
13931            "edges",
13932            "dispatch_hints",
13933        ] {
13934            assert_eq!(
13935                graph_table_rows(&store, table),
13936                graph_table_rows(&cold_store, table),
13937                "refresh through a persisted barrel must preserve cold-build {table} rows"
13938            );
13939        }
13940    }
13941
13942    fn build_reference_connection(
13943        project_root: &Path,
13944        extract: &FileExtract,
13945        resolved: &ResolvedRef,
13946    ) -> Connection {
13947        let mut conn = Connection::open_in_memory().expect("open reference db");
13948        configure_build_connection(&conn).expect("configure reference db");
13949        initialize_schema(&conn).expect("initialize reference schema");
13950        {
13951            let tx = conn.transaction().expect("reference transaction");
13952            clear_tables(&tx).expect("reference clear");
13953            insert_meta(&tx).expect("reference meta");
13954            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13955            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13956            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13957                .expect("reference dispatch edges");
13958            assert_eq!(supplemental, 0);
13959            tx.commit().expect("reference commit");
13960        }
13961        conn
13962    }
13963
13964    fn build_optimized_connection(
13965        project_root: &Path,
13966        extract: &FileExtract,
13967        resolved: &ResolvedRef,
13968    ) -> Connection {
13969        let mut conn = Connection::open_in_memory().expect("open optimized db");
13970        configure_build_connection(&conn).expect("configure optimized db");
13971        initialize_schema(&conn).expect("initialize optimized schema");
13972        {
13973            let tx = conn.transaction().expect("optimized transaction");
13974            clear_tables(&tx).expect("optimized clear");
13975            insert_meta(&tx).expect("optimized meta");
13976            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13977            {
13978                let workspace_root = project_root.display().to_string();
13979                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13980                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13981                    .expect("optimized file extract");
13982                insert_resolved_ref_prepared(&mut inserts, resolved)
13983                    .expect("optimized resolved ref");
13984            }
13985            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13986            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13987                .expect("optimized dispatch edges");
13988            assert_eq!(supplemental, 0);
13989            tx.commit().expect("optimized commit");
13990        }
13991        conn
13992    }
13993
13994    fn fixture_extract(_project_root: &Path) -> FileExtract {
13995        let rel_path = "src/main.ts".to_string();
13996        let target_path = "src/helper.ts".to_string();
13997        let node = NodeRecord {
13998            id: "node-main".to_string(),
13999            file_path: rel_path.clone(),
14000            name: "main".to_string(),
14001            scoped_name: "main".to_string(),
14002            kind: "function".to_string(),
14003            range: Range {
14004                start_line: 0,
14005                start_col: 0,
14006                end_line: 0,
14007                end_col: 32,
14008            },
14009            range_ordinal: 0,
14010            signature: Some("export function main()".to_string()),
14011            exported: true,
14012            is_default_export: false,
14013            is_type_like: false,
14014            is_callgraph_entry_point: true,
14015        };
14016        let mut dependencies = BTreeSet::new();
14017        dependencies.insert(target_path.clone());
14018        let raw_ref = RawRef {
14019            ref_id: "ref-main-helper".to_string(),
14020            caller_node: Some(node.id.clone()),
14021            caller_symbol: Some(node.scoped_name.clone()),
14022            caller_file: rel_path.clone(),
14023            kind: "call".to_string(),
14024            short_name: Some("helper".to_string()),
14025            full_ref: Some("helper".to_string()),
14026            module_path: None,
14027            import_kind: None,
14028            local_name: Some("helper".to_string()),
14029            requested_name: Some("helper".to_string()),
14030            namespace_alias: None,
14031            wildcard: false,
14032            line: 1,
14033            byte_start: 24,
14034            byte_end: 32,
14035            dependencies,
14036        };
14037        FileExtract {
14038            rel_path,
14039            freshness: FileFreshness {
14040                mtime: UNIX_EPOCH + Duration::from_secs(123),
14041                size: 40,
14042                content_hash: cache_freshness::hash_bytes(b"fixture source"),
14043            },
14044            lang: LangId::TypeScript,
14045            data: FileCallData {
14046                calls_by_symbol: HashMap::new(),
14047                value_refs_by_symbol: HashMap::new(),
14048                exported_symbols: Vec::new(),
14049                symbol_metadata: HashMap::new(),
14050                default_export_symbol: None,
14051                import_block: ImportBlock::empty(),
14052                lang: LangId::TypeScript,
14053            },
14054            nodes: vec![node.clone()],
14055            raw_refs: vec![raw_ref],
14056            dispatch_hints: vec![DispatchHint {
14057                id: "dispatch-main-helper".to_string(),
14058                method_name: "helper".to_string(),
14059                caller_node: node.id,
14060                file: "src/main.ts".to_string(),
14061                line: 1,
14062                byte_start: 24,
14063                byte_end: 32,
14064            }],
14065            surface_fingerprint: "surface".to_string(),
14066        }
14067    }
14068
14069    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
14070        let raw = extract.raw_refs[0].clone();
14071        let mut dependencies = raw.dependencies.clone();
14072        dependencies.insert("src/helper.ts".to_string());
14073        ResolvedRef {
14074            edge: Some(EdgeRecord {
14075                edge_id: "edge-main-helper".to_string(),
14076                source_node: raw.caller_node.clone().expect("caller node"),
14077                target_node: Some("node-helper".to_string()),
14078                target_file: "src/helper.ts".to_string(),
14079                target_symbol: "helper".to_string(),
14080                kind: "call".to_string(),
14081                line: raw.line,
14082            }),
14083            raw,
14084            status: "resolved".to_string(),
14085            target_node: Some("node-helper".to_string()),
14086            target_file: Some("src/helper.ts".to_string()),
14087            target_symbol: Some("helper".to_string()),
14088            dependencies,
14089        }
14090    }
14091
14092    fn write_chunked_equivalence_fixture(project_root: &Path) {
14093        let ts_dir = project_root.join("ts");
14094        fs::create_dir_all(&ts_dir).expect("create ts dir");
14095        fs::write(
14096            ts_dir.join("leaf.ts"),
14097            "export function leaf(value: number) {\n  return value + 1;\n}\n",
14098        )
14099        .expect("write ts leaf");
14100        fs::write(
14101            ts_dir.join("mid.ts"),
14102            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
14103        )
14104        .expect("write ts mid");
14105        fs::write(
14106            ts_dir.join("entry.ts"),
14107            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
14108        )
14109        .expect("write ts entry");
14110        fs::write(
14111            ts_dir.join("worker.ts"),
14112            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
14113        )
14114        .expect("write ts worker");
14115        for idx in 0..4 {
14116            fs::write(
14117                ts_dir.join(format!("extra_{idx}.ts")),
14118                format!(
14119                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
14120                ),
14121            )
14122            .expect("write ts extra");
14123        }
14124
14125        let rust_dir = project_root.join("src");
14126        let commands_dir = rust_dir.join("commands");
14127        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14128        fs::write(
14129            rust_dir.join("context.rs"),
14130            r#"pub struct AppContext;
14131
14132impl AppContext {
14133    pub fn callgraph_store_for_ops(&self) -> usize {
14134        1
14135    }
14136}
14137"#,
14138        )
14139        .expect("write rust context");
14140        fs::write(
14141            rust_dir.join("lib.rs"),
14142            "pub mod context;\npub mod commands;\n",
14143        )
14144        .expect("write rust lib");
14145        fs::write(
14146            commands_dir.join("mod.rs"),
14147            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14148        )
14149        .expect("write rust commands mod");
14150        for name in ["callers", "impact", "trace_to"] {
14151            fs::write(
14152                commands_dir.join(format!("{name}.rs")),
14153                format!(
14154                    r#"use crate::context::AppContext;
14155
14156pub fn handle_{name}(ctx: &AppContext) -> usize {{
14157    ctx.callgraph_store_for_ops()
14158}}
14159"#
14160                ),
14161            )
14162            .expect("write rust command");
14163        }
14164    }
14165
14166    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14167        let src_dir = project_root.join("src");
14168        fs::create_dir_all(&src_dir).expect("create src dir");
14169
14170        let target_path = src_dir.join("target.ts");
14171        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
14172            .expect("write target");
14173
14174        let index_path = src_dir.join("index.ts");
14175        fs::write(&index_path, barrel_source).expect("write barrel");
14176
14177        let mut files = vec![target_path, index_path];
14178        for (file_name, function_name) in [
14179            ("consumer_a.ts", "consumerA"),
14180            ("consumer_b.ts", "consumerB"),
14181            ("consumer_c.ts", "consumerC"),
14182        ] {
14183            let path = src_dir.join(file_name);
14184            fs::write(
14185                &path,
14186                format!(
14187                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
14188                ),
14189            )
14190            .expect("write consumer");
14191            files.push(path);
14192        }
14193        files
14194    }
14195
14196    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14197        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14198        table_rows(&conn, table)
14199    }
14200
14201    fn graph_table_rows_without(
14202        store: &CallGraphStore,
14203        table: &str,
14204        excluded_columns: &[&str],
14205    ) -> Vec<String> {
14206        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14207        table_rows_without(&conn, table, excluded_columns)
14208    }
14209
14210    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14211        table_rows_without(conn, table, &[])
14212    }
14213
14214    fn table_rows_without(
14215        conn: &Connection,
14216        table: &str,
14217        excluded_columns: &[&str],
14218    ) -> Vec<String> {
14219        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14220        let columns: Vec<String> = conn
14221            .prepare(&format!("PRAGMA table_info({table})"))
14222            .expect("prepare table_info")
14223            .query_map([], |row| row.get::<_, String>(1))
14224            .expect("query table_info")
14225            .collect::<std::result::Result<Vec<String>, _>>()
14226            .expect("collect columns")
14227            .into_iter()
14228            .filter(|column| !excluded_columns.contains(column.as_str()))
14229            .collect();
14230        let sql = format!(
14231            "SELECT {} FROM {table} ORDER BY {}",
14232            columns.join(", "),
14233            columns.join(", ")
14234        );
14235        conn.prepare(&sql)
14236            .expect("prepare table rows")
14237            .query_map([], |row| row_to_strings(row, columns.len()))
14238            .expect("query table rows")
14239            .collect::<std::result::Result<_, _>>()
14240            .expect("collect table rows")
14241    }
14242
14243    fn assert_cold_build_stats_match_except_elapsed(
14244        expected: &ColdBuildStats,
14245        actual: &ColdBuildStats,
14246    ) {
14247        assert_eq!(actual.files, expected.files, "file counts must match");
14248        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14249        assert_eq!(actual.refs, expected.refs, "ref counts must match");
14250        assert_eq!(actual.edges, expected.edges, "edge counts must match");
14251        assert_eq!(
14252            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14253            expected
14254                .failed_files
14255                .iter()
14256                .cloned()
14257                .collect::<BTreeSet<_>>(),
14258            "failed file sets must match"
14259        );
14260    }
14261
14262    fn backend_state_rows(conn: &Connection) -> Vec<String> {
14263        conn.prepare(
14264            "SELECT backend, workspace_root, file_path, content_hash, status
14265             FROM backend_file_state
14266             ORDER BY backend, workspace_root, file_path, content_hash, status",
14267        )
14268        .expect("prepare backend rows")
14269        .query_map([], |row| row_to_strings(row, 5))
14270        .expect("query backend rows")
14271        .collect::<std::result::Result<_, _>>()
14272        .expect("collect backend rows")
14273    }
14274
14275    fn secondary_indexes(conn: &Connection) -> Vec<String> {
14276        let mut indexes = Vec::new();
14277        for table in [
14278            "files",
14279            "nodes",
14280            "refs",
14281            "file_dependencies",
14282            "edges",
14283            "dispatch_hints",
14284            "type_ref_names",
14285            "backend_file_state",
14286            "meta",
14287        ] {
14288            let sql = format!("PRAGMA index_list({table})");
14289            let mut stmt = conn.prepare(&sql).expect("prepare index list");
14290            let rows = stmt
14291                .query_map([], |row| row.get::<_, String>(1))
14292                .expect("query index list");
14293            for name in rows {
14294                let name = name.expect("index name");
14295                if name.starts_with("idx_") {
14296                    indexes.push(format!("{table}:{name}"));
14297                }
14298            }
14299        }
14300        indexes.sort();
14301        indexes
14302    }
14303
14304    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14305        let mut values = Vec::with_capacity(len);
14306        for index in 0..len {
14307            let value = row.get_ref(index)?;
14308            values.push(match value {
14309                rusqlite::types::ValueRef::Null => "NULL".to_string(),
14310                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14311                rusqlite::types::ValueRef::Real(value) => value.to_string(),
14312                rusqlite::types::ValueRef::Text(value) => {
14313                    String::from_utf8_lossy(value).into_owned()
14314                }
14315                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14316            });
14317        }
14318        Ok(values.join("\u{1f}"))
14319    }
14320}
14321
14322#[cfg(test)]
14323mod rust_resolution_tests {
14324    use super::*;
14325    use crate::inspect::job::CallgraphSnapshot;
14326    use std::fs;
14327    use tempfile::tempdir;
14328
14329    #[test]
14330    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14331        let dir = tempdir().expect("tempdir");
14332        let root = dir.path();
14333        write_rust_manifest(root, "scoped-alias-fixture");
14334        write_file(
14335            root,
14336            "src/lib.rs",
14337            r#"pub mod finalization_contract;
14338
14339pub fn run_alias() {
14340    use crate::finalization_contract as fc;
14341    fc::check_mason_contract();
14342}
14343"#,
14344        );
14345        write_file(
14346            root,
14347            "src/finalization_contract.rs",
14348            r#"pub fn check_mason_contract() {}
14349fn planted_dead() {}
14350"#,
14351        );
14352
14353        let (store, snapshot) = cold_build_twice(root);
14354        assert_direct_caller(
14355            &store,
14356            "src/finalization_contract.rs",
14357            "check_mason_contract",
14358            "src/lib.rs",
14359            "run_alias",
14360        );
14361        assert_projected_call(
14362            root,
14363            &snapshot,
14364            "src/finalization_contract.rs",
14365            "check_mason_contract",
14366        );
14367        assert_no_projected_call(
14368            root,
14369            &snapshot,
14370            "src/finalization_contract.rs",
14371            "planted_dead",
14372        );
14373        assert!(
14374            store
14375                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14376                .expect("planted dead callers")
14377                .is_empty(),
14378            "planted-dead guard should stay without callers"
14379        );
14380    }
14381
14382    #[test]
14383    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14384        let dir = tempdir().expect("tempdir");
14385        let root = dir.path();
14386        write_rust_manifest(root, "inline-module-fixture");
14387        write_file(
14388            root,
14389            "src/lib.rs",
14390            r#"mod work_graph { fn operations() {} }
14391mod manifest { fn operations() {} }
14392mod audit { fn operations() {} }
14393mod dispatch { fn operations() {} }
14394mod finalization { fn operations() {} }
14395
14396pub fn run_inline_operations() {
14397    work_graph::operations();
14398    manifest::operations();
14399    audit::operations();
14400    dispatch::operations();
14401    finalization::operations();
14402}
14403
14404fn planted_dead() {}
14405"#,
14406        );
14407
14408        let (store, snapshot) = cold_build_twice(root);
14409        for module in [
14410            "work_graph",
14411            "manifest",
14412            "audit",
14413            "dispatch",
14414            "finalization",
14415        ] {
14416            assert_direct_caller(
14417                &store,
14418                "src/lib.rs",
14419                &format!("{module}::operations"),
14420                "src/lib.rs",
14421                "run_inline_operations",
14422            );
14423        }
14424        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14425        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14426    }
14427
14428    #[test]
14429    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14430        let dir = tempdir().expect("tempdir");
14431        let root = dir.path();
14432        fs::write(
14433            root.join("Cargo.toml"),
14434            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14435        )
14436        .expect("write workspace manifest");
14437        write_file(
14438            root,
14439            "crates/but-action/Cargo.toml",
14440            r#"[package]
14441name = "but-action"
14442version = "0.1.0"
14443edition = "2021"
14444"#,
14445        );
14446        write_file(
14447            root,
14448            "crates/but-action/src/lib.rs",
14449            "mod action;\npub use action::{list_actions};\n",
14450        );
14451        write_file(
14452            root,
14453            "crates/but-action/src/action.rs",
14454            "pub fn list_actions() {}\nfn planted_dead() {}\n",
14455        );
14456        write_file(
14457            root,
14458            "crates/app/Cargo.toml",
14459            r#"[package]
14460name = "app"
14461version = "0.1.0"
14462edition = "2021"
14463"#,
14464        );
14465        write_file(
14466            root,
14467            "crates/app/src/lib.rs",
14468            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
14469        );
14470
14471        let (store, snapshot) = cold_build_twice(root);
14472        assert_direct_caller(
14473            &store,
14474            "crates/but-action/src/action.rs",
14475            "list_actions",
14476            "crates/app/src/lib.rs",
14477            "run_actions",
14478        );
14479        assert!(
14480            store
14481                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14482                .expect("lib reexport callers")
14483                .is_empty(),
14484            "call should target the reexported source function, not lib.rs"
14485        );
14486        assert_projected_call(
14487            root,
14488            &snapshot,
14489            "crates/but-action/src/action.rs",
14490            "list_actions",
14491        );
14492        assert_no_projected_call(
14493            root,
14494            &snapshot,
14495            "crates/but-action/src/action.rs",
14496            "planted_dead",
14497        );
14498    }
14499
14500    #[test]
14501    fn rust_generic_self_turbofish_method_dispatch_resolves() {
14502        let dir = tempdir().expect("tempdir");
14503        let root = dir.path();
14504        write_rust_manifest(root, "generic-self-fixture");
14505        write_file(
14506            root,
14507            "src/lib.rs",
14508            r#"pub struct Matcher;
14509
14510impl Matcher {
14511    pub fn run(&self) -> bool {
14512        self.fuzzy_match_optimal::<usize>("needle")
14513    }
14514
14515    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14516        let _ = std::marker::PhantomData::<T>;
14517        true
14518    }
14519
14520    fn planted_dead(&self) {}
14521}
14522
14523pub fn entry() -> bool {
14524    let matcher = Matcher;
14525    matcher.run()
14526}
14527"#,
14528        );
14529
14530        let (store, snapshot) = cold_build_twice(root);
14531        assert_direct_caller(
14532            &store,
14533            "src/lib.rs",
14534            "Matcher::fuzzy_match_optimal",
14535            "src/lib.rs",
14536            "Matcher::run",
14537        );
14538        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14539        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14540    }
14541
14542    #[test]
14543    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14544        let dir = tempdir().expect("tempdir");
14545        let root = dir.path();
14546        write_rust_manifest(root, "manifest-operations-fixture");
14547        write_file(
14548            root,
14549            "src/main.rs",
14550            r#"mod dispatch;
14551use dispatch::{manifest_operations};
14552
14553fn main() {
14554    manifest_operations();
14555}
14556"#,
14557        );
14558        write_file(
14559            root,
14560            "src/dispatch.rs",
14561            r#"mod work_graph { fn operations() {} }
14562mod manifest { fn operations() {} }
14563mod audit { fn operations() {} }
14564mod descriptor { fn operations() {} }
14565mod writer { fn operations() {} }
14566
14567pub fn manifest_operations() {
14568    manifest::operations();
14569}
14570
14571pub fn work_graph_operations() {
14572    work_graph::operations();
14573}
14574
14575pub fn audit_operations() {
14576    audit::operations();
14577}
14578
14579pub fn descriptor_operations() {
14580    descriptor::operations();
14581}
14582
14583pub fn writer_operations() {
14584    writer::operations();
14585}
14586
14587fn planted_dead() {}
14588"#,
14589        );
14590
14591        let (store, snapshot) = cold_build_twice(root);
14592        assert_direct_caller(
14593            &store,
14594            "src/dispatch.rs",
14595            "manifest_operations",
14596            "src/main.rs",
14597            "main",
14598        );
14599        assert_direct_caller(
14600            &store,
14601            "src/dispatch.rs",
14602            "manifest::operations",
14603            "src/dispatch.rs",
14604            "manifest_operations",
14605        );
14606        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14607        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14608        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14609    }
14610
14611    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14612        let files = rust_files(root);
14613        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14614            .expect("open first store");
14615        first.cold_build(&files).expect("first cold build");
14616        let first_snapshot =
14617            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14618
14619        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14620            .expect("open second store");
14621        second.cold_build(&files).expect("second cold build");
14622        let second_snapshot =
14623            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14624
14625        assert_eq!(
14626            projection_rows(&first_snapshot),
14627            projection_rows(&second_snapshot),
14628            "cold-build projection should be deterministic"
14629        );
14630        (first, first_snapshot)
14631    }
14632
14633    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14634        let mut rows = Vec::new();
14635        for export in &snapshot.exported_symbols {
14636            rows.push(format!(
14637                "export\t{}\t{}\t{}\t{}",
14638                export.file.display(),
14639                export.symbol,
14640                export.kind,
14641                export.line
14642            ));
14643        }
14644        for call in &snapshot.outbound_calls {
14645            rows.push(format!(
14646                "call\t{}\t{}\t{}\t{}\t{}",
14647                call.caller_file.display(),
14648                call.caller_symbol,
14649                call.target,
14650                call.line,
14651                call.provenance
14652            ));
14653        }
14654        for file in &snapshot.entry_points {
14655            rows.push(format!("entry_file\t{}", file.display()));
14656        }
14657        for (file, symbols) in &snapshot.entry_point_symbols {
14658            for symbol in symbols {
14659                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14660            }
14661        }
14662        rows.sort();
14663        rows
14664    }
14665
14666    fn assert_direct_caller(
14667        store: &CallGraphStore,
14668        target_rel: &str,
14669        target_symbol: &str,
14670        caller_rel: &str,
14671        caller_symbol: &str,
14672    ) {
14673        let callers = store
14674            .direct_callers_of(Path::new(target_rel), target_symbol)
14675            .unwrap_or_else(|error| {
14676                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14677            });
14678        assert!(
14679            callers.iter().any(|site| {
14680                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14681            }),
14682            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14683        );
14684    }
14685
14686    fn assert_projected_call(
14687        root: &Path,
14688        snapshot: &CallgraphSnapshot,
14689        target_rel: &str,
14690        symbol: &str,
14691    ) {
14692        let target = projected_target(root, target_rel, symbol);
14693        assert!(
14694            snapshot.outbound_calls.iter().any(|call| {
14695                call.target == target
14696                    || call.target.starts_with(&format!(
14697                        "{target}{}",
14698                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14699                    ))
14700            }),
14701            "expected projected call to {target}; calls: {:#?}",
14702            snapshot.outbound_calls
14703        );
14704    }
14705
14706    fn assert_no_projected_call(
14707        root: &Path,
14708        snapshot: &CallgraphSnapshot,
14709        target_rel: &str,
14710        symbol: &str,
14711    ) {
14712        let target = projected_target(root, target_rel, symbol);
14713        assert!(
14714            snapshot.outbound_calls.iter().all(|call| {
14715                call.target != target
14716                    && !call.target.starts_with(&format!(
14717                        "{target}{}",
14718                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14719                    ))
14720            }),
14721            "did not expect projected call to {target}; calls: {:#?}",
14722            snapshot.outbound_calls
14723        );
14724    }
14725
14726    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14727        // Projection targets carry the normalized (verbatim-stripped)
14728        // canonical form; bare fs::canonicalize diverges on Windows.
14729        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14730        format!("{}::{symbol}", path.display())
14731    }
14732
14733    fn write_rust_manifest(root: &Path, name: &str) {
14734        write_file(
14735            root,
14736            "Cargo.toml",
14737            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14738        );
14739    }
14740
14741    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14742        let path = root.join(rel_path);
14743        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14744        fs::write(&path, source).expect("write fixture file");
14745        path
14746    }
14747
14748    fn rust_files(root: &Path) -> Vec<PathBuf> {
14749        let mut files = Vec::new();
14750        collect_rust_files(root, &mut files);
14751        files.sort();
14752        files
14753    }
14754
14755    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14756        for entry in fs::read_dir(dir).expect("read fixture dir") {
14757            let entry = entry.expect("read fixture entry");
14758            let path = entry.path();
14759            if path.is_dir() {
14760                let name = path
14761                    .file_name()
14762                    .and_then(|name| name.to_str())
14763                    .unwrap_or("");
14764                if !name.starts_with(".store") {
14765                    collect_rust_files(&path, files);
14766                }
14767            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14768                files.push(path);
14769            }
14770        }
14771    }
14772}
14773
14774#[cfg(test)]
14775mod build_pool_tests {
14776    use super::build_pool_size;
14777
14778    #[test]
14779    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14780        let size = build_pool_size();
14781        // Never zero, never the full core count, never above the 8 cap — this is
14782        // the starvation guard for the cold-build's all-cores tree-sitter pass.
14783        assert!(size >= 1, "pool size must be at least 1");
14784        assert!(size <= 8, "pool size must be capped at 8, got {size}");
14785
14786        let cores = std::thread::available_parallelism()
14787            .map(|p| p.get())
14788            .unwrap_or(1);
14789        let expected = cores.div_ceil(2).clamp(1, 8);
14790        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14791    }
14792}
14793
14794#[cfg(test)]
14795mod reexport_resolution_tests {
14796    use super::*;
14797
14798    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14799        ProjectIndex {
14800            project_root: PathBuf::from("/fixture"),
14801            files: files.into_iter().collect(),
14802            caller_data: HashMap::new(),
14803            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14804        }
14805    }
14806
14807    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14808        DbFileIndex {
14809            lang: None,
14810            exports: HashSet::new(),
14811            default_export: None,
14812            export_aliases: HashMap::new(),
14813            node_by_scoped: HashMap::new(),
14814            node_by_bare: HashMap::new(),
14815            node_kind_by_id: HashMap::new(),
14816            module_targets: HashMap::new(),
14817            reexports: reexport_targets
14818                .iter()
14819                .map(|target| ReexportIndex {
14820                    target_file: Some((*target).to_string()),
14821                    named: HashMap::new(),
14822                    wildcard: true,
14823                })
14824                .collect(),
14825        }
14826    }
14827
14828    /// A dense wildcard re-export cycle (barrel files re-exporting each
14829    /// other) must resolve in O(files), not O(branching^depth). Without the
14830    /// resolver's memoization, resolving a MISSING symbol through this
14831    /// 12-file complete digraph explores ~11^16 paths and this test never
14832    /// finishes: the depth cap bounds path length, not path count, and one
14833    /// such resolution can pin a worker thread at 100% CPU indefinitely.
14834    #[test]
14835    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14836        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14837        let files = names
14838            .iter()
14839            .map(|name| {
14840                let targets: Vec<&str> = names
14841                    .iter()
14842                    .filter(|other| *other != name)
14843                    .map(String::as_str)
14844                    .collect();
14845                (name.clone(), barrel_file(&targets))
14846            })
14847            .collect();
14848        let index = barrel_index(files);
14849
14850        assert_eq!(
14851            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14852            None
14853        );
14854    }
14855
14856    /// Depth-dominance counterexample: the walk first reaches `shared` down a
14857    /// 16-hop chain (no budget left for its leaf), then reaches it again
14858    /// directly at depth 1. Plain visited-set pruning would skip the second
14859    /// visit and lose a resolution the capped resolver finds; the
14860    /// depth-dominance memo revisits because the second arrival is shallower.
14861    #[test]
14862    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14863        let mut leaf = barrel_file(&[]);
14864        leaf.exports.insert("deep_symbol".to_string());
14865        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14866        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
14867        // entry's SECOND reexport goes straight to shared.
14868        files.push((
14869            "src/entry.ts".to_string(),
14870            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14871        ));
14872        for i in 0..15 {
14873            let next = if i == 14 {
14874                "src/shared.ts".to_string()
14875            } else {
14876                format!("src/chain{}.ts", i + 1)
14877            };
14878            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14879        }
14880        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14881        files.push(("src/leaf.ts".to_string(), leaf));
14882        let index = barrel_index(files);
14883
14884        assert_eq!(
14885            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14886            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14887            "a shallower re-visit must not be pruned by a deeper capped visit"
14888        );
14889    }
14890
14891    #[test]
14892    fn symbol_reachable_through_reexport_cycle_still_resolves() {
14893        let mut leaf = barrel_file(&[]);
14894        leaf.exports.insert("real_symbol".to_string());
14895        let index = barrel_index(vec![
14896            (
14897                "src/a.ts".to_string(),
14898                barrel_file(&["src/b.ts", "src/a.ts"]),
14899            ),
14900            (
14901                "src/b.ts".to_string(),
14902                barrel_file(&["src/a.ts", "src/leaf.ts"]),
14903            ),
14904            ("src/leaf.ts".to_string(), leaf),
14905        ]);
14906
14907        assert_eq!(
14908            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14909            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14910        );
14911    }
14912}
14913
14914#[cfg(test)]
14915mod method_dispatch_inference_tests {
14916    use super::*;
14917    use std::fs;
14918    use tempfile::tempdir;
14919
14920    #[test]
14921    fn java_field_receiver_type_selects_declared_class_method() {
14922        let source = r#"class EntryPoint {
14923    private UserService userService;
14924
14925    void handle() {
14926        userService.find();
14927    }
14928}
14929
14930class UserService {
14931    void find() {}
14932}
14933
14934class AuditService {
14935    void find() {}
14936}
14937"#;
14938        let dir = tempdir().expect("temp dir");
14939        let root = dir.path();
14940        write_fixture(root, "src/EntryPoint.java", source);
14941        let reference = reference(
14942            "java",
14943            "src/EntryPoint.java",
14944            "EntryPoint::handle",
14945            "userService",
14946            "find",
14947            line_of(source, "userService.find()"),
14948        );
14949        let mut cache = DispatchSourceCache::new();
14950
14951        let receiver_type =
14952            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14953        assert_eq!(receiver_type, "UserService");
14954
14955        let candidates = vec![
14956            method_candidate("audit", "AuditService::find"),
14957            method_candidate("user", "UserService::find"),
14958        ];
14959        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14960            .expect("type candidate");
14961        assert_eq!(selected.scoped_name, "UserService::find");
14962
14963        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14964        assert!(
14965            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14966        );
14967    }
14968
14969    #[test]
14970    fn kotlin_property_and_local_value_types_are_inferred() {
14971        let source = r#"class Handler {
14972    private val auditService: AuditService = AuditService()
14973
14974    fun handle() {
14975        auditService.find()
14976        val userService: UserService = UserService()
14977        userService.find()
14978        val billingService = BillingService()
14979        billingService.find()
14980    }
14981}
14982
14983class UserService { fun find() {} }
14984class AuditService { fun find() {} }
14985class BillingService { fun find() {} }
14986"#;
14987        let dir = tempdir().expect("temp dir");
14988        let root = dir.path();
14989        write_fixture(root, "src/Handler.kt", source);
14990        let mut cache = DispatchSourceCache::new();
14991
14992        let audit_ref = reference(
14993            "kotlin",
14994            "src/Handler.kt",
14995            "Handler::handle",
14996            "auditService",
14997            "find",
14998            line_of(source, "auditService.find()"),
14999        );
15000        assert_eq!(
15001            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15002            Some("AuditService")
15003        );
15004
15005        let user_ref = reference(
15006            "kotlin",
15007            "src/Handler.kt",
15008            "Handler::handle",
15009            "userService",
15010            "find",
15011            line_of(source, "userService.find()"),
15012        );
15013        assert_eq!(
15014            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15015            Some("UserService")
15016        );
15017
15018        let billing_ref = reference(
15019            "kotlin",
15020            "src/Handler.kt",
15021            "Handler::handle",
15022            "billingService",
15023            "find",
15024            line_of(source, "billingService.find()"),
15025        );
15026        assert_eq!(
15027            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
15028            Some("BillingService")
15029        );
15030    }
15031
15032    #[test]
15033    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
15034        let source = r#"struct Foo { void run(); };
15035struct PointerFoo { void run(); };
15036struct FactoryFoo { void run(); };
15037FactoryFoo makeFactoryFoo();
15038
15039void handle() {
15040    Foo foo;
15041    foo.run();
15042    PointerFoo* pointerFoo = nullptr;
15043    pointerFoo->run();
15044    auto factoryFoo = makeFactoryFoo();
15045    factoryFoo.run();
15046}
15047"#;
15048        let dir = tempdir().expect("temp dir");
15049        let root = dir.path();
15050        write_fixture(root, "src/fixture.cpp", source);
15051        let mut cache = DispatchSourceCache::new();
15052
15053        let foo_ref = reference(
15054            "cpp",
15055            "src/fixture.cpp",
15056            "handle",
15057            "foo",
15058            "run",
15059            line_of(source, "foo.run()"),
15060        );
15061        assert_eq!(
15062            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
15063            Some("Foo")
15064        );
15065
15066        let pointer_ref = reference(
15067            "cpp",
15068            "src/fixture.cpp",
15069            "handle",
15070            "pointerFoo",
15071            "run",
15072            line_of(source, "pointerFoo->run()"),
15073        );
15074        assert_eq!(
15075            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
15076            Some("PointerFoo")
15077        );
15078
15079        let factory_ref = reference(
15080            "cpp",
15081            "src/fixture.cpp",
15082            "handle",
15083            "factoryFoo",
15084            "run",
15085            line_of(source, "factoryFoo.run()"),
15086        );
15087        assert_eq!(
15088            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15089            Some("FactoryFoo")
15090        );
15091    }
15092
15093    #[test]
15094    fn rust_direct_self_field_name_trims_separator_whitespace() {
15095        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15096            assert_eq!(
15097                rust_direct_self_field_name(receiver_expression),
15098                Some("engine")
15099            );
15100        }
15101    }
15102
15103    #[test]
15104    fn rust_direct_self_field_receiver_type_is_conservative() {
15105        let source = r#"struct Engine;
15106
15107struct Car {
15108    engine: Engine,
15109}
15110
15111impl Car {
15112    fn run(&self) {
15113        self.engine.start();
15114    }
15115}
15116
15117struct NestedCar {
15118    engine: Engine,
15119}
15120
15121impl NestedCar {
15122    fn run(&self) {
15123        self.inner.engine.start();
15124    }
15125}
15126
15127struct WrappedCar {
15128    engine: Option<Engine>,
15129}
15130
15131impl WrappedCar {
15132    fn run(&self) {
15133        self.engine.start(); // wrapped
15134    }
15135}
15136
15137struct GenericCar<T> {
15138    engine: T,
15139}
15140
15141impl<T> GenericCar<T> {
15142    fn run(&self) {
15143        self.engine.start(); // generic
15144    }
15145}
15146
15147type EngineAlias = Engine;
15148
15149struct AliasCar {
15150    engine: EngineAlias,
15151}
15152
15153impl AliasCar {
15154    fn run(&self) {
15155        self.engine.start(); // alias
15156    }
15157}
15158"#;
15159        let dir = tempdir().expect("temp dir");
15160        let root = dir.path();
15161        write_fixture(root, "src/lib.rs", source);
15162        let mut cache = DispatchSourceCache::new();
15163
15164        let mut direct = reference(
15165            "rust",
15166            "src/lib.rs",
15167            "Car::run",
15168            "engine",
15169            "start",
15170            line_of(source, "self.engine.start()"),
15171        );
15172        direct.receiver_expression = "self.engine".to_string();
15173        assert_eq!(
15174            infer_receiver_type(root, &direct, &mut cache).as_deref(),
15175            Some("Engine")
15176        );
15177
15178        let mut mismatched_impl_target = direct.clone();
15179        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15180        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15181
15182        let mut nested = reference(
15183            "rust",
15184            "src/lib.rs",
15185            "NestedCar::run",
15186            "engine",
15187            "start",
15188            line_of(source, "self.inner.engine.start()"),
15189        );
15190        nested.receiver_expression = "self.inner.engine".to_string();
15191        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15192
15193        let mut wrapped = reference(
15194            "rust",
15195            "src/lib.rs",
15196            "WrappedCar::run",
15197            "engine",
15198            "start",
15199            line_of(source, "self.engine.start(); // wrapped"),
15200        );
15201        wrapped.receiver_expression = "self.engine".to_string();
15202        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15203
15204        let mut generic = reference(
15205            "rust",
15206            "src/lib.rs",
15207            "GenericCar::run",
15208            "engine",
15209            "start",
15210            line_of(source, "self.engine.start(); // generic"),
15211        );
15212        generic.receiver_expression = "self.engine".to_string();
15213        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15214
15215        let mut alias = reference(
15216            "rust",
15217            "src/lib.rs",
15218            "AliasCar::run",
15219            "engine",
15220            "start",
15221            line_of(source, "self.engine.start(); // alias"),
15222        );
15223        alias.receiver_expression = "self.engine".to_string();
15224        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15225    }
15226
15227    #[test]
15228    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15229        let source = r#"struct Engine;
15230
15231struct Car {
15232    engine: &'static Engine,
15233}
15234
15235impl Car {
15236    fn run(&self) {
15237        self.engine.start();
15238    }
15239}
15240"#;
15241        let dir = tempdir().expect("temp dir");
15242        let root = dir.path();
15243        write_fixture(root, "src/lib.rs", source);
15244        let mut cache = DispatchSourceCache::new();
15245        let mut reference = reference(
15246            "rust",
15247            "src/lib.rs",
15248            "Car::run",
15249            "engine",
15250            "start",
15251            line_of(source, "self.engine.start()"),
15252        );
15253        reference.receiver_expression = "self.engine".to_string();
15254
15255        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15256    }
15257
15258    #[test]
15259    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15260        let source = r#"trait Drive {
15261    fn run(&self);
15262}
15263
15264struct Engine;
15265
15266struct Car {
15267    engine: Engine,
15268}
15269
15270impl Drive for Car {
15271    fn run(&self) {
15272        self.engine.start();
15273    }
15274}
15275"#;
15276        let dir = tempdir().expect("temp dir");
15277        let root = dir.path();
15278        write_fixture(root, "src/lib.rs", source);
15279        let mut cache = DispatchSourceCache::new();
15280        let mut reference = reference(
15281            "rust",
15282            "src/lib.rs",
15283            "Car::run",
15284            "engine",
15285            "start",
15286            line_of(source, "self.engine.start()"),
15287        );
15288        reference.receiver_expression = "self.engine".to_string();
15289
15290        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15291    }
15292
15293    #[test]
15294    fn rust_self_field_does_not_bind_struct_from_another_module() {
15295        let source = r#"struct Engine;
15296
15297mod unrelated {
15298    struct Car {
15299        engine: Engine,
15300    }
15301}
15302
15303impl Car {
15304    fn run(&self) {
15305        self.engine.start();
15306    }
15307}
15308"#;
15309        let dir = tempdir().expect("temp dir");
15310        let root = dir.path();
15311        write_fixture(root, "src/lib.rs", source);
15312        let mut cache = DispatchSourceCache::new();
15313        let mut reference = reference(
15314            "rust",
15315            "src/lib.rs",
15316            "Car::run",
15317            "engine",
15318            "start",
15319            line_of(source, "self.engine.start()"),
15320        );
15321        reference.receiver_expression = "self.engine".to_string();
15322
15323        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15324    }
15325
15326    #[test]
15327    fn unknown_java_receiver_still_uses_name_match_fallback() {
15328        let source = r#"class EntryPoint {
15329    void handle() {
15330        service.runSpecial();
15331    }
15332}
15333
15334class OnlyService {
15335    void runSpecial() {}
15336}
15337"#;
15338        let dir = tempdir().expect("temp dir");
15339        let root = dir.path();
15340        write_fixture(root, "src/EntryPoint.java", source);
15341        let reference = reference(
15342            "java",
15343            "src/EntryPoint.java",
15344            "EntryPoint::handle",
15345            "service",
15346            "runSpecial",
15347            line_of(source, "service.runSpecial()"),
15348        );
15349        let mut cache = DispatchSourceCache::new();
15350
15351        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15352        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15353        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15354        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15355    }
15356
15357    fn reference(
15358        lang: &str,
15359        caller_file: &str,
15360        caller_symbol: &str,
15361        receiver: &str,
15362        method_name: &str,
15363        line: u32,
15364    ) -> NameMatchRef {
15365        NameMatchRef {
15366            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15367            caller_node: format!("{caller_symbol}:node"),
15368            caller_file: caller_file.to_string(),
15369            caller_symbol: caller_symbol.to_string(),
15370            caller_signature: None,
15371            receiver_expression: receiver.to_string(),
15372            receiver: receiver.to_string(),
15373            method_name: method_name.to_string(),
15374            colon_dispatch: false,
15375            line,
15376            lang: lang.to_string(),
15377        }
15378    }
15379
15380    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15381        NameMatchCandidate {
15382            node_id: node_id.to_string(),
15383            file_path: "src/targets.fixture".to_string(),
15384            scoped_name: scoped_name.to_string(),
15385            kind: "method".to_string(),
15386            start_line: 1,
15387        }
15388    }
15389
15390    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15391        let path = root.join(rel_path);
15392        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15393        fs::write(path, source).expect("write fixture");
15394    }
15395
15396    fn line_of(source: &str, needle: &str) -> u32 {
15397        source
15398            .lines()
15399            .position(|line| line.contains(needle))
15400            .map(|index| index as u32 + 1)
15401            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15402    }
15403}