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 NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43/// Marker-protected generations older than this absolute age are reclaimed even
44/// if a stale reader marker remains. Current and newest-previous generations are
45/// always retained, bounding the root-keyed callgraph store to roughly two or
46/// three large generations without adding user-visible configuration.
47const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
52const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
53const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
54const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
55const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
56
57fn write_amplification_baseline_enabled() -> bool {
58    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
59}
60
61type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
62
63#[derive(Clone, Debug, Eq, Hash, PartialEq)]
64struct RebuildCooldownKey {
65    callgraph_dir: PathBuf,
66    project_key: String,
67}
68
69#[derive(Clone, Debug)]
70struct RebuildCooldownRecord {
71    project_root: PathBuf,
72    published_at: Instant,
73    cross_root_cooldown_armed: bool,
74}
75
76// Prevent repeated rebuilds when requests rapidly switch between project
77// roots. Allow the first successful rebuild for a different root; after that
78// transition, report the artifact as unavailable instead of publishing another
79// complete generation. Record only successful publications in this map.
80static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
81    OnceLock::new();
82
83#[derive(Clone, Debug, Eq, Hash, PartialEq)]
84struct RootRepairWarningKey {
85    project_key: String,
86}
87
88#[derive(Clone, Debug)]
89struct RootRepairWarningRecord {
90    window_start: Instant,
91    last_emitted: Instant,
92    entry_count: u64,
93    suppressed: u64,
94}
95
96static ROOT_REPAIR_WARNINGS: OnceLock<
97    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
98> = OnceLock::new();
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct CallgraphWriteMetricsSnapshot {
102    pub commits_60s: u64,
103    pub pages_or_bytes_written_60s: u64,
104}
105
106#[derive(Debug, Default)]
107struct CallgraphWriteMetrics {
108    window_start_ms: AtomicU64,
109    commits_60s: AtomicU64,
110    pages_or_bytes_written_60s: AtomicU64,
111}
112
113static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
114    OnceLock::new();
115
116fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
117    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
118    let mut metrics = metrics
119        .lock()
120        .expect("callgraph write metrics mutex poisoned");
121    Arc::clone(
122        metrics
123            .entry(project_key.to_string())
124            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
125    )
126}
127
128fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
129    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
130    if current_start == 0 {
131        let _ = metrics.window_start_ms.compare_exchange(
132            0,
133            now_ms,
134            AtomicOrdering::AcqRel,
135            AtomicOrdering::Acquire,
136        );
137        return;
138    }
139    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
140        return;
141    }
142    if metrics
143        .window_start_ms
144        .compare_exchange(
145            current_start,
146            now_ms,
147            AtomicOrdering::AcqRel,
148            AtomicOrdering::Acquire,
149        )
150        .is_ok()
151    {
152        metrics.commits_60s.store(0, AtomicOrdering::Release);
153        metrics
154            .pages_or_bytes_written_60s
155            .store(0, AtomicOrdering::Release);
156    }
157}
158
159impl CallgraphWriteMetrics {
160    fn record_commit(&self, pages_or_bytes_written: u64) {
161        let now_ms = unix_millis_now();
162        roll_callgraph_write_metric_window(self, now_ms);
163        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
164        self.pages_or_bytes_written_60s
165            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
166    }
167
168    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
169        roll_callgraph_write_metric_window(self, unix_millis_now());
170        CallgraphWriteMetricsSnapshot {
171            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
172            pages_or_bytes_written_60s: self
173                .pages_or_bytes_written_60s
174                .load(AtomicOrdering::Acquire),
175        }
176    }
177}
178
179pub(crate) fn callgraph_write_metrics_for_project(
180    project_key: &str,
181) -> CallgraphWriteMetricsSnapshot {
182    callgraph_write_metrics_for_key(project_key).snapshot()
183}
184
185pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
186    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
187        return CallgraphWriteMetricsSnapshot::default();
188    };
189    let metrics = metrics
190        .lock()
191        .expect("callgraph write metrics mutex poisoned");
192    metrics.values().map(|metrics| metrics.snapshot()).fold(
193        CallgraphWriteMetricsSnapshot::default(),
194        |total, current| CallgraphWriteMetricsSnapshot {
195            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
196            pages_or_bytes_written_60s: total
197                .pages_or_bytes_written_60s
198                .saturating_add(current.pages_or_bytes_written_60s),
199        },
200    )
201}
202
203const ROOT_REPAIR_WARNING_TEXT: &str =
204    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
205
206fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
207    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
208    let mut warnings = warnings.lock().ok()?;
209    let entry = warnings.entry(key);
210    let record = match entry {
211        Entry::Vacant(entry) => {
212            entry.insert(RootRepairWarningRecord {
213                window_start: now,
214                last_emitted: now,
215                entry_count: 1,
216                suppressed: 0,
217            });
218            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
219        }
220        Entry::Occupied(entry) => entry.into_mut(),
221    };
222
223    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
224        let suppressed = record.suppressed;
225        record.window_start = now;
226        record.last_emitted = now;
227        record.entry_count = 1;
228        record.suppressed = 0;
229        return Some(if suppressed == 0 {
230            ROOT_REPAIR_WARNING_TEXT.to_string()
231        } else {
232            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
233        });
234    }
235
236    record.entry_count = record.entry_count.saturating_add(1);
237    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
238        record.suppressed = record.suppressed.saturating_add(1);
239        None
240    } else {
241        record.last_emitted = now;
242        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
243    }
244}
245
246pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
247    next_root_repair_warning(
248        RootRepairWarningKey {
249            project_key: project_key.to_string(),
250        },
251        Instant::now(),
252    )
253}
254
255/// Return the number of repair entries in the active 60-second window.
256///
257/// The window start is returned for callers that need to show freshness without
258/// adding another status verdict or turning this into a user-facing setting.
259pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
260    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
261    let warnings = warnings.lock().ok()?;
262    let record = warnings.get(&RootRepairWarningKey {
263        project_key: project_key.to_string(),
264    })?;
265    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
266        .then_some((record.entry_count, record.window_start))
267}
268
269pub(crate) fn repair_entry_rate_total() -> u64 {
270    let Ok(warnings) = ROOT_REPAIR_WARNINGS
271        .get_or_init(|| Mutex::new(HashMap::new()))
272        .lock()
273    else {
274        return 0;
275    };
276    let now = Instant::now();
277    warnings
278        .values()
279        .filter(|record| {
280            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
281        })
282        .map(|record| record.entry_count)
283        .sum()
284}
285
286#[cfg(test)]
287pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
288    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
289    let mut warnings = warnings.lock().unwrap();
290    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
291        project_key: project_key.to_string(),
292    }) {
293        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
294    }
295}
296
297#[cfg(test)]
298mod root_repair_warning_tests {
299    use super::*;
300
301    #[test]
302    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
303        let key = RootRepairWarningKey {
304            project_key: "test-project".to_string(),
305        };
306        let first_at = Instant::now();
307        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
308        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
309        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
310        assert_eq!(
311            repair_entry_rate("test-project").map(|rate| rate.0),
312            Some(2)
313        );
314
315        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
316        assert!(repeated.ends_with("(repeated 1x in 60s)"));
317        expire_repair_entry_window_for_test("test-project");
318        assert!(repair_entry_rate("test-project").is_none());
319    }
320}
321
322#[cfg(test)]
323mod write_amplification_tests {
324    use super::*;
325    use std::fs;
326    use tempfile::tempdir;
327
328    #[test]
329    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
330        let temp = tempdir().unwrap();
331        let root = temp.path().join("root");
332        fs::create_dir_all(&root).unwrap();
333        let source = root.join("main.ts");
334        fs::write(&source, "export function main() {}\n").unwrap();
335        let store_dir = temp.path().join("store");
336        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
337
338        let conn = store.conn.lock().unwrap();
339        let synchronous: i64 = conn
340            .pragma_query_value(None, "synchronous", |row| row.get(0))
341            .unwrap();
342        let autocheckpoint: i64 = conn
343            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
344            .unwrap();
345        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
346        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
347        drop(conn);
348        store.cold_build(std::slice::from_ref(&source)).unwrap();
349        drop(store);
350
351        let readonly = CallGraphStore::open_readonly(store_dir, root)
352            .unwrap()
353            .expect("writer-created empty schema should be readable");
354        let conn = readonly.inner.conn.lock().unwrap();
355        let synchronous: i64 = conn
356            .pragma_query_value(None, "synchronous", |row| row.get(0))
357            .unwrap();
358        assert_eq!(synchronous, 1);
359    }
360
361    #[test]
362    fn own_refresh_skips_identical_extract_but_not_position_shift() {
363        let temp = tempdir().unwrap();
364        let root = temp.path().join("root");
365        fs::create_dir_all(&root).unwrap();
366        let source = root.join("main.ts");
367        fs::write(&source, "export function main() { return 1; }\n").unwrap();
368        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
369        store.cold_build(std::slice::from_ref(&source)).unwrap();
370        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
371        assert!(write_metrics.commits_60s > 0);
372        assert!(write_metrics.pages_or_bytes_written_60s > 0);
373
374        let before = store.conn.lock().unwrap().total_changes();
375        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
376        let (stats, _) = store
377            .refresh_files_profiled(std::slice::from_ref(&source))
378            .unwrap();
379        let after = store.conn.lock().unwrap().total_changes();
380        assert_eq!(stats.unchanged_extract_files, 1);
381        assert_eq!(stats.refreshed_own_files, 0);
382        assert_eq!(
383            after - before,
384            3,
385            "files, backend freshness, and the durable projection revision update"
386        );
387
388        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
389        let (shifted_stats, _) = store
390            .refresh_files_profiled(std::slice::from_ref(&source))
391            .unwrap();
392        assert_eq!(shifted_stats.unchanged_extract_files, 0);
393        assert_eq!(shifted_stats.refreshed_own_files, 1);
394    }
395
396    #[test]
397    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
398        let now = Instant::now();
399        assert!(idle_checkpoint_due(None, now));
400        assert!(!idle_checkpoint_due(
401            Some(now),
402            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
403        ));
404        assert!(idle_checkpoint_due(
405            Some(now),
406            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
407        ));
408    }
409
410    #[test]
411    fn write_metrics_decay_after_the_sixty_second_window() {
412        let key = format!("metrics-test-{}", now_nanos());
413        let metrics = callgraph_write_metrics_for_key(&key);
414        metrics.record_commit(17);
415        assert_eq!(metrics.snapshot().commits_60s, 1);
416        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
417        metrics.window_start_ms.store(
418            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
419            AtomicOrdering::Release,
420        );
421        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
422    }
423}
424
425#[cfg(test)]
426type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
427// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
428// thread running the cold build, and the only caller (a test) installs and
429// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
430// across parallel tests — one test's installed observer fired during ANOTHER
431// test's `cold_build_with_lease`, asserting against the wrong build's edges
432// (flaked on Windows CI under parallel scheduling). Production never sets it.
433thread_local! {
434    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
435        const { std::cell::RefCell::new(None) };
436    #[cfg(test)]
437    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
438        const { std::cell::RefCell::new(None) };
439    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
440        const { std::cell::RefCell::new(None) };
441    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
442    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
443        const { std::cell::Cell::new(false) };
444    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
445        const { std::cell::RefCell::new(None) };
446    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
447        const { std::cell::RefCell::new(None) };
448}
449
450mod dead_code_projection;
451pub use dead_code_projection::project_dead_code_snapshot;
452pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
453#[cfg(test)]
454pub(crate) use dead_code_projection::set_projection_before_open_observer;
455
456#[doc(hidden)]
457pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
458    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
459}
460
461#[cfg(test)]
462fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
463    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
464}
465
466#[cfg(test)]
467fn notify_cold_build_before_publish_observer() {
468    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
469    if let Some(observer) = observer {
470        observer();
471    }
472}
473
474#[cfg(not(test))]
475fn notify_cold_build_before_publish_observer() {}
476
477#[doc(hidden)]
478pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
479    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
480}
481
482#[doc(hidden)]
483pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
484    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
485}
486
487#[doc(hidden)]
488pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
489    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
490}
491
492struct PublishAdmissionGuard {
493    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
494}
495
496impl Drop for PublishAdmissionGuard {
497    fn drop(&mut self) {
498        PUBLISH_ADMISSION.with(|slot| {
499            *slot.borrow_mut() = self.previous.take();
500        });
501    }
502}
503
504pub(crate) fn with_publish_epoch<R>(
505    epoch: crate::root_cache::ArtifactPublishEpoch,
506    expected: u64,
507    run: impl FnOnce() -> R,
508) -> R {
509    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
510    let _guard = PublishAdmissionGuard { previous };
511    run()
512}
513
514fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
515    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
516    match admission {
517        Some((epoch, expected)) => epoch
518            .run_if_current(expected, publish)
519            .unwrap_or(Err(CallGraphStoreError::Superseded)),
520        None => publish(),
521    }
522}
523
524struct RefreshCommitAdmissionGuard {
525    previous: Option<(
526        SubcLifecycleAdmission,
527        Arc<std::sync::atomic::AtomicU64>,
528        u64,
529    )>,
530}
531
532impl Drop for RefreshCommitAdmissionGuard {
533    fn drop(&mut self) {
534        REFRESH_COMMIT_ADMISSION.with(|slot| {
535            *slot.borrow_mut() = self.previous.take();
536        });
537    }
538}
539
540fn with_refresh_commit_admission<R>(
541    lifecycle: SubcLifecycleAdmission,
542    generation_flag: Arc<std::sync::atomic::AtomicU64>,
543    expected_generation: u64,
544    run: impl FnOnce() -> R,
545) -> R {
546    let previous = REFRESH_COMMIT_ADMISSION
547        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
548    let _guard = RefreshCommitAdmissionGuard { previous };
549    run()
550}
551
552fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
553    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
554    let commit = || {
555        publish_if_current(|| {
556            tx.commit()?;
557            Ok(())
558        })
559    };
560    match admission {
561        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
562            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
563            .unwrap_or(Err(CallGraphStoreError::Superseded)),
564        None => commit(),
565    }
566}
567
568fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
569    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
570    if let Some(observer) = observer {
571        observer(temp_path, target_path);
572    }
573}
574
575#[derive(Debug)]
576pub enum CallGraphStoreError {
577    Io(std::io::Error),
578    Sqlite(rusqlite::Error),
579    Json(serde_json::Error),
580    Aft(AftError),
581    Lock(crate::fs_lock::AcquireError),
582    MissingCallerData { file: String },
583    Unavailable(String),
584    Superseded,
585    StaleFiles(Vec<String>),
586}
587
588impl fmt::Display for CallGraphStoreError {
589    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
590        match self {
591            Self::Io(error) => write!(formatter, "I/O error: {error}"),
592            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
593            Self::Json(error) => write!(formatter, "json error: {error}"),
594            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
595            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
596            Self::MissingCallerData { file } => {
597                write!(formatter, "missing extracted caller data for {file}")
598            }
599            Self::Unavailable(message) => {
600                write!(formatter, "callgraph store unavailable: {message}")
601            }
602            Self::Superseded => {
603                write!(formatter, "callgraph store build superseded before publish")
604            }
605            Self::StaleFiles(files) => {
606                write!(
607                    formatter,
608                    "callgraph store has stale files: {}",
609                    files.join(", ")
610                )
611            }
612        }
613    }
614}
615
616impl std::error::Error for CallGraphStoreError {}
617
618impl From<std::io::Error> for CallGraphStoreError {
619    fn from(error: std::io::Error) -> Self {
620        Self::Io(error)
621    }
622}
623
624impl From<rusqlite::Error> for CallGraphStoreError {
625    fn from(error: rusqlite::Error) -> Self {
626        Self::Sqlite(error)
627    }
628}
629
630impl From<serde_json::Error> for CallGraphStoreError {
631    fn from(error: serde_json::Error) -> Self {
632        Self::Json(error)
633    }
634}
635
636impl From<AftError> for CallGraphStoreError {
637    fn from(error: AftError) -> Self {
638        Self::Aft(error)
639    }
640}
641
642impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
643    fn from(error: crate::fs_lock::AcquireError) -> Self {
644        Self::Lock(error)
645    }
646}
647
648pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
649
650/// Config flag name gating whether the store is opened (default on). Production
651/// commands open it through `open_if_enabled` so the substrate can be disabled
652/// without code changes.
653pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
654
655#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
656pub struct CallGraphStoreOptions {
657    pub enabled: bool,
658}
659
660pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
661
662/// Shared context state that lets the refresh worker observe a store installed
663/// after its batch was opened. The worker clones the installed store Arc before
664/// checking it, so no context lock guard crosses the check or enqueue call.
665#[derive(Clone)]
666pub(crate) struct CallgraphRefreshState {
667    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
668    heavy_root_work_allowed: Arc<AtomicBool>,
669}
670
671impl CallgraphRefreshState {
672    pub(crate) fn new(
673        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
674        heavy_root_work_allowed: Arc<AtomicBool>,
675    ) -> Self {
676        Self {
677            store,
678            heavy_root_work_allowed,
679        }
680    }
681
682    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
683        self.store
684            .read()
685            .unwrap_or_else(std::sync::PoisonError::into_inner)
686            .as_ref()
687            .map(Arc::clone)
688    }
689}
690
691type WorkspaceCratePrefixes = HashMap<String, String>;
692
693#[derive(Clone, Debug, Default)]
694struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
695
696const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
697
698pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
699    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
700}
701
702#[derive(Clone, Debug, Hash, PartialEq, Eq)]
703struct RefreshRoot {
704    callgraph_dir: PathBuf,
705    project_root: PathBuf,
706}
707
708#[derive(Clone)]
709pub(crate) struct CallgraphRefreshTicket {
710    lifecycle: SubcLifecycleAdmission,
711    generation_flag: Arc<std::sync::atomic::AtomicU64>,
712    expected_generation: u64,
713    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
714    expected_publish_epoch: u64,
715}
716
717impl CallgraphRefreshTicket {
718    pub(crate) fn new(
719        lifecycle: SubcLifecycleAdmission,
720        generation_flag: Arc<std::sync::atomic::AtomicU64>,
721        expected_generation: u64,
722        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
723        expected_publish_epoch: u64,
724    ) -> Self {
725        Self {
726            lifecycle,
727            generation_flag,
728            expected_generation,
729            publish_epoch,
730            expected_publish_epoch,
731        }
732    }
733
734    fn is_current(&self) -> bool {
735        self.lifecycle
736            .is_current(self.generation_flag.as_ref(), self.expected_generation)
737            && self.publish_epoch.current() == self.expected_publish_epoch
738    }
739}
740
741#[derive(Clone)]
742struct RefreshBatch {
743    root: RefreshRoot,
744    paths: BTreeSet<PathBuf>,
745    pending_sinks: Vec<PendingCallGraphStorePaths>,
746    refresh_states: Vec<CallgraphRefreshState>,
747    ticket: Option<CallgraphRefreshTicket>,
748}
749
750impl RefreshBatch {
751    fn defer(&self) {
752        for sink in &self.pending_sinks {
753            sink.lock().extend(self.paths.iter().cloned());
754        }
755    }
756
757    fn defer_after_open_failure(&self) {
758        self.defer();
759        if self
760            .ticket
761            .as_ref()
762            .is_some_and(|ticket| !ticket.is_current())
763            || !self
764                .refresh_states
765                .iter()
766                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
767        {
768            return;
769        }
770
771        let ready_store_installed = self.refresh_states.iter().any(|state| {
772            let store = state.installed_store_snapshot();
773            store.is_some_and(|store| {
774                store.project_root() == self.root.project_root
775                    && !store.is_legacy_fallback()
776                    && store.is_current()
777            })
778        });
779        if !ready_store_installed {
780            return;
781        }
782
783        // This re-check and the ready-store install's pending-sink take form a
784        // check-then-act handoff: after this defer, exactly one site observes
785        // the parked paths with a ready current store, so no polling is needed.
786        for sink in &self.pending_sinks {
787            let paths = {
788                let mut pending = sink.lock();
789                self.paths
790                    .iter()
791                    .filter(|path| pending.remove(*path))
792                    .cloned()
793                    .collect::<Vec<_>>()
794            };
795            if paths.is_empty() {
796                continue;
797            }
798            let _ = enqueue_callgraph_store_refresh_inner(
799                self.root.callgraph_dir.clone(),
800                self.root.project_root.clone(),
801                paths,
802                Arc::clone(sink),
803                self.refresh_states.clone(),
804                self.ticket.clone(),
805            );
806        }
807    }
808
809    fn merge(
810        &mut self,
811        paths: impl IntoIterator<Item = PathBuf>,
812        sink: PendingCallGraphStorePaths,
813        refresh_states: Vec<CallgraphRefreshState>,
814        ticket: Option<CallgraphRefreshTicket>,
815    ) {
816        self.paths.extend(paths);
817        if ticket.is_some() {
818            self.ticket = ticket;
819        }
820        if !self
821            .pending_sinks
822            .iter()
823            .any(|existing| Arc::ptr_eq(existing, &sink))
824        {
825            self.pending_sinks.push(sink);
826        }
827        for refresh_state in refresh_states {
828            if !self.refresh_states.iter().any(|existing| {
829                Arc::ptr_eq(&existing.store, &refresh_state.store)
830                    && Arc::ptr_eq(
831                        &existing.heavy_root_work_allowed,
832                        &refresh_state.heavy_root_work_allowed,
833                    )
834            }) {
835                self.refresh_states.push(refresh_state);
836            }
837        }
838    }
839}
840
841#[derive(Default)]
842struct RefreshQueue {
843    order: VecDeque<RefreshRoot>,
844    queued: HashMap<RefreshRoot, RefreshBatch>,
845    active: Option<RefreshBatch>,
846    shutdown_requested: bool,
847}
848
849struct RefreshWorkerShared {
850    queue: Mutex<RefreshQueue>,
851    wake: Condvar,
852}
853
854struct RefreshWorker {
855    shared: Arc<RefreshWorkerShared>,
856    thread: Mutex<Option<JoinHandle<()>>>,
857}
858
859struct RefreshWorkerWatchdog {
860    first_path: PathBuf,
861    batch_len: usize,
862    started: Instant,
863}
864
865impl RefreshWorkerWatchdog {
866    fn start(paths: &[PathBuf]) -> Self {
867        Self {
868            first_path: paths
869                .first()
870                .expect("non-empty callgraph refresh batch has a first path")
871                .clone(),
872            batch_len: paths.len(),
873            started: Instant::now(),
874        }
875    }
876}
877
878impl Drop for RefreshWorkerWatchdog {
879    fn drop(&mut self) {
880        let elapsed = self.started.elapsed();
881        if elapsed < REFRESH_WORKER_WARN_AFTER {
882            return;
883        }
884        let path = if self.batch_len == 1 {
885            self.first_path.display().to_string()
886        } else {
887            format!(
888                "{} (+{} paths)",
889                self.first_path.display(),
890                self.batch_len - 1
891            )
892        };
893        log::warn!(
894            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
895            path,
896            elapsed.as_millis()
897        );
898        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
899            log::warn!(
900                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
901                path,
902                elapsed.as_millis()
903            );
904        }
905    }
906}
907
908impl RefreshWorker {
909    fn spawn() -> Arc<Self> {
910        let shared = Arc::new(RefreshWorkerShared {
911            queue: Mutex::new(RefreshQueue::default()),
912            wake: Condvar::new(),
913        });
914        let thread_shared = Arc::clone(&shared);
915        let thread = std::thread::Builder::new()
916            .name("aft-callgraph-refresh".to_string())
917            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
918            .expect("failed to spawn callgraph refresh worker");
919        Arc::new(Self {
920            shared,
921            thread: Mutex::new(Some(thread)),
922        })
923    }
924
925    fn enqueue(
926        &self,
927        root: RefreshRoot,
928        paths: Vec<PathBuf>,
929        pending_sink: PendingCallGraphStorePaths,
930        refresh_states: Vec<CallgraphRefreshState>,
931        ticket: Option<CallgraphRefreshTicket>,
932    ) -> bool {
933        let mut queue = self
934            .shared
935            .queue
936            .lock()
937            .expect("callgraph refresh queue mutex poisoned");
938        if queue.shutdown_requested {
939            pending_sink.lock().extend(paths);
940            return false;
941        }
942        if let Some(batch) = queue.queued.get_mut(&root) {
943            batch.merge(paths, pending_sink, refresh_states, ticket);
944        } else {
945            queue.order.push_back(root.clone());
946            queue.queued.insert(
947                root.clone(),
948                RefreshBatch {
949                    root,
950                    paths: paths.into_iter().collect(),
951                    pending_sinks: vec![pending_sink],
952                    refresh_states,
953                    ticket,
954                },
955            );
956        }
957        self.shared.wake.notify_one();
958        true
959    }
960
961    fn shutdown_with_budget(&self, budget: Duration) -> bool {
962        let deadline = Instant::now() + budget;
963        let mut queue = self
964            .shared
965            .queue
966            .lock()
967            .expect("callgraph refresh queue mutex poisoned");
968        queue.shutdown_requested = true;
969        self.shared.wake.notify_one();
970        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
971            let remaining = deadline.saturating_duration_since(Instant::now());
972            let (next, _) = self
973                .shared
974                .wake
975                .wait_timeout(queue, remaining)
976                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
977            queue = next;
978        }
979        let drained = queue.active.is_none() && queue.order.is_empty();
980        if !drained {
981            if let Some(active) = queue.active.as_ref() {
982                active.defer();
983            }
984            for batch in queue.queued.values() {
985                batch.defer();
986            }
987            queue.order.clear();
988            queue.queued.clear();
989        }
990        drop(queue);
991
992        if drained {
993            if let Some(thread) = self
994                .thread
995                .lock()
996                .expect("callgraph refresh worker thread mutex poisoned")
997                .take()
998            {
999                let _ = thread.join();
1000            }
1001        }
1002        drained
1003    }
1004}
1005
1006static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1007
1008pub fn enqueue_callgraph_store_refresh(
1009    callgraph_dir: PathBuf,
1010    project_root: PathBuf,
1011    paths: Vec<PathBuf>,
1012    pending_sink: PendingCallGraphStorePaths,
1013) -> bool {
1014    enqueue_callgraph_store_refresh_inner(
1015        callgraph_dir,
1016        project_root,
1017        paths,
1018        pending_sink,
1019        Vec::new(),
1020        None,
1021    )
1022}
1023
1024#[cfg(test)]
1025pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1026    callgraph_dir: PathBuf,
1027    project_root: PathBuf,
1028    paths: Vec<PathBuf>,
1029    pending_sink: PendingCallGraphStorePaths,
1030    ticket: CallgraphRefreshTicket,
1031) -> bool {
1032    enqueue_callgraph_store_refresh_inner(
1033        callgraph_dir,
1034        project_root,
1035        paths,
1036        pending_sink,
1037        Vec::new(),
1038        Some(ticket),
1039    )
1040}
1041
1042pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1043    callgraph_dir: PathBuf,
1044    project_root: PathBuf,
1045    paths: Vec<PathBuf>,
1046    pending_sink: PendingCallGraphStorePaths,
1047    refresh_state: CallgraphRefreshState,
1048    ticket: CallgraphRefreshTicket,
1049) -> bool {
1050    enqueue_callgraph_store_refresh_inner(
1051        callgraph_dir,
1052        project_root,
1053        paths,
1054        pending_sink,
1055        vec![refresh_state],
1056        Some(ticket),
1057    )
1058}
1059
1060fn enqueue_callgraph_store_refresh_inner(
1061    callgraph_dir: PathBuf,
1062    project_root: PathBuf,
1063    paths: Vec<PathBuf>,
1064    pending_sink: PendingCallGraphStorePaths,
1065    refresh_states: Vec<CallgraphRefreshState>,
1066    ticket: Option<CallgraphRefreshTicket>,
1067) -> bool {
1068    if paths.is_empty() {
1069        return true;
1070    }
1071    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1072    let worker = {
1073        let mut worker = slot
1074            .lock()
1075            .expect("callgraph refresh worker mutex poisoned");
1076        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1077    };
1078    worker.enqueue(
1079        RefreshRoot {
1080            callgraph_dir,
1081            project_root,
1082        },
1083        paths,
1084        pending_sink,
1085        refresh_states,
1086        ticket,
1087    )
1088}
1089
1090pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1091    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1092}
1093
1094#[doc(hidden)]
1095pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1096    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1097    let worker = slot
1098        .lock()
1099        .expect("callgraph refresh worker mutex poisoned")
1100        .clone();
1101    let Some(worker) = worker else {
1102        return true;
1103    };
1104    let drained = worker.shutdown_with_budget(budget);
1105    if drained {
1106        let mut current = slot
1107            .lock()
1108            .expect("callgraph refresh worker mutex poisoned");
1109        if current
1110            .as_ref()
1111            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1112        {
1113            *current = None;
1114        }
1115    }
1116    drained
1117}
1118
1119fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1120    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1121}
1122
1123fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1124    // The worker owns these caches so maps are shared only by refreshes for the
1125    // same canonical root and disappear when the worker shuts down.
1126    let mut workspace_crate_prefixes = HashMap::new();
1127    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1128    loop {
1129        let batch = {
1130            let mut queue = shared
1131                .queue
1132                .lock()
1133                .expect("callgraph refresh queue mutex poisoned");
1134            loop {
1135                if let Some(root) = queue.order.pop_front() {
1136                    let batch = queue
1137                        .queued
1138                        .remove(&root)
1139                        .expect("queued callgraph refresh root has a batch");
1140                    queue.active = Some(batch.clone());
1141                    break batch;
1142                }
1143                if queue.shutdown_requested {
1144                    return;
1145                }
1146                queue = shared
1147                    .wake
1148                    .wait(queue)
1149                    .expect("callgraph refresh queue mutex poisoned while waiting");
1150            }
1151        };
1152
1153        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1154
1155        let mut queue = shared
1156            .queue
1157            .lock()
1158            .expect("callgraph refresh queue mutex poisoned");
1159        queue.active = None;
1160        let became_idle = queue.order.is_empty();
1161        shared.wake.notify_all();
1162        drop(queue);
1163
1164        if became_idle {
1165            let checkpoint_due = idle_checkpoint_due(
1166                last_idle_checkpoints.get(&batch.root).copied(),
1167                Instant::now(),
1168            );
1169            if checkpoint_due {
1170                if let Some(store) = store {
1171                    if store.checkpoint_wal_truncate() {
1172                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1173                    }
1174                }
1175            }
1176        }
1177    }
1178}
1179
1180fn process_callgraph_refresh_batch(
1181    batch: &RefreshBatch,
1182    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1183) -> Option<CallGraphStore> {
1184    // A manifest event is an invalidation signal, not a source file to parse.
1185    // Drop the root's map even for a superseded batch: the filesystem changed,
1186    // and a later configure must never inherit crate membership from before it.
1187    if batch
1188        .paths
1189        .iter()
1190        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1191    {
1192        workspace_crate_prefixes.remove(&batch.root);
1193    }
1194
1195    let paths = batch
1196        .paths
1197        .iter()
1198        .filter(|path| crate::parser::detect_language(path).is_some())
1199        .cloned()
1200        .collect::<Vec<_>>();
1201    if paths.is_empty() {
1202        return None;
1203    }
1204    note_refresh_worker_batch_for_test(&batch.root.project_root);
1205    if batch
1206        .ticket
1207        .as_ref()
1208        .is_some_and(|ticket| !ticket.is_current())
1209    {
1210        // Superseded before starting: park the paths so the next configure's
1211        // pending replay (or unbind cleanup) decides their fate.
1212        batch.defer();
1213        return None;
1214    }
1215    let workspace_crate_prefix_cache =
1216        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1217    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1218    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1219    note_refresh_worker_call_for_test(&batch.root.project_root);
1220    let opened = if test_seam.fail_open {
1221        Ok(None)
1222    } else {
1223        CallGraphStore::open_ready(
1224            batch.root.callgraph_dir.clone(),
1225            batch.root.project_root.clone(),
1226        )
1227    };
1228    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1229        // The gate is deliberately after open_ready so tests can hold a failed
1230        // open between its result and the defer that parks the batch.
1231        let _ = gate.held_tx.send(());
1232        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1233    }
1234    let store = match opened {
1235        Ok(Some(store)) => store,
1236        Ok(None) => {
1237            batch.defer_after_open_failure();
1238            return None;
1239        }
1240        Err(error) => {
1241            batch.defer_after_open_failure();
1242            crate::slog_warn!(
1243                "callgraph store writer open failed during refresh; deferred paths: {}",
1244                error
1245            );
1246            return None;
1247        }
1248    };
1249    if !test_seam.delay.is_zero() {
1250        std::thread::sleep(test_seam.delay);
1251    }
1252    if batch
1253        .ticket
1254        .as_ref()
1255        .is_some_and(|ticket| !ticket.is_current())
1256    {
1257        // This is a superseded-ticket defer, not an open-failure defer: leave
1258        // the paths for the replacement configure instead of self-replaying.
1259        batch.defer();
1260        return Some(store);
1261    }
1262    let refresh_result = if test_seam.fail_refresh {
1263        Err(CallGraphStoreError::Unavailable(
1264            "injected refresh worker failure".to_string(),
1265        ))
1266    } else if let Some(ticket) = &batch.ticket {
1267        with_publish_epoch(
1268            ticket.publish_epoch.clone(),
1269            ticket.expected_publish_epoch,
1270            || {
1271                with_refresh_commit_admission(
1272                    ticket.lifecycle.clone(),
1273                    Arc::clone(&ticket.generation_flag),
1274                    ticket.expected_generation,
1275                    || {
1276                        store
1277                            .refresh_files_with_workspace_crate_prefix_cache(
1278                                &paths,
1279                                workspace_crate_prefix_cache.clone(),
1280                            )
1281                            .map(|_| ())
1282                    },
1283                )
1284            },
1285        )
1286    } else {
1287        store
1288            .refresh_files_with_workspace_crate_prefix_cache(
1289                &paths,
1290                workspace_crate_prefix_cache.clone(),
1291            )
1292            .map(|_| ())
1293    };
1294    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1295        // The commit lost the fence race: a newer configure or publication
1296        // owns the store now. Defer instead of stale-marking — the paths were
1297        // never committed, and the replacement generation re-indexes them.
1298        batch.defer();
1299        return Some(store);
1300    }
1301    if let Err(error) = refresh_result {
1302        crate::slog_warn!("callgraph store refresh failed: {}", error);
1303        match store.mark_files_stale(&paths) {
1304            Ok(marked) => {
1305                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1306                crate::slog_warn!(
1307                    "marked {} callgraph store file(s) stale after refresh failure",
1308                    marked.len()
1309                );
1310            }
1311            Err(mark_error) => crate::slog_warn!(
1312                "failed to mark callgraph store files stale after refresh failure: {}",
1313                mark_error
1314            ),
1315        }
1316    } else {
1317        crate::logging::note_callgraph_invalidations(paths.len());
1318    }
1319    Some(store)
1320}
1321
1322fn workspace_crate_prefix_cache_for_root(
1323    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1324    root: &RefreshRoot,
1325) -> WorkspaceCratePrefixCache {
1326    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1327        // Eviction only costs a future rebuild; it cannot make resolution stale.
1328        if let Some(evicted) = caches.keys().next().cloned() {
1329            caches.remove(&evicted);
1330        }
1331    }
1332    caches.entry(root.clone()).or_default().clone()
1333}
1334
1335#[derive(Clone, Copy, Default)]
1336struct RefreshWorkerTestSeam {
1337    delay: Duration,
1338    fail_refresh: bool,
1339    fail_open: bool,
1340    refresh_calls: usize,
1341    worker_calls: usize,
1342    stale_marks: usize,
1343}
1344
1345static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1346    OnceLock::new();
1347
1348struct RefreshWorkerTestGate {
1349    held_tx: crossbeam_channel::Sender<()>,
1350    release_rx: crossbeam_channel::Receiver<()>,
1351}
1352
1353static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1354    OnceLock::new();
1355
1356#[doc(hidden)]
1357pub fn install_callgraph_refresh_worker_test_gate(
1358    project_root: PathBuf,
1359) -> (
1360    crossbeam_channel::Receiver<()>,
1361    crossbeam_channel::Sender<()>,
1362) {
1363    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1364    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1365    REFRESH_WORKER_TEST_GATES
1366        .get_or_init(|| Mutex::new(HashMap::new()))
1367        .lock()
1368        .expect("callgraph refresh test gate mutex poisoned")
1369        .insert(
1370            project_root,
1371            RefreshWorkerTestGate {
1372                held_tx,
1373                release_rx,
1374            },
1375        );
1376    (held_rx, release_tx)
1377}
1378
1379fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1380    REFRESH_WORKER_TEST_GATES
1381        .get_or_init(|| Mutex::new(HashMap::new()))
1382        .lock()
1383        .expect("callgraph refresh test gate mutex poisoned")
1384        .remove(project_root)
1385}
1386
1387fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1388    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1389        return RefreshWorkerTestSeam::default();
1390    };
1391    seams
1392        .lock()
1393        .expect("callgraph refresh test seam mutex poisoned")
1394        .get(project_root)
1395        .copied()
1396        .unwrap_or_default()
1397}
1398
1399fn note_refresh_worker_batch_for_test(project_root: &Path) {
1400    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1401        if let Some(seam) = seams
1402            .lock()
1403            .expect("callgraph refresh test seam mutex poisoned")
1404            .get_mut(project_root)
1405        {
1406            seam.worker_calls += 1;
1407        }
1408    }
1409}
1410
1411fn note_refresh_worker_call_for_test(project_root: &Path) {
1412    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1413        if let Some(seam) = seams
1414            .lock()
1415            .expect("callgraph refresh test seam mutex poisoned")
1416            .get_mut(project_root)
1417        {
1418            seam.refresh_calls += 1;
1419        }
1420    }
1421}
1422
1423fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1424    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1425        if let Some(seam) = seams
1426            .lock()
1427            .expect("callgraph refresh test seam mutex poisoned")
1428            .get_mut(project_root)
1429        {
1430            seam.stale_marks += 1;
1431        }
1432    }
1433}
1434
1435#[doc(hidden)]
1436pub fn set_callgraph_refresh_worker_test_seam(
1437    project_root: PathBuf,
1438    delay: Duration,
1439    fail_refresh: bool,
1440) {
1441    REFRESH_WORKER_TEST_SEAMS
1442        .get_or_init(|| Mutex::new(HashMap::new()))
1443        .lock()
1444        .expect("callgraph refresh test seam mutex poisoned")
1445        .insert(
1446            project_root,
1447            RefreshWorkerTestSeam {
1448                delay,
1449                fail_refresh,
1450                ..RefreshWorkerTestSeam::default()
1451            },
1452        );
1453}
1454
1455#[doc(hidden)]
1456pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1457    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1458        if let Some(seam) = seams
1459            .lock()
1460            .expect("callgraph refresh test seam mutex poisoned")
1461            .get_mut(&project_root)
1462        {
1463            seam.fail_open = enabled;
1464        }
1465    }
1466}
1467
1468#[doc(hidden)]
1469pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1470    let seam = refresh_worker_test_seam(project_root);
1471    (seam.refresh_calls, seam.stale_marks)
1472}
1473
1474#[doc(hidden)]
1475pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1476    refresh_worker_test_seam(project_root).worker_calls
1477}
1478
1479#[doc(hidden)]
1480pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1481    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1482        seams
1483            .lock()
1484            .expect("callgraph refresh test seam mutex poisoned")
1485            .remove(project_root);
1486    }
1487}
1488
1489#[derive(Debug)]
1490pub struct CallGraphStore {
1491    project_root: PathBuf,
1492    project_key: String,
1493    /// The concrete on-disk DB file this store opened. With the generation
1494    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1495    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1496    sqlite_path: PathBuf,
1497    /// Root-keyed directory whose pointer controls this store. For a legacy
1498    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1499    /// newly published root-keyed generation invalidates the fallback reader.
1500    publication_dir: PathBuf,
1501    /// True only when the root-keyed read path opened data from a legacy
1502    /// harness partition. Writer-capable callers use this to schedule migration
1503    /// without making read-only/worktree callers acquire a writer lease.
1504    legacy_fallback: bool,
1505    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1506    /// or `None` when it opened the legacy single-file DB. Used to detect when
1507    /// another process has published a newer generation so this process can
1508    /// drop its connection and reopen (see `current_generation`).
1509    generation: Option<String>,
1510    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1511    read_marker: Option<crate::root_cache::ReadMarker>,
1512    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1513    // Failed validations are not cached, so a later successful build remains visible.
1514    database_ready: AtomicBool,
1515    write_metrics: Arc<CallgraphWriteMetrics>,
1516    conn: Mutex<Connection>,
1517}
1518
1519#[derive(Debug)]
1520pub struct ReadonlyCallGraphStore {
1521    inner: CallGraphStore,
1522}
1523
1524pub trait CallGraphRead {
1525    fn project_root(&self) -> &Path;
1526    fn project_key(&self) -> &str;
1527    fn sqlite_path(&self) -> &Path;
1528    fn is_current(&self) -> bool;
1529    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1530    fn indexed_file_count(&self) -> Result<usize>;
1531    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1532    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1533    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1534    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1535    fn direct_callers_for_symbols(
1536        &self,
1537        targets: &[(String, String)],
1538    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1539        targets
1540            .iter()
1541            .cloned()
1542            .map(|target| {
1543                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1544                Ok((target, callers))
1545            })
1546            .collect()
1547    }
1548    fn direct_caller_counts_of(
1549        &self,
1550        targets: &[(String, String)],
1551    ) -> Result<HashMap<(String, String), usize>>;
1552    fn outgoing_calls_for_symbols(
1553        &self,
1554        sources: &[(String, String)],
1555    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1556    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1557        -> Result<StoreCallersResult>;
1558    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1559    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1560    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1561    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1562    fn call_tree(
1563        &self,
1564        file_rel: &Path,
1565        symbol: &str,
1566        depth: usize,
1567    ) -> Result<callgraph::CallTreeNode>;
1568    fn trace_to(
1569        &self,
1570        file_rel: &Path,
1571        symbol: &str,
1572        max_depth: usize,
1573    ) -> Result<callgraph::TraceToResult>;
1574    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1575    fn trace_to_symbol(
1576        &self,
1577        file_rel: &Path,
1578        symbol: &str,
1579        to_symbol: &str,
1580        to_file: Option<&Path>,
1581        max_depth: usize,
1582    ) -> Result<callgraph::TraceToSymbolResult>;
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Eq)]
1586enum OpenRootRepair {
1587    None,
1588    ReRooted,
1589    NeedsRebuild {
1590        previous_roots: Vec<String>,
1591        current_root: String,
1592        reason: String,
1593    },
1594}
1595
1596struct OpenedStore {
1597    store: CallGraphStore,
1598    root_repair: OpenRootRepair,
1599}
1600
1601#[derive(Clone, Debug)]
1602struct LegacyCallgraphPartition {
1603    harness: String,
1604    dir: PathBuf,
1605    key: String,
1606    bytes: u64,
1607    freshness: Option<SystemTime>,
1608}
1609
1610#[derive(Clone, Debug)]
1611struct LegacyCallgraphTarget {
1612    partition: LegacyCallgraphPartition,
1613    sqlite_path: PathBuf,
1614    generation: Option<String>,
1615    source_bytes: u64,
1616    source_blake3: String,
1617}
1618
1619#[derive(Clone, Debug)]
1620struct SourceFingerprint {
1621    bytes: u64,
1622    blake3: String,
1623}
1624
1625#[derive(Clone, Debug)]
1626struct PublishedLegacyMigration {
1627    generation: String,
1628    migrated_bytes: u64,
1629}
1630
1631#[derive(Debug, Clone)]
1632pub struct ColdBuildStats {
1633    pub files: usize,
1634    pub nodes: usize,
1635    pub refs: usize,
1636    pub edges: usize,
1637    pub failed_files: Vec<String>,
1638    pub elapsed_ms: u128,
1639}
1640
1641#[derive(Debug, Clone)]
1642pub struct IncrementalStats {
1643    pub changed_files: Vec<String>,
1644    pub surface_changed: Vec<String>,
1645    pub deleted_files: Vec<String>,
1646    pub dependency_selected_refs: usize,
1647    pub refreshed_own_files: usize,
1648    pub unchanged_extract_files: usize,
1649}
1650
1651/// Phase timings for the copy-based incremental refresh benchmark.
1652#[doc(hidden)]
1653#[derive(Debug, Clone, Default, PartialEq, Eq)]
1654pub struct RefreshFilesProfile {
1655    pub parse: Duration,
1656    pub dependency_selection: Duration,
1657    pub row_deletes: Duration,
1658    pub row_inserts: Duration,
1659    pub dependent_parse: Duration,
1660    pub index_load: Duration,
1661    pub ref_resolution: Duration,
1662    pub method_dispatch: Duration,
1663    pub commit: Duration,
1664    pub total: Duration,
1665}
1666
1667impl RefreshFilesProfile {
1668    pub fn report(&self) -> String {
1669        format!(
1670            "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",
1671            self.parse.as_millis(),
1672            self.dependency_selection.as_millis(),
1673            self.row_deletes.as_millis(),
1674            self.row_inserts.as_millis(),
1675            self.dependent_parse.as_millis(),
1676            self.index_load.as_millis(),
1677            self.ref_resolution.as_millis(),
1678            self.method_dispatch.as_millis(),
1679            self.commit.as_millis(),
1680            self.total.as_millis(),
1681        )
1682    }
1683}
1684
1685#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1686pub struct StoredEdge {
1687    pub source_file: String,
1688    pub source_symbol: String,
1689    pub target_file: String,
1690    pub target_symbol: String,
1691    pub kind: String,
1692    pub line: u32,
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Eq)]
1696pub struct StoreNode {
1697    node_id: String,
1698    pub file: String,
1699    pub symbol: String,
1700    pub name: String,
1701    pub kind: String,
1702    pub line: u32,
1703    pub end_line: u32,
1704    pub signature: Option<String>,
1705    pub exported: bool,
1706    pub is_entry_point: bool,
1707    pub lang: LangId,
1708}
1709
1710#[cfg(test)]
1711impl StoreNode {
1712    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1713        Self {
1714            node_id: format!("{file}:{symbol}"),
1715            file: file.to_string(),
1716            symbol: symbol.to_string(),
1717            name: symbol.to_string(),
1718            kind: "function".to_string(),
1719            line: 1,
1720            end_line: 1,
1721            signature: None,
1722            exported: is_entry_point,
1723            is_entry_point,
1724            lang: LangId::TypeScript,
1725        }
1726    }
1727}
1728
1729#[derive(Debug, Clone, PartialEq, Eq)]
1730pub struct StoreCallSite {
1731    pub caller: StoreNode,
1732    pub target_file: String,
1733    pub target_symbol: String,
1734    pub target: Option<StoreNode>,
1735    pub line: u32,
1736    pub byte_start: usize,
1737    pub byte_end: usize,
1738    pub resolved: bool,
1739    pub provenance: String,
1740}
1741
1742impl StoreCallSite {
1743    pub fn approximate(&self) -> bool {
1744        self.provenance == PROVENANCE_NAME_MATCH
1745    }
1746
1747    pub fn resolved_by(&self) -> &str {
1748        &self.provenance
1749    }
1750
1751    pub fn supplemental_resolution(&self) -> Option<&str> {
1752        match self.provenance.as_str() {
1753            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1754            _ => None,
1755        }
1756    }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq)]
1760pub struct StoreUnresolvedCall {
1761    pub caller: StoreNode,
1762    pub symbol: String,
1763    pub full_ref: Option<String>,
1764    pub line: u32,
1765    pub byte_start: usize,
1766    pub byte_end: usize,
1767}
1768
1769#[derive(Debug, Clone, PartialEq, Eq)]
1770pub struct StoreCallersResult {
1771    pub target: StoreNode,
1772    pub callers: Vec<StoreCallSite>,
1773    pub scanned_files: usize,
1774    pub depth_limited: bool,
1775    pub truncated: usize,
1776}
1777
1778#[derive(Debug, Clone, PartialEq, Eq)]
1779pub struct StoreImpactCaller {
1780    pub site: StoreCallSite,
1781    pub signature: Option<String>,
1782    pub is_entry_point: bool,
1783    pub call_expression: Option<String>,
1784    pub parameters: Vec<String>,
1785}
1786
1787#[derive(Debug, Clone, PartialEq, Eq)]
1788pub struct StoreImpactResult {
1789    pub target: StoreNode,
1790    pub parameters: Vec<String>,
1791    pub callers: Vec<StoreImpactCaller>,
1792    pub depth_limited: bool,
1793    pub truncated: usize,
1794}
1795
1796#[derive(Debug, Clone)]
1797struct ExtractFailure {
1798    rel_path: String,
1799    freshness: Option<FileFreshness>,
1800}
1801
1802#[derive(Debug, Clone)]
1803struct BuildExtractsResult {
1804    extracts: Vec<FileExtract>,
1805    failures: Vec<ExtractFailure>,
1806}
1807
1808#[derive(Debug, Clone)]
1809enum StoreForwardCall {
1810    Resolved(StoreCallSite),
1811    Unresolved(StoreUnresolvedCall),
1812}
1813
1814impl StoreForwardCall {
1815    fn byte_start(&self) -> usize {
1816        match self {
1817            Self::Resolved(site) => site.byte_start,
1818            Self::Unresolved(call) => call.byte_start,
1819        }
1820    }
1821
1822    fn line(&self) -> u32 {
1823        match self {
1824            Self::Resolved(site) => site.line,
1825            Self::Unresolved(call) => call.line,
1826        }
1827    }
1828}
1829
1830#[derive(Debug, Clone)]
1831struct FileExtract {
1832    rel_path: String,
1833    freshness: FileFreshness,
1834    lang: LangId,
1835    data: FileCallData,
1836    nodes: Vec<NodeRecord>,
1837    raw_refs: Vec<RawRef>,
1838    dispatch_hints: Vec<DispatchHint>,
1839    surface_fingerprint: String,
1840}
1841
1842#[derive(Debug, Clone)]
1843struct NodeRecord {
1844    id: String,
1845    file_path: String,
1846    name: String,
1847    scoped_name: String,
1848    kind: String,
1849    range: Range,
1850    range_ordinal: u32,
1851    signature: Option<String>,
1852    exported: bool,
1853    is_default_export: bool,
1854    is_type_like: bool,
1855    is_callgraph_entry_point: bool,
1856}
1857
1858#[derive(Debug, Clone)]
1859struct RawRef {
1860    ref_id: String,
1861    caller_node: Option<String>,
1862    caller_symbol: Option<String>,
1863    caller_file: String,
1864    kind: String,
1865    short_name: Option<String>,
1866    full_ref: Option<String>,
1867    module_path: Option<String>,
1868    import_kind: Option<String>,
1869    local_name: Option<String>,
1870    requested_name: Option<String>,
1871    namespace_alias: Option<String>,
1872    wildcard: bool,
1873    line: u32,
1874    byte_start: usize,
1875    byte_end: usize,
1876    dependencies: BTreeSet<String>,
1877}
1878
1879#[derive(Debug, Clone)]
1880struct ResolvedRef {
1881    raw: RawRef,
1882    status: String,
1883    target_node: Option<String>,
1884    target_file: Option<String>,
1885    target_symbol: Option<String>,
1886    dependencies: BTreeSet<String>,
1887    edge: Option<EdgeRecord>,
1888}
1889
1890#[derive(Debug, Clone)]
1891struct EdgeRecord {
1892    edge_id: String,
1893    source_node: String,
1894    target_node: Option<String>,
1895    target_file: String,
1896    target_symbol: String,
1897    kind: String,
1898    line: u32,
1899}
1900
1901#[derive(Debug, Clone)]
1902struct DispatchHint {
1903    id: String,
1904    method_name: String,
1905    caller_node: String,
1906    file: String,
1907    line: u32,
1908    byte_start: usize,
1909    byte_end: usize,
1910}
1911
1912#[derive(Debug, Clone)]
1913struct NameMatchRef {
1914    ref_id: String,
1915    caller_node: String,
1916    caller_file: String,
1917    caller_symbol: String,
1918    caller_signature: Option<String>,
1919    receiver_expression: String,
1920    receiver: String,
1921    method_name: String,
1922    colon_dispatch: bool,
1923    line: u32,
1924    lang: String,
1925}
1926
1927#[derive(Debug, Clone)]
1928struct NameMatchCandidate {
1929    node_id: String,
1930    file_path: String,
1931    scoped_name: String,
1932    kind: String,
1933    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
1934    start_line: u32,
1935}
1936
1937#[derive(Debug, Clone)]
1938struct FileRow {
1939    surface_fingerprint: String,
1940    freshness: FileFreshness,
1941}
1942
1943#[derive(Debug, Clone)]
1944struct DbFileIndex {
1945    lang: Option<LangId>,
1946    exports: HashSet<String>,
1947    default_export: Option<String>,
1948    export_aliases: HashMap<String, String>,
1949    node_by_scoped: HashMap<String, String>,
1950    node_by_bare: HashMap<String, String>,
1951    module_targets: HashMap<String, Option<String>>,
1952    reexports: Vec<ReexportIndex>,
1953}
1954
1955#[derive(Debug, Clone)]
1956struct ReexportIndex {
1957    target_file: Option<String>,
1958    named: HashMap<String, String>,
1959    wildcard: bool,
1960}
1961
1962#[derive(Debug, Clone)]
1963struct ProjectIndex<'a> {
1964    project_root: PathBuf,
1965    files: HashMap<String, DbFileIndex>,
1966    caller_data: HashMap<String, &'a FileCallData>,
1967    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1968    /// watcher events replace the cache before another batch can resolve refs.
1969    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1970    /// its own workspace mapping.
1971    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1972}
1973
1974impl ProjectIndex<'_> {
1975    /// Resolve a crate name to its `src` prefix, building the workspace map on
1976    /// first use. Refresh-worker indexes for the same root share this cache.
1977    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1978        self.workspace_crate_prefixes
1979            .0
1980            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1981            .get(crate_name)
1982            .cloned()
1983    }
1984}
1985
1986impl CallGraphStore {
1987    pub fn open_if_enabled(
1988        options: CallGraphStoreOptions,
1989        callgraph_dir: PathBuf,
1990        project_root: PathBuf,
1991    ) -> Result<Option<Self>> {
1992        if !options.enabled {
1993            return Ok(None);
1994        }
1995        Self::open(callgraph_dir, project_root).map(Some)
1996    }
1997
1998    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1999        let project_key = crate::search_index::artifact_cache_key(&project_root);
2000        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2001        else {
2002            return Err(CallGraphStoreError::Unavailable(
2003                "writer capability denied; use the read-only callgraph opener".to_string(),
2004            ));
2005        };
2006        std::fs::create_dir_all(&callgraph_dir)?;
2007        // Resolve the current generation via the pointer (falling back to the
2008        // legacy single-file DB). If nothing is published yet, open the legacy
2009        // path so a brand-new store still gets a writable DB + schema.
2010        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2011            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2012        let OpenedStore { store, root_repair } = Self::open_at_path(
2013            project_root.clone(),
2014            project_key,
2015            sqlite_path,
2016            generation,
2017            true,
2018            Some(Arc::clone(&writer_lease)),
2019            None,
2020        )?;
2021        match root_repair {
2022            OpenRootRepair::NeedsRebuild { .. } => {
2023                log_root_repair_rebuild(&root_repair);
2024                drop(store);
2025                drop(writer_lease);
2026                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2027                let (store, _stats) =
2028                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2029                Ok(store)
2030            }
2031            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2032        }
2033    }
2034
2035    pub fn open_readonly(
2036        callgraph_dir: PathBuf,
2037        project_root: PathBuf,
2038    ) -> Result<Option<ReadonlyCallGraphStore>> {
2039        let project_key = crate::search_index::artifact_cache_key(&project_root);
2040        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2041        {
2042            let conn = open_readonly_connection(&sqlite_path)?;
2043            if !database_ready(&conn).unwrap_or(false) {
2044                return Ok(None);
2045            }
2046            let marker_label = generation.as_deref().unwrap_or("legacy");
2047            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2048            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2049                Self::from_connection(
2050                    project_root,
2051                    project_key,
2052                    sqlite_path,
2053                    callgraph_dir,
2054                    false,
2055                    generation,
2056                    None,
2057                    Some(read_marker),
2058                    conn,
2059                ),
2060            )));
2061        }
2062
2063        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2064            return Ok(None);
2065        };
2066        crate::slog_warn!(
2067            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2068            target.partition.harness,
2069            target.sqlite_path.display()
2070        );
2071        let conn = open_readonly_connection(&target.sqlite_path)?;
2072        if !database_ready(&conn).unwrap_or(false) {
2073            return Ok(None);
2074        }
2075        let marker_label =
2076            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2077        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2078        Ok(Some(ReadonlyCallGraphStore::from_inner(
2079            Self::from_connection(
2080                project_root,
2081                project_key,
2082                target.sqlite_path,
2083                callgraph_dir,
2084                true,
2085                target.generation,
2086                None,
2087                Some(read_marker),
2088                conn,
2089            ),
2090        )))
2091    }
2092
2093    /// Open the currently-published ready store with write access so moved-root
2094    /// metadata can be repaired before projection readers consume it. Unlike
2095    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2096    /// ready generation exists, it returns `Ok(None)` instead of creating an
2097    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2098    pub fn open_ready_repairing(
2099        callgraph_dir: PathBuf,
2100        project_root: PathBuf,
2101    ) -> Result<Option<Self>> {
2102        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2103    }
2104
2105    /// Open a ready store for bounded maintenance work without repairing root
2106    /// metadata or starting a cold rebuild. A store that needs either action is
2107    /// reported as unavailable so a background build can own that work.
2108    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2109        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2110    }
2111
2112    pub fn open_ready_no_rebuild(
2113        callgraph_dir: PathBuf,
2114        project_root: PathBuf,
2115    ) -> Result<Option<Self>> {
2116        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2117    }
2118
2119    fn open_ready_with_rebuild_policy(
2120        callgraph_dir: PathBuf,
2121        project_root: PathBuf,
2122        allow_cold_build: bool,
2123        allow_root_repair: bool,
2124    ) -> Result<Option<Self>> {
2125        let project_key = crate::search_index::artifact_cache_key(&project_root);
2126        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2127        else {
2128            return Ok(None);
2129        };
2130        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2131        else {
2132            return Ok(None);
2133        };
2134        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2135            project_root.clone(),
2136            project_key.clone(),
2137            sqlite_path,
2138            generation,
2139            true,
2140            Some(Arc::clone(&writer_lease)),
2141            None,
2142            allow_root_repair,
2143        )?;
2144        match root_repair {
2145            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2146                log_root_repair_rebuild(&root_repair);
2147                drop(store);
2148                drop(writer_lease);
2149                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2150                let (store, _stats) =
2151                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2152                Ok(Some(store))
2153            }
2154            OpenRootRepair::NeedsRebuild { .. } => {
2155                if let Some(message) = note_repair_entry(&project_key) {
2156                    crate::slog_warn!("{message}");
2157                }
2158                Ok(None)
2159            }
2160            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2161        }
2162    }
2163
2164    pub fn cold_build_with_lease(
2165        callgraph_dir: PathBuf,
2166        project_root: PathBuf,
2167        files: &[PathBuf],
2168    ) -> Result<(Self, ColdBuildStats)> {
2169        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2170    }
2171
2172    pub fn cold_build_with_lease_chunked(
2173        callgraph_dir: PathBuf,
2174        project_root: PathBuf,
2175        files: &[PathBuf],
2176        chunk_size: usize,
2177    ) -> Result<(Self, ColdBuildStats)> {
2178        Self::cold_build_with_lease_chunked_inner(
2179            callgraph_dir,
2180            project_root,
2181            files,
2182            chunk_size,
2183            false,
2184        )
2185    }
2186
2187    pub(crate) fn force_cold_build_with_lease_chunked(
2188        callgraph_dir: PathBuf,
2189        project_root: PathBuf,
2190        files: &[PathBuf],
2191        chunk_size: usize,
2192    ) -> Result<(Self, ColdBuildStats)> {
2193        Self::cold_build_with_lease_chunked_inner(
2194            callgraph_dir,
2195            project_root,
2196            files,
2197            chunk_size,
2198            true,
2199        )
2200    }
2201
2202    fn cold_build_with_lease_chunked_inner(
2203        callgraph_dir: PathBuf,
2204        project_root: PathBuf,
2205        files: &[PathBuf],
2206        chunk_size: usize,
2207        require_new_publication: bool,
2208    ) -> Result<(Self, ColdBuildStats)> {
2209        let project_key = crate::search_index::artifact_cache_key(&project_root);
2210        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2211        else {
2212            let operation = if require_new_publication {
2213                "forced rebuild"
2214            } else {
2215                "cold build"
2216            };
2217            return Err(CallGraphStoreError::Unavailable(format!(
2218                "{operation} could not acquire writer capability"
2219            )));
2220        };
2221        std::fs::create_dir_all(&callgraph_dir)?;
2222        let (stats, generation) = Self::cold_build_publish_locked(
2223            &callgraph_dir,
2224            &project_root,
2225            &project_key,
2226            files,
2227            chunk_size,
2228            Arc::clone(&writer_lease),
2229        )?;
2230        let store = Self::open_generation(
2231            &callgraph_dir,
2232            project_root,
2233            project_key,
2234            generation,
2235            writer_lease,
2236        )?;
2237        Ok((store, stats))
2238    }
2239
2240    pub fn ensure_built_with_lease(
2241        callgraph_dir: PathBuf,
2242        project_root: PathBuf,
2243        files: &[PathBuf],
2244    ) -> Result<(Self, Option<ColdBuildStats>)> {
2245        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2246    }
2247
2248    pub fn ensure_built_with_lease_chunked(
2249        callgraph_dir: PathBuf,
2250        project_root: PathBuf,
2251        files: &[PathBuf],
2252        chunk_size: usize,
2253    ) -> Result<(Self, Option<ColdBuildStats>)> {
2254        let project_key = crate::search_index::artifact_cache_key(&project_root);
2255        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2256        else {
2257            return Err(CallGraphStoreError::Unavailable(
2258                "callgraph ensure could not acquire writer capability".to_string(),
2259            ));
2260        };
2261        std::fs::create_dir_all(&callgraph_dir)?;
2262        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2263        // Another process may have published a ready generation while we waited
2264        // for the lock — open it instead of rebuilding. If that generation is
2265        // from this same project at an older filesystem root, repair the root
2266        // metadata in-place while still holding the build lease. If data rows
2267        // contain absolute paths, publish a fresh generation under this lease
2268        // rather than recursively reacquiring the same lock.
2269        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2270        {
2271            let OpenedStore { store, root_repair } = Self::open_at_path(
2272                project_root.clone(),
2273                project_key.clone(),
2274                sqlite_path,
2275                generation,
2276                true,
2277                Some(Arc::clone(&writer_lease)),
2278                None,
2279            )?;
2280            match root_repair {
2281                OpenRootRepair::NeedsRebuild { .. } => {
2282                    log_root_repair_rebuild(&root_repair);
2283                    drop(store);
2284                    let (stats, generation) = Self::cold_build_publish_locked(
2285                        &callgraph_dir,
2286                        &project_root,
2287                        &project_key,
2288                        files,
2289                        chunk_size,
2290                        Arc::clone(&writer_lease),
2291                    )?;
2292                    let store = Self::open_generation(
2293                        &callgraph_dir,
2294                        project_root,
2295                        project_key,
2296                        generation,
2297                        writer_lease,
2298                    )?;
2299                    return Ok((store, Some(stats)));
2300                }
2301                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2302                    return Ok((store, None));
2303                }
2304            }
2305        }
2306        if let Some(store) = try_legacy_migration_or_fallback(
2307            &callgraph_dir,
2308            &project_root,
2309            &project_key,
2310            Arc::clone(&writer_lease),
2311        )? {
2312            return Ok((store, None));
2313        }
2314        let (stats, generation) = Self::cold_build_publish_locked(
2315            &callgraph_dir,
2316            &project_root,
2317            &project_key,
2318            files,
2319            chunk_size,
2320            Arc::clone(&writer_lease),
2321        )?;
2322        let store = Self::open_generation(
2323            &callgraph_dir,
2324            project_root,
2325            project_key,
2326            generation,
2327            writer_lease,
2328        )?;
2329        Ok((store, Some(stats)))
2330    }
2331
2332    /// Migrate a legacy harness-partition store without falling through to a
2333    /// cold build. This is used after a query has already opened a read-only
2334    /// fallback: the caller runs it on the same limited background lane as cold
2335    /// builds while queries continue using that fallback. Public so crash/retry
2336    /// tests can drive the migration synchronously on a thread where the
2337    /// thread-local failure seams apply.
2338    pub fn migrate_legacy_with_lease(
2339        callgraph_dir: PathBuf,
2340        project_root: PathBuf,
2341    ) -> Result<Option<Self>> {
2342        let project_key = crate::search_index::artifact_cache_key(&project_root);
2343        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2344        else {
2345            return Ok(None);
2346        };
2347        std::fs::create_dir_all(&callgraph_dir)?;
2348        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2349
2350        // Another writer may have completed the migration while this worker was
2351        // waiting for the lease. Adopt its root-keyed generation rather than
2352        // copying the legacy source a second time.
2353        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2354        {
2355            let OpenedStore { store, root_repair } = Self::open_at_path(
2356                project_root,
2357                project_key,
2358                sqlite_path,
2359                generation,
2360                true,
2361                Some(writer_lease),
2362                None,
2363            )?;
2364            return match root_repair {
2365                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2366                OpenRootRepair::NeedsRebuild { reason, .. } => {
2367                    Err(CallGraphStoreError::Unavailable(format!(
2368                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2369                    )))
2370                }
2371            };
2372        }
2373
2374        let store = try_legacy_migration_or_fallback(
2375            &callgraph_dir,
2376            &project_root,
2377            &project_key,
2378            writer_lease,
2379        )?;
2380        // A disk-floor or backup-budget failure returns a readable legacy store.
2381        // Keep the already-resident fallback instead of sending this duplicate
2382        // reader through the background-install channel.
2383        Ok(store.filter(|store| !store.is_legacy_fallback()))
2384    }
2385
2386    /// Build a fresh DB and publish it as a new generation, then atomically flip
2387    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2388    /// succeeds even when other processes hold an older generation open (the
2389    /// multi-TUI Windows case). The builder owns the temp + generation files
2390    /// exclusively (unique pid+nanos names), so it can rename/replace them
2391    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2392    ///
2393    /// Returns the published generation file name so callers open exactly the
2394    /// generation they built (avoiding a race where a concurrent build's flip
2395    /// would otherwise reopen a different generation).
2396    fn cold_build_publish_locked(
2397        callgraph_dir: &Path,
2398        project_root: &Path,
2399        project_key: &str,
2400        files: &[PathBuf],
2401        chunk_size: usize,
2402        writer_lease: Arc<crate::root_cache::WriterLease>,
2403    ) -> Result<(ColdBuildStats, String)> {
2404        if let Some((previous_root, remaining)) =
2405            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2406        {
2407            return Err(CallGraphStoreError::Unavailable(format!(
2408                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2409                previous_root.display(),
2410                remaining.as_millis()
2411            )));
2412        }
2413        let generation = generation_file_name(project_key);
2414        let gen_path = callgraph_dir.join(&generation);
2415        let temp_path = callgraph_dir.join(format!(
2416            "{generation}.tmp.{}.{}",
2417            std::process::id(),
2418            now_nanos()
2419        ));
2420        remove_sqlite_file_set(&temp_path);
2421
2422        let stats = {
2423            let temp_store = Self::open_at_path(
2424                project_root.to_path_buf(),
2425                project_key.to_string(),
2426                temp_path.clone(),
2427                None,
2428                false,
2429                Some(Arc::clone(&writer_lease)),
2430                None,
2431            )?
2432            .store;
2433            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2434            let _ = temp_store.checkpoint_wal_truncate();
2435            temp_store.prepare_for_atomic_swap()?;
2436            stats
2437        };
2438
2439        notify_cold_build_before_publish_observer();
2440        let publication = publish_if_current(|| {
2441            verify_writer_lease(&writer_lease)?;
2442            // Move the finished build to its final generation path. This target is
2443            // brand-new and owned by us, so the rename never hits an open file.
2444            remove_sqlite_file_set(&gen_path);
2445            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2446            crate::fs_lock::sync_parent(&gen_path);
2447            remove_sqlite_sidecars(&gen_path);
2448
2449            notify_cold_build_swap_observer(&temp_path, &gen_path);
2450
2451            // Atomically publish the new generation, then best-effort GC old ones.
2452            verify_writer_lease(&writer_lease)?;
2453            publish_pointer(callgraph_dir, project_key, &generation)?;
2454            gc_old_generations(callgraph_dir, project_key, &generation);
2455            // Store-wide orphan sweep on the same cadence: reclaims aged build
2456            // temps for roots that no longer build here, which the per-root GC
2457            // above never reaches.
2458            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2459            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2460                let inspect_root =
2461                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2462                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2463                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2464            }
2465            Ok(())
2466        });
2467        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2468            remove_sqlite_file_set(&temp_path);
2469        }
2470        publication?;
2471        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2472        Ok((stats, generation))
2473    }
2474
2475    /// Open a specific just-published generation (read-write, WAL) so a builder
2476    /// returns a store pinned to exactly what it built.
2477    fn open_generation(
2478        callgraph_dir: &Path,
2479        project_root: PathBuf,
2480        project_key: String,
2481        generation: String,
2482        writer_lease: Arc<crate::root_cache::WriterLease>,
2483    ) -> Result<Self> {
2484        let gen_path = callgraph_dir.join(&generation);
2485        Ok(Self::open_at_path(
2486            project_root,
2487            project_key,
2488            gen_path,
2489            Some(generation),
2490            true,
2491            Some(writer_lease),
2492            None,
2493        )?
2494        .store)
2495    }
2496
2497    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2498        let project_key = crate::search_index::artifact_cache_key(project_root);
2499        // A cold build is needed unless a ready generation (or ready legacy DB)
2500        // is currently published.
2501        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2502    }
2503
2504    fn open_at_path(
2505        project_root: PathBuf,
2506        project_key: String,
2507        sqlite_path: PathBuf,
2508        generation: Option<String>,
2509        use_wal: bool,
2510        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2511        read_marker: Option<crate::root_cache::ReadMarker>,
2512    ) -> Result<OpenedStore> {
2513        Self::open_at_path_with_root_repair(
2514            project_root,
2515            project_key,
2516            sqlite_path,
2517            generation,
2518            use_wal,
2519            writer_lease,
2520            read_marker,
2521            true,
2522        )
2523    }
2524
2525    fn open_at_path_with_root_repair(
2526        project_root: PathBuf,
2527        project_key: String,
2528        sqlite_path: PathBuf,
2529        generation: Option<String>,
2530        use_wal: bool,
2531        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2532        read_marker: Option<crate::root_cache::ReadMarker>,
2533        allow_root_repair: bool,
2534    ) -> Result<OpenedStore> {
2535        if let Some(lease) = writer_lease.as_ref() {
2536            verify_writer_lease(lease)?;
2537        }
2538        if let Some(parent) = sqlite_path.parent() {
2539            std::fs::create_dir_all(parent)?;
2540        }
2541        let mut conn = Connection::open(&sqlite_path)?;
2542        if use_wal {
2543            configure_connection(&conn)?;
2544        } else {
2545            configure_build_connection(&conn)?;
2546        }
2547        if let Some(lease) = writer_lease.as_ref() {
2548            verify_writer_lease(lease)?;
2549        }
2550        initialize_schema(&conn)?;
2551        if let Some(lease) = writer_lease.as_ref() {
2552            verify_writer_lease(lease)?;
2553        }
2554        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2555        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2556            (Some(marker), _, _) => Some(marker),
2557            (None, Some(label), Some(cache_dir)) => {
2558                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2559            }
2560            (None, _, _) => None,
2561        };
2562        let publication_dir = sqlite_path
2563            .parent()
2564            .map(Path::to_path_buf)
2565            .unwrap_or_default();
2566        let store = Self::from_connection(
2567            project_root,
2568            project_key,
2569            sqlite_path,
2570            publication_dir,
2571            false,
2572            generation,
2573            writer_lease,
2574            read_marker,
2575            conn,
2576        );
2577        Ok(OpenedStore { store, root_repair })
2578    }
2579
2580    fn prepare_for_atomic_swap(&self) -> Result<()> {
2581        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2582        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2583        Ok(())
2584    }
2585
2586    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2587        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2588            self.sqlite_path
2589                .parent()
2590                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2591        });
2592        if protected_reader {
2593            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2594        } else {
2595            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2596        }
2597    }
2598
2599    fn from_connection(
2600        project_root: PathBuf,
2601        project_key: String,
2602        sqlite_path: PathBuf,
2603        publication_dir: PathBuf,
2604        legacy_fallback: bool,
2605        generation: Option<String>,
2606        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2607        read_marker: Option<crate::root_cache::ReadMarker>,
2608        conn: Connection,
2609    ) -> Self {
2610        let write_metrics = callgraph_write_metrics_for_key(&project_key);
2611        Self {
2612            project_root,
2613            project_key,
2614            sqlite_path,
2615            publication_dir,
2616            legacy_fallback,
2617            generation,
2618            writer_lease,
2619            read_marker,
2620            database_ready: AtomicBool::new(false),
2621            write_metrics,
2622            conn: Mutex::new(conn),
2623        }
2624    }
2625
2626    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2627        if self.database_ready.load(AtomicOrdering::Acquire) {
2628            return Ok(());
2629        }
2630        ensure_database_ready(conn)?;
2631        self.database_ready.store(true, AtomicOrdering::Release);
2632        Ok(())
2633    }
2634
2635    pub fn project_root(&self) -> &Path {
2636        &self.project_root
2637    }
2638
2639    pub fn project_key(&self) -> &str {
2640        &self.project_key
2641    }
2642
2643    pub fn sqlite_path(&self) -> &Path {
2644        &self.sqlite_path
2645    }
2646
2647    /// The generation file named by the publication pointer when this store opened.
2648    pub(crate) fn projection_generation(&self) -> Option<&str> {
2649        self.generation.as_deref()
2650    }
2651
2652    /// Read the durable revision that changes in the same transaction as graph writes.
2653    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2654        self.refresh_read_marker()?;
2655        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2656        self.ensure_ready(&conn)?;
2657        projection_write_revision(&conn)
2658    }
2659
2660    /// Whether this store is reading from a legacy harness partition because
2661    /// the root-keyed store has not published a generation yet.
2662    pub fn is_legacy_fallback(&self) -> bool {
2663        self.legacy_fallback
2664    }
2665
2666    pub(crate) fn is_legacy_migration(&self) -> bool {
2667        self.generation.as_deref().is_some_and(|generation| {
2668            migration_generation_requires_manifest(generation)
2669                && migration_manifest_valid(&self.publication_dir, generation)
2670        })
2671    }
2672
2673    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2674        self.writer_lease.as_ref().map(|lease| lease.epoch())
2675    }
2676
2677    fn verify_writer_lease(&self) -> Result<()> {
2678        let Some(lease) = self.writer_lease.as_ref() else {
2679            return Err(CallGraphStoreError::Unavailable(
2680                "callgraph store opened read-only; write API is unavailable".to_string(),
2681            ));
2682        };
2683        verify_writer_lease(lease)
2684    }
2685
2686    fn refresh_read_marker(&self) -> Result<()> {
2687        if let Some(marker) = self.read_marker.as_ref() {
2688            marker.touch_if_due()?;
2689        }
2690        Ok(())
2691    }
2692
2693    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2694        self.write_metrics
2695            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2696    }
2697
2698    fn checkpoint_wal_truncate(&self) -> bool {
2699        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2700        checkpoint_wal_truncate(&conn)
2701    }
2702
2703    /// True if this store still reflects the currently-published generation.
2704    /// Cheap (one small pointer-file read). When false, another process (or a
2705    /// local cold rebuild) has published a newer generation and the holder
2706    /// should drop this store and reopen via the pointer to converge. A missing
2707    /// pointer keeps the current store (legacy DB still valid, or transient).
2708    pub fn is_current(&self) -> bool {
2709        let _ = self.refresh_read_marker();
2710        match (
2711            read_pointer(&self.publication_dir, &self.project_key),
2712            &self.generation,
2713        ) {
2714            // Even when both generations happen to have the same filename, the
2715            // root-keyed pointer names a different directory from the fallback.
2716            (Some(_), _) if self.legacy_fallback => false,
2717            (Some(published), Some(opened)) => &published == opened,
2718            // A generation now supersedes the legacy single-file DB we opened.
2719            (Some(_), None) => false,
2720            // No pointer: keep serving (legacy DB, or an anomalous pointer
2721            // removal where our open generation file is still valid).
2722            (None, _) => true,
2723        }
2724    }
2725
2726    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2727        self.cold_build_chunked(files, 0)
2728    }
2729
2730    pub fn cold_build_chunked(
2731        &self,
2732        files: &[PathBuf],
2733        chunk_size: usize,
2734    ) -> Result<ColdBuildStats> {
2735        let started = Instant::now();
2736        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2737        macro_rules! phase {
2738            ($label:expr, $t:expr) => {
2739                if bench {
2740                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2741                    let _ = std::io::Write::flush(&mut std::io::stderr());
2742                }
2743            };
2744        }
2745        let files = normalize_file_list(&self.project_root, files)?;
2746
2747        if chunk_size == 0 {
2748            let t = Instant::now();
2749            let build = build_extracts_parallel(&self.project_root, &files);
2750            phase!("extract_parallel", t);
2751            let extracts = build.extracts;
2752            let failures = build.failures;
2753            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2754
2755            let t = Instant::now();
2756            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2757            phase!("build_index", t);
2758            let t = Instant::now();
2759            let mut resolved_refs = Vec::new();
2760            for extract in &extracts {
2761                for raw_ref in &extract.raw_refs {
2762                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2763                }
2764            }
2765            phase!("resolve_refs", t);
2766            let ref_count = resolved_refs.len();
2767            let edge_count = resolved_refs
2768                .iter()
2769                .filter(|item| item.edge.is_some())
2770                .count();
2771
2772            let t = Instant::now();
2773            self.verify_writer_lease()?;
2774            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2775            let total_changes_before = conn.total_changes();
2776            let tx = conn.transaction()?;
2777            clear_tables(&tx)?;
2778            insert_meta(&tx)?;
2779            drop_cold_build_secondary_indexes(&tx)?;
2780            {
2781                let workspace_root = self.project_root.display().to_string();
2782                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2783                for extract in &extracts {
2784                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2785                }
2786                for failure in &failures {
2787                    insert_backend_state_prepared(
2788                        &mut inserts.backend_state,
2789                        &workspace_root,
2790                        &failure.rel_path,
2791                        failure
2792                            .freshness
2793                            .as_ref()
2794                            .map(|freshness| &freshness.content_hash),
2795                        "stale",
2796                    )?;
2797                }
2798                for resolved in &resolved_refs {
2799                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2800                }
2801            }
2802            create_cold_build_secondary_indexes(&tx)?;
2803            let supplemental_edge_count =
2804                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2805            set_meta_ready(&tx, true)?;
2806            tx.commit()?;
2807            self.record_commit(total_changes_before, &conn);
2808            phase!("sqlite_insert", t);
2809
2810            let elapsed_ms = started.elapsed().as_millis();
2811            crate::slog_info!(
2812                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2813                extracts.len(),
2814                node_count,
2815                ref_count,
2816                edge_count + supplemental_edge_count,
2817                elapsed_ms
2818            );
2819            return Ok(ColdBuildStats {
2820                files: extracts.len(),
2821                nodes: node_count,
2822                refs: ref_count,
2823                edges: edge_count + supplemental_edge_count,
2824                failed_files: failures
2825                    .into_iter()
2826                    .map(|failure| failure.rel_path)
2827                    .collect(),
2828                elapsed_ms,
2829            });
2830        }
2831
2832        // Chunked implementation: parse and resolve in batches to reduce peak
2833        // memory during cold build without changing the persisted graph.
2834        let t = Instant::now();
2835        self.verify_writer_lease()?;
2836        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2837        let total_changes_before = conn.total_changes();
2838        let tx = conn.transaction()?;
2839        clear_tables(&tx)?;
2840        insert_meta(&tx)?;
2841        drop_cold_build_secondary_indexes(&tx)?;
2842
2843        let mut all_raw_refs = Vec::new();
2844        let mut failures = Vec::new();
2845        let mut node_count = 0;
2846        let mut files_parsed = 0;
2847
2848        let mut persistent_call_data = Vec::new();
2849        let mut file_to_call_data_index = HashMap::new();
2850        let mut files_index = HashMap::new();
2851
2852        let workspace_root = self.project_root.display().to_string();
2853
2854        {
2855            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2856            for chunk in files.chunks(chunk_size) {
2857                let build = build_extracts_parallel(&self.project_root, chunk);
2858                failures.extend(build.failures.clone());
2859
2860                for extract in build.extracts {
2861                    files_parsed += 1;
2862                    node_count += extract.nodes.len();
2863                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2864
2865                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2866                    files_index.insert(extract.rel_path.clone(), db_file_index);
2867
2868                    persistent_call_data.push(extract.data);
2869                    let idx = persistent_call_data.len() - 1;
2870                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2871
2872                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2873                }
2874                for failure in &build.failures {
2875                    insert_backend_state_prepared(
2876                        &mut inserts.backend_state,
2877                        &workspace_root,
2878                        &failure.rel_path,
2879                        failure
2880                            .freshness
2881                            .as_ref()
2882                            .map(|freshness| &freshness.content_hash),
2883                        "stale",
2884                    )?;
2885                }
2886            }
2887        }
2888
2889        let mut caller_data = HashMap::new();
2890        for (rel_path, idx) in &file_to_call_data_index {
2891            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2892        }
2893        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2894        let index = ProjectIndex::from_parts(
2895            &self.project_root,
2896            files_index,
2897            caller_data,
2898            WorkspaceCratePrefixCache::default(),
2899        );
2900
2901        let mut resolved_refs = Vec::new();
2902        for (_, raw_refs) in all_raw_refs {
2903            for raw_ref in raw_refs {
2904                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2905            }
2906        }
2907
2908        let ref_count = resolved_refs.len();
2909        let edge_count = resolved_refs
2910            .iter()
2911            .filter(|item| item.edge.is_some())
2912            .count();
2913
2914        {
2915            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2916            for resolved in &resolved_refs {
2917                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2918            }
2919        }
2920        create_cold_build_secondary_indexes(&tx)?;
2921        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2922            &tx,
2923            &self.project_root,
2924            &indexed_caller_files,
2925            chunk_size,
2926        )?;
2927        set_meta_ready(&tx, true)?;
2928        bump_projection_write_revision(&tx)?;
2929        tx.commit()?;
2930        self.record_commit(total_changes_before, &conn);
2931        phase!("sqlite_insert", t);
2932
2933        let elapsed_ms = started.elapsed().as_millis();
2934        crate::slog_info!(
2935            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2936            files_parsed,
2937            node_count,
2938            ref_count,
2939            edge_count + supplemental_edge_count,
2940            elapsed_ms
2941        );
2942        Ok(ColdBuildStats {
2943            files: files_parsed,
2944            nodes: node_count,
2945            refs: ref_count,
2946            edges: edge_count + supplemental_edge_count,
2947            failed_files: failures
2948                .into_iter()
2949                .map(|failure| failure.rel_path)
2950                .collect(),
2951            elapsed_ms,
2952        })
2953    }
2954
2955    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2956        self.refresh_files_with_workspace_crate_prefix_cache(
2957            changed_files,
2958            WorkspaceCratePrefixCache::default(),
2959        )
2960    }
2961
2962    fn refresh_files_with_workspace_crate_prefix_cache(
2963        &self,
2964        changed_files: &[PathBuf],
2965        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2966    ) -> Result<IncrementalStats> {
2967        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2968            changed_files,
2969            workspace_crate_prefixes,
2970        )?;
2971        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2972            eprintln!("refresh_files phases: {}", profile.report());
2973        }
2974        Ok(stats)
2975    }
2976
2977    /// Run an incremental refresh and return phase timings for an offline store copy.
2978    #[doc(hidden)]
2979    pub fn refresh_files_profiled(
2980        &self,
2981        changed_files: &[PathBuf],
2982    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2983        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2984            changed_files,
2985            WorkspaceCratePrefixCache::default(),
2986        )
2987    }
2988
2989    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2990        &self,
2991        changed_files: &[PathBuf],
2992        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2993    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2994        let total_started = Instant::now();
2995        let mut profile = RefreshFilesProfile::default();
2996        self.verify_writer_lease()?;
2997        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2998        let total_changes_before = conn.total_changes();
2999        let tx = conn.transaction()?;
3000        ensure_database_ready(&tx)?;
3001        let mut changed = Vec::new();
3002        let mut surface_changed = BTreeSet::new();
3003        let mut deleted = BTreeSet::new();
3004        let mut own_refresh = BTreeSet::new();
3005        let mut candidate_own_refresh = BTreeSet::new();
3006        let mut unchanged_extracts = 0usize;
3007        let mut selected_ref_ids = BTreeSet::new();
3008        let mut selected_refs_by_caller = BTreeMap::new();
3009        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3010
3011        for input in changed_files {
3012            let abs_path = normalize_file_path(&self.project_root, input)?;
3013            let rel_path = relative_path(&self.project_root, &abs_path);
3014            changed.push(rel_path.clone());
3015            let old_row = load_file_row(&tx, &rel_path)?;
3016            if !abs_path.exists() {
3017                if old_row.is_some() {
3018                    surface_changed.insert(rel_path.clone());
3019                    deleted.insert(rel_path.clone());
3020                    let started = Instant::now();
3021                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3022                    profile.dependency_selection += started.elapsed();
3023                    record_dependent_refs(
3024                        &mut selected_ref_ids,
3025                        &mut selected_refs_by_caller,
3026                        dependent_refs,
3027                    );
3028                    let started = Instant::now();
3029                    delete_file_rows(&tx, &rel_path)?;
3030                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3031                    profile.row_deletes += started.elapsed();
3032                }
3033                continue;
3034            }
3035
3036            if let Some(row) = &old_row {
3037                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3038                    FreshnessVerdict::HotFresh => continue,
3039                    FreshnessVerdict::ContentFresh {
3040                        new_mtime,
3041                        new_size,
3042                    } => {
3043                        update_file_fresh_metadata(
3044                            &tx,
3045                            &self.project_root,
3046                            &rel_path,
3047                            &row.freshness.content_hash,
3048                            new_mtime,
3049                            new_size,
3050                        )?;
3051                        continue;
3052                    }
3053                    FreshnessVerdict::Deleted => {
3054                        surface_changed.insert(rel_path.clone());
3055                        deleted.insert(rel_path.clone());
3056                        let started = Instant::now();
3057                        let dependent_refs =
3058                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3059                        profile.dependency_selection += started.elapsed();
3060                        record_dependent_refs(
3061                            &mut selected_ref_ids,
3062                            &mut selected_refs_by_caller,
3063                            dependent_refs,
3064                        );
3065                        let started = Instant::now();
3066                        delete_file_rows(&tx, &rel_path)?;
3067                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3068                        profile.row_deletes += started.elapsed();
3069                        continue;
3070                    }
3071                    FreshnessVerdict::Stale => {}
3072                }
3073            }
3074
3075            let started = Instant::now();
3076            let extract = build_file_extract(&self.project_root, &abs_path)?;
3077            profile.parse += started.elapsed();
3078            let surface_is_changed = old_row
3079                .as_ref()
3080                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3081                .unwrap_or(true);
3082            if surface_is_changed {
3083                surface_changed.insert(rel_path.clone());
3084                let started = Instant::now();
3085                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3086                profile.dependency_selection += started.elapsed();
3087                record_dependent_refs(
3088                    &mut selected_ref_ids,
3089                    &mut selected_refs_by_caller,
3090                    dependent_refs,
3091                );
3092            }
3093            candidate_own_refresh.insert(rel_path.clone());
3094            changed_extracts.insert(rel_path, extract);
3095        }
3096
3097        let dependency_selected_refs = selected_ref_ids.len();
3098        let mut touched_callers: BTreeSet<String> =
3099            selected_refs_by_caller.keys().cloned().collect();
3100        touched_callers.extend(candidate_own_refresh.iter().cloned());
3101
3102        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3103        for rel_path in &touched_callers {
3104            if deleted.contains(rel_path) {
3105                continue;
3106            }
3107            if let Some(extract) = changed_extracts.get(rel_path) {
3108                caller_extracts.insert(rel_path.clone(), extract.clone());
3109                continue;
3110            }
3111            let abs_path = self.project_root.join(rel_path);
3112            if abs_path.exists() {
3113                let started = Instant::now();
3114                let extract = build_file_extract(&self.project_root, &abs_path)?;
3115                profile.dependent_parse += started.elapsed();
3116                caller_extracts.insert(rel_path.clone(), extract);
3117            }
3118        }
3119
3120        let started = Instant::now();
3121        let index = ProjectIndex::from_db_and_callers(
3122            &tx,
3123            &self.project_root,
3124            &caller_extracts,
3125            workspace_crate_prefixes,
3126        )?;
3127        profile.index_load += started.elapsed();
3128
3129        for rel_path in &candidate_own_refresh {
3130            let Some(extract) = changed_extracts.get(rel_path) else {
3131                continue;
3132            };
3133            if !write_amplification_baseline_enabled()
3134                && stored_extract_matches(&tx, rel_path, extract, &index)?
3135            {
3136                unchanged_extracts += 1;
3137                update_file_fresh_metadata(
3138                    &tx,
3139                    &self.project_root,
3140                    rel_path,
3141                    &extract.freshness.content_hash,
3142                    extract.freshness.mtime,
3143                    extract.freshness.size,
3144                )?;
3145                continue;
3146            }
3147
3148            own_refresh.insert(rel_path.clone());
3149            let started = Instant::now();
3150            delete_file_rows(&tx, rel_path)?;
3151            profile.row_deletes += started.elapsed();
3152            let started = Instant::now();
3153            insert_file_extract(&tx, &self.project_root, extract)?;
3154            profile.row_inserts += started.elapsed();
3155        }
3156
3157        let dependency_callers = touched_callers
3158            .iter()
3159            .filter(|rel_path| {
3160                !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3161            })
3162            .cloned()
3163            .collect::<Vec<_>>();
3164        for rel_path in dependency_callers {
3165            let Some(extract) = caller_extracts.get(&rel_path) else {
3166                continue;
3167            };
3168            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3169                continue;
3170            }
3171
3172            own_refresh.insert(rel_path.clone());
3173            let started = Instant::now();
3174            delete_file_rows(&tx, &rel_path)?;
3175            profile.row_deletes += started.elapsed();
3176            let started = Instant::now();
3177            insert_file_extract(&tx, &self.project_root, extract)?;
3178            profile.row_inserts += started.elapsed();
3179        }
3180        let started = Instant::now();
3181        for rel_path in &touched_callers {
3182            if deleted.contains(rel_path) {
3183                continue;
3184            }
3185            let Some(extract) = caller_extracts.get(rel_path) else {
3186                continue;
3187            };
3188            if own_refresh.contains(rel_path) {
3189                delete_refs_for_caller(&tx, rel_path)?;
3190                for raw_ref in &extract.raw_refs {
3191                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3192                    insert_resolved_ref(&tx, &resolved)?;
3193                }
3194                continue;
3195            }
3196
3197            let selected_for_caller = selected_refs_by_caller
3198                .get(rel_path)
3199                .cloned()
3200                .unwrap_or_default();
3201            delete_ref_ids(&tx, &selected_for_caller)?;
3202            for raw_ref in &extract.raw_refs {
3203                if selected_for_caller.contains(&raw_ref.ref_id) {
3204                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3205                    insert_resolved_ref(&tx, &resolved)?;
3206                }
3207            }
3208        }
3209        profile.ref_resolution += started.elapsed();
3210
3211        let started = Instant::now();
3212        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3213        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3214        profile.method_dispatch += started.elapsed();
3215
3216        bump_projection_write_revision(&tx)?;
3217        let started = Instant::now();
3218        commit_incremental_if_current(tx)?;
3219        self.record_commit(total_changes_before, &conn);
3220        profile.commit += started.elapsed();
3221        profile.total = total_started.elapsed();
3222        Ok((
3223            IncrementalStats {
3224                changed_files: changed,
3225                surface_changed: surface_changed.into_iter().collect(),
3226                deleted_files: deleted.into_iter().collect(),
3227                dependency_selected_refs,
3228                refreshed_own_files: own_refresh.len(),
3229                unchanged_extract_files: unchanged_extracts,
3230            },
3231            profile,
3232        ))
3233    }
3234
3235    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3236        self.cold_build(current_files)
3237    }
3238
3239    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3240        self.verify_writer_lease()?;
3241        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3242        let total_changes_before = conn.total_changes();
3243        let tx = conn.transaction()?;
3244        let mut marked = Vec::new();
3245        for path in files {
3246            let abs_path = normalize_file_path(&self.project_root, path)?;
3247            let rel_path = relative_path(&self.project_root, &abs_path);
3248            let freshness = cache_freshness::collect(&abs_path).ok();
3249            mark_backend_state(
3250                &tx,
3251                &self.project_root,
3252                &rel_path,
3253                freshness.as_ref().map(|freshness| &freshness.content_hash),
3254                "stale",
3255            )?;
3256            marked.push(rel_path);
3257        }
3258        bump_projection_write_revision(&tx)?;
3259        tx.commit()?;
3260        self.record_commit(total_changes_before, &conn);
3261        marked.sort();
3262        marked.dedup();
3263        Ok(marked)
3264    }
3265
3266    pub fn stale_files(&self) -> Result<Vec<String>> {
3267        self.refresh_read_marker()?;
3268        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3269        let mut stmt = conn.prepare(
3270            "SELECT DISTINCT file_path FROM backend_file_state
3271             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3272             ORDER BY file_path",
3273        )?;
3274        let rows = stmt.query_map(
3275            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3276            |row| row.get::<_, String>(0),
3277        )?;
3278        rows.collect::<std::result::Result<Vec<_>, _>>()
3279            .map_err(Into::into)
3280    }
3281
3282    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3283        self.refresh_read_marker()?;
3284        let rel_path = relative_path(
3285            &self.project_root,
3286            &normalize_file_path(&self.project_root, file)?,
3287        );
3288        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3289        conn.query_row(
3290            "SELECT status FROM backend_file_state
3291             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3292             ORDER BY updated_at DESC LIMIT 1",
3293            params![
3294                BACKEND_TREESITTER,
3295                self.project_root.display().to_string(),
3296                rel_path
3297            ],
3298            |row| row.get(0),
3299        )
3300        .optional()
3301        .map_err(Into::into)
3302    }
3303
3304    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3305        self.refresh_read_marker()?;
3306        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3307        self.ensure_ready(&conn)?;
3308        edge_snapshot_with_conn(&conn)
3309    }
3310
3311    pub fn indexed_file_count(&self) -> Result<usize> {
3312        self.refresh_read_marker()?;
3313        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3314        self.ensure_ready(&conn)?;
3315        indexed_file_count(&conn)
3316    }
3317
3318    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3319        self.refresh_read_marker()?;
3320        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3321        let rel_path = relative_path(&self.project_root, &abs_path);
3322        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3323        self.ensure_ready(&conn)?;
3324        resolve_node_for_rel(&conn, &rel_path, symbol)
3325    }
3326
3327    /// Return all positional nodes matching a legacy symbol query in a file.
3328    ///
3329    /// Consumers that need legacy compatibility can collapse these by
3330    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3331    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3332        self.refresh_read_marker()?;
3333        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3334        let rel_path = relative_path(&self.project_root, &abs_path);
3335        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3336        self.ensure_ready(&conn)?;
3337        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3338    }
3339
3340    /// Return all positional nodes matching a symbol query anywhere in the store.
3341    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3342        self.refresh_read_marker()?;
3343        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3344        self.ensure_ready(&conn)?;
3345        nodes_matching_symbol(&conn, symbol)
3346    }
3347
3348    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3349    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3350        self.refresh_read_marker()?;
3351        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3352        let rel_path = relative_path(&self.project_root, &abs_path);
3353        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3354        self.ensure_ready(&conn)?;
3355        direct_callers_for_tuple(&conn, &rel_path, symbol)
3356    }
3357
3358    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
3359    pub fn direct_callers_for_symbols(
3360        &self,
3361        targets: &[(String, String)],
3362    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3363        if targets.is_empty() {
3364            return Ok(HashMap::new());
3365        }
3366        self.refresh_read_marker()?;
3367        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3368        self.ensure_ready(&conn)?;
3369        direct_callers_for_tuples(&conn, targets)
3370    }
3371
3372    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3373    pub fn direct_caller_counts_of(
3374        &self,
3375        targets: &[(String, String)],
3376    ) -> Result<HashMap<(String, String), usize>> {
3377        if targets.is_empty() {
3378            return Ok(HashMap::new());
3379        }
3380        self.refresh_read_marker()?;
3381        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3382        self.ensure_ready(&conn)?;
3383        direct_caller_counts_for_tuples(&conn, targets)
3384    }
3385
3386    pub fn callers_of(
3387        &self,
3388        file_rel: &Path,
3389        symbol: &str,
3390        depth: usize,
3391    ) -> Result<StoreCallersResult> {
3392        let target = self.node_for(file_rel, symbol)?;
3393        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3394        self.ensure_ready(&conn)?;
3395        let effective_depth = depth.max(1);
3396        let mut visited = HashSet::new();
3397        let mut callers = Vec::new();
3398        let mut depth_limited = false;
3399        let mut truncated = 0usize;
3400        collect_callers_recursive(
3401            &conn,
3402            &target.file,
3403            &target.symbol,
3404            effective_depth,
3405            0,
3406            &mut visited,
3407            &mut callers,
3408            &mut depth_limited,
3409            &mut truncated,
3410        )?;
3411        Ok(StoreCallersResult {
3412            target,
3413            callers,
3414            scanned_files: indexed_file_count(&conn)?,
3415            depth_limited,
3416            truncated,
3417        })
3418    }
3419
3420    pub fn impact_of(
3421        &self,
3422        file_rel: &Path,
3423        symbol: &str,
3424        depth: usize,
3425    ) -> Result<StoreImpactResult> {
3426        let callers = self.callers_of(file_rel, symbol, depth)?;
3427        let target_parameters = callers
3428            .target
3429            .signature
3430            .as_deref()
3431            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3432            .unwrap_or_default();
3433        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3434        for site in &callers.callers {
3435            source_lines_by_file
3436                .entry(site.caller.file.clone())
3437                .or_insert_with(|| {
3438                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3439                });
3440        }
3441        let enriched = callers
3442            .callers
3443            .iter()
3444            .map(|site| StoreImpactCaller {
3445                site: site.clone(),
3446                signature: site.caller.signature.clone(),
3447                is_entry_point: site.caller.is_entry_point,
3448                call_expression: source_lines_by_file
3449                    .get(&site.caller.file)
3450                    .and_then(|lines| lines.as_ref())
3451                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3452                    .cloned(),
3453                parameters: site
3454                    .caller
3455                    .signature
3456                    .as_deref()
3457                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3458                    .unwrap_or_default(),
3459            })
3460            .collect();
3461        Ok(StoreImpactResult {
3462            target: callers.target,
3463            parameters: target_parameters,
3464            callers: enriched,
3465            depth_limited: callers.depth_limited,
3466            truncated: callers.truncated,
3467        })
3468    }
3469
3470    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3471        self.refresh_read_marker()?;
3472        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3473        self.ensure_ready(&conn)?;
3474        outgoing_calls_for_node(&conn, node)
3475    }
3476
3477    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3478    pub fn outgoing_calls_for_symbols(
3479        &self,
3480        sources: &[(String, String)],
3481    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3482        if sources.is_empty() {
3483            return Ok(HashMap::new());
3484        }
3485        self.refresh_read_marker()?;
3486        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3487        self.ensure_ready(&conn)?;
3488        outgoing_calls_for_symbol_tuples(&conn, sources)
3489    }
3490
3491    /// Return resolved direct self-call refs suppressed from the general edge table.
3492    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3493        self.refresh_read_marker()?;
3494        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3495        self.ensure_ready(&conn)?;
3496        resolved_self_calls_for_node(&conn, node)
3497    }
3498
3499    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3500        self.refresh_read_marker()?;
3501        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3502        self.ensure_ready(&conn)?;
3503        unresolved_calls_for_node(&conn, node)
3504    }
3505
3506    pub fn call_tree(
3507        &self,
3508        file_rel: &Path,
3509        symbol: &str,
3510        max_depth: usize,
3511    ) -> Result<callgraph::CallTreeNode> {
3512        let node = self.node_for(file_rel, symbol)?;
3513        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3514        self.ensure_ready(&conn)?;
3515        let mut visited = HashSet::new();
3516        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3517    }
3518
3519    pub fn trace_to(
3520        &self,
3521        file_rel: &Path,
3522        symbol: &str,
3523        max_depth: usize,
3524    ) -> Result<callgraph::TraceToResult> {
3525        let target = self.node_for(file_rel, symbol)?;
3526        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3527        self.ensure_ready(&conn)?;
3528        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3529
3530        #[derive(Clone)]
3531        struct PathElem {
3532            node: StoreNode,
3533        }
3534
3535        let initial = vec![PathElem {
3536            node: target.clone(),
3537        }];
3538        let mut complete_paths = Vec::new();
3539        if target.is_entry_point {
3540            complete_paths.push(initial.clone());
3541        }
3542
3543        let mut queue = vec![(initial, 0usize)];
3544        let mut max_depth_reached = false;
3545        let mut truncated_paths = 0usize;
3546
3547        while let Some((path, depth)) = queue.pop() {
3548            if depth >= effective_max {
3549                max_depth_reached = true;
3550                continue;
3551            }
3552            let Some(current) = path.last() else {
3553                continue;
3554            };
3555            let callers =
3556                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3557            if callers.is_empty() {
3558                if path.len() > 1 {
3559                    truncated_paths += 1;
3560                }
3561                continue;
3562            }
3563
3564            let mut has_new_path = false;
3565            for site in callers {
3566                if path.iter().any(|elem| {
3567                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3568                }) {
3569                    continue;
3570                }
3571                has_new_path = true;
3572                let mut new_path = path.clone();
3573                new_path.push(PathElem {
3574                    node: site.caller.clone(),
3575                });
3576                if site.caller.is_entry_point {
3577                    complete_paths.push(new_path.clone());
3578                }
3579                queue.push((new_path, depth + 1));
3580            }
3581            if !has_new_path && path.len() > 1 {
3582                truncated_paths += 1;
3583            }
3584        }
3585
3586        let mut paths: Vec<callgraph::TracePath> = complete_paths
3587            .into_iter()
3588            .map(|mut elems| {
3589                elems.reverse();
3590                let hops = elems
3591                    .iter()
3592                    .enumerate()
3593                    .map(|(index, elem)| callgraph::TraceHop {
3594                        symbol: elem.node.symbol.clone(),
3595                        file: elem.node.file.clone(),
3596                        line: elem.node.line,
3597                        signature: elem.node.signature.clone(),
3598                        is_entry_point: index == 0 && elem.node.is_entry_point,
3599                    })
3600                    .collect();
3601                callgraph::TracePath { hops }
3602            })
3603            .collect();
3604        paths.sort_by(|left, right| {
3605            let left_entry = left
3606                .hops
3607                .first()
3608                .map(|hop| hop.symbol.as_str())
3609                .unwrap_or("");
3610            let right_entry = right
3611                .hops
3612                .first()
3613                .map(|hop| hop.symbol.as_str())
3614                .unwrap_or("");
3615            left_entry
3616                .cmp(right_entry)
3617                .then(left.hops.len().cmp(&right.hops.len()))
3618        });
3619        let entry_points_found = paths
3620            .iter()
3621            .filter_map(|path| path.hops.first())
3622            .filter(|hop| hop.is_entry_point)
3623            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3624            .collect::<HashSet<_>>()
3625            .len();
3626
3627        Ok(callgraph::TraceToResult {
3628            target_symbol: target.symbol,
3629            target_file: target.file,
3630            total_paths: paths.len(),
3631            paths,
3632            entry_points_found,
3633            max_depth_reached,
3634            truncated_paths,
3635        })
3636    }
3637
3638    pub fn trace_to_symbol_candidates(
3639        &self,
3640        to_symbol: &str,
3641    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3642        self.refresh_read_marker()?;
3643        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3644        self.ensure_ready(&conn)?;
3645        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3646        for node in nodes_matching_symbol(&conn, to_symbol)? {
3647            candidates_by_file
3648                .entry(node.file)
3649                .and_modify(|line| *line = (*line).min(node.line))
3650                .or_insert(node.line);
3651        }
3652        let mut candidates: Vec<_> = candidates_by_file
3653            .into_iter()
3654            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3655            .collect();
3656        candidates
3657            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3658        Ok(candidates)
3659    }
3660
3661    pub fn trace_to_symbol(
3662        &self,
3663        file_rel: &Path,
3664        symbol: &str,
3665        to_symbol: &str,
3666        to_file: Option<&Path>,
3667        max_depth: usize,
3668    ) -> Result<callgraph::TraceToSymbolResult> {
3669        let origin = self.node_for(file_rel, symbol)?;
3670        let target_file = to_file
3671            .map(|path| normalize_file_path(&self.project_root, path))
3672            .transpose()?
3673            .map(|path| relative_path(&self.project_root, &path));
3674        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3675        self.ensure_ready(&conn)?;
3676        let effective_max = if max_depth == 0 {
3677            10
3678        } else {
3679            max_depth.min(16)
3680        };
3681
3682        let start_hop = trace_to_symbol_hop(&origin);
3683        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3684            return Ok(callgraph::TraceToSymbolResult {
3685                path: Some(vec![start_hop]),
3686                complete: true,
3687                reason: None,
3688            });
3689        }
3690
3691        let mut queue = VecDeque::new();
3692        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3693        let mut visited = HashSet::new();
3694        visited.insert((origin.file.clone(), origin.symbol.clone()));
3695        let mut max_depth_exhausted = false;
3696
3697        while let Some((current, path, depth)) = queue.pop_front() {
3698            let callees = outgoing_calls_for_node(&conn, &current)?
3699                .into_iter()
3700                .filter_map(|site| site.target)
3701                .collect::<Vec<_>>();
3702
3703            if depth >= effective_max {
3704                if callees
3705                    .iter()
3706                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3707                {
3708                    max_depth_exhausted = true;
3709                }
3710                continue;
3711            }
3712
3713            for callee in callees {
3714                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3715                    continue;
3716                }
3717                let mut next_path = path.clone();
3718                next_path.push(trace_to_symbol_hop(&callee));
3719                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3720                    return Ok(callgraph::TraceToSymbolResult {
3721                        path: Some(next_path),
3722                        complete: true,
3723                        reason: None,
3724                    });
3725                }
3726                queue.push_back((callee, next_path, depth + 1));
3727            }
3728        }
3729
3730        if max_depth_exhausted {
3731            Ok(callgraph::TraceToSymbolResult {
3732                path: None,
3733                complete: false,
3734                reason: Some("max_depth_exhausted".to_string()),
3735            })
3736        } else {
3737            Ok(callgraph::TraceToSymbolResult {
3738                path: None,
3739                complete: true,
3740                reason: Some("no_path_found".to_string()),
3741            })
3742        }
3743    }
3744}
3745
3746impl ReadonlyCallGraphStore {
3747    fn from_inner(inner: CallGraphStore) -> Self {
3748        Self { inner }
3749    }
3750
3751    pub fn project_root(&self) -> &Path {
3752        self.inner.project_root()
3753    }
3754
3755    pub fn project_key(&self) -> &str {
3756        self.inner.project_key()
3757    }
3758
3759    pub fn sqlite_path(&self) -> &Path {
3760        self.inner.sqlite_path()
3761    }
3762
3763    pub(crate) fn projection_generation(&self) -> Option<&str> {
3764        self.inner.projection_generation()
3765    }
3766
3767    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3768        self.inner.projection_write_revision()
3769    }
3770
3771    /// Report the open generation handle. SQLite-owned allocations are measured
3772    /// once by the process-wide SQLite allocator counters.
3773    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3774        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3775    }
3776
3777    /// Whether this reader is temporarily serving a legacy harness partition.
3778    pub fn is_legacy_fallback(&self) -> bool {
3779        self.inner.is_legacy_fallback()
3780    }
3781
3782    pub fn is_current(&self) -> bool {
3783        self.inner.is_current()
3784    }
3785
3786    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3787        self.inner.edge_snapshot()
3788    }
3789
3790    pub fn indexed_file_count(&self) -> Result<usize> {
3791        self.inner.indexed_file_count()
3792    }
3793
3794    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3795        self.inner.node_for(file_rel, symbol)
3796    }
3797
3798    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3799        self.inner.nodes_for(file_rel, symbol)
3800    }
3801
3802    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3803        self.inner.nodes_matching(symbol)
3804    }
3805
3806    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3807        self.inner.direct_callers_of(file_rel, symbol)
3808    }
3809
3810    pub fn direct_callers_for_symbols(
3811        &self,
3812        targets: &[(String, String)],
3813    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3814        self.inner.direct_callers_for_symbols(targets)
3815    }
3816
3817    pub fn direct_caller_counts_of(
3818        &self,
3819        targets: &[(String, String)],
3820    ) -> Result<HashMap<(String, String), usize>> {
3821        self.inner.direct_caller_counts_of(targets)
3822    }
3823
3824    pub fn callers_of(
3825        &self,
3826        file_rel: &Path,
3827        symbol: &str,
3828        depth: usize,
3829    ) -> Result<StoreCallersResult> {
3830        self.inner.callers_of(file_rel, symbol, depth)
3831    }
3832
3833    pub fn impact_of(
3834        &self,
3835        file_rel: &Path,
3836        symbol: &str,
3837        depth: usize,
3838    ) -> Result<StoreImpactResult> {
3839        self.inner.impact_of(file_rel, symbol, depth)
3840    }
3841
3842    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3843        self.inner.outgoing_calls_of(node)
3844    }
3845
3846    pub fn outgoing_calls_for_symbols(
3847        &self,
3848        sources: &[(String, String)],
3849    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3850        self.inner.outgoing_calls_for_symbols(sources)
3851    }
3852
3853    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3854        self.inner.resolved_self_calls_of(node)
3855    }
3856
3857    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3858        self.inner.unresolved_calls_of(node)
3859    }
3860
3861    pub fn call_tree(
3862        &self,
3863        file_rel: &Path,
3864        symbol: &str,
3865        depth: usize,
3866    ) -> Result<callgraph::CallTreeNode> {
3867        self.inner.call_tree(file_rel, symbol, depth)
3868    }
3869
3870    pub fn trace_to(
3871        &self,
3872        file_rel: &Path,
3873        symbol: &str,
3874        max_depth: usize,
3875    ) -> Result<callgraph::TraceToResult> {
3876        self.inner.trace_to(file_rel, symbol, max_depth)
3877    }
3878
3879    pub fn trace_to_symbol_candidates(
3880        &self,
3881        to_symbol: &str,
3882    ) -> Result<Vec<TraceToSymbolCandidate>> {
3883        self.inner.trace_to_symbol_candidates(to_symbol)
3884    }
3885
3886    pub fn trace_to_symbol(
3887        &self,
3888        file_rel: &Path,
3889        symbol: &str,
3890        to_symbol: &str,
3891        to_file: Option<&Path>,
3892        max_depth: usize,
3893    ) -> Result<callgraph::TraceToSymbolResult> {
3894        self.inner
3895            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3896    }
3897}
3898
3899impl CallGraphRead for CallGraphStore {
3900    fn project_root(&self) -> &Path {
3901        CallGraphStore::project_root(self)
3902    }
3903    fn project_key(&self) -> &str {
3904        CallGraphStore::project_key(self)
3905    }
3906    fn sqlite_path(&self) -> &Path {
3907        CallGraphStore::sqlite_path(self)
3908    }
3909    fn is_current(&self) -> bool {
3910        CallGraphStore::is_current(self)
3911    }
3912    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3913        CallGraphStore::edge_snapshot(self)
3914    }
3915    fn indexed_file_count(&self) -> Result<usize> {
3916        CallGraphStore::indexed_file_count(self)
3917    }
3918    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3919        CallGraphStore::node_for(self, file_rel, symbol)
3920    }
3921    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3922        CallGraphStore::nodes_for(self, file_rel, symbol)
3923    }
3924    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3925        CallGraphStore::nodes_matching(self, symbol)
3926    }
3927    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3928        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3929    }
3930    fn direct_callers_for_symbols(
3931        &self,
3932        targets: &[(String, String)],
3933    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3934        CallGraphStore::direct_callers_for_symbols(self, targets)
3935    }
3936    fn direct_caller_counts_of(
3937        &self,
3938        targets: &[(String, String)],
3939    ) -> Result<HashMap<(String, String), usize>> {
3940        CallGraphStore::direct_caller_counts_of(self, targets)
3941    }
3942    fn callers_of(
3943        &self,
3944        file_rel: &Path,
3945        symbol: &str,
3946        depth: usize,
3947    ) -> Result<StoreCallersResult> {
3948        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3949    }
3950    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3951        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3952    }
3953    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3954        CallGraphStore::outgoing_calls_of(self, node)
3955    }
3956    fn outgoing_calls_for_symbols(
3957        &self,
3958        sources: &[(String, String)],
3959    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3960        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3961    }
3962    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3963        CallGraphStore::resolved_self_calls_of(self, node)
3964    }
3965    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3966        CallGraphStore::unresolved_calls_of(self, node)
3967    }
3968    fn call_tree(
3969        &self,
3970        file_rel: &Path,
3971        symbol: &str,
3972        depth: usize,
3973    ) -> Result<callgraph::CallTreeNode> {
3974        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3975    }
3976    fn trace_to(
3977        &self,
3978        file_rel: &Path,
3979        symbol: &str,
3980        max_depth: usize,
3981    ) -> Result<callgraph::TraceToResult> {
3982        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3983    }
3984    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3985        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3986    }
3987    fn trace_to_symbol(
3988        &self,
3989        file_rel: &Path,
3990        symbol: &str,
3991        to_symbol: &str,
3992        to_file: Option<&Path>,
3993        max_depth: usize,
3994    ) -> Result<callgraph::TraceToSymbolResult> {
3995        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3996    }
3997}
3998
3999impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4000    fn project_root(&self) -> &Path {
4001        (**self).project_root()
4002    }
4003    fn project_key(&self) -> &str {
4004        (**self).project_key()
4005    }
4006    fn sqlite_path(&self) -> &Path {
4007        (**self).sqlite_path()
4008    }
4009    fn is_current(&self) -> bool {
4010        (**self).is_current()
4011    }
4012    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4013        (**self).edge_snapshot()
4014    }
4015    fn indexed_file_count(&self) -> Result<usize> {
4016        (**self).indexed_file_count()
4017    }
4018    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4019        (**self).node_for(file_rel, symbol)
4020    }
4021    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4022        (**self).nodes_for(file_rel, symbol)
4023    }
4024    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4025        (**self).nodes_matching(symbol)
4026    }
4027    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4028        (**self).direct_callers_of(file_rel, symbol)
4029    }
4030    fn direct_callers_for_symbols(
4031        &self,
4032        targets: &[(String, String)],
4033    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4034        (**self).direct_callers_for_symbols(targets)
4035    }
4036    fn direct_caller_counts_of(
4037        &self,
4038        targets: &[(String, String)],
4039    ) -> Result<HashMap<(String, String), usize>> {
4040        (**self).direct_caller_counts_of(targets)
4041    }
4042    fn callers_of(
4043        &self,
4044        file_rel: &Path,
4045        symbol: &str,
4046        depth: usize,
4047    ) -> Result<StoreCallersResult> {
4048        (**self).callers_of(file_rel, symbol, depth)
4049    }
4050    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4051        (**self).impact_of(file_rel, symbol, depth)
4052    }
4053    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4054        (**self).outgoing_calls_of(node)
4055    }
4056    fn outgoing_calls_for_symbols(
4057        &self,
4058        sources: &[(String, String)],
4059    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4060        (**self).outgoing_calls_for_symbols(sources)
4061    }
4062    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4063        (**self).resolved_self_calls_of(node)
4064    }
4065    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4066        (**self).unresolved_calls_of(node)
4067    }
4068    fn call_tree(
4069        &self,
4070        file_rel: &Path,
4071        symbol: &str,
4072        depth: usize,
4073    ) -> Result<callgraph::CallTreeNode> {
4074        (**self).call_tree(file_rel, symbol, depth)
4075    }
4076    fn trace_to(
4077        &self,
4078        file_rel: &Path,
4079        symbol: &str,
4080        max_depth: usize,
4081    ) -> Result<callgraph::TraceToResult> {
4082        (**self).trace_to(file_rel, symbol, max_depth)
4083    }
4084    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4085        (**self).trace_to_symbol_candidates(to_symbol)
4086    }
4087    fn trace_to_symbol(
4088        &self,
4089        file_rel: &Path,
4090        symbol: &str,
4091        to_symbol: &str,
4092        to_file: Option<&Path>,
4093        max_depth: usize,
4094    ) -> Result<callgraph::TraceToSymbolResult> {
4095        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4096    }
4097}
4098
4099impl CallGraphRead for ReadonlyCallGraphStore {
4100    fn project_root(&self) -> &Path {
4101        self.project_root()
4102    }
4103    fn project_key(&self) -> &str {
4104        self.project_key()
4105    }
4106    fn sqlite_path(&self) -> &Path {
4107        self.sqlite_path()
4108    }
4109    fn is_current(&self) -> bool {
4110        self.is_current()
4111    }
4112    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4113        self.edge_snapshot()
4114    }
4115    fn indexed_file_count(&self) -> Result<usize> {
4116        self.indexed_file_count()
4117    }
4118    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4119        self.node_for(file_rel, symbol)
4120    }
4121    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4122        self.nodes_for(file_rel, symbol)
4123    }
4124    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4125        self.nodes_matching(symbol)
4126    }
4127    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4128        self.direct_callers_of(file_rel, symbol)
4129    }
4130    fn direct_callers_for_symbols(
4131        &self,
4132        targets: &[(String, String)],
4133    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4134        self.direct_callers_for_symbols(targets)
4135    }
4136    fn direct_caller_counts_of(
4137        &self,
4138        targets: &[(String, String)],
4139    ) -> Result<HashMap<(String, String), usize>> {
4140        self.direct_caller_counts_of(targets)
4141    }
4142    fn callers_of(
4143        &self,
4144        file_rel: &Path,
4145        symbol: &str,
4146        depth: usize,
4147    ) -> Result<StoreCallersResult> {
4148        self.callers_of(file_rel, symbol, depth)
4149    }
4150    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4151        self.impact_of(file_rel, symbol, depth)
4152    }
4153    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4154        self.outgoing_calls_of(node)
4155    }
4156    fn outgoing_calls_for_symbols(
4157        &self,
4158        sources: &[(String, String)],
4159    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4160        self.outgoing_calls_for_symbols(sources)
4161    }
4162    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4163        self.resolved_self_calls_of(node)
4164    }
4165    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4166        self.unresolved_calls_of(node)
4167    }
4168    fn call_tree(
4169        &self,
4170        file_rel: &Path,
4171        symbol: &str,
4172        depth: usize,
4173    ) -> Result<callgraph::CallTreeNode> {
4174        self.call_tree(file_rel, symbol, depth)
4175    }
4176    fn trace_to(
4177        &self,
4178        file_rel: &Path,
4179        symbol: &str,
4180        max_depth: usize,
4181    ) -> Result<callgraph::TraceToResult> {
4182        self.trace_to(file_rel, symbol, max_depth)
4183    }
4184    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4185        self.trace_to_symbol_candidates(to_symbol)
4186    }
4187    fn trace_to_symbol(
4188        &self,
4189        file_rel: &Path,
4190        symbol: &str,
4191        to_symbol: &str,
4192        to_file: Option<&Path>,
4193        max_depth: usize,
4194    ) -> Result<callgraph::TraceToSymbolResult> {
4195        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4196    }
4197}
4198
4199fn indexed_file_count(conn: &Connection) -> Result<usize> {
4200    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4201    Ok(count.max(0) as usize)
4202}
4203
4204fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4205    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4206    match candidates.as_slice() {
4207        [candidate] => Ok(candidate.clone()),
4208        [] => Err(AftError::SymbolNotFound {
4209            name: symbol.to_string(),
4210            file: rel_path.to_string(),
4211        }
4212        .into()),
4213        _ => Err(AftError::AmbiguousSymbol {
4214            name: symbol.to_string(),
4215            candidates: candidates
4216                .iter()
4217                .map(|candidate| candidate.symbol.clone())
4218                .collect(),
4219        }
4220        .into()),
4221    }
4222}
4223
4224fn nodes_for_file_matching_symbol(
4225    conn: &Connection,
4226    rel_path: &str,
4227    symbol: &str,
4228) -> Result<Vec<StoreNode>> {
4229    let qualified_query = symbol.contains("::");
4230    let sql = if qualified_query {
4231        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4232                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4233         FROM nodes n JOIN files f ON f.path = n.file_path
4234         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4235         ORDER BY n.scoped_name, n.start_line, n.start_col"
4236    } else {
4237        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4238                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4239         FROM nodes n JOIN files f ON f.path = n.file_path
4240         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4241         ORDER BY n.scoped_name, n.start_line, n.start_col"
4242    };
4243    let mut stmt = conn.prepare(sql)?;
4244    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4245    rows.collect::<std::result::Result<Vec<_>, _>>()
4246        .map_err(Into::into)
4247}
4248
4249fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4250    let qualified_query = symbol.contains("::");
4251    let sql = if qualified_query {
4252        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4253                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4254         FROM nodes n JOIN files f ON f.path = n.file_path
4255         WHERE n.scoped_name = ?1
4256         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4257    } else {
4258        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4259                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4260         FROM nodes n JOIN files f ON f.path = n.file_path
4261         WHERE n.scoped_name = ?1 OR n.name = ?1
4262         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4263    };
4264    let mut stmt = conn.prepare(sql)?;
4265    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4266    rows.collect::<std::result::Result<Vec<_>, _>>()
4267        .map_err(Into::into)
4268}
4269
4270fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4271    store_node_from_row_at(row, 0)
4272}
4273
4274fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4275    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4276    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4277    let lang_label_value: String = row.get(offset + 10)?;
4278    Ok(StoreNode {
4279        node_id: row.get(offset)?,
4280        file: row.get(offset + 1)?,
4281        symbol: row.get(offset + 2)?,
4282        name: row.get(offset + 3)?,
4283        kind: row.get(offset + 4)?,
4284        line: start_line.saturating_add(1),
4285        end_line: end_line.saturating_add(1),
4286        signature: row.get(offset + 7)?,
4287        exported: row.get::<_, i64>(offset + 8)? != 0,
4288        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4289        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4290    })
4291}
4292
4293fn optional_store_node_from_row_at(
4294    row: &rusqlite::Row<'_>,
4295    offset: usize,
4296) -> rusqlite::Result<Option<StoreNode>> {
4297    if row.get::<_, Option<String>>(offset)?.is_some() {
4298        store_node_from_row_at(row, offset).map(Some)
4299    } else {
4300        Ok(None)
4301    }
4302}
4303
4304#[allow(clippy::too_many_arguments)]
4305fn collect_callers_recursive(
4306    conn: &Connection,
4307    file: &str,
4308    symbol: &str,
4309    max_depth: usize,
4310    current_depth: usize,
4311    visited: &mut HashSet<(String, String)>,
4312    result: &mut Vec<StoreCallSite>,
4313    depth_limited: &mut bool,
4314    truncated: &mut usize,
4315) -> Result<()> {
4316    if current_depth >= max_depth {
4317        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4318        if omitted > 0 {
4319            *depth_limited = true;
4320            *truncated += omitted;
4321        }
4322        return Ok(());
4323    }
4324
4325    if !visited.insert((file.to_string(), symbol.to_string())) {
4326        return Ok(());
4327    }
4328
4329    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4330    for site in sites {
4331        result.push(site.clone());
4332        if current_depth + 1 < max_depth {
4333            collect_callers_recursive(
4334                conn,
4335                &site.caller.file,
4336                &site.caller.symbol,
4337                max_depth,
4338                current_depth + 1,
4339                visited,
4340                result,
4341                depth_limited,
4342                truncated,
4343            )?;
4344        } else {
4345            let omitted =
4346                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4347            if omitted > 0 {
4348                *depth_limited = true;
4349                *truncated += omitted;
4350            }
4351        }
4352    }
4353    Ok(())
4354}
4355
4356// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4357const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4358
4359fn direct_caller_counts_for_tuples(
4360    conn: &Connection,
4361    targets: &[(String, String)],
4362) -> Result<HashMap<(String, String), usize>> {
4363    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4364    let mut counts = unique_targets
4365        .iter()
4366        .cloned()
4367        .map(|target| (target, 0usize))
4368        .collect::<HashMap<_, _>>();
4369
4370    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4371    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4372        let requested_values = (0..chunk.len())
4373            .map(|_| "(?, ?)")
4374            .collect::<Vec<_>>()
4375            .join(", ");
4376        let sql = format!(
4377            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4378             deduped AS (
4379                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4380                 FROM requested requested
4381                 JOIN edges e
4382                   ON e.target_file = requested.target_file
4383                  AND e.target_symbol = requested.target_symbol
4384                  AND e.kind = 'call'
4385                 JOIN refs r ON r.ref_id = e.ref_id
4386                 JOIN nodes src ON src.id = e.source_node
4387                 JOIN files src_file ON src_file.path = src.file_path
4388                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4389             )
4390             SELECT target_file, target_symbol, COUNT(*)
4391             FROM deduped
4392             GROUP BY target_file, target_symbol"
4393        );
4394        let bindings = chunk
4395            .iter()
4396            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4397        let mut stmt = conn.prepare(&sql)?;
4398        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4399            Ok((
4400                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4401                row.get::<_, i64>(2)?,
4402            ))
4403        })?;
4404        for row in rows {
4405            let (target, count) = row?;
4406            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4407        }
4408    }
4409
4410    Ok(counts)
4411}
4412
4413fn direct_caller_count_for_tuple(
4414    conn: &Connection,
4415    target_file: &str,
4416    target_symbol: &str,
4417) -> Result<usize> {
4418    let count: i64 = conn.query_row(
4419        "SELECT COUNT(*)
4420         FROM edges e
4421         JOIN refs r ON r.ref_id = e.ref_id
4422         JOIN nodes src ON src.id = e.source_node
4423         JOIN files src_file ON src_file.path = src.file_path
4424         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4425        params![target_file, target_symbol],
4426        |row| row.get(0),
4427    )?;
4428    Ok(usize::try_from(count).unwrap_or(usize::MAX))
4429}
4430
4431fn direct_callers_for_tuple(
4432    conn: &Connection,
4433    target_file: &str,
4434    target_symbol: &str,
4435) -> Result<Vec<StoreCallSite>> {
4436    let mut stmt = conn.prepare(
4437        "SELECT e.target_file, e.target_symbol, e.line,
4438                r.byte_start, r.byte_end, r.status, e.provenance,
4439                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4440                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4441                src_file.lang,
4442                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4443                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4444                tgt_file.lang
4445         FROM edges e
4446         JOIN refs r ON r.ref_id = e.ref_id
4447         JOIN nodes src ON src.id = e.source_node
4448         JOIN files src_file ON src_file.path = src.file_path
4449         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4450             ON tgt.id = e.target_node
4451         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4452         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4453    )?;
4454    let rows = stmt.query_map(
4455        params![target_file, target_symbol],
4456        direct_call_site_from_row,
4457    )?;
4458    rows.collect::<std::result::Result<Vec<_>, _>>()
4459        .map_err(Into::into)
4460}
4461
4462fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4463    let caller = store_node_from_row_at(row, 7)?;
4464    let target = optional_store_node_from_row_at(row, 18)?;
4465    Ok(StoreCallSite {
4466        caller,
4467        target_file: row.get(0)?,
4468        target_symbol: row.get(1)?,
4469        target,
4470        line: row.get::<_, i64>(2)?.max(0) as u32,
4471        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4472        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4473        resolved: row.get::<_, String>(5)? == "resolved",
4474        provenance: row.get(6)?,
4475    })
4476}
4477
4478fn direct_callers_for_tuples(
4479    conn: &Connection,
4480    targets: &[(String, String)],
4481) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4482    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4483    let mut callers_by_target = unique_targets
4484        .iter()
4485        .cloned()
4486        .map(|target| (target, Vec::new()))
4487        .collect::<HashMap<_, _>>();
4488    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4489
4490    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4491        let requested_values = (0..chunk.len())
4492            .map(|_| "(?, ?)")
4493            .collect::<Vec<_>>()
4494            .join(", ");
4495        let sql = format!(
4496            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4497             SELECT e.target_file, e.target_symbol, e.line,
4498                    r.byte_start, r.byte_end, r.status, e.provenance,
4499                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4500                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4501                    src_file.lang,
4502                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4503                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4504                    tgt_file.lang
4505             FROM requested requested
4506             JOIN edges e
4507               ON e.target_file = requested.target_file
4508              AND e.target_symbol = requested.target_symbol
4509              AND e.kind = 'call'
4510             JOIN refs r ON r.ref_id = e.ref_id
4511             JOIN nodes src ON src.id = e.source_node
4512             JOIN files src_file ON src_file.path = src.file_path
4513             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4514                 ON tgt.id = e.target_node
4515             ORDER BY e.target_file, e.target_symbol, e.source_node,
4516                      r.byte_start, r.line, r.ref_id"
4517        );
4518        let bindings = chunk
4519            .iter()
4520            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4521        let mut stmt = conn.prepare(&sql)?;
4522        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4523            let call = direct_call_site_from_row(row)?;
4524            let target_key = (call.target_file.clone(), call.target_symbol.clone());
4525            Ok((target_key, call))
4526        })?;
4527        for row in rows {
4528            let (target, call) = row?;
4529            callers_by_target
4530                .get_mut(&target)
4531                .expect("batched caller row belongs to a requested target")
4532                .push(call);
4533        }
4534    }
4535
4536    Ok(callers_by_target)
4537}
4538
4539// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4540const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4541// Outgoing-edge batches bind one source node per parameter.
4542const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4543
4544fn outgoing_calls_for_symbol_tuples(
4545    conn: &Connection,
4546    sources: &[(String, String)],
4547) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4548    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4549    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4550    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4551    let source_nodes = unique_sources
4552        .iter()
4553        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4554        .cloned()
4555        .collect::<Vec<_>>();
4556    let source_nodes_by_id = source_nodes
4557        .iter()
4558        .cloned()
4559        .map(|node| (node.node_id.clone(), node))
4560        .collect::<HashMap<_, _>>();
4561    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4562
4563    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4564        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4565        let sql = format!(
4566            "SELECT e.source_node,
4567                    e.target_file, e.target_symbol, e.line,
4568                    r.byte_start, r.byte_end, r.status, e.provenance,
4569                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4570                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4571                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4572                    tgt_file.lang
4573             FROM edges e
4574             JOIN refs r ON r.ref_id = e.ref_id
4575             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4576             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4577             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4578             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4579        );
4580        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4581        let mut stmt = conn.prepare(&sql)?;
4582        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4583            let source_node_id = row.get::<_, String>(0)?;
4584            let caller = source_nodes_by_id
4585                .get(&source_node_id)
4586                .expect("batched outgoing row belongs to a requested source node")
4587                .clone();
4588            let target = optional_store_node_from_row_at(row, 8)?;
4589            Ok((
4590                source_node_id,
4591                StoreCallSite {
4592                    caller,
4593                    target_file: row.get(1)?,
4594                    target_symbol: row.get(2)?,
4595                    target,
4596                    line: row.get::<_, i64>(3)?.max(0) as u32,
4597                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4598                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4599                    resolved: row.get::<_, String>(6)? == "resolved",
4600                    provenance: row.get(7)?,
4601                },
4602            ))
4603        })?;
4604        for row in rows {
4605            let (source_node_id, call) = row?;
4606            calls_by_node.entry(source_node_id).or_default().push(call);
4607        }
4608    }
4609
4610    let mut calls_by_source = HashMap::new();
4611    for source in &unique_sources {
4612        let mut calls = Vec::new();
4613        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4614            for node in nodes {
4615                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4616                    calls.extend(node_calls);
4617                }
4618            }
4619        }
4620        calls_by_source.insert(source.clone(), calls);
4621    }
4622
4623    // Resolve each logical target once for the whole frontier. Keeping this separate
4624    // preserves positional-symbol representatives without a correlated lookup per edge.
4625    let target_tuples = calls_by_source
4626        .values()
4627        .flatten()
4628        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4629        .collect::<Vec<_>>();
4630    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4631    for calls in calls_by_source.values_mut() {
4632        for call in calls {
4633            if let Some(target) = target_nodes
4634                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4635                .and_then(|nodes| nodes.first())
4636            {
4637                call.target = Some(target.clone());
4638            }
4639        }
4640    }
4641
4642    Ok(calls_by_source)
4643}
4644
4645fn nodes_for_symbol_tuples(
4646    conn: &Connection,
4647    symbols: &[(String, String)],
4648) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4649    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4650    let mut nodes_by_symbol = unique_symbols
4651        .iter()
4652        .cloned()
4653        .map(|symbol| (symbol, Vec::new()))
4654        .collect::<HashMap<_, _>>();
4655    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4656
4657    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4658        let requested_values = (0..chunk.len())
4659            .map(|_| "(?, ?)")
4660            .collect::<Vec<_>>()
4661            .join(", ");
4662        let sql = format!(
4663            "WITH requested(file, symbol) AS (VALUES {requested_values})
4664             SELECT requested.file, requested.symbol,
4665                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4666                    node.start_line, node.end_line, node.signature, node.exported,
4667                    node.is_callgraph_entry_point, node_file.lang
4668             FROM requested
4669             JOIN nodes node INDEXED BY idx_nodes_file
4670               ON node.file_path = requested.file
4671              AND node.scoped_name = requested.symbol
4672             JOIN files node_file ON node_file.path = node.file_path
4673             ORDER BY requested.file, requested.symbol,
4674                      node.scoped_name, node.start_line, node.end_line,
4675                      node.start_col, node.range_ordinal"
4676        );
4677        let bindings = chunk
4678            .iter()
4679            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4680        let mut stmt = conn.prepare(&sql)?;
4681        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4682            Ok((
4683                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4684                store_node_from_row_at(row, 2)?,
4685            ))
4686        })?;
4687        for row in rows {
4688            let (symbol, node) = row?;
4689            nodes_by_symbol.entry(symbol).or_default().push(node);
4690        }
4691    }
4692
4693    Ok(nodes_by_symbol)
4694}
4695
4696fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4697    let mut stmt = conn.prepare(
4698        "SELECT e.target_file, e.target_symbol, e.line,
4699                r.byte_start, r.byte_end, r.status, e.provenance,
4700                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4701                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4702                tgt_file.lang
4703         FROM edges e
4704         JOIN refs r ON r.ref_id = e.ref_id
4705         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4706             ON tgt.id = e.target_node
4707         WHERE e.kind = 'call' AND e.source_node = ?1
4708         ORDER BY r.byte_start, r.line, r.ref_id",
4709    )?;
4710    let rows = stmt.query_map(params![node.node_id], |row| {
4711        let target = optional_store_node_from_row_at(row, 7)?;
4712        Ok(StoreCallSite {
4713            caller: node.clone(),
4714            target_file: row.get(0)?,
4715            target_symbol: row.get(1)?,
4716            target,
4717            line: row.get::<_, i64>(2)?.max(0) as u32,
4718            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4719            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4720            resolved: row.get::<_, String>(5)? == "resolved",
4721            provenance: row.get(6)?,
4722        })
4723    })?;
4724    rows.collect::<std::result::Result<Vec<_>, _>>()
4725        .map_err(Into::into)
4726}
4727
4728fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4729    let mut stmt = conn.prepare(
4730        "SELECT r.target_file, r.target_symbol, r.line,
4731                r.byte_start, r.byte_end, r.status, r.provenance,
4732                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4733                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4734                tgt_file.lang
4735         FROM refs r
4736         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4737             ON tgt.id = r.target_node
4738         WHERE r.caller_node = ?1
4739           AND r.kind = 'call'
4740           AND r.status <> 'unresolved'
4741           AND r.target_file = ?2
4742           AND r.target_symbol = ?3
4743           AND r.provenance = ?4
4744           AND NOT EXISTS (
4745               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4746           )
4747         ORDER BY r.byte_start, r.line, r.ref_id",
4748    )?;
4749    let rows = stmt.query_map(
4750        params![
4751            &node.node_id,
4752            &node.file,
4753            &node.symbol,
4754            PROVENANCE_TREESITTER
4755        ],
4756        |row| {
4757            let target = optional_store_node_from_row_at(row, 7)?;
4758            Ok(StoreCallSite {
4759                caller: node.clone(),
4760                target_file: row.get(0)?,
4761                target_symbol: row.get(1)?,
4762                target,
4763                line: row.get::<_, i64>(2)?.max(0) as u32,
4764                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4765                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4766                resolved: row.get::<_, String>(5)? == "resolved",
4767                provenance: row.get(6)?,
4768            })
4769        },
4770    )?;
4771    rows.collect::<std::result::Result<Vec<_>, _>>()
4772        .map_err(Into::into)
4773}
4774
4775fn unresolved_calls_for_node(
4776    conn: &Connection,
4777    node: &StoreNode,
4778) -> Result<Vec<StoreUnresolvedCall>> {
4779    let mut stmt = conn.prepare(
4780        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4781         FROM refs
4782         WHERE caller_node = ?1
4783           AND kind = 'call'
4784           AND status = 'unresolved'
4785           AND NOT EXISTS (
4786               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4787           )
4788         ORDER BY byte_start, line, ref_id",
4789    )?;
4790    let rows = stmt.query_map(params![node.node_id], |row| {
4791        Ok(StoreUnresolvedCall {
4792            caller: node.clone(),
4793            symbol: row.get(0)?,
4794            full_ref: row.get(1)?,
4795            line: row.get::<_, i64>(2)?.max(0) as u32,
4796            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4797            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4798        })
4799    })?;
4800    rows.collect::<std::result::Result<Vec<_>, _>>()
4801        .map_err(Into::into)
4802}
4803
4804fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4805    let mut calls = Vec::new();
4806    calls.extend(
4807        outgoing_calls_for_node(conn, node)?
4808            .into_iter()
4809            .map(StoreForwardCall::Resolved),
4810    );
4811    calls.extend(
4812        unresolved_calls_for_node(conn, node)?
4813            .into_iter()
4814            .map(StoreForwardCall::Unresolved),
4815    );
4816    calls.sort_by(|left, right| {
4817        left.byte_start()
4818            .cmp(&right.byte_start())
4819            .then(left.line().cmp(&right.line()))
4820    });
4821    Ok(calls)
4822}
4823
4824fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4825    let resolved_count: i64 = conn.query_row(
4826        "SELECT COUNT(*)
4827         FROM edges e
4828         JOIN refs r ON r.ref_id = e.ref_id
4829         WHERE e.kind = 'call' AND e.source_node = ?1",
4830        params![&node.node_id],
4831        |row| row.get(0),
4832    )?;
4833    let unresolved_count: i64 = conn.query_row(
4834        "SELECT COUNT(*)
4835         FROM refs
4836         WHERE caller_node = ?1
4837           AND kind = 'call'
4838           AND status = 'unresolved'
4839           AND NOT EXISTS (
4840               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4841           )",
4842        params![&node.node_id],
4843        |row| row.get(0),
4844    )?;
4845    let total = resolved_count.saturating_add(unresolved_count);
4846    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4847}
4848
4849fn call_tree_inner(
4850    conn: &Connection,
4851    node: &StoreNode,
4852    max_depth: usize,
4853    current_depth: usize,
4854    visited: &mut HashSet<(String, String)>,
4855) -> Result<callgraph::CallTreeNode> {
4856    let visit_key = (node.file.clone(), node.symbol.clone());
4857    if visited.contains(&visit_key) {
4858        return Ok(callgraph::CallTreeNode {
4859            name: node.symbol.clone(),
4860            file: node.file.clone(),
4861            line: node.line,
4862            signature: node.signature.clone(),
4863            resolved: true,
4864            children: Vec::new(),
4865            depth_limited: false,
4866            truncated: 0,
4867        });
4868    }
4869    visited.insert(visit_key.clone());
4870
4871    let mut children = Vec::new();
4872    let mut depth_limited = false;
4873    let mut truncated = 0usize;
4874
4875    if current_depth < max_depth {
4876        let calls = forward_calls_for_node(conn, node)?;
4877        for call in calls {
4878            match call {
4879                StoreForwardCall::Resolved(site) => {
4880                    if let Some(target) = site.target {
4881                        let child =
4882                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4883                        depth_limited |= child.depth_limited;
4884                        truncated += child.truncated;
4885                        children.push(child);
4886                    } else {
4887                        children.push(callgraph::CallTreeNode {
4888                            name: site.target_symbol,
4889                            file: site.target_file,
4890                            line: site.line,
4891                            signature: None,
4892                            resolved: false,
4893                            children: Vec::new(),
4894                            depth_limited: false,
4895                            truncated: 0,
4896                        });
4897                    }
4898                }
4899                StoreForwardCall::Unresolved(call) => {
4900                    children.push(callgraph::CallTreeNode {
4901                        name: call.symbol,
4902                        file: call.caller.file,
4903                        line: call.line,
4904                        signature: None,
4905                        resolved: false,
4906                        children: Vec::new(),
4907                        depth_limited: false,
4908                        truncated: 0,
4909                    });
4910                }
4911            }
4912        }
4913    } else {
4914        truncated = forward_call_count_for_node(conn, node)?;
4915        depth_limited = truncated > 0;
4916    }
4917
4918    visited.remove(&visit_key);
4919    Ok(callgraph::CallTreeNode {
4920        name: node.symbol.clone(),
4921        file: node.file.clone(),
4922        line: node.line,
4923        signature: node.signature.clone(),
4924        resolved: true,
4925        children,
4926        depth_limited,
4927        truncated,
4928    })
4929}
4930
4931fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4932    callgraph::TraceToSymbolHop {
4933        symbol: node.symbol.clone(),
4934        file: node.file.clone(),
4935        line: node.line,
4936    }
4937}
4938
4939fn trace_to_symbol_matches_target(
4940    node: &StoreNode,
4941    to_symbol: &str,
4942    to_file: Option<&str>,
4943) -> bool {
4944    if !symbol_query_matches(&node.symbol, to_symbol) {
4945        return false;
4946    }
4947    match to_file {
4948        Some(file) => node.file == file,
4949        None => true,
4950    }
4951}
4952
4953fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4954    symbol == query || unqualified_name(symbol) == query
4955}
4956
4957fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4958    let source = std::fs::read_to_string(path).ok()?;
4959    Some(source.lines().map(|line| line.trim().to_string()).collect())
4960}
4961
4962#[doc(hidden)]
4963pub fn live_callgraph_edge_snapshot(
4964    project_root: &Path,
4965    files: &[PathBuf],
4966) -> Result<BTreeSet<StoredEdge>> {
4967    let files = normalize_file_list(project_root, files)?;
4968    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4969    let mut file_data = Vec::new();
4970    for file in &files {
4971        let canon = canonicalize_path(file);
4972        let data = graph.build_file(&canon)?.clone();
4973        file_data.push((canon, data));
4974    }
4975
4976    let mut edges = BTreeSet::new();
4977    for (caller_file, data) in &file_data {
4978        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4979            for call_site in call_sites {
4980                let resolution = graph.resolve_cross_file_edge(
4981                    &call_site.full_callee,
4982                    &call_site.callee_name,
4983                    caller_file,
4984                    &data.import_block,
4985                );
4986                let (target_file, target_symbol) = match resolution {
4987                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4988                    EdgeResolution::Unresolved { callee_name } => {
4989                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4990                            continue;
4991                        }
4992                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4993                            data,
4994                            caller_file,
4995                            &callee_name,
4996                        ) else {
4997                            continue;
4998                        };
4999                        (caller_file.clone(), target_symbol)
5000                    }
5001                };
5002                if target_file == *caller_file && target_symbol == *caller_symbol {
5003                    continue;
5004                }
5005                edges.insert(StoredEdge {
5006                    source_file: relative_path(project_root, caller_file),
5007                    source_symbol: caller_symbol.clone(),
5008                    target_file: relative_path(project_root, &target_file),
5009                    target_symbol,
5010                    kind: "call".to_string(),
5011                    line: call_site.line,
5012                });
5013            }
5014        }
5015    }
5016    Ok(edges)
5017}
5018
5019fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5020{
5021    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5022}
5023
5024fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5025    RebuildCooldownKey {
5026        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5027            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5028        project_key: project_key.to_string(),
5029    }
5030}
5031
5032fn rebuild_cooldown_denial(
5033    callgraph_dir: &Path,
5034    project_key: &str,
5035    project_root: &Path,
5036    now: Instant,
5037) -> Option<(PathBuf, Duration)> {
5038    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5039    let records = rebuild_cooldown_records()
5040        .lock()
5041        .unwrap_or_else(std::sync::PoisonError::into_inner);
5042    let record = records.get(&key)?;
5043    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5044        return None;
5045    }
5046    let elapsed = now.saturating_duration_since(record.published_at);
5047    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5048}
5049
5050fn record_successful_rebuild(
5051    callgraph_dir: &Path,
5052    project_key: &str,
5053    project_root: &Path,
5054    published_at: Instant,
5055) {
5056    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5057    let mut records = rebuild_cooldown_records()
5058        .lock()
5059        .unwrap_or_else(std::sync::PoisonError::into_inner);
5060    if records.len() >= 4_096 && !records.contains_key(&key) {
5061        if let Some(evict) = records.keys().next().cloned() {
5062            records.remove(&evict);
5063        }
5064    }
5065    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5066        previous.cross_root_cooldown_armed || previous.project_root != project_root
5067    });
5068    records.insert(
5069        key,
5070        RebuildCooldownRecord {
5071            project_root: project_root.to_path_buf(),
5072            published_at,
5073            cross_root_cooldown_armed,
5074        },
5075    );
5076}
5077
5078fn acquire_writer_lease(
5079    callgraph_dir: &Path,
5080    project_key: &str,
5081    project_root: &Path,
5082) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5083    crate::root_cache::WriterLease::acquire_shared(
5084        crate::root_cache::RootCacheDomain::Callgraph,
5085        callgraph_dir,
5086        project_key,
5087        project_root,
5088    )
5089    .map_err(CallGraphStoreError::from)
5090}
5091
5092fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5093    if lease.verify()? {
5094        Ok(())
5095    } else {
5096        Err(CallGraphStoreError::Unavailable(format!(
5097            "callgraph writer lease for key {} lost epoch {}; aborting write",
5098            lease.key(),
5099            lease.epoch()
5100        )))
5101    }
5102}
5103
5104fn legacy_migration_completion_line(
5105    project_key: &str,
5106    method: &str,
5107    legacy_bytes: u64,
5108    migrated_bytes: u64,
5109) -> String {
5110    format!(
5111        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5112    )
5113}
5114
5115fn log_legacy_migration_completion(
5116    project_key: &str,
5117    method: &str,
5118    legacy_bytes: u64,
5119    migrated_bytes: u64,
5120) {
5121    crate::slog_info!(
5122        "{}",
5123        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5124    );
5125}
5126
5127fn try_legacy_migration_or_fallback(
5128    callgraph_dir: &Path,
5129    project_root: &Path,
5130    project_key: &str,
5131    writer_lease: Arc<crate::root_cache::WriterLease>,
5132) -> Result<Option<CallGraphStore>> {
5133    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5134    if partitions.is_empty() {
5135        return Ok(None);
5136    }
5137
5138    for partition in &partitions {
5139        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5140            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5141                return open_legacy_fallback_store(
5142                    callgraph_dir,
5143                    project_root,
5144                    project_key,
5145                    &partitions,
5146                );
5147            }
5148            match publish_generation_copy_migration(
5149                callgraph_dir,
5150                project_key,
5151                &source,
5152                Arc::clone(&writer_lease),
5153            ) {
5154                Ok(published) => {
5155                    log_legacy_migration_completion(
5156                        project_key,
5157                        "generation_copy",
5158                        source.source_bytes,
5159                        published.migrated_bytes,
5160                    );
5161                    return CallGraphStore::open_generation(
5162                        callgraph_dir,
5163                        project_root.to_path_buf(),
5164                        project_key.to_string(),
5165                        published.generation,
5166                        writer_lease,
5167                    )
5168                    .map(Some);
5169                }
5170                Err(error) => {
5171                    crate::slog_warn!(
5172                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5173                        source.sqlite_path.display(),
5174                        error
5175                    );
5176                    return open_legacy_fallback_store(
5177                        callgraph_dir,
5178                        project_root,
5179                        project_key,
5180                        &partitions,
5181                    );
5182                }
5183            }
5184        }
5185
5186        if let Some(source) = current_legacy_generation(partition)? {
5187            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5188                return open_legacy_fallback_store(
5189                    callgraph_dir,
5190                    project_root,
5191                    project_key,
5192                    &partitions,
5193                );
5194            }
5195            match publish_backup_migration(
5196                callgraph_dir,
5197                project_key,
5198                &source,
5199                Arc::clone(&writer_lease),
5200            ) {
5201                Ok(published) => {
5202                    log_legacy_migration_completion(
5203                        project_key,
5204                        "sqlite_backup",
5205                        source.source_bytes,
5206                        published.migrated_bytes,
5207                    );
5208                    return CallGraphStore::open_generation(
5209                        callgraph_dir,
5210                        project_root.to_path_buf(),
5211                        project_key.to_string(),
5212                        published.generation,
5213                        writer_lease,
5214                    )
5215                    .map(Some);
5216                }
5217                Err(error) => {
5218                    crate::slog_warn!(
5219                        "root-keyed callgraph backup migration failed from {}: {}",
5220                        source.sqlite_path.display(),
5221                        error
5222                    );
5223                    return open_legacy_fallback_store(
5224                        callgraph_dir,
5225                        project_root,
5226                        project_key,
5227                        &partitions,
5228                    );
5229                }
5230            }
5231        }
5232    }
5233
5234    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5235}
5236
5237fn open_legacy_fallback_store(
5238    callgraph_dir: &Path,
5239    project_root: &Path,
5240    project_key: &str,
5241    partitions: &[LegacyCallgraphPartition],
5242) -> Result<Option<CallGraphStore>> {
5243    let Some(target) = first_ready_legacy_target(partitions)? else {
5244        return Ok(None);
5245    };
5246    crate::slog_warn!(
5247        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5248        target.partition.harness,
5249        target.sqlite_path.display()
5250    );
5251    let conn = open_readonly_connection(&target.sqlite_path)?;
5252    if !database_ready(&conn).unwrap_or(false) {
5253        return Ok(None);
5254    }
5255    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5256    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5257    Ok(Some(CallGraphStore::from_connection(
5258        project_root.to_path_buf(),
5259        project_key.to_string(),
5260        target.sqlite_path,
5261        callgraph_dir.to_path_buf(),
5262        true,
5263        target.generation,
5264        None,
5265        Some(read_marker),
5266        conn,
5267    )))
5268}
5269
5270fn migration_disk_floor_allows(
5271    source: &LegacyCallgraphTarget,
5272    callgraph_dir: &Path,
5273) -> Result<bool> {
5274    let available = migration_available_disk(callgraph_dir)?;
5275    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5276        source.source_bytes,
5277        available,
5278    );
5279    if decision.should_skip_copy() {
5280        crate::slog_warn!(
5281            "{}",
5282            decision.warning_message(&source.sqlite_path, callgraph_dir)
5283        );
5284        return Ok(false);
5285    }
5286    Ok(true)
5287}
5288
5289fn migration_available_disk(path: &Path) -> Result<u64> {
5290    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5291        return Ok(bytes);
5292    }
5293    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5294}
5295
5296fn legacy_callgraph_partitions(
5297    callgraph_dir: &Path,
5298    project_key: &str,
5299) -> Result<Vec<LegacyCallgraphPartition>> {
5300    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5301        return Ok(Vec::new());
5302    };
5303    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5304    let mut partitions = inventory
5305        .into_iter()
5306        .filter(|entry| {
5307            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5308                && entry.key == project_key
5309        })
5310        .map(|entry| {
5311            let dir = if entry.path.is_dir() {
5312                entry.path.clone()
5313            } else {
5314                entry
5315                    .path
5316                    .parent()
5317                    .map(Path::to_path_buf)
5318                    .unwrap_or_else(|| entry.path.clone())
5319            };
5320            LegacyCallgraphPartition {
5321                harness: entry.harness,
5322                dir,
5323                key: entry.key,
5324                bytes: entry.bytes,
5325                freshness: entry.callgraph_pointer_mtime,
5326            }
5327        })
5328        .collect::<Vec<_>>();
5329    partitions.sort_by(|left, right| {
5330        right
5331            .freshness
5332            .cmp(&left.freshness)
5333            .then_with(|| right.bytes.cmp(&left.bytes))
5334            .then_with(|| left.harness.cmp(&right.harness))
5335    });
5336    Ok(partitions)
5337}
5338
5339fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5340    let domain_dir = callgraph_dir.parent()?;
5341    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5342        return None;
5343    }
5344    domain_dir.parent().map(Path::to_path_buf)
5345}
5346
5347pub(crate) fn all_legacy_partitions_migrated_for_keys(
5348    callgraph_dir: &Path,
5349    configured_keys: &BTreeSet<String>,
5350) -> Result<bool> {
5351    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5352        return Ok(false);
5353    };
5354    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5355        .into_iter()
5356        .filter(|entry| {
5357            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5358                && configured_keys.contains(&entry.key)
5359        })
5360        .map(|entry| entry.key)
5361        .collect::<BTreeSet<_>>();
5362    if legacy_keys.is_empty() {
5363        return Ok(false);
5364    }
5365
5366    for key in legacy_keys {
5367        let migrated_dir = storage_root.join("callgraph").join(&key);
5368        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5369            return Ok(false);
5370        };
5371        if !migration_generation_requires_manifest(&generation)
5372            || !migration_manifest_valid(&migrated_dir, &generation)
5373        {
5374            return Ok(false);
5375        }
5376    }
5377    Ok(true)
5378}
5379
5380fn newest_superseded_legacy_generation(
5381    partition: &LegacyCallgraphPartition,
5382) -> Result<Option<LegacyCallgraphTarget>> {
5383    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5384        return Ok(None);
5385    };
5386    let prefix = format!("{}.g", partition.key);
5387    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5388        return Ok(None);
5389    };
5390    let mut candidates = Vec::new();
5391    for entry in entries.flatten() {
5392        let name = entry.file_name().to_string_lossy().to_string();
5393        if name == current
5394            || name.contains(".tmp.")
5395            || !name.starts_with(&prefix)
5396            || !name.ends_with(".sqlite")
5397        {
5398            continue;
5399        }
5400        let path = entry.path();
5401        if !db_path_ready(&path) {
5402            continue;
5403        }
5404        let modified = entry
5405            .metadata()
5406            .and_then(|metadata| metadata.modified())
5407            .unwrap_or(SystemTime::UNIX_EPOCH);
5408        candidates.push((modified, path, name));
5409    }
5410    candidates.sort_by(|left, right| right.0.cmp(&left.0));
5411    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5412        return Ok(None);
5413    };
5414    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5415    Ok(Some(LegacyCallgraphTarget {
5416        partition: partition.clone(),
5417        sqlite_path,
5418        generation: Some(generation),
5419        source_bytes,
5420        source_blake3: String::new(),
5421    }))
5422}
5423
5424fn current_legacy_generation(
5425    partition: &LegacyCallgraphPartition,
5426) -> Result<Option<LegacyCallgraphTarget>> {
5427    let Some(target) = ready_legacy_target(partition)? else {
5428        return Ok(None);
5429    };
5430    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5431    if has_superseded {
5432        return Ok(None);
5433    }
5434    Ok(Some(target))
5435}
5436
5437fn freshest_legacy_fallback_target(
5438    callgraph_dir: &Path,
5439    project_key: &str,
5440) -> Result<Option<LegacyCallgraphTarget>> {
5441    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5442    first_ready_legacy_target(&partitions)
5443}
5444
5445fn first_ready_legacy_target(
5446    partitions: &[LegacyCallgraphPartition],
5447) -> Result<Option<LegacyCallgraphTarget>> {
5448    for partition in partitions {
5449        if let Some(target) = ready_legacy_target(partition)? {
5450            return Ok(Some(target));
5451        }
5452    }
5453    Ok(None)
5454}
5455
5456fn ready_legacy_target(
5457    partition: &LegacyCallgraphPartition,
5458) -> Result<Option<LegacyCallgraphTarget>> {
5459    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5460        let sqlite_path = partition.dir.join(&generation);
5461        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5462            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5463            return Ok(Some(LegacyCallgraphTarget {
5464                partition: partition.clone(),
5465                sqlite_path,
5466                generation: Some(generation),
5467                source_bytes,
5468                source_blake3: String::new(),
5469            }));
5470        }
5471    }
5472
5473    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5474    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5475        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5476        return Ok(Some(LegacyCallgraphTarget {
5477            partition: partition.clone(),
5478            sqlite_path,
5479            generation: None,
5480            source_bytes,
5481            source_blake3: String::new(),
5482        }));
5483    }
5484    Ok(None)
5485}
5486
5487fn publish_generation_copy_migration(
5488    callgraph_dir: &Path,
5489    project_key: &str,
5490    source: &LegacyCallgraphTarget,
5491    writer_lease: Arc<crate::root_cache::WriterLease>,
5492) -> Result<PublishedLegacyMigration> {
5493    let generation = migration_generation_file_name(project_key, "copy");
5494    let temp_path = migration_temp_path(callgraph_dir, &generation);
5495    remove_sqlite_file_set(&temp_path);
5496    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5497    fail_after_temp_copy_for_test()?;
5498
5499    let mut source = source.clone();
5500    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5501    source.source_blake3 = fingerprint.blake3;
5502    let generation = publish_migrated_generation(
5503        callgraph_dir,
5504        project_key,
5505        &generation,
5506        &temp_path,
5507        &source,
5508        fingerprint.bytes,
5509        writer_lease,
5510        "generation_copy",
5511    )?;
5512    Ok(PublishedLegacyMigration {
5513        generation,
5514        migrated_bytes: fingerprint.bytes,
5515    })
5516}
5517
5518fn publish_backup_migration(
5519    callgraph_dir: &Path,
5520    project_key: &str,
5521    source: &LegacyCallgraphTarget,
5522    writer_lease: Arc<crate::root_cache::WriterLease>,
5523) -> Result<PublishedLegacyMigration> {
5524    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5525        return Err(CallGraphStoreError::Unavailable(
5526            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5527        ));
5528    }
5529
5530    let generation = migration_generation_file_name(project_key, "backup");
5531    let temp_path = migration_temp_path(callgraph_dir, &generation);
5532    remove_sqlite_file_set(&temp_path);
5533
5534    let source_conn = open_readonly_connection(&source.sqlite_path)?;
5535    let mut destination = Connection::open(&temp_path)?;
5536    destination.busy_timeout(Duration::from_secs(5))?;
5537    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5538    let started = Instant::now();
5539    let mut retries = 0;
5540    loop {
5541        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5542            rusqlite::backup::StepResult::Done => break,
5543            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5544            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5545                retries += 1;
5546                if retries > MIGRATION_BACKUP_RETRY_BUDGET
5547                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5548                {
5549                    return Err(CallGraphStoreError::Unavailable(format!(
5550                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5551                    )));
5552                }
5553                std::thread::sleep(Duration::from_millis(20));
5554            }
5555            _ => {
5556                return Err(CallGraphStoreError::Unavailable(
5557                    "legacy callgraph backup returned an unknown step result".to_string(),
5558                ));
5559            }
5560        }
5561    }
5562    drop(backup);
5563
5564    let integrity: String =
5565        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5566    if integrity != "ok" {
5567        return Err(CallGraphStoreError::Unavailable(format!(
5568            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5569        )));
5570    }
5571    if !database_ready(&destination)? {
5572        return Err(CallGraphStoreError::Unavailable(
5573            "legacy callgraph backup produced a database without ready metadata".to_string(),
5574        ));
5575    }
5576    destination.execute_batch("PRAGMA optimize;")?;
5577    drop(destination);
5578    sync_file(&temp_path)?;
5579    fail_after_temp_copy_for_test()?;
5580
5581    let mut source = source.clone();
5582    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5583    source.source_blake3 = fingerprint.blake3;
5584    let generation = publish_migrated_generation(
5585        callgraph_dir,
5586        project_key,
5587        &generation,
5588        &temp_path,
5589        &source,
5590        fingerprint.bytes,
5591        writer_lease,
5592        "sqlite_backup",
5593    )?;
5594    Ok(PublishedLegacyMigration {
5595        generation,
5596        migrated_bytes: fingerprint.bytes,
5597    })
5598}
5599
5600fn publish_migrated_generation(
5601    callgraph_dir: &Path,
5602    project_key: &str,
5603    generation: &str,
5604    temp_path: &Path,
5605    source: &LegacyCallgraphTarget,
5606    migrated_bytes: u64,
5607    writer_lease: Arc<crate::root_cache::WriterLease>,
5608    method: &str,
5609) -> Result<String> {
5610    let gen_path = callgraph_dir.join(generation);
5611    checkpoint_sqlite_before_publication(temp_path);
5612    let publication = publish_if_current(|| {
5613        verify_writer_lease(&writer_lease)?;
5614        remove_sqlite_file_set(&gen_path);
5615        rename_sqlite_file_set(temp_path, &gen_path)?;
5616        crate::fs_lock::sync_parent(&gen_path);
5617
5618        verify_writer_lease(&writer_lease)?;
5619        publish_pointer(callgraph_dir, project_key, generation)?;
5620        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5621        Ok(generation.to_string())
5622    });
5623    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5624        remove_sqlite_file_set(temp_path);
5625    }
5626    publication
5627}
5628
5629fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5630    if let Some(parent) = destination.parent() {
5631        std::fs::create_dir_all(parent)?;
5632    }
5633    for suffix in SQLITE_FILE_SET_SUFFIXES {
5634        let source_path = sqlite_file_set_path(source, suffix);
5635        if !source_path.is_file() {
5636            continue;
5637        }
5638        let destination_path = sqlite_file_set_path(destination, suffix);
5639        std::fs::copy(&source_path, &destination_path)?;
5640        sync_file(&destination_path)?;
5641    }
5642    Ok(())
5643}
5644
5645fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5646    for suffix in SQLITE_FILE_SET_SUFFIXES {
5647        let source_path = sqlite_file_set_path(source, suffix);
5648        if !source_path.exists() {
5649            continue;
5650        }
5651        let destination_path = sqlite_file_set_path(destination, suffix);
5652        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5653            let _ = std::fs::remove_file(&source_path);
5654            return Err(error.into());
5655        }
5656    }
5657    Ok(())
5658}
5659
5660fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5661    let mut bytes = 0_u64;
5662    for suffix in SQLITE_FILE_SET_SUFFIXES {
5663        let member = sqlite_file_set_path(path, suffix);
5664        if !member.is_file() {
5665            continue;
5666        }
5667        bytes = bytes.saturating_add(member.metadata()?.len());
5668    }
5669    Ok(bytes)
5670}
5671
5672fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5673    let mut hasher = blake3::Hasher::new();
5674    let mut bytes = 0_u64;
5675    let mut buffer = [0_u8; 64 * 1024];
5676    for suffix in SQLITE_FILE_SET_SUFFIXES {
5677        let member = sqlite_file_set_path(path, suffix);
5678        if !member.is_file() {
5679            continue;
5680        }
5681        hasher.update(suffix.as_bytes());
5682        let mut file = std::fs::File::open(&member)?;
5683        loop {
5684            let read = file.read(&mut buffer)?;
5685            if read == 0 {
5686                break;
5687            }
5688            bytes = bytes.saturating_add(read as u64);
5689            hasher.update(&buffer[..read]);
5690        }
5691    }
5692    Ok(SourceFingerprint {
5693        bytes,
5694        blake3: hash_to_hex(hasher.finalize()),
5695    })
5696}
5697
5698fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5699    if suffix.is_empty() {
5700        path.to_path_buf()
5701    } else {
5702        PathBuf::from(format!("{}{suffix}", path.display()))
5703    }
5704}
5705
5706fn sync_file(path: &Path) -> Result<()> {
5707    let file = std::fs::OpenOptions::new()
5708        .read(true)
5709        .write(true)
5710        .open(path)?;
5711    file.sync_all()?;
5712    Ok(())
5713}
5714
5715fn fail_after_temp_copy_for_test() -> Result<()> {
5716    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5717        return Err(CallGraphStoreError::Unavailable(
5718            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5719        ));
5720    }
5721    Ok(())
5722}
5723
5724fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5725    format!(
5726        "{project_key}.g{}.{}{}{}.sqlite",
5727        now_nanos(),
5728        std::process::id(),
5729        MIGRATION_GENERATION_TAG,
5730        method
5731    )
5732}
5733
5734fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5735    callgraph_dir.join(format!(
5736        "{generation}.tmp.{}.{}",
5737        std::process::id(),
5738        now_nanos()
5739    ))
5740}
5741
5742fn write_migration_manifest(
5743    callgraph_dir: &Path,
5744    generation: &str,
5745    source: &LegacyCallgraphTarget,
5746    migrated_bytes: u64,
5747    method: &str,
5748) -> Result<()> {
5749    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5750    let temp_path = manifest_path.with_extension(format!(
5751        "migration.json.tmp.{}.{}",
5752        std::process::id(),
5753        now_nanos()
5754    ));
5755    let manifest = serde_json::json!({
5756        "version": MIGRATION_MANIFEST_VERSION,
5757        "method": method,
5758        "target_generation": generation,
5759        "source_harness": source.partition.harness,
5760        "source_path": source.sqlite_path.display().to_string(),
5761        "source_generation": source.generation,
5762        "source_bytes": source.source_bytes,
5763        "source_blake3": source.source_blake3,
5764        "migrated_bytes": migrated_bytes,
5765    });
5766    {
5767        use std::io::Write as _;
5768        let mut file = std::fs::File::create(&temp_path)?;
5769        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5770        file.write_all(b"\n")?;
5771        file.sync_all()?;
5772    }
5773    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5774        let _ = std::fs::remove_file(&temp_path);
5775        return Err(error.into());
5776    }
5777    crate::fs_lock::sync_parent(&manifest_path);
5778    Ok(())
5779}
5780
5781fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5782    callgraph_dir.join(format!("{generation}.migration.json"))
5783}
5784
5785fn migration_generation_requires_manifest(generation: &str) -> bool {
5786    generation.contains(MIGRATION_GENERATION_TAG)
5787}
5788
5789fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5790    if !migration_generation_requires_manifest(generation) {
5791        return true;
5792    }
5793    let path = migration_manifest_path(callgraph_dir, generation);
5794    let Ok(bytes) = std::fs::read(path) else {
5795        return false;
5796    };
5797    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5798        return false;
5799    };
5800    value.get("version").and_then(serde_json::Value::as_u64)
5801        == Some(MIGRATION_MANIFEST_VERSION as u64)
5802        && value
5803            .get("target_generation")
5804            .and_then(serde_json::Value::as_str)
5805            == Some(generation)
5806        && value
5807            .get("source_bytes")
5808            .and_then(serde_json::Value::as_u64)
5809            .is_some_and(|bytes| bytes > 0)
5810        && value
5811            .get("source_blake3")
5812            .and_then(serde_json::Value::as_str)
5813            .is_some_and(|hash| hash.len() == 64)
5814}
5815
5816fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5817    let pointer_generation = read_pointer(callgraph_dir, project_key);
5818    if let Some(generation) = pointer_generation.as_deref() {
5819        if migration_generation_requires_manifest(generation)
5820            && !migration_manifest_valid(callgraph_dir, generation)
5821        {
5822            let path = callgraph_dir.join(generation);
5823            remove_sqlite_file_set(&path);
5824            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5825            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5826        }
5827    }
5828
5829    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5830        return;
5831    };
5832    for entry in entries.flatten() {
5833        let name = entry.file_name().to_string_lossy().to_string();
5834        let path = entry.path();
5835        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5836            let _ = std::fs::remove_file(path);
5837            continue;
5838        }
5839        if name.starts_with(&format!("{project_key}.g"))
5840            && name.ends_with(".sqlite")
5841            && name.contains(MIGRATION_GENERATION_TAG)
5842            && pointer_generation.as_deref() != Some(&name)
5843            && !migration_manifest_valid(callgraph_dir, &name)
5844        {
5845            remove_sqlite_file_set(&path);
5846            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5847        }
5848    }
5849    crate::fs_lock::sync_parent(callgraph_dir);
5850}
5851
5852fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5853    let mut hasher = blake3::Hasher::new();
5854    hasher.update(path.to_string_lossy().as_bytes());
5855    if let Some(generation) = generation {
5856        hasher.update(generation.as_bytes());
5857    }
5858    let digest = hash_to_hex(hasher.finalize());
5859    format!("legacy-{}", &digest[..16])
5860}
5861
5862fn open_readonly_connection(path: &Path) -> Result<Connection> {
5863    let uri = sqlite_readonly_uri(path);
5864    let conn = Connection::open_with_flags(
5865        &uri,
5866        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5867    )?;
5868    conn.pragma_update(
5869        None,
5870        "synchronous",
5871        if write_amplification_baseline_enabled() {
5872            "FULL"
5873        } else {
5874            "NORMAL"
5875        },
5876    )?;
5877    conn.busy_timeout(reader_busy_timeout())?;
5878    conn.execute_batch("PRAGMA query_only=ON;")?;
5879    Ok(conn)
5880}
5881
5882fn reader_busy_timeout() -> Duration {
5883    let jitter = (now_nanos() % 500) as u64;
5884    Duration::from_millis(250 + jitter)
5885}
5886
5887fn sqlite_readonly_uri(path: &Path) -> String {
5888    let raw = path.to_string_lossy().replace('\\', "/");
5889    let encoded = percent_encode_sqlite_uri_path(&raw);
5890    if raw.starts_with('/') {
5891        format!("file://{encoded}?mode=ro")
5892    } else if raw.as_bytes().get(1) == Some(&b':') {
5893        format!("file:///{encoded}?mode=ro")
5894    } else {
5895        format!("file:{encoded}?mode=ro")
5896    }
5897}
5898
5899fn percent_encode_sqlite_uri_path(path: &str) -> String {
5900    let mut encoded = String::with_capacity(path.len());
5901    for byte in path.bytes() {
5902        match byte {
5903            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5904                encoded.push(byte as char)
5905            }
5906            _ => encoded.push_str(&format!("%{byte:02X}")),
5907        }
5908    }
5909    encoded
5910}
5911
5912fn configure_connection(conn: &Connection) -> Result<()> {
5913    conn.pragma_update(None, "journal_mode", "WAL")?;
5914    let baseline = write_amplification_baseline_enabled();
5915    conn.pragma_update(
5916        None,
5917        "synchronous",
5918        if baseline { "FULL" } else { "NORMAL" },
5919    )?;
5920    conn.pragma_update(
5921        None,
5922        "wal_autocheckpoint",
5923        if baseline {
5924            1_000
5925        } else {
5926            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5927        },
5928    )?;
5929    conn.pragma_update(None, "busy_timeout", 5_000)?;
5930    Ok(())
5931}
5932
5933fn configure_build_connection(conn: &Connection) -> Result<()> {
5934    conn.pragma_update(None, "journal_mode", "DELETE")?;
5935    conn.pragma_update(
5936        None,
5937        "synchronous",
5938        if write_amplification_baseline_enabled() {
5939            "FULL"
5940        } else {
5941            "NORMAL"
5942        },
5943    )?;
5944    conn.pragma_update(None, "busy_timeout", 5_000)?;
5945    Ok(())
5946}
5947
5948/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
5949/// private temporary copy before publishing it; a busy reader is harmless because
5950/// the next publication or cleanup pass can retry without affecting the source.
5951fn checkpoint_sqlite_before_publication(path: &Path) {
5952    let Ok(conn) = Connection::open(path) else {
5953        return;
5954    };
5955    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5956    let _ = conn.busy_timeout(Duration::from_secs(5));
5957    let _ = checkpoint_wal_truncate(&conn);
5958}
5959
5960fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5961    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5962        row.get::<_, i64>(0)
5963    }) {
5964        Ok(0) => true,
5965        Ok(_) => false,
5966        Err(rusqlite::Error::SqliteFailure(error, _))
5967            if matches!(
5968                error.code,
5969                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5970            ) =>
5971        {
5972            false
5973        }
5974        Err(error) => {
5975            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5976            false
5977        }
5978    }
5979}
5980
5981fn initialize_schema(conn: &Connection) -> Result<()> {
5982    conn.execute_batch(
5983        "CREATE TABLE IF NOT EXISTS files (
5984            path                TEXT PRIMARY KEY,
5985            content_hash        TEXT NOT NULL,
5986            mtime_ns            INTEGER NOT NULL,
5987            size                INTEGER NOT NULL,
5988            lang                TEXT NOT NULL,
5989            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5990            is_public_api       INTEGER NOT NULL DEFAULT 0,
5991            surface_fingerprint TEXT NOT NULL,
5992            indexed_at          INTEGER NOT NULL
5993        );
5994
5995        CREATE TABLE IF NOT EXISTS nodes (
5996            id                         TEXT PRIMARY KEY,
5997            file_path                  TEXT NOT NULL,
5998            name                       TEXT NOT NULL,
5999            scoped_name                TEXT NOT NULL,
6000            kind                       TEXT NOT NULL,
6001            start_line                 INTEGER NOT NULL,
6002            start_col                  INTEGER NOT NULL,
6003            end_line                   INTEGER NOT NULL,
6004            end_col                    INTEGER NOT NULL,
6005            range_ordinal              INTEGER NOT NULL,
6006            signature                  TEXT,
6007            exported                   INTEGER NOT NULL,
6008            is_default_export          INTEGER NOT NULL,
6009            is_type_like               INTEGER NOT NULL,
6010            is_callgraph_entry_point   INTEGER NOT NULL,
6011            provenance                 TEXT NOT NULL,
6012            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6013        );
6014        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6015        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6016        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6017
6018        CREATE TABLE IF NOT EXISTS refs (
6019            ref_id          TEXT PRIMARY KEY,
6020            caller_node     TEXT,
6021            caller_file     TEXT NOT NULL,
6022            kind            TEXT NOT NULL,
6023            short_name      TEXT,
6024            full_ref        TEXT,
6025            module_path     TEXT,
6026            import_kind     TEXT,
6027            local_name      TEXT,
6028            requested_name  TEXT,
6029            namespace_alias TEXT,
6030            wildcard        INTEGER NOT NULL DEFAULT 0,
6031            line            INTEGER NOT NULL,
6032            byte_start      INTEGER NOT NULL,
6033            byte_end        INTEGER NOT NULL,
6034            status          TEXT NOT NULL,
6035            target_node     TEXT,
6036            target_file     TEXT,
6037            target_symbol   TEXT,
6038            provenance      TEXT NOT NULL
6039        );
6040        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6041        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6042        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6043        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6044        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6045
6046        CREATE TABLE IF NOT EXISTS file_dependencies (
6047            file_path   TEXT NOT NULL,
6048            dep_file    TEXT NOT NULL,
6049            PRIMARY KEY(file_path, dep_file)
6050        );
6051        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6052
6053        CREATE TABLE IF NOT EXISTS edges (
6054            edge_id       TEXT PRIMARY KEY,
6055            ref_id        TEXT NOT NULL,
6056            source_node   TEXT NOT NULL,
6057            target_node   TEXT,
6058            target_file   TEXT NOT NULL,
6059            target_symbol TEXT NOT NULL,
6060            kind          TEXT NOT NULL,
6061            line          INTEGER NOT NULL,
6062            provenance    TEXT NOT NULL
6063        );
6064        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6065        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6066        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6067        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6068
6069        CREATE TABLE IF NOT EXISTS dispatch_hints (
6070            id           TEXT PRIMARY KEY,
6071            method_name  TEXT NOT NULL,
6072            caller_node  TEXT NOT NULL,
6073            file         TEXT NOT NULL,
6074            line         INTEGER NOT NULL,
6075            byte_start   INTEGER NOT NULL,
6076            byte_end     INTEGER NOT NULL,
6077            provenance   TEXT NOT NULL
6078        );
6079        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6080
6081        CREATE TABLE IF NOT EXISTS type_ref_names (
6082            name TEXT PRIMARY KEY
6083        );
6084
6085        CREATE TABLE IF NOT EXISTS backend_file_state (
6086            backend        TEXT NOT NULL,
6087            workspace_root TEXT NOT NULL,
6088            file_path      TEXT NOT NULL,
6089            content_hash   TEXT NOT NULL,
6090            status         TEXT NOT NULL,
6091            updated_at     INTEGER NOT NULL,
6092            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6093        );
6094        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6095
6096        CREATE TABLE IF NOT EXISTS meta (
6097            k TEXT PRIMARY KEY,
6098            v TEXT NOT NULL
6099        );",
6100    )?;
6101    insert_meta(conn)?;
6102    Ok(())
6103}
6104
6105fn insert_meta(conn: &Connection) -> Result<()> {
6106    conn.execute(
6107        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6108        params![SCHEMA_VERSION.to_string()],
6109    )?;
6110    conn.execute(
6111        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6112        params![schema_fingerprint()],
6113    )?;
6114    conn.execute(
6115        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6116        [],
6117    )?;
6118    Ok(())
6119}
6120
6121/// Return the durable revision paired atomically with graph mutations. Stores
6122/// created by older binaries lack the revision row, so callers cannot detect
6123/// in-place graph changes and must not cache their snapshots.
6124fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6125    let revision: Option<String> = conn
6126        .query_row(
6127            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6128            [],
6129            |row| row.get(0),
6130        )
6131        .optional()?;
6132    revision
6133        .map(|revision| {
6134            revision.parse::<u64>().map_err(|error| {
6135                CallGraphStoreError::Unavailable(format!(
6136                    "callgraph projection write revision is invalid: {error}"
6137                ))
6138            })
6139        })
6140        .transpose()
6141}
6142
6143/// Advance the projection revision inside the graph mutation transaction so a
6144/// cached snapshot never survives an in-place refresh.
6145fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6146    tx.execute(
6147        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6148         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6149        [],
6150    )?;
6151    Ok(())
6152}
6153
6154fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6155    conn.execute(
6156        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6157        params![if ready { "1" } else { "0" }],
6158    )?;
6159    Ok(())
6160}
6161
6162fn database_ready(conn: &Connection) -> Result<bool> {
6163    let schema_version: Option<String> = conn
6164        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6165            row.get(0)
6166        })
6167        .optional()?;
6168    let fingerprint: Option<String> = conn
6169        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6170            row.get(0)
6171        })
6172        .optional()?;
6173    let ready: Option<String> = conn
6174        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6175        .optional()?;
6176
6177    let expected_schema = SCHEMA_VERSION.to_string();
6178    let expected_fingerprint = schema_fingerprint();
6179    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6180        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6181        && ready.as_deref() == Some("1"))
6182}
6183
6184fn ensure_database_ready(conn: &Connection) -> Result<()> {
6185    if database_ready(conn)? {
6186        Ok(())
6187    } else {
6188        Err(CallGraphStoreError::Unavailable(
6189            "database is missing, stale, or mid-build".to_string(),
6190        ))
6191    }
6192}
6193
6194fn schema_fingerprint() -> String {
6195    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6196    // edge sources, broader call extraction) even if the table SHAPE is
6197    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6198    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6199    let input =
6200        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6201    hash_to_hex(blake3::hash(input.as_bytes()))
6202}
6203
6204fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6205    tx.execute_batch(
6206        "DELETE FROM edges;
6207         DELETE FROM file_dependencies;
6208         DELETE FROM refs;
6209         DELETE FROM dispatch_hints;
6210         DELETE FROM type_ref_names;
6211         DELETE FROM backend_file_state;
6212         DELETE FROM nodes;
6213         DELETE FROM files;",
6214    )?;
6215    Ok(())
6216}
6217
6218fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6219    tx.execute_batch(
6220        "DROP INDEX IF EXISTS idx_nodes_file;
6221         DROP INDEX IF EXISTS idx_nodes_name;
6222         DROP INDEX IF EXISTS idx_nodes_scoped;
6223         DROP INDEX IF EXISTS idx_refs_short_name;
6224         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6225         DROP INDEX IF EXISTS idx_refs_caller_file;
6226         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6227         DROP INDEX IF EXISTS idx_refs_target_file;
6228         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6229         DROP INDEX IF EXISTS idx_edges_source_kind;
6230         DROP INDEX IF EXISTS idx_edges_target_kind;
6231         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6232         DROP INDEX IF EXISTS idx_edges_ref_id;
6233         DROP INDEX IF EXISTS idx_dispatch_hints_method;
6234         DROP INDEX IF EXISTS idx_backend_file_state_file;",
6235    )?;
6236    Ok(())
6237}
6238
6239fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6240    tx.execute_batch(
6241        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6242         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6243         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6244         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6245         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6246         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6247         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6248         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6249         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6250         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6251         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6252         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6253         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6254         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6255         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6256    )?;
6257    Ok(())
6258}
6259
6260const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6261    ("files", "path"),
6262    ("nodes", "file_path"),
6263    ("refs", "caller_file"),
6264    ("refs", "target_file"),
6265    ("file_dependencies", "file_path"),
6266    ("file_dependencies", "dep_file"),
6267    ("edges", "target_file"),
6268    ("dispatch_hints", "file"),
6269    ("backend_file_state", "file_path"),
6270];
6271
6272/// Reconcile `backend_file_state.workspace_root` when the opener's project root
6273/// differs from what is stored. The store key is the git-root commit hash, so
6274/// multiple live checkouts/clones share one on-disk generation.
6275///
6276/// Cheap in-place re-root is only safe when every previously stored root path is
6277/// gone from disk (true move/rename). If any stale root still exists, another
6278/// clone is still alive and rewriting metadata would ping-pong relative rows
6279/// between trees (possibly on different branches). We then return
6280/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
6281/// opener. That can make each clone rebuild on open when they alternate — bounded
6282/// by open frequency — but each rebuild is correct for its opener, unlike silent
6283/// cross-clone corruption.
6284fn reconcile_workspace_roots(
6285    conn: &mut Connection,
6286    project_root: &Path,
6287    allow_repair: bool,
6288) -> Result<OpenRootRepair> {
6289    let roots = stored_workspace_roots(conn)?;
6290    let current_root = project_root.display().to_string();
6291    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6292        return Ok(OpenRootRepair::None);
6293    }
6294
6295    if let Some(sample) = sample_absolute_data_path(conn)? {
6296        return Ok(OpenRootRepair::NeedsRebuild {
6297            previous_roots: roots,
6298            current_root,
6299            reason: format!("absolute store data path row {sample}"),
6300        });
6301    }
6302
6303    for stored_root in roots.iter() {
6304        if stored_root == &current_root {
6305            continue;
6306        }
6307        if Path::new(stored_root).exists() {
6308            let reason = format!(
6309                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6310            );
6311            return Ok(OpenRootRepair::NeedsRebuild {
6312                previous_roots: roots,
6313                current_root,
6314                reason,
6315            });
6316        }
6317    }
6318
6319    if !allow_repair {
6320        return Ok(OpenRootRepair::NeedsRebuild {
6321            previous_roots: roots,
6322            current_root,
6323            reason: "workspace root metadata requires deferred repair".to_string(),
6324        });
6325    }
6326
6327    publish_if_current(|| {
6328        let tx = conn.transaction()?;
6329        tx.execute(
6330            "UPDATE OR IGNORE backend_file_state
6331             SET workspace_root = ?1
6332             WHERE workspace_root <> ?1",
6333            params![&current_root],
6334        )?;
6335        tx.execute(
6336            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6337            params![&current_root],
6338        )?;
6339        tx.commit()?;
6340        Ok(())
6341    })?;
6342
6343    crate::slog_info!(
6344        "callgraph store re-rooted from {} to {}",
6345        roots.join(", "),
6346        current_root
6347    );
6348    Ok(OpenRootRepair::ReRooted)
6349}
6350
6351fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6352    let mut stmt = conn.prepare(
6353        "SELECT DISTINCT workspace_root
6354         FROM backend_file_state
6355         ORDER BY workspace_root",
6356    )?;
6357    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6358    rows.collect::<std::result::Result<Vec<_>, _>>()
6359        .map_err(Into::into)
6360}
6361
6362fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6363    for (table, column) in STORE_DATA_PATH_COLUMNS {
6364        let sql = format!(
6365            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6366        );
6367        let mut stmt = conn.prepare(&sql)?;
6368        let mut rows = stmt.query([])?;
6369        while let Some(row) = rows.next()? {
6370            let value: String = row.get(0)?;
6371            if stored_path_is_absolute(&value) {
6372                return Ok(Some(format!("{table}.{column}={value}")));
6373            }
6374        }
6375    }
6376    Ok(None)
6377}
6378
6379fn stored_path_is_absolute(value: &str) -> bool {
6380    if value.is_empty() {
6381        return false;
6382    }
6383    if Path::new(value).is_absolute() || value.starts_with('/') {
6384        return true;
6385    }
6386    let bytes = value.as_bytes();
6387    if bytes.len() >= 3
6388        && bytes[1] == b':'
6389        && (bytes[2] == b'/' || bytes[2] == b'\\')
6390        && bytes[0].is_ascii_alphabetic()
6391    {
6392        return true;
6393    }
6394    value.starts_with("\\\\") || value.starts_with("//")
6395}
6396
6397fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6398    if let OpenRootRepair::NeedsRebuild {
6399        previous_roots,
6400        current_root,
6401        reason,
6402    } = repair
6403    {
6404        crate::slog_info!(
6405            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6406            previous_roots.join(", "),
6407            current_root,
6408            reason
6409        );
6410    }
6411}
6412
6413/// Nanosecond clock used to make temp/generation file names unique.
6414fn now_nanos() -> u128 {
6415    SystemTime::now()
6416        .duration_since(UNIX_EPOCH)
6417        .unwrap_or(Duration::ZERO)
6418        .as_nanos()
6419}
6420
6421/// The pointer file `<dir>/<key>.current`. Its single line names the current
6422/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
6423/// can always be atomically replaced via rename even on Windows — Rust opens
6424/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
6425fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6426    callgraph_dir.join(format!("{project_key}.current"))
6427}
6428
6429/// The legacy single-file DB path used before the generation scheme. Still read
6430/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
6431/// build publishes a generation.
6432fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6433    callgraph_dir.join(format!("{project_key}.sqlite"))
6434}
6435
6436/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
6437/// cold build writes a brand-new generation file, so publishing NEVER replaces
6438/// a file another process holds open (the root Windows fix).
6439fn generation_file_name(project_key: &str) -> String {
6440    format!(
6441        "{project_key}.g{}.{}.sqlite",
6442        now_nanos(),
6443        std::process::id()
6444    )
6445}
6446
6447/// Read the pointer; returns the generation file name if present and non-empty.
6448fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6449    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6450    let name = text.trim();
6451    if name.is_empty() {
6452        None
6453    } else {
6454        Some(name.to_string())
6455    }
6456}
6457
6458/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
6459/// `ready` flag). Uses a throwaway read-only connection.
6460fn db_path_ready(path: &Path) -> bool {
6461    (|| -> Result<bool> {
6462        let conn = open_readonly_connection(path)?;
6463        database_ready(&conn)
6464    })()
6465    .unwrap_or(false)
6466}
6467
6468/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
6469/// where `generation` is `Some(name)` for a pointer-published generation or
6470/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
6471/// published (caller treats that as "needs cold build").
6472///
6473/// Handles the GC race (the pointer names a generation that was just deleted) by
6474/// re-reading the pointer and retrying a few times.
6475fn resolve_ready_target(
6476    callgraph_dir: &Path,
6477    project_key: &str,
6478) -> Option<(PathBuf, Option<String>)> {
6479    for _ in 0..5 {
6480        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6481            let gen_path = callgraph_dir.join(&generation);
6482            if gen_path.is_file() {
6483                return (migration_manifest_valid(callgraph_dir, &generation)
6484                    && db_path_ready(&gen_path))
6485                .then_some((gen_path, Some(generation)));
6486            }
6487            // Pointer names a missing generation (a GC/publish race): re-read the
6488            // pointer and retry rather than failing the reader.
6489            std::thread::sleep(Duration::from_millis(5));
6490            continue;
6491        }
6492        // No pointer: fall back to the legacy single-file DB if it is ready.
6493        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6494        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6495    }
6496    None
6497}
6498
6499/// Atomically publish `generation` as the current store by flipping the pointer
6500/// file. Writes a temp file, fsyncs, then renames over the pointer — never
6501/// replacing an open DB file, so it succeeds cross-platform.
6502fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6503    let pointer = pointer_path(callgraph_dir, project_key);
6504    let tmp = callgraph_dir.join(format!(
6505        "{project_key}.current.tmp.{}.{}",
6506        std::process::id(),
6507        now_nanos()
6508    ));
6509    {
6510        use std::io::Write as _;
6511        let mut file = std::fs::File::create(&tmp)?;
6512        file.write_all(generation.as_bytes())?;
6513        file.write_all(b"\n")?;
6514        file.sync_all()?;
6515    }
6516    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6517        let _ = std::fs::remove_file(&tmp);
6518        return Err(error.into());
6519    }
6520    crate::fs_lock::sync_parent(&pointer);
6521    Ok(())
6522}
6523
6524#[derive(Clone, Debug)]
6525struct GenerationGcCandidate {
6526    name: String,
6527    path: PathBuf,
6528    modified: SystemTime,
6529}
6530
6531/// Best-effort GC of superseded generation files. The current pointer target and
6532/// newest previous generation are always retained. Older generations are removed
6533/// when they have no protected read marker, or after the absolute retention TTL
6534/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
6535/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
6536fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6537    let temp_grace = Duration::from_secs(60);
6538    let now = SystemTime::now();
6539    let pointer_current =
6540        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6541    let gen_prefix = format!("{project_key}.g");
6542    let tmp_prefixes = [
6543        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
6544        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
6545        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
6546    ];
6547    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6548        return;
6549    };
6550    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6551    for entry in entries.flatten() {
6552        let name = entry.file_name();
6553        let name = name.to_string_lossy().to_string();
6554        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6555        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6556
6557        // Orphaned temp files from a crashed build/publish: remove once aged out.
6558        if name.contains(".tmp.") {
6559            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6560                let _ = std::fs::remove_file(entry.path());
6561            }
6562            continue;
6563        }
6564
6565        // Superseded legacy single-file DB: best-effort delete once a generation
6566        // is published (ignored if another process still holds it open).
6567        if name == format!("{project_key}.sqlite") {
6568            remove_sqlite_file_set(&entry.path());
6569            continue;
6570        }
6571
6572        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6573            gens.push(GenerationGcCandidate {
6574                name,
6575                path: entry.path(),
6576                modified: mtime,
6577            });
6578        }
6579    }
6580
6581    let mut superseded = gens
6582        .iter()
6583        .filter(|generation| generation.name != pointer_current)
6584        .collect::<Vec<_>>();
6585    superseded.sort_by(|left, right| {
6586        right
6587            .modified
6588            .cmp(&left.modified)
6589            .then_with(|| right.name.cmp(&left.name))
6590    });
6591    let previous = superseded.first().map(|generation| generation.name.clone());
6592
6593    for generation in gens {
6594        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6595        if generation.name == pointer_current
6596            || Some(generation.name.as_str()) == previous.as_deref()
6597        {
6598            continue;
6599        }
6600
6601        let age = now
6602            .duration_since(generation.modified)
6603            .unwrap_or(Duration::ZERO);
6604        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6605            continue;
6606        }
6607
6608        remove_sqlite_file_set(&generation.path);
6609        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6610        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6611            callgraph_dir,
6612            &generation.name,
6613        ));
6614    }
6615}
6616
6617fn remove_sqlite_file_set(path: &Path) {
6618    let _ = std::fs::remove_file(path);
6619    remove_sqlite_sidecars(path);
6620}
6621
6622fn remove_sqlite_sidecars(path: &Path) {
6623    let path_text = path.to_string_lossy();
6624    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6625    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6626    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6627}
6628
6629/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
6630///
6631/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
6632/// place on success; a build that dies (process kill, crash, host restart) leaves
6633/// the temporary behind. The largest observed cold build finishes well under a
6634/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
6635/// never rename. A live build's temporary is minutes old at most.
6636///
6637/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
6638/// liveness check read false-positive on exactly the oldest files — the ones most
6639/// worth deleting: in production an orphan's embedded pid had been recycled to an
6640/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
6641/// cannot lie that way, so it is the honest orphan predicate.
6642const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6643
6644/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
6645/// same cadence as [`gc_old_generations`] (after a generation is published) but,
6646/// unlike it, is not scoped to the building root: it covers every directory in the
6647/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
6648///
6649/// That last case is the production hole this fixes. The per-root cleanup in
6650/// [`gc_old_generations`] only fires when a root actually builds, so when activity
6651/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
6652/// store's orphans become permanent — gigabytes accumulated in a legacy store
6653/// whose roots no longer built there, while the active store stayed clean. A
6654/// sibling root that still builds triggers this pass and cleans both layouts.
6655fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6656    sweep_orphaned_build_temps(callgraph_dir);
6657    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6658        return;
6659    };
6660    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6661
6662    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
6663    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6664        for entry in entries.flatten() {
6665            if entry.path().is_dir() {
6666                sweep_orphaned_build_temps(&entry.path());
6667            }
6668        }
6669    }
6670
6671    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
6672    if let Ok(entries) = std::fs::read_dir(&storage_root) {
6673        for entry in entries.flatten() {
6674            let legacy_dir = entry.path().join(domain);
6675            if legacy_dir.is_dir() {
6676                sweep_orphaned_build_temps(&legacy_dir);
6677            }
6678        }
6679    }
6680}
6681
6682/// Sweep one callgraph directory, removing build temporaries older than
6683/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
6684fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6685    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6686}
6687
6688/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
6689/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
6690fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6691    let now = SystemTime::now();
6692    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6693        return;
6694    };
6695    let mut removed_any = false;
6696    for entry in entries.flatten() {
6697        let name = entry.file_name().to_string_lossy().to_string();
6698        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
6699        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
6700        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
6701        // without that substring — a completed `.sqlite` generation, a pointer, a
6702        // read-marker dir — is left alone: those belong to generation GC.
6703        if !name.contains(".sqlite.tmp.") {
6704            continue;
6705        }
6706        let mtime = entry
6707            .metadata()
6708            .and_then(|meta| meta.modified())
6709            .unwrap_or(now);
6710        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6711            continue;
6712        }
6713        // Deletion races a concurrent build finishing: that build renames the temp
6714        // into place, so the file is gone by the time we unlink. The 24h age makes
6715        // this overlap practically impossible, but treat a missing file as success
6716        // (the rename won) rather than an error, and never touch a path that does
6717        // not match the temporary shape above.
6718        match std::fs::remove_file(entry.path()) {
6719            Ok(()) => removed_any = true,
6720            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6721            Err(_) => {}
6722        }
6723    }
6724    if removed_any {
6725        crate::fs_lock::sync_parent(callgraph_dir);
6726    }
6727}
6728
6729/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6730/// the global all-cores rayon pool. The store cold-build is the heaviest
6731/// background pass (parse-dominated) and runs on a separate thread off the
6732/// single-threaded request loop; left unbounded it monopolizes every core and
6733/// starves the bridge so interactive tools time out (the same starvation the
6734/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6735/// match the main thread, since the extract walks tree-sitter ASTs.
6736fn build_pool_size() -> usize {
6737    std::thread::available_parallelism()
6738        .map(|parallelism| parallelism.get())
6739        .unwrap_or(1)
6740        .div_ceil(2)
6741        .clamp(1, 8)
6742}
6743
6744fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6745    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6746        Ok(extract) => Ok(extract),
6747        Err(error) => {
6748            let abs_path =
6749                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6750            let rel_path = relative_path(project_root, &abs_path);
6751            let freshness = cache_freshness::collect(&abs_path).ok();
6752            log::debug!(
6753                "callgraph store: skipping {} during cold build: {}",
6754                abs_path.display(),
6755                error
6756            );
6757            Err(ExtractFailure {
6758                rel_path,
6759                freshness,
6760            })
6761        }
6762    };
6763
6764    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6765        files.par_iter().map(extract_one).collect()
6766    };
6767
6768    // Run inside a dedicated bounded pool when one builds; fall back to the
6769    // global pool only if the bounded pool can't be constructed.
6770    let results = match rayon::ThreadPoolBuilder::new()
6771        .num_threads(build_pool_size())
6772        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6773        .stack_size(8 * 1024 * 1024)
6774        .build()
6775    {
6776        Ok(pool) => pool.install(run),
6777        Err(error) => {
6778            log::warn!(
6779                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6780            );
6781            run()
6782        }
6783    };
6784
6785    let mut extracts = Vec::new();
6786    let mut failures = Vec::new();
6787    for result in results {
6788        match result {
6789            Ok(extract) => extracts.push(extract),
6790            Err(failure) => failures.push(failure),
6791        }
6792    }
6793    BuildExtractsResult { extracts, failures }
6794}
6795
6796fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6797    let metadata = std::fs::metadata(path)?;
6798    let size = metadata.len();
6799    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6800        cache_freshness::zero_hash()
6801    } else if source.len() as u64 == size {
6802        cache_freshness::hash_bytes(source.as_bytes())
6803    } else {
6804        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6805    };
6806    Ok(FileFreshness {
6807        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6808        size,
6809        content_hash,
6810    })
6811}
6812
6813fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6814    let abs_path = normalize_file_path(project_root, path)?;
6815    let rel_path = relative_path(project_root, &abs_path);
6816    let source = std::fs::read_to_string(&abs_path)?;
6817    let freshness = collect_source_freshness(&abs_path, &source)?;
6818    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6819    let lang = data.lang;
6820    if lang == LangId::Rust {
6821        extend_rust_imports_with_nested_uses(&source, &mut data);
6822    }
6823    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6824    let node_by_scoped: HashMap<String, String> = nodes
6825        .iter()
6826        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6827        .collect();
6828    let import_dependencies =
6829        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6830    let line_index = LineIndex::new(&source);
6831    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6832    let rust_reexports = if lang == LangId::Rust {
6833        collect_rust_pub_use_reexport_refs(
6834            project_root,
6835            &abs_path,
6836            &rel_path,
6837            &data.import_block.imports,
6838            &line_index,
6839        )
6840    } else {
6841        ReexportRefs {
6842            raw_refs: Vec::new(),
6843            surface_parts: Vec::new(),
6844        }
6845    };
6846    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6847    let mut raw_refs = Vec::new();
6848    raw_refs.extend(build_call_refs(
6849        &rel_path,
6850        &data,
6851        &node_by_scoped,
6852        &import_dependencies,
6853    ));
6854    raw_refs.extend(build_import_refs(
6855        project_root,
6856        &abs_path,
6857        &rel_path,
6858        &data.import_block.imports,
6859        &line_index,
6860    ));
6861    let mut surface_parts = reexports.surface_parts;
6862    surface_parts.extend(rust_reexports.surface_parts);
6863    surface_parts.extend(source_less_exports.surface_parts);
6864    raw_refs.extend(reexports.raw_refs);
6865    raw_refs.extend(rust_reexports.raw_refs);
6866    raw_refs.extend(source_less_exports.raw_refs);
6867    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6868    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6869
6870    Ok(FileExtract {
6871        rel_path,
6872        freshness,
6873        lang,
6874        data,
6875        nodes,
6876        raw_refs,
6877        dispatch_hints,
6878        surface_fingerprint,
6879    })
6880}
6881
6882fn build_node_records(
6883    rel_path: &str,
6884    source: &str,
6885    data: &FileCallData,
6886) -> Result<Vec<NodeRecord>> {
6887    let mut records = Vec::new();
6888    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6889    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6890    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6891
6892    for (scoped_name, meta) in metadata {
6893        let name = unqualified_name(scoped_name).to_string();
6894        let range = selection_range(source, scoped_name, &name, &meta.range);
6895        let range_key = (
6896            range.start_line,
6897            range.start_col,
6898            range.end_line,
6899            range.end_col,
6900        );
6901        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6902        let range_ordinal = *ordinal;
6903        *ordinal += 1;
6904        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6905        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6906        let is_default_export = data
6907            .default_export_symbol
6908            .as_deref()
6909            .map(|default| default == scoped_name || default == name)
6910            .unwrap_or(false);
6911        records.push(NodeRecord {
6912            id,
6913            file_path: rel_path.to_string(),
6914            name: name.clone(),
6915            scoped_name: scoped_name.clone(),
6916            kind: symbol_kind_label(&meta.kind).to_string(),
6917            range,
6918            range_ordinal,
6919            signature: meta.signature.clone(),
6920            exported,
6921            is_default_export,
6922            is_type_like: is_type_like(&meta.kind),
6923            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6924                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6925        });
6926    }
6927
6928    Ok(records)
6929}
6930
6931fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6932    if scoped_name == TOP_LEVEL_SYMBOL {
6933        return Range {
6934            start_line: 0,
6935            start_col: 0,
6936            end_line: 0,
6937            end_col: 0,
6938        };
6939    }
6940    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6941        return fallback.clone();
6942    };
6943    let start_col = fallback.start_col as usize;
6944    let search_start = start_col.min(line.len());
6945    if let Some(offset) = line[search_start..].find(name) {
6946        let col = search_start + offset;
6947        return Range {
6948            start_line: fallback.start_line,
6949            start_col: col as u32,
6950            end_line: fallback.start_line,
6951            end_col: (col + name.len()) as u32,
6952        };
6953    }
6954    if let Some(offset) = line.find(name) {
6955        return Range {
6956            start_line: fallback.start_line,
6957            start_col: offset as u32,
6958            end_line: fallback.start_line,
6959            end_col: (offset + name.len()) as u32,
6960        };
6961    }
6962    Range {
6963        start_line: fallback.start_line,
6964        start_col: fallback.start_col,
6965        end_line: fallback.start_line,
6966        end_col: fallback.start_col.saturating_add(name.len() as u32),
6967    }
6968}
6969
6970fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6971    if scoped_name == TOP_LEVEL_SYMBOL {
6972        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6973    }
6974    let input = format!(
6975        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6976        range.start_line, range.start_col, range.end_line, range.end_col
6977    );
6978    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6979}
6980
6981fn build_call_refs(
6982    rel_path: &str,
6983    data: &FileCallData,
6984    node_by_scoped: &HashMap<String, String>,
6985    import_dependencies: &BTreeSet<String>,
6986) -> Vec<RawRef> {
6987    let mut refs = Vec::new();
6988    let mut ordinal = 0usize;
6989    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6990    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6991    for (caller_symbol, call_sites) in symbols {
6992        let caller_node = node_by_scoped.get(caller_symbol).cloned();
6993        for call_site in call_sites {
6994            ordinal += 1;
6995            let ref_id = ref_id(&[
6996                rel_path,
6997                "call",
6998                caller_symbol,
6999                &call_site.line.to_string(),
7000                &call_site.byte_start.to_string(),
7001                &call_site.byte_end.to_string(),
7002                &call_site.full_callee,
7003                &ordinal.to_string(),
7004            ]);
7005            refs.push(RawRef {
7006                ref_id,
7007                caller_node: caller_node.clone(),
7008                caller_symbol: Some(caller_symbol.clone()),
7009                caller_file: rel_path.to_string(),
7010                kind: "call".to_string(),
7011                short_name: Some(call_site.callee_name.clone()),
7012                full_ref: Some(call_site.full_callee.clone()),
7013                module_path: None,
7014                import_kind: None,
7015                local_name: Some(call_site.callee_name.clone()),
7016                requested_name: Some(call_site.callee_name.clone()),
7017                namespace_alias: namespace_alias(&call_site.full_callee),
7018                wildcard: false,
7019                line: call_site.line,
7020                byte_start: call_site.byte_start,
7021                byte_end: call_site.byte_end,
7022                dependencies: import_dependencies.clone(),
7023            });
7024        }
7025    }
7026    refs
7027}
7028
7029fn build_import_refs(
7030    project_root: &Path,
7031    abs_path: &Path,
7032    rel_path: &str,
7033    imports: &[ImportStatement],
7034    line_index: &LineIndex,
7035) -> Vec<RawRef> {
7036    let mut refs = Vec::new();
7037    for (index, import) in imports.iter().enumerate() {
7038        let import_kind = import_kind_label(import.kind).to_string();
7039        let local_name = import_local_names(import).join(",");
7040        let requested_name = import_requested_names(import).join(",");
7041        let ref_id = ref_id(&[
7042            rel_path,
7043            "import",
7044            &import.byte_range.start.to_string(),
7045            &import.byte_range.end.to_string(),
7046            &import.module_path,
7047            &index.to_string(),
7048        ]);
7049        refs.push(RawRef {
7050            ref_id,
7051            caller_node: None,
7052            caller_symbol: None,
7053            caller_file: rel_path.to_string(),
7054            kind: "import".to_string(),
7055            short_name: None,
7056            full_ref: Some(import.raw_text.clone()),
7057            module_path: Some(import.module_path.clone()),
7058            import_kind: Some(import_kind),
7059            local_name: empty_to_none(local_name),
7060            requested_name: empty_to_none(requested_name),
7061            namespace_alias: import.namespace_import.clone(),
7062            wildcard: import_is_wildcard(import),
7063            line: line_index.byte_to_line(import.byte_range.start),
7064            byte_start: import.byte_range.start,
7065            byte_end: import.byte_range.end,
7066            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7067        });
7068    }
7069    refs
7070}
7071
7072fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7073    let grammar = grammar_for(LangId::Rust);
7074    let mut parser = Parser::new();
7075    if parser.set_language(&grammar).is_err() {
7076        return;
7077    }
7078    let Some(tree) = parser.parse(source, None) else {
7079        return;
7080    };
7081
7082    let mut seen = data
7083        .import_block
7084        .imports
7085        .iter()
7086        .map(|import| (import.byte_range.start, import.byte_range.end))
7087        .collect::<HashSet<_>>();
7088    let mut nested_imports = Vec::new();
7089    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7090    if nested_imports.is_empty() {
7091        return;
7092    }
7093
7094    data.import_block.imports.extend(nested_imports);
7095    data.import_block
7096        .imports
7097        .sort_by_key(|import| import.byte_range.start);
7098    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7099}
7100
7101fn collect_rust_use_imports(
7102    source: &str,
7103    node: Node<'_>,
7104    seen: &mut HashSet<(usize, usize)>,
7105    imports: &mut Vec<ImportStatement>,
7106) {
7107    if node.kind() == "use_declaration" {
7108        let range = node.byte_range();
7109        if seen.insert((range.start, range.end)) {
7110            if let Some(import) = rust_import_from_use_node(source, node) {
7111                imports.push(import);
7112            }
7113        }
7114    }
7115
7116    let mut cursor = node.walk();
7117    if !cursor.goto_first_child() {
7118        return;
7119    }
7120    loop {
7121        collect_rust_use_imports(source, cursor.node(), seen, imports);
7122        if !cursor.goto_next_sibling() {
7123            break;
7124        }
7125    }
7126}
7127
7128fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7129    let raw_text = source[node.byte_range()].to_string();
7130    let body = rust_use_body(&raw_text)?.to_string();
7131    let visibility = rust_use_visibility(&raw_text);
7132    let names = rust_use_list_names(&body);
7133    let group = classify_rust_import_group(&body);
7134    let byte_range = node.byte_range();
7135
7136    Some(ImportStatement {
7137        module_path: body,
7138        names: names.clone(),
7139        default_import: visibility.clone(),
7140        namespace_import: None,
7141        kind: ImportKind::Value,
7142        group,
7143        byte_range,
7144        raw_text,
7145        form: ImportForm::RustUse {
7146            visibility,
7147            named: names,
7148        },
7149    })
7150}
7151
7152fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7153    let start = imports.iter().map(|import| import.byte_range.start).min()?;
7154    let end = imports.iter().map(|import| import.byte_range.end).max()?;
7155    Some(start..end)
7156}
7157
7158fn rust_use_visibility(raw_text: &str) -> Option<String> {
7159    let use_pos = raw_text.find("use ")?;
7160    let prefix = raw_text[..use_pos].trim();
7161    if prefix.is_empty() {
7162        None
7163    } else {
7164        Some(prefix.to_string())
7165    }
7166}
7167
7168fn rust_use_body(raw_text: &str) -> Option<&str> {
7169    let use_pos = raw_text.find("use ")?;
7170    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7171}
7172
7173fn rust_use_list_names(body: &str) -> Vec<String> {
7174    let Some(open) = body.find("::{") else {
7175        return Vec::new();
7176    };
7177    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7178        return Vec::new();
7179    };
7180    body[open + 3..close]
7181        .split(',')
7182        .filter_map(|spec| {
7183            let spec = spec.trim();
7184            if spec.is_empty() {
7185                None
7186            } else {
7187                Some(spec.to_string())
7188            }
7189        })
7190        .collect()
7191}
7192
7193fn classify_rust_import_group(body: &str) -> ImportGroup {
7194    let first = body
7195        .split("::")
7196        .next()
7197        .unwrap_or(body)
7198        .split_whitespace()
7199        .next()
7200        .unwrap_or(body);
7201    match first.trim() {
7202        "std" | "core" | "alloc" => ImportGroup::Stdlib,
7203        "crate" | "self" | "super" => ImportGroup::Internal,
7204        _ => ImportGroup::External,
7205    }
7206}
7207
7208#[derive(Debug, Clone)]
7209struct ReexportRefs {
7210    raw_refs: Vec<RawRef>,
7211    surface_parts: Vec<String>,
7212}
7213
7214fn collect_reexport_refs(
7215    project_root: &Path,
7216    abs_path: &Path,
7217    rel_path: &str,
7218    source: &str,
7219) -> ReexportRefs {
7220    let mut raw_refs = Vec::new();
7221    let mut surface_parts = Vec::new();
7222    let mut search_start = 0usize;
7223    let mut ordinal = 0usize;
7224    while let Some(export_offset) = source[search_start..].find("export") {
7225        let start = search_start + export_offset;
7226        let Some(statement_end_offset) = source[start..].find(';') else {
7227            break;
7228        };
7229        let end = start + statement_end_offset + 1;
7230        let statement = &source[start..end];
7231        search_start = end;
7232        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7233            continue;
7234        }
7235        let Some(module_path) = quoted_module_path(statement) else {
7236            continue;
7237        };
7238        ordinal += 1;
7239        let wildcard = statement.contains('*');
7240        let line = source[..start]
7241            .bytes()
7242            .filter(|byte| *byte == b'\n')
7243            .count() as u32
7244            + 1;
7245        let ref_id = ref_id(&[
7246            rel_path,
7247            "reexport",
7248            &start.to_string(),
7249            &end.to_string(),
7250            &module_path,
7251            &ordinal.to_string(),
7252        ]);
7253        surface_parts.push(format!("reexport\t{statement}"));
7254        raw_refs.push(RawRef {
7255            ref_id,
7256            caller_node: None,
7257            caller_symbol: None,
7258            caller_file: rel_path.to_string(),
7259            kind: "reexport".to_string(),
7260            short_name: None,
7261            full_ref: Some(statement.to_string()),
7262            module_path: Some(module_path.clone()),
7263            import_kind: Some("reexport".to_string()),
7264            local_name: None,
7265            requested_name: None,
7266            namespace_alias: None,
7267            wildcard,
7268            line,
7269            byte_start: start,
7270            byte_end: end,
7271            dependencies: module_dependencies(project_root, abs_path, &module_path),
7272        });
7273    }
7274    ReexportRefs {
7275        raw_refs,
7276        surface_parts,
7277    }
7278}
7279
7280fn collect_rust_pub_use_reexport_refs(
7281    project_root: &Path,
7282    abs_path: &Path,
7283    rel_path: &str,
7284    imports: &[ImportStatement],
7285    line_index: &LineIndex,
7286) -> ReexportRefs {
7287    let mut raw_refs = Vec::new();
7288    let mut surface_parts = Vec::new();
7289    let mut ordinal = 0usize;
7290
7291    for import in imports {
7292        let Some(visibility) = &import.default_import else {
7293            continue;
7294        };
7295        if !visibility.starts_with("pub") {
7296            continue;
7297        }
7298        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7299            continue;
7300        };
7301        ordinal += 1;
7302        let ref_id = ref_id(&[
7303            rel_path,
7304            "rust_reexport",
7305            &import.byte_range.start.to_string(),
7306            &import.byte_range.end.to_string(),
7307            &module_path,
7308            &ordinal.to_string(),
7309        ]);
7310        surface_parts.push(format!("reexport\t{}", import.raw_text));
7311        raw_refs.push(RawRef {
7312            ref_id,
7313            caller_node: None,
7314            caller_symbol: None,
7315            caller_file: rel_path.to_string(),
7316            kind: "reexport".to_string(),
7317            short_name: None,
7318            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7319            module_path: Some(module_path.clone()),
7320            import_kind: Some("reexport".to_string()),
7321            local_name: None,
7322            requested_name: None,
7323            namespace_alias: None,
7324            wildcard,
7325            line: line_index.byte_to_line(import.byte_range.start),
7326            byte_start: import.byte_range.start,
7327            byte_end: import.byte_range.end,
7328            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7329        });
7330    }
7331
7332    ReexportRefs {
7333        raw_refs,
7334        surface_parts,
7335    }
7336}
7337
7338fn rust_pub_use_reexport_parts(
7339    import: &ImportStatement,
7340) -> Option<(String, HashMap<String, String>, bool)> {
7341    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7342    let body = body.trim();
7343    if let Some(module_path) = body.strip_suffix("::*") {
7344        return Some((module_path.trim().to_string(), HashMap::new(), true));
7345    }
7346
7347    if let Some(brace_start) = body.find("::{") {
7348        let module_path = body[..brace_start].trim().to_string();
7349        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7350        if names.is_empty() {
7351            return None;
7352        }
7353        return Some((module_path, names, false));
7354    }
7355
7356    let (module_path, spec) = body.rsplit_once("::")?;
7357    let names = rust_reexport_names_from_specs(spec);
7358    if names.is_empty() {
7359        return None;
7360    }
7361    Some((module_path.trim().to_string(), names, false))
7362}
7363
7364fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7365    let mut names = HashMap::new();
7366    for spec in specs.split(',') {
7367        let spec = spec.trim();
7368        if spec.is_empty() || spec == "self" {
7369            continue;
7370        }
7371        if let Some((source, local)) = spec.split_once(" as ") {
7372            let source = source.trim();
7373            let local = local.trim();
7374            if !source.is_empty() && !local.is_empty() && source != "self" {
7375                names.insert(local.to_string(), source.to_string());
7376            }
7377        } else {
7378            names.insert(spec.to_string(), spec.to_string());
7379        }
7380    }
7381    names
7382}
7383
7384fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7385    if named.is_empty() {
7386        return fallback.to_string();
7387    }
7388    let mut specs = named
7389        .iter()
7390        .map(|(local, source)| {
7391            if local == source {
7392                source.clone()
7393            } else {
7394                format!("{source} as {local}")
7395            }
7396        })
7397        .collect::<Vec<_>>();
7398    specs.sort();
7399    format!("pub use {{{}}};", specs.join(", "))
7400}
7401
7402fn quoted_module_path(statement: &str) -> Option<String> {
7403    let quote = match (statement.find('\''), statement.find('"')) {
7404        (Some(single), Some(double)) if single < double => '\'',
7405        (Some(_), Some(_)) => '"',
7406        (Some(_), None) => '\'',
7407        (None, Some(_)) => '"',
7408        (None, None) => return None,
7409    };
7410    let start = statement.find(quote)? + 1;
7411    let end = statement[start..].find(quote)? + start;
7412    Some(statement[start..end].to_string())
7413}
7414
7415#[derive(Debug, Clone)]
7416struct SourceLessExportRefs {
7417    raw_refs: Vec<RawRef>,
7418    surface_parts: Vec<String>,
7419}
7420
7421fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7422    let mut raw_refs = Vec::new();
7423    let mut surface_parts = Vec::new();
7424    let mut search_start = 0usize;
7425    let mut ordinal = 0usize;
7426    while let Some(export_offset) = source[search_start..].find("export") {
7427        let start = search_start + export_offset;
7428        let Some(statement_end_offset) = source[start..].find(';') else {
7429            break;
7430        };
7431        let end = start + statement_end_offset + 1;
7432        let statement = &source[start..end];
7433        search_start = end;
7434        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7435            continue;
7436        }
7437        let aliases = parse_reexport_names(statement);
7438        if aliases.is_empty() {
7439            continue;
7440        }
7441        let line = source[..start]
7442            .bytes()
7443            .filter(|byte| *byte == b'\n')
7444            .count() as u32
7445            + 1;
7446        for (exported, source_symbol) in aliases {
7447            ordinal += 1;
7448            let ref_id = ref_id(&[
7449                rel_path,
7450                "export_alias",
7451                &start.to_string(),
7452                &end.to_string(),
7453                &exported,
7454                &source_symbol,
7455                &ordinal.to_string(),
7456            ]);
7457            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7458            raw_refs.push(RawRef {
7459                ref_id,
7460                caller_node: None,
7461                caller_symbol: None,
7462                caller_file: rel_path.to_string(),
7463                kind: "export_alias".to_string(),
7464                short_name: None,
7465                full_ref: Some(statement.to_string()),
7466                module_path: None,
7467                import_kind: Some("export_alias".to_string()),
7468                local_name: Some(exported),
7469                requested_name: Some(source_symbol),
7470                namespace_alias: None,
7471                wildcard: false,
7472                line,
7473                byte_start: start,
7474                byte_end: end,
7475                dependencies: BTreeSet::new(),
7476            });
7477        }
7478    }
7479    SourceLessExportRefs {
7480        raw_refs,
7481        surface_parts,
7482    }
7483}
7484
7485fn build_dispatch_hints(
7486    rel_path: &str,
7487    data: &FileCallData,
7488    node_by_scoped: &HashMap<String, String>,
7489) -> Vec<DispatchHint> {
7490    let mut hints = Vec::new();
7491    let mut ordinal = 0usize;
7492    for (caller_symbol, call_sites) in &data.calls_by_symbol {
7493        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7494            continue;
7495        };
7496        for call_site in call_sites {
7497            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7498                continue;
7499            }
7500            ordinal += 1;
7501            hints.push(DispatchHint {
7502                id: ref_id(&[
7503                    rel_path,
7504                    "dispatch",
7505                    caller_symbol,
7506                    &call_site.line.to_string(),
7507                    &call_site.byte_start.to_string(),
7508                    &call_site.byte_end.to_string(),
7509                    &ordinal.to_string(),
7510                ]),
7511                method_name: call_site.callee_name.clone(),
7512                caller_node: caller_node.clone(),
7513                file: rel_path.to_string(),
7514                line: call_site.line,
7515                byte_start: call_site.byte_start,
7516                byte_end: call_site.byte_end,
7517            });
7518        }
7519    }
7520    hints
7521}
7522
7523fn surface_fingerprint(
7524    nodes: &mut [NodeRecord],
7525    data: &FileCallData,
7526    reexport_parts: &[String],
7527) -> String {
7528    nodes.sort_by(|left, right| {
7529        (left.file_path.as_str(), left.scoped_name.as_str())
7530            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7531    });
7532    let mut parts = Vec::new();
7533    for node in nodes.iter() {
7534        parts.push(format!(
7535            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7536            node.scoped_name,
7537            node.name,
7538            node.kind,
7539            node.exported,
7540            node.range.start_line,
7541            node.range.start_col,
7542            node.range.end_line,
7543            node.range.end_col,
7544            node.range_ordinal,
7545            node.signature.as_deref().unwrap_or("")
7546        ));
7547    }
7548    let mut exports = data.exported_symbols.clone();
7549    exports.sort();
7550    for export in exports {
7551        parts.push(format!("export\t{export}"));
7552    }
7553    if let Some(default_export) = &data.default_export_symbol {
7554        parts.push(format!("default\t{default_export}"));
7555    }
7556    let mut imports: Vec<String> = data
7557        .import_block
7558        .imports
7559        .iter()
7560        .map(|import| {
7561            format!(
7562                "import\t{}\t{:?}\t{}",
7563                import.module_path, import.form, import.raw_text
7564            )
7565        })
7566        .collect();
7567    imports.sort();
7568    parts.extend(imports);
7569    parts.extend(reexport_parts.iter().cloned());
7570    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7571}
7572
7573fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7574    if raw.kind != "call" {
7575        return Ok(ResolvedRef {
7576            dependencies: raw.dependencies.clone(),
7577            raw,
7578            status: "unresolved".to_string(),
7579            target_node: None,
7580            target_file: None,
7581            target_symbol: None,
7582            edge: None,
7583        });
7584    }
7585
7586    let caller_file = raw.caller_file.clone();
7587    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7588        CallGraphStoreError::MissingCallerData {
7589            file: caller_file.clone(),
7590        }
7591    })?;
7592    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7593    let short_name = raw.short_name.as_deref().unwrap_or_default();
7594    let mut dependencies = raw.dependencies.clone();
7595
7596    let resolved = match index.lang_for(&caller_file) {
7597        Some(LangId::Rust) => {
7598            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7599        }
7600        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7601            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7602        }
7603        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7604    };
7605
7606    let Some((status, target_file, target_symbol)) = resolved else {
7607        return Ok(ResolvedRef {
7608            raw,
7609            status: "unresolved".to_string(),
7610            target_node: None,
7611            target_file: None,
7612            target_symbol: None,
7613            dependencies,
7614            edge: None,
7615        });
7616    };
7617
7618    dependencies.insert(target_file.clone());
7619    let target_node = index.node_for_symbol(&target_file, &target_symbol);
7620    let source_node = raw.caller_node.clone();
7621    let edge = if let Some(source_node) = source_node {
7622        if target_file == caller_file
7623            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7624        {
7625            None
7626        } else {
7627            Some(EdgeRecord {
7628                edge_id: ref_id(&[&raw.ref_id, "edge"]),
7629                source_node,
7630                target_node: target_node.clone(),
7631                target_file: target_file.clone(),
7632                target_symbol: target_symbol.clone(),
7633                kind: "call".to_string(),
7634                line: raw.line,
7635            })
7636        }
7637    } else {
7638        None
7639    };
7640
7641    Ok(ResolvedRef {
7642        raw,
7643        status,
7644        target_node,
7645        target_file: Some(target_file),
7646        target_symbol: Some(target_symbol),
7647        dependencies,
7648        edge,
7649    })
7650}
7651
7652fn resolve_js_ts_target(
7653    index: &ProjectIndex<'_>,
7654    caller_file: &str,
7655    full_ref: &str,
7656    short_name: &str,
7657    caller_data: &FileCallData,
7658) -> Option<(String, String, String)> {
7659    if let Some((namespace, member)) = full_ref.split_once('.') {
7660        for import in &caller_data.import_block.imports {
7661            if import.namespace_import.as_deref() == Some(namespace) {
7662                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7663                    if let Some((file, symbol)) =
7664                        resolve_exported_symbol(index, &target_file, member, 0)
7665                    {
7666                        return Some(("resolved".to_string(), file, symbol));
7667                    }
7668                }
7669            }
7670        }
7671    }
7672
7673    for import in &caller_data.import_block.imports {
7674        for spec in &import.names {
7675            if crate::imports::specifier_local_name(spec) == short_name {
7676                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7677                    let requested = crate::imports::specifier_imported_name(spec);
7678                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7679                        .unwrap_or_else(|| (target_file, requested.to_string()));
7680                    return Some(("resolved".to_string(), file, symbol));
7681                }
7682            }
7683        }
7684
7685        if import.default_import.as_deref() == Some(short_name) {
7686            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7687                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7688                    .or_else(|| {
7689                        index
7690                            .files
7691                            .get(&target_file)
7692                            .and_then(|file| file.default_export.clone())
7693                            .map(|symbol| (target_file.clone(), symbol))
7694                    })
7695                    .unwrap_or_else(|| {
7696                        let file_name = Path::new(&target_file)
7697                            .file_name()
7698                            .and_then(|name| name.to_str())
7699                            .unwrap_or("unknown")
7700                            .to_string();
7701                        (target_file, format!("<default:{file_name}>"))
7702                    });
7703                return Some(("resolved".to_string(), file, symbol));
7704            }
7705        }
7706    }
7707
7708    for import in &caller_data.import_block.imports {
7709        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7710            if index
7711                .files
7712                .get(&target_file)
7713                .map(|file| file.exports.contains(short_name))
7714                .unwrap_or(false)
7715            {
7716                return Some(("resolved".to_string(), target_file, short_name.to_string()));
7717            }
7718        }
7719    }
7720
7721    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7722}
7723
7724fn resolve_exported_symbol(
7725    index: &ProjectIndex<'_>,
7726    file: &str,
7727    requested: &str,
7728    depth: usize,
7729) -> Option<(String, String)> {
7730    let mut visited = std::collections::HashMap::new();
7731    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7732}
7733
7734/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7735/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7736/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7737/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7738/// (file, symbol) pair — but only when the earlier visit had at least as much
7739/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7740/// first visit had to cut off at the cap, so plain visited-set pruning would
7741/// lose resolutions the capped walk finds).
7742fn resolve_exported_symbol_inner(
7743    index: &ProjectIndex<'_>,
7744    file: &str,
7745    requested: &str,
7746    depth: usize,
7747    visited: &mut std::collections::HashMap<(String, String), usize>,
7748) -> Option<(String, String)> {
7749    if depth > 16 {
7750        return None;
7751    }
7752    if requested != "default" {
7753        if let Some(source_symbol) = index
7754            .files
7755            .get(file)
7756            .and_then(|item| item.export_aliases.get(requested))
7757        {
7758            return Some((file.to_string(), source_symbol.clone()));
7759        }
7760        if index
7761            .files
7762            .get(file)
7763            .map(|item| item.exports.contains(requested))
7764            .unwrap_or(false)
7765        {
7766            return Some((file.to_string(), requested.to_string()));
7767        }
7768    } else if let Some(default) = index
7769        .files
7770        .get(file)
7771        .and_then(|item| item.default_export.clone())
7772    {
7773        return Some((file.to_string(), default));
7774    }
7775
7776    // Memo check sits after the local-export fast paths: the common direct
7777    // hit never allocates the key, and a hit through the memo would have
7778    // returned above anyway.
7779    match visited.entry((file.to_string(), requested.to_string())) {
7780        std::collections::hash_map::Entry::Occupied(mut seen) => {
7781            if *seen.get() <= depth {
7782                return None;
7783            }
7784            seen.insert(depth);
7785        }
7786        std::collections::hash_map::Entry::Vacant(slot) => {
7787            slot.insert(depth);
7788        }
7789    }
7790
7791    for reexport in index.reexports_for(file) {
7792        let mut next_requested = requested.to_string();
7793        let matches = if reexport.wildcard {
7794            true
7795        } else if let Some(source_name) = reexport.named.get(requested) {
7796            next_requested = source_name.clone();
7797            true
7798        } else {
7799            false
7800        };
7801        if !matches {
7802            continue;
7803        }
7804        if let Some(target_file) = &reexport.target_file {
7805            if let Some(target) = resolve_exported_symbol_inner(
7806                index,
7807                target_file,
7808                &next_requested,
7809                depth + 1,
7810                visited,
7811            ) {
7812                return Some(target);
7813            }
7814        }
7815    }
7816    None
7817}
7818
7819fn resolve_rust_target(
7820    index: &ProjectIndex<'_>,
7821    caller_file: &str,
7822    full_ref: &str,
7823    short_name: &str,
7824    caller_data: &FileCallData,
7825    raw: &RawRef,
7826) -> Option<(String, String, String)> {
7827    if full_ref.contains("::") {
7828        if let Some((target_file, target_symbol)) =
7829            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7830        {
7831            return Some(("resolved".to_string(), target_file, target_symbol));
7832        }
7833    }
7834
7835    for import in &caller_data.import_block.imports {
7836        if let Some((target_file, target_symbol)) =
7837            rust_target_for_use(index, caller_file, import, short_name)
7838        {
7839            return Some(("resolved".to_string(), target_file, target_symbol));
7840        }
7841    }
7842
7843    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7844}
7845
7846fn rust_target_for_qualified(
7847    index: &ProjectIndex<'_>,
7848    caller_file: &str,
7849    full_ref: &str,
7850    short_name: &str,
7851    caller_data: &FileCallData,
7852    raw: &RawRef,
7853) -> Option<(String, String)> {
7854    let mut segments: Vec<&str> = full_ref.split("::").collect();
7855    if segments.len() < 2 {
7856        return None;
7857    }
7858    segments.pop();
7859    let requested_symbol = rust_target_symbol(full_ref, short_name);
7860
7861    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7862        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7863        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7864            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7865                return Some(rust_resolve_reexport_if_symbol_missing(
7866                    index,
7867                    target_file,
7868                    requested_symbol.clone(),
7869                ));
7870            }
7871        }
7872
7873        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7874        if let Some(target) =
7875            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7876        {
7877            return Some(target);
7878        }
7879        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7880            return Some(rust_resolve_reexport_if_symbol_missing(
7881                index,
7882                target_file,
7883                requested_symbol.clone(),
7884            ));
7885        }
7886    }
7887    None
7888}
7889
7890fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7891    full_ref
7892        .rsplit("::")
7893        .next()
7894        .filter(|name| !name.is_empty())
7895        .unwrap_or(short_name)
7896        .to_string()
7897}
7898
7899fn rust_resolve_reexport_if_symbol_missing(
7900    index: &ProjectIndex<'_>,
7901    target_file: String,
7902    target_symbol: String,
7903) -> (String, String) {
7904    if index
7905        .node_for_symbol(&target_file, &target_symbol)
7906        .is_some()
7907    {
7908        return (target_file, target_symbol);
7909    }
7910    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7911        resolved
7912    } else {
7913        (target_file, target_symbol)
7914    }
7915}
7916
7917fn rust_module_path_candidates(
7918    segments: &[&str],
7919    caller_data: &FileCallData,
7920    raw: &RawRef,
7921) -> Vec<Vec<String>> {
7922    let mut candidates = Vec::new();
7923    if let Some(first) = segments.first().copied() {
7924        for import in &caller_data.import_block.imports {
7925            if !rust_import_is_visible_to_call(import, raw) {
7926                continue;
7927            }
7928            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7929                continue;
7930            };
7931            if local_name == first {
7932                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7933                rust_push_unique_path_candidate(&mut candidates, path_segments);
7934            }
7935        }
7936    }
7937    rust_push_unique_path_candidate(
7938        &mut candidates,
7939        segments
7940            .iter()
7941            .map(|segment| (*segment).to_string())
7942            .collect(),
7943    );
7944    candidates
7945}
7946
7947fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7948    if !candidates.iter().any(|existing| existing == &candidate) {
7949        candidates.push(candidate);
7950    }
7951}
7952
7953fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7954    import.byte_range.start <= raw.byte_start
7955}
7956
7957fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7958    let path = import.module_path.trim().trim_end_matches(';').trim();
7959    if path.contains("::{") || path.contains('{') || path.contains('*') {
7960        return None;
7961    }
7962    let (path_without_alias, alias) = path
7963        .split_once(" as ")
7964        .map(|(left, right)| (left.trim(), Some(right.trim())))
7965        .unwrap_or((path, None));
7966    let segments = path_without_alias
7967        .split("::")
7968        .map(str::trim)
7969        .filter(|segment| !segment.is_empty())
7970        .collect::<Vec<_>>();
7971    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7972    if local_name.chars().next().is_some_and(char::is_uppercase) {
7973        return None;
7974    }
7975    Some((
7976        local_name,
7977        segments
7978            .into_iter()
7979            .map(|segment| segment.to_string())
7980            .collect(),
7981    ))
7982}
7983
7984fn rust_inline_scoped_target(
7985    index: &ProjectIndex<'_>,
7986    caller_file: &str,
7987    module_segments: &[String],
7988    short_name: &str,
7989) -> Option<(String, String)> {
7990    let src_prefix = rust_src_prefix(caller_file);
7991    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7992    file_paths.sort();
7993    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7994        let caller = file_paths.remove(position);
7995        file_paths.insert(0, caller);
7996    }
7997
7998    for file_path in file_paths {
7999        if index.lang_for(&file_path) != Some(LangId::Rust)
8000            || rust_src_prefix(&file_path) != src_prefix
8001        {
8002            continue;
8003        }
8004        let file_module_segments = rust_module_segments_for_rel(&file_path);
8005        if !module_segments.starts_with(&file_module_segments) {
8006            continue;
8007        }
8008        let scoped_segments = &module_segments[file_module_segments.len()..];
8009        if scoped_segments.is_empty() {
8010            continue;
8011        }
8012        let mut scoped_symbol = scoped_segments.join("::");
8013        scoped_symbol.push_str("::");
8014        scoped_symbol.push_str(short_name);
8015        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8016            return Some((file_path, scoped_symbol));
8017        }
8018    }
8019    None
8020}
8021
8022fn rust_target_for_use(
8023    index: &ProjectIndex<'_>,
8024    caller_file: &str,
8025    import: &ImportStatement,
8026    short_name: &str,
8027) -> Option<(String, String)> {
8028    let path = import.module_path.trim().trim_end_matches(';');
8029    if let Some(brace_start) = path.find("::{") {
8030        let prefix = &path[..brace_start];
8031        if import.names.iter().any(|name| name == short_name) {
8032            let prefix_segments: Vec<&str> = prefix.split("::").collect();
8033            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8034            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8035            return Some((file, short_name.to_string()));
8036        }
8037        return None;
8038    }
8039
8040    let (path_without_alias, alias) = path
8041        .split_once(" as ")
8042        .map(|(left, right)| (left.trim(), Some(right.trim())))
8043        .unwrap_or((path, None));
8044    let segments: Vec<&str> = path_without_alias.split("::").collect();
8045    let imported = alias.or_else(|| segments.last().copied())?;
8046    if imported != short_name {
8047        return None;
8048    }
8049    if segments.len() < 2 {
8050        return None;
8051    }
8052    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8053    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8054    Some((file, segments.last().unwrap_or(&short_name).to_string()))
8055}
8056
8057fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8058    let crate_name = segments.first().copied()?;
8059    let src_prefix = index.crate_src_prefix(crate_name)?;
8060    let module_segments = segments[1..]
8061        .iter()
8062        .map(|segment| segment.to_string())
8063        .collect::<Vec<_>>();
8064    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8065}
8066
8067#[cfg(test)]
8068static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8069    OnceLock::new();
8070
8071#[cfg(test)]
8072fn note_workspace_crate_prefix_build(project_root: &Path) {
8073    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8074        .get_or_init(|| Mutex::new(HashMap::new()))
8075        .lock()
8076        .expect("workspace crate prefix build counts mutex poisoned");
8077    *counts.entry(project_root.to_path_buf()).or_default() += 1;
8078}
8079
8080#[cfg(not(test))]
8081fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8082
8083#[cfg(test)]
8084fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8085    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8086        .get_or_init(|| Mutex::new(HashMap::new()))
8087        .lock()
8088        .expect("workspace crate prefix build counts mutex poisoned")
8089        .remove(project_root);
8090}
8091
8092#[cfg(test)]
8093fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8094    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8095        .get_or_init(|| Mutex::new(HashMap::new()))
8096        .lock()
8097        .expect("workspace crate prefix build counts mutex poisoned")
8098        .get(project_root)
8099        .copied()
8100        .unwrap_or(0)
8101}
8102
8103/// Walk the project tree once and map every Rust crate name (package name with
8104/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
8105/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
8106/// longer re-walks the filesystem once per ref.
8107fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8108    note_workspace_crate_prefix_build(project_root);
8109    let mut prefixes = HashMap::new();
8110    let mut stack = vec![project_root.to_path_buf()];
8111    while let Some(dir) = stack.pop() {
8112        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8113        if matches!(name, "target" | "node_modules" | ".git") {
8114            continue;
8115        }
8116        let manifest = dir.join("Cargo.toml");
8117        if manifest.is_file() {
8118            let crate_names = rust_manifest_crate_names(&manifest);
8119            if !crate_names.is_empty() {
8120                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8121                for crate_name in crate_names {
8122                    prefixes
8123                        .entry(crate_name)
8124                        .or_insert_with(|| src_prefix.clone());
8125                }
8126            }
8127        }
8128        let Ok(entries) = std::fs::read_dir(&dir) else {
8129            continue;
8130        };
8131        for entry in entries.flatten() {
8132            let path = entry.path();
8133            if path.is_dir() {
8134                stack.push(path);
8135            }
8136        }
8137    }
8138    prefixes
8139}
8140
8141/// Extract the crate names a manifest defines: the normalized package name
8142/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
8143/// reachable by either spelling, matching the previous match semantics.
8144fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8145    let Ok(source) = std::fs::read_to_string(manifest) else {
8146        return Vec::new();
8147    };
8148    let mut in_lib = false;
8149    let mut package_name = None;
8150    let mut lib_name = None;
8151    for line in source.lines() {
8152        let trimmed = line.trim();
8153        if trimmed.starts_with('[') {
8154            in_lib = trimmed == "[lib]";
8155            continue;
8156        }
8157        let Some((key, value)) = trimmed.split_once('=') else {
8158            continue;
8159        };
8160        let key = key.trim();
8161        let value = value.trim().trim_matches('"');
8162        if in_lib && key == "name" {
8163            lib_name = Some(value.to_string());
8164        } else if !in_lib && key == "name" && package_name.is_none() {
8165            package_name = Some(value.to_string());
8166        }
8167    }
8168    let mut names = Vec::new();
8169    if let Some(lib) = lib_name {
8170        names.push(lib);
8171    }
8172    if let Some(package) = package_name {
8173        let normalized = package.replace('-', "_");
8174        if !names.contains(&normalized) {
8175            names.push(normalized);
8176        }
8177    }
8178    names
8179}
8180
8181fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8182    if segments.is_empty() {
8183        return Some(Vec::new());
8184    }
8185    let caller_segments = rust_module_segments_for_rel(caller_file);
8186    match segments[0] {
8187        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8188        "self" => {
8189            let mut resolved = caller_segments;
8190            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8191            Some(resolved)
8192        }
8193        "super" => {
8194            let mut resolved = caller_segments;
8195            resolved.pop();
8196            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8197            Some(resolved)
8198        }
8199        _ => {
8200            let mut resolved = caller_segments;
8201            resolved.pop();
8202            resolved.extend(segments.iter().map(|item| item.to_string()));
8203            Some(resolved)
8204        }
8205    }
8206}
8207
8208fn rust_file_for_segments(
8209    index: &ProjectIndex<'_>,
8210    caller_file: &str,
8211    segments: &[String],
8212) -> Option<String> {
8213    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8214}
8215
8216fn rust_file_for_src_prefix(
8217    index: &ProjectIndex<'_>,
8218    src_prefix: &str,
8219    segments: &[String],
8220) -> Option<String> {
8221    let candidate = if segments.is_empty() {
8222        [src_prefix, "lib.rs"].join("/")
8223    } else {
8224        format!("{}/{}.rs", src_prefix, segments.join("/"))
8225    };
8226    if index.files.contains_key(&candidate) {
8227        return Some(candidate);
8228    }
8229    if !segments.is_empty() {
8230        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8231        if index.files.contains_key(&mod_candidate) {
8232            return Some(mod_candidate);
8233        }
8234    }
8235    None
8236}
8237
8238fn rust_src_prefix(rel_path: &str) -> String {
8239    rel_path
8240        .split_once("/src/")
8241        .map(|(prefix, _)| format!("{prefix}/src"))
8242        .unwrap_or_else(|| "src".to_string())
8243}
8244
8245fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8246    let after_src = rel_path
8247        .split_once("/src/")
8248        .map(|(_, rest)| rest)
8249        .or_else(|| rel_path.strip_prefix("src/"))
8250        .unwrap_or(rel_path);
8251    if matches!(after_src, "lib.rs" | "main.rs") {
8252        return Vec::new();
8253    }
8254    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8255        return prefix.split('/').map(|item| item.to_string()).collect();
8256    }
8257    after_src
8258        .strip_suffix(".rs")
8259        .unwrap_or(after_src)
8260        .split('/')
8261        .map(|item| item.to_string())
8262        .collect()
8263}
8264
8265fn resolve_local_target(
8266    _index: &ProjectIndex<'_>,
8267    caller_file: &str,
8268    full_ref: &str,
8269    short_name: &str,
8270    caller_data: &FileCallData,
8271) -> Option<(String, String, String)> {
8272    if !callgraph::is_bare_callee(full_ref, short_name) {
8273        return None;
8274    }
8275    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8276        .ok()
8277        .map(|symbol| {
8278            (
8279                "resolved_local".to_string(),
8280                caller_file.to_string(),
8281                symbol,
8282            )
8283        })
8284}
8285
8286impl<'a> ProjectIndex<'a> {
8287    fn from_parts(
8288        project_root: &Path,
8289        files: HashMap<String, DbFileIndex>,
8290        caller_data: HashMap<String, &'a FileCallData>,
8291        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8292    ) -> Self {
8293        Self {
8294            project_root: project_root.to_path_buf(),
8295            files,
8296            caller_data,
8297            workspace_crate_prefixes,
8298        }
8299    }
8300
8301    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8302        let mut files = HashMap::new();
8303        let mut caller_data = HashMap::new();
8304        for extract in extracts {
8305            let index = DbFileIndex::from_extract(project_root, extract);
8306            caller_data.insert(extract.rel_path.clone(), &extract.data);
8307            files.insert(extract.rel_path.clone(), index);
8308        }
8309        Self::from_parts(
8310            project_root,
8311            files,
8312            caller_data,
8313            WorkspaceCratePrefixCache::default(),
8314        )
8315    }
8316
8317    fn from_db_and_callers(
8318        tx: &Transaction<'_>,
8319        project_root: &Path,
8320        caller_extracts: &'a HashMap<String, FileExtract>,
8321        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8322    ) -> Result<Self> {
8323        let mut files = load_db_file_indexes(tx, project_root)?;
8324        let mut caller_data = HashMap::new();
8325        for (rel_path, extract) in caller_extracts {
8326            files.insert(
8327                rel_path.clone(),
8328                DbFileIndex::from_extract(project_root, extract),
8329            );
8330            caller_data.insert(rel_path.clone(), &extract.data);
8331        }
8332        Ok(Self::from_parts(
8333            project_root,
8334            files,
8335            caller_data,
8336            workspace_crate_prefixes,
8337        ))
8338    }
8339
8340    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8341        self.files.get(rel_path).and_then(|file| file.lang)
8342    }
8343
8344    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8345        self.files
8346            .get(caller_file)
8347            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8348    }
8349
8350    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8351        self.files
8352            .get(rel_path)
8353            .map(|file| file.reexports.as_slice())
8354            .unwrap_or(&[])
8355    }
8356
8357    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8358        self.files.get(rel_path).and_then(|file| {
8359            file.node_by_scoped
8360                .get(symbol)
8361                .cloned()
8362                .or_else(|| file.node_by_bare.get(symbol).cloned())
8363        })
8364    }
8365}
8366
8367impl DbFileIndex {
8368    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8369        let mut node_by_scoped = HashMap::new();
8370        let mut node_by_bare = HashMap::new();
8371        for node in &extract.nodes {
8372            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8373            node_by_bare
8374                .entry(node.name.clone())
8375                .or_insert(node.id.clone());
8376        }
8377        let mut export_aliases = HashMap::new();
8378        for raw_ref in &extract.raw_refs {
8379            if raw_ref.kind == "export_alias" {
8380                if let (Some(exported), Some(source_symbol)) =
8381                    (&raw_ref.local_name, &raw_ref.requested_name)
8382                {
8383                    export_aliases.insert(exported.clone(), source_symbol.clone());
8384                }
8385            }
8386        }
8387        let mut module_targets = HashMap::new();
8388        let mut reexports = Vec::new();
8389        for raw_ref in &extract.raw_refs {
8390            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8391                continue;
8392            }
8393            let Some(module_path) = &raw_ref.module_path else {
8394                continue;
8395            };
8396            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8397            module_targets
8398                .entry(module_path.clone())
8399                .or_insert_with(|| target_file.clone());
8400            if raw_ref.kind == "reexport" {
8401                reexports.push(reexport_index_from_raw(raw_ref, target_file));
8402            }
8403        }
8404        Self {
8405            lang: Some(extract.lang),
8406            exports: extract.data.exported_symbols.iter().cloned().collect(),
8407            default_export: extract.data.default_export_symbol.clone(),
8408            export_aliases,
8409            node_by_scoped,
8410            node_by_bare,
8411            module_targets,
8412            reexports,
8413        }
8414    }
8415}
8416
8417fn load_db_file_indexes(
8418    tx: &Transaction<'_>,
8419    project_root: &Path,
8420) -> Result<HashMap<String, DbFileIndex>> {
8421    let mut files = HashMap::new();
8422    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8423    let rows = stmt.query_map([], |row| {
8424        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8425    })?;
8426    for row in rows {
8427        let (rel_path, lang) = row?;
8428        files.insert(
8429            rel_path.clone(),
8430            DbFileIndex {
8431                lang: lang_from_label(&lang),
8432                exports: HashSet::new(),
8433                default_export: None,
8434                export_aliases: HashMap::new(),
8435                node_by_scoped: HashMap::new(),
8436                node_by_bare: HashMap::new(),
8437                module_targets: HashMap::new(),
8438                reexports: Vec::new(),
8439            },
8440        );
8441    }
8442
8443    let mut node_stmt = tx.prepare(
8444        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8445    )?;
8446    let nodes = node_stmt.query_map([], |row| {
8447        Ok((
8448            row.get::<_, String>(0)?,
8449            row.get::<_, String>(1)?,
8450            row.get::<_, String>(2)?,
8451            row.get::<_, String>(3)?,
8452            row.get::<_, i64>(4)? != 0,
8453            row.get::<_, i64>(5)? != 0,
8454        ))
8455    })?;
8456    for row in nodes {
8457        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8458        let file = files
8459            .entry(file_path.clone())
8460            .or_insert_with(|| DbFileIndex {
8461                lang: None,
8462                exports: HashSet::new(),
8463                default_export: None,
8464                export_aliases: HashMap::new(),
8465                node_by_scoped: HashMap::new(),
8466                node_by_bare: HashMap::new(),
8467                module_targets: HashMap::new(),
8468                reexports: Vec::new(),
8469            });
8470        if exported {
8471            file.exports.insert(name.clone());
8472            file.exports.insert(scoped_name.clone());
8473        }
8474        if is_default_export {
8475            file.default_export = Some(scoped_name.clone());
8476        }
8477        file.node_by_scoped.insert(scoped_name, id.clone());
8478        file.node_by_bare.entry(name).or_insert(id);
8479    }
8480    let file_keys: HashSet<String> = files.keys().cloned().collect();
8481    // Persisted caller extracts supply import targets. Only reexports from other
8482    // files need dependency reconstruction, and their caller dependencies are
8483    // loaded once instead of issuing repeated SQLite queries per reference.
8484    let dependencies_by_file = load_file_dependencies_index(tx)?;
8485    let mut ref_stmt = tx.prepare(
8486        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8487         FROM refs WHERE kind IN ('reexport', 'export_alias')",
8488    )?;
8489    let ref_rows = ref_stmt.query_map([], |row| {
8490        Ok((
8491            row.get::<_, String>(0)?,
8492            row.get::<_, String>(1)?,
8493            row.get::<_, String>(2)?,
8494            row.get::<_, Option<String>>(3)?,
8495            row.get::<_, Option<String>>(4)?,
8496            row.get::<_, i64>(5)? != 0,
8497            row.get::<_, Option<String>>(6)?,
8498            row.get::<_, Option<String>>(7)?,
8499        ))
8500    })?;
8501    for row in ref_rows {
8502        let (
8503            ref_id,
8504            caller_file,
8505            kind,
8506            module_path,
8507            full_ref,
8508            wildcard,
8509            local_name,
8510            requested_name,
8511        ) = row?;
8512        if kind == "export_alias" {
8513            if let (Some(exported), Some(source_symbol), Some(file)) =
8514                (local_name, requested_name, files.get_mut(&caller_file))
8515            {
8516                file.export_aliases.insert(exported, source_symbol);
8517            }
8518            continue;
8519        }
8520        let Some(module_path) = module_path else {
8521            continue;
8522        };
8523        let file_deps = dependencies_by_file
8524            .get(&caller_file)
8525            .cloned()
8526            .unwrap_or_default();
8527        let deps = stored_dependencies_for_module(
8528            project_root,
8529            &caller_file,
8530            &module_path,
8531            &file_deps,
8532            &file_keys,
8533        );
8534        let target_file = deps
8535            .iter()
8536            .find(|dep| file_keys.contains(*dep))
8537            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8538        if let Some(file) = files.get_mut(&caller_file) {
8539            file.module_targets
8540                .entry(module_path.clone())
8541                .or_insert_with(|| target_file.clone());
8542            if kind == "reexport" {
8543                let raw = RawRef {
8544                    ref_id,
8545                    caller_node: None,
8546                    caller_symbol: None,
8547                    caller_file,
8548                    kind,
8549                    short_name: None,
8550                    full_ref,
8551                    module_path: Some(module_path),
8552                    import_kind: Some("reexport".to_string()),
8553                    local_name: None,
8554                    requested_name: None,
8555                    namespace_alias: None,
8556                    wildcard,
8557                    line: 0,
8558                    byte_start: 0,
8559                    byte_end: 0,
8560                    dependencies: deps,
8561                };
8562                file.reexports
8563                    .push(reexport_index_from_raw(&raw, target_file));
8564            }
8565        }
8566    }
8567
8568    Ok(files)
8569}
8570
8571fn stored_dependencies_for_module(
8572    project_root: &Path,
8573    caller_file: &str,
8574    module_path: &str,
8575    caller_dependencies: &BTreeSet<String>,
8576    indexed_files: &HashSet<String>,
8577) -> BTreeSet<String> {
8578    let caller_path = project_root.join(caller_file);
8579    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8580    if module_path.starts_with('.') {
8581        let caller_dir = caller_path.parent().unwrap_or(project_root);
8582        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8583            let normalized = if candidate.is_file() {
8584                canonicalize_path(&candidate)
8585            } else {
8586                candidate
8587            };
8588            candidates.insert(relative_path(project_root, &normalized));
8589        }
8590    }
8591    let exact = candidates
8592        .intersection(caller_dependencies)
8593        .filter(|dependency| indexed_files.contains(*dependency))
8594        .cloned()
8595        .collect::<BTreeSet<_>>();
8596    if !exact.is_empty() || module_path.starts_with('.') {
8597        return exact;
8598    }
8599
8600    let module_path = rust_module_path_without_alias_or_use_list(module_path)
8601        .trim_matches(|character| matches!(character, '\'' | '"'));
8602    let package_name = module_path
8603        .split('/')
8604        .next_back()
8605        .unwrap_or(module_path)
8606        .replace('_', "-");
8607    let matched = caller_dependencies
8608        .iter()
8609        .filter(|dependency| indexed_files.contains(*dependency))
8610        .filter(|dependency| {
8611            dependency.as_str() == module_path
8612                || dependency.ends_with(&format!("/{module_path}"))
8613                || Path::new(dependency).components().any(|component| {
8614                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8615                })
8616        })
8617        .cloned()
8618        .collect::<BTreeSet<_>>();
8619    if matched.len() == 1 {
8620        matched
8621    } else {
8622        BTreeSet::new()
8623    }
8624}
8625
8626fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8627    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8628    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8629    let rows = stmt.query_map([], |row| {
8630        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8631    })?;
8632    for row in rows {
8633        let (file_path, dependency) = row?;
8634        by_file.entry(file_path).or_default().insert(dependency);
8635    }
8636    Ok(by_file)
8637}
8638
8639struct ColdBuildInsertStatements<'stmt> {
8640    file: Statement<'stmt>,
8641    node: Statement<'stmt>,
8642    file_dependency: Statement<'stmt>,
8643    dispatch_hint: Statement<'stmt>,
8644    backend_state: Statement<'stmt>,
8645    reference: Statement<'stmt>,
8646    edge: Statement<'stmt>,
8647}
8648
8649impl<'stmt> ColdBuildInsertStatements<'stmt> {
8650    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8651        Ok(Self {
8652            file: tx.prepare(
8653                "INSERT OR REPLACE INTO files(
8654                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8655                    is_public_api, surface_fingerprint, indexed_at
8656                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8657            )?,
8658            node: tx.prepare(
8659                "INSERT OR REPLACE INTO nodes(
8660                    id, file_path, name, scoped_name, kind, start_line, start_col,
8661                    end_line, end_col, range_ordinal, signature, exported,
8662                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
8663                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8664            )?,
8665            file_dependency: tx.prepare(
8666                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8667            )?,
8668            dispatch_hint: tx.prepare(
8669                "INSERT OR REPLACE INTO dispatch_hints(
8670                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8671                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8672            )?,
8673            backend_state: tx.prepare(
8674                "INSERT OR REPLACE INTO backend_file_state(
8675                    backend, workspace_root, file_path, content_hash, status, updated_at
8676                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8677            )?,
8678            reference: tx.prepare(
8679                "INSERT OR REPLACE INTO refs(
8680                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8681                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8682                    byte_start, byte_end, status, target_node, target_file, target_symbol,
8683                    provenance
8684                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8685            )?,
8686            edge: tx.prepare(
8687                "INSERT OR REPLACE INTO edges(
8688                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8689                    kind, line, provenance
8690                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8691            )?,
8692        })
8693    }
8694}
8695
8696fn insert_file_extract_prepared(
8697    statements: &mut ColdBuildInsertStatements<'_>,
8698    workspace_root: &str,
8699    extract: &FileExtract,
8700) -> Result<()> {
8701    statements.file.execute(params![
8702        extract.rel_path,
8703        hash_to_hex(extract.freshness.content_hash),
8704        system_time_to_ns(extract.freshness.mtime),
8705        extract.freshness.size as i64,
8706        lang_label(extract.lang),
8707        extract.surface_fingerprint,
8708        unix_seconds_now(),
8709    ])?;
8710    for node in &extract.nodes {
8711        statements.node.execute(params![
8712            node.id,
8713            node.file_path,
8714            node.name,
8715            node.scoped_name,
8716            node.kind,
8717            node.range.start_line as i64,
8718            node.range.start_col as i64,
8719            node.range.end_line as i64,
8720            node.range.end_col as i64,
8721            node.range_ordinal as i64,
8722            node.signature,
8723            bool_int(node.exported),
8724            bool_int(node.is_default_export),
8725            bool_int(node.is_type_like),
8726            bool_int(node.is_callgraph_entry_point),
8727            PROVENANCE_TREESITTER,
8728        ])?;
8729    }
8730
8731    let mut dependencies = BTreeSet::new();
8732    for raw_ref in &extract.raw_refs {
8733        dependencies.extend(raw_ref.dependencies.iter().cloned());
8734    }
8735    for dep_file in &dependencies {
8736        statements
8737            .file_dependency
8738            .execute(params![extract.rel_path, dep_file])?;
8739    }
8740
8741    for hint in &extract.dispatch_hints {
8742        statements.dispatch_hint.execute(params![
8743            hint.id,
8744            hint.method_name,
8745            hint.caller_node,
8746            hint.file,
8747            hint.line as i64,
8748            hint.byte_start as i64,
8749            hint.byte_end as i64,
8750            PROVENANCE_TREESITTER,
8751        ])?;
8752    }
8753    insert_backend_state_prepared(
8754        &mut statements.backend_state,
8755        workspace_root,
8756        &extract.rel_path,
8757        Some(&extract.freshness.content_hash),
8758        "fresh",
8759    )?;
8760    Ok(())
8761}
8762
8763fn insert_backend_state_prepared(
8764    stmt: &mut Statement<'_>,
8765    workspace_root: &str,
8766    rel_path: &str,
8767    content_hash: Option<&blake3::Hash>,
8768    status: &str,
8769) -> Result<()> {
8770    let hash = content_hash
8771        .map(|hash| hash_to_hex(*hash))
8772        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8773    stmt.execute(params![
8774        BACKEND_TREESITTER,
8775        workspace_root,
8776        rel_path,
8777        hash,
8778        status,
8779        unix_seconds_now(),
8780    ])?;
8781    Ok(())
8782}
8783
8784fn insert_resolved_ref_prepared(
8785    statements: &mut ColdBuildInsertStatements<'_>,
8786    resolved: &ResolvedRef,
8787) -> Result<()> {
8788    let raw = &resolved.raw;
8789    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8790    statements.reference.execute(params![
8791        raw.ref_id,
8792        raw.caller_node,
8793        raw.caller_file,
8794        raw.kind,
8795        raw.short_name,
8796        raw.full_ref,
8797        raw.module_path,
8798        raw.import_kind,
8799        raw.local_name,
8800        raw.requested_name,
8801        raw.namespace_alias,
8802        bool_int(raw.wildcard),
8803        raw.line as i64,
8804        raw.byte_start as i64,
8805        raw.byte_end as i64,
8806        resolved.status,
8807        resolved.target_node,
8808        resolved.target_file,
8809        resolved.target_symbol,
8810        PROVENANCE_TREESITTER,
8811    ])?;
8812    if let Some(edge) = &resolved.edge {
8813        statements.edge.execute(params![
8814            edge.edge_id,
8815            raw.ref_id,
8816            edge.source_node,
8817            edge.target_node,
8818            edge.target_file,
8819            edge.target_symbol,
8820            edge.kind,
8821            edge.line as i64,
8822            PROVENANCE_TREESITTER,
8823        ])?;
8824    }
8825    Ok(())
8826}
8827
8828fn insert_file_extract(
8829    tx: &Transaction<'_>,
8830    project_root: &Path,
8831    extract: &FileExtract,
8832) -> Result<()> {
8833    tx.execute(
8834        "INSERT OR REPLACE INTO files(
8835            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8836            is_public_api, surface_fingerprint, indexed_at
8837        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8838        params![
8839            extract.rel_path,
8840            hash_to_hex(extract.freshness.content_hash),
8841            system_time_to_ns(extract.freshness.mtime),
8842            extract.freshness.size as i64,
8843            lang_label(extract.lang),
8844            extract.surface_fingerprint,
8845            unix_seconds_now(),
8846        ],
8847    )?;
8848    for node in &extract.nodes {
8849        tx.execute(
8850            "INSERT OR REPLACE INTO nodes(
8851                id, file_path, name, scoped_name, kind, start_line, start_col,
8852                end_line, end_col, range_ordinal, signature, exported,
8853                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8854            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8855            params![
8856                node.id,
8857                node.file_path,
8858                node.name,
8859                node.scoped_name,
8860                node.kind,
8861                node.range.start_line as i64,
8862                node.range.start_col as i64,
8863                node.range.end_line as i64,
8864                node.range.end_col as i64,
8865                node.range_ordinal as i64,
8866                node.signature,
8867                bool_int(node.exported),
8868                bool_int(node.is_default_export),
8869                bool_int(node.is_type_like),
8870                bool_int(node.is_callgraph_entry_point),
8871                PROVENANCE_TREESITTER,
8872            ],
8873        )?;
8874    }
8875    let mut dependencies = BTreeSet::new();
8876    for raw_ref in &extract.raw_refs {
8877        dependencies.extend(raw_ref.dependencies.iter().cloned());
8878    }
8879    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8880
8881    for hint in &extract.dispatch_hints {
8882        tx.execute(
8883            "INSERT OR REPLACE INTO dispatch_hints(
8884                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8885            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8886            params![
8887                hint.id,
8888                hint.method_name,
8889                hint.caller_node,
8890                hint.file,
8891                hint.line as i64,
8892                hint.byte_start as i64,
8893                hint.byte_end as i64,
8894                PROVENANCE_TREESITTER,
8895            ],
8896        )?;
8897    }
8898    mark_backend_state(
8899        tx,
8900        project_root,
8901        &extract.rel_path,
8902        Some(&extract.freshness.content_hash),
8903        "fresh",
8904    )?;
8905    Ok(())
8906}
8907
8908fn insert_file_dependencies(
8909    tx: &Transaction<'_>,
8910    file_path: &str,
8911    dependencies: &BTreeSet<String>,
8912) -> Result<()> {
8913    for dep_file in dependencies {
8914        tx.execute(
8915            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8916            params![file_path, dep_file],
8917        )?;
8918    }
8919    Ok(())
8920}
8921
8922fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8923    let raw = &resolved.raw;
8924    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8925    tx.execute(
8926        "INSERT OR REPLACE INTO refs(
8927            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8928            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8929            byte_start, byte_end, status, target_node, target_file, target_symbol,
8930            provenance
8931        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8932        params![
8933            raw.ref_id,
8934            raw.caller_node,
8935            raw.caller_file,
8936            raw.kind,
8937            raw.short_name,
8938            raw.full_ref,
8939            raw.module_path,
8940            raw.import_kind,
8941            raw.local_name,
8942            raw.requested_name,
8943            raw.namespace_alias,
8944            bool_int(raw.wildcard),
8945            raw.line as i64,
8946            raw.byte_start as i64,
8947            raw.byte_end as i64,
8948            resolved.status,
8949            resolved.target_node,
8950            resolved.target_file,
8951            resolved.target_symbol,
8952            PROVENANCE_TREESITTER,
8953        ],
8954    )?;
8955    if let Some(edge) = &resolved.edge {
8956        tx.execute(
8957            "INSERT OR REPLACE INTO edges(
8958                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8959                kind, line, provenance
8960            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8961            params![
8962                edge.edge_id,
8963                raw.ref_id,
8964                edge.source_node,
8965                edge.target_node,
8966                edge.target_file,
8967                edge.target_symbol,
8968                edge.kind,
8969                edge.line as i64,
8970                PROVENANCE_TREESITTER,
8971            ],
8972        )?;
8973    }
8974    Ok(())
8975}
8976
8977fn insert_method_dispatch_edges(
8978    tx: &Transaction<'_>,
8979    project_root: &Path,
8980    caller_files: Option<&BTreeSet<String>>,
8981) -> Result<usize> {
8982    let references = load_name_match_refs(tx, caller_files)?;
8983    if references.is_empty() {
8984        return Ok(0);
8985    }
8986
8987    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8988    let mut source_cache: DispatchSourceCache = HashMap::new();
8989    let mut inserted = 0usize;
8990    for reference in references {
8991        let key = (reference.method_name.clone(), reference.lang.clone());
8992        let candidates = match candidates_by_name.entry(key) {
8993            Entry::Occupied(entry) => entry.into_mut(),
8994            Entry::Vacant(entry) => {
8995                let candidates =
8996                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8997                entry.insert(candidates)
8998            }
8999        };
9000
9001        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9002            ReceiverTypeInference::Known(receiver_type) => {
9003                let Some(candidate) =
9004                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9005                else {
9006                    continue;
9007                };
9008                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9009                inserted += 1;
9010                continue;
9011            }
9012            ReceiverTypeInference::RustDirectSelfField {
9013                receiver_type,
9014                declaration_file,
9015                module_scope,
9016            } => {
9017                let Some(candidate) = select_rust_direct_self_field_candidate(
9018                    project_root,
9019                    &reference,
9020                    candidates.as_slice(),
9021                    &receiver_type,
9022                    &declaration_file,
9023                    &module_scope,
9024                    &mut source_cache,
9025                ) else {
9026                    continue;
9027                };
9028                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9029                inserted += 1;
9030                continue;
9031            }
9032            ReceiverTypeInference::KnownButUnresolved => continue,
9033            ReceiverTypeInference::Unknown => {}
9034        }
9035
9036        if method_name_match_denylisted(&reference.method_name) {
9037            continue;
9038        }
9039
9040        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9041            continue;
9042        };
9043        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9044        inserted += 1;
9045    }
9046    Ok(inserted)
9047}
9048
9049fn insert_method_dispatch_edges_chunked(
9050    tx: &Transaction<'_>,
9051    project_root: &Path,
9052    caller_files: &BTreeSet<String>,
9053    chunk_size: usize,
9054) -> Result<usize> {
9055    if caller_files.is_empty() {
9056        return Ok(0);
9057    }
9058    if chunk_size == 0 || caller_files.len() <= chunk_size {
9059        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9060    }
9061
9062    let mut inserted = 0usize;
9063    let mut batch = BTreeSet::new();
9064    for caller_file in caller_files {
9065        batch.insert(caller_file.clone());
9066        if batch.len() == chunk_size {
9067            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9068            batch.clear();
9069        }
9070    }
9071    if !batch.is_empty() {
9072        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9073    }
9074    Ok(inserted)
9075}
9076
9077fn insert_method_dispatch_edge(
9078    tx: &Transaction<'_>,
9079    reference: &NameMatchRef,
9080    candidate: &NameMatchCandidate,
9081    provenance: &str,
9082) -> Result<()> {
9083    tx.execute(
9084        "INSERT OR REPLACE INTO edges(
9085            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9086            kind, line, provenance
9087        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9088        params![
9089            ref_id(&[&reference.ref_id, provenance, "edge"]),
9090            &reference.ref_id,
9091            &reference.caller_node,
9092            &candidate.node_id,
9093            &candidate.file_path,
9094            &candidate.scoped_name,
9095            reference.line as i64,
9096            provenance,
9097        ],
9098    )?;
9099    Ok(())
9100}
9101
9102fn delete_method_dispatch_edges_for_callers(
9103    tx: &Transaction<'_>,
9104    caller_files: &BTreeSet<String>,
9105) -> Result<()> {
9106    if caller_files.is_empty() {
9107        return Ok(());
9108    }
9109
9110    let mut stmt = tx.prepare(
9111        "DELETE FROM edges
9112         WHERE provenance IN (?1, ?2)
9113           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9114    )?;
9115    for caller_file in caller_files {
9116        stmt.execute(params![
9117            PROVENANCE_NAME_MATCH,
9118            PROVENANCE_TYPE_MATCH,
9119            caller_file
9120        ])?;
9121    }
9122    Ok(())
9123}
9124
9125fn load_name_match_refs(
9126    tx: &Transaction<'_>,
9127    caller_files: Option<&BTreeSet<String>>,
9128) -> Result<Vec<NameMatchRef>> {
9129    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9130                           n.signature, r.short_name, r.full_ref, r.line, f.lang
9131                    FROM refs r
9132                    JOIN files f ON f.path = r.caller_file
9133                    JOIN nodes n ON n.id = r.caller_node
9134                    WHERE r.kind = 'call'
9135                      AND r.status = 'unresolved'
9136                      AND r.caller_node IS NOT NULL
9137                      AND r.full_ref IS NOT NULL
9138                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9139                      AND NOT EXISTS (
9140                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9141                      )";
9142    let mut references = Vec::new();
9143
9144    if let Some(caller_files) = caller_files {
9145        if caller_files.is_empty() {
9146            return Ok(references);
9147        }
9148        let sql = format!(
9149            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9150        );
9151        let mut stmt = tx.prepare(&sql)?;
9152        for caller_file in caller_files {
9153            let rows = stmt.query_map(params![caller_file], |row| {
9154                Ok((
9155                    row.get::<_, String>(0)?,
9156                    row.get::<_, Option<String>>(1)?,
9157                    row.get::<_, String>(2)?,
9158                    row.get::<_, String>(3)?,
9159                    row.get::<_, Option<String>>(4)?,
9160                    row.get::<_, Option<String>>(5)?,
9161                    row.get::<_, Option<String>>(6)?,
9162                    row.get::<_, i64>(7)?,
9163                    row.get::<_, String>(8)?,
9164                ))
9165            })?;
9166            for row in rows {
9167                let (
9168                    ref_id,
9169                    caller_node,
9170                    caller_file,
9171                    caller_symbol,
9172                    caller_signature,
9173                    short_name,
9174                    full_ref,
9175                    line,
9176                    lang,
9177                ) = row?;
9178                if let Some(reference) = name_match_ref_from_parts(
9179                    ref_id,
9180                    caller_node,
9181                    caller_file,
9182                    caller_symbol,
9183                    caller_signature,
9184                    short_name,
9185                    full_ref,
9186                    line,
9187                    lang,
9188                ) {
9189                    references.push(reference);
9190                }
9191            }
9192        }
9193        return Ok(references);
9194    }
9195
9196    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9197    let mut stmt = tx.prepare(&sql)?;
9198    let rows = stmt.query_map([], |row| {
9199        Ok((
9200            row.get::<_, String>(0)?,
9201            row.get::<_, Option<String>>(1)?,
9202            row.get::<_, String>(2)?,
9203            row.get::<_, String>(3)?,
9204            row.get::<_, Option<String>>(4)?,
9205            row.get::<_, Option<String>>(5)?,
9206            row.get::<_, Option<String>>(6)?,
9207            row.get::<_, i64>(7)?,
9208            row.get::<_, String>(8)?,
9209        ))
9210    })?;
9211    for row in rows {
9212        let (
9213            ref_id,
9214            caller_node,
9215            caller_file,
9216            caller_symbol,
9217            caller_signature,
9218            short_name,
9219            full_ref,
9220            line,
9221            lang,
9222        ) = row?;
9223        if let Some(reference) = name_match_ref_from_parts(
9224            ref_id,
9225            caller_node,
9226            caller_file,
9227            caller_symbol,
9228            caller_signature,
9229            short_name,
9230            full_ref,
9231            line,
9232            lang,
9233        ) {
9234            references.push(reference);
9235        }
9236    }
9237    Ok(references)
9238}
9239
9240#[allow(clippy::too_many_arguments)]
9241fn name_match_ref_from_parts(
9242    ref_id: String,
9243    caller_node: Option<String>,
9244    caller_file: String,
9245    caller_symbol: String,
9246    caller_signature: Option<String>,
9247    short_name: Option<String>,
9248    full_ref: Option<String>,
9249    line: i64,
9250    lang: String,
9251) -> Option<NameMatchRef> {
9252    let caller_node = caller_node?;
9253    let full_ref = full_ref?;
9254    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9255    let method_name = if member.is_empty() {
9256        short_name.as_deref()?.to_string()
9257    } else {
9258        member
9259    };
9260    Some(NameMatchRef {
9261        ref_id,
9262        caller_node,
9263        caller_file,
9264        caller_symbol,
9265        caller_signature,
9266        receiver_expression,
9267        receiver,
9268        method_name,
9269        colon_dispatch,
9270        line: line.max(0) as u32,
9271        lang,
9272    })
9273}
9274
9275fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9276    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9277    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9278    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9279    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9280        .into_iter()
9281        .flatten()
9282        .max_by_key(|(index, _, _)| *index)?;
9283    if delimiter == 0 {
9284        return None;
9285    }
9286    let member_start = delimiter + delimiter_len;
9287    if member_start >= full_ref.len() {
9288        return None;
9289    }
9290    let receiver_expression = full_ref[..delimiter].trim();
9291    let receiver = last_name_segment(receiver_expression).trim();
9292    let member = &full_ref[member_start..];
9293    if receiver.is_empty() || member.is_empty() {
9294        return None;
9295    }
9296    Some((
9297        receiver_expression.to_string(),
9298        receiver.to_string(),
9299        member.to_string(),
9300        colon_dispatch,
9301    ))
9302}
9303
9304fn last_name_segment(value: &str) -> &str {
9305    value
9306        .rsplit(['.', ':', '/', '\\', '-', '>'])
9307        .find(|segment| !segment.is_empty())
9308        .unwrap_or(value)
9309}
9310
9311fn load_name_match_candidates(
9312    tx: &Transaction<'_>,
9313    method_name: &str,
9314    lang: &str,
9315) -> Result<Vec<NameMatchCandidate>> {
9316    let mut stmt = tx.prepare(
9317        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9318         FROM nodes n JOIN files f ON f.path = n.file_path
9319         WHERE n.name = ?1
9320           AND f.lang = ?2
9321           AND n.kind IN ('method', 'function')
9322         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9323    )?;
9324    let rows = stmt.query_map(params![method_name, lang], |row| {
9325        Ok(NameMatchCandidate {
9326            node_id: row.get(0)?,
9327            file_path: row.get(1)?,
9328            scoped_name: row.get(2)?,
9329            kind: row.get(3)?,
9330            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9331        })
9332    })?;
9333    rows.collect::<std::result::Result<Vec<_>, _>>()
9334        .map_err(Into::into)
9335}
9336
9337struct ParsedDispatchSource {
9338    source: String,
9339    tree: tree_sitter::Tree,
9340}
9341
9342type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9343
9344#[derive(Debug, Clone, PartialEq, Eq)]
9345enum ReceiverTypeInference {
9346    Unknown,
9347    Known(String),
9348    RustDirectSelfField {
9349        receiver_type: String,
9350        declaration_file: String,
9351        module_scope: Vec<(usize, usize)>,
9352    },
9353    KnownButUnresolved,
9354}
9355
9356#[cfg(test)]
9357fn infer_receiver_type(
9358    project_root: &Path,
9359    reference: &NameMatchRef,
9360    source_cache: &mut DispatchSourceCache,
9361) -> Option<String> {
9362    match infer_receiver_type_state(project_root, reference, source_cache) {
9363        ReceiverTypeInference::Known(receiver_type)
9364        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9365        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9366    }
9367}
9368
9369fn infer_receiver_type_state(
9370    project_root: &Path,
9371    reference: &NameMatchRef,
9372    source_cache: &mut DispatchSourceCache,
9373) -> ReceiverTypeInference {
9374    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9375    match reference.lang.as_str() {
9376        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9377        "java" => {
9378            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9379                .map(known)
9380                .unwrap_or(ReceiverTypeInference::Unknown)
9381        }
9382        "kotlin" => {
9383            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9384                .map(known)
9385                .unwrap_or(ReceiverTypeInference::Unknown)
9386        }
9387        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9388            .map(known)
9389            .unwrap_or(ReceiverTypeInference::Unknown),
9390        _ => ReceiverTypeInference::Unknown,
9391    }
9392}
9393
9394fn parse_dispatch_source(
9395    project_root: &Path,
9396    caller_file: &str,
9397    lang: LangId,
9398) -> Option<ParsedDispatchSource> {
9399    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9400    let grammar = crate::parser::grammar_for(lang);
9401    let mut parser = tree_sitter::Parser::new();
9402    parser.set_language(&grammar).ok()?;
9403    let tree = parser.parse(&source, None)?;
9404    Some(ParsedDispatchSource { source, tree })
9405}
9406
9407fn parsed_dispatch_source<'a>(
9408    project_root: &Path,
9409    reference: &NameMatchRef,
9410    lang: LangId,
9411    source_cache: &'a mut DispatchSourceCache,
9412) -> Option<&'a ParsedDispatchSource> {
9413    parsed_dispatch_source_for_file(
9414        project_root,
9415        &reference.caller_file,
9416        &reference.lang,
9417        lang,
9418        source_cache,
9419    )
9420}
9421
9422fn parsed_dispatch_source_for_file<'a>(
9423    project_root: &Path,
9424    file_path: &str,
9425    lang_label: &str,
9426    lang: LangId,
9427    source_cache: &'a mut DispatchSourceCache,
9428) -> Option<&'a ParsedDispatchSource> {
9429    let key = (file_path.to_string(), lang_label.to_string());
9430    source_cache
9431        .entry(key)
9432        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9433        .as_ref()
9434}
9435
9436fn infer_java_like_receiver_type(
9437    project_root: &Path,
9438    reference: &NameMatchRef,
9439    lang: LangId,
9440    source_cache: &mut DispatchSourceCache,
9441) -> Option<String> {
9442    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9443        return None;
9444    }
9445
9446    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9447    let root = parsed.tree.root_node();
9448    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9449
9450    let callable_scope = type_node
9451        .and_then(|node| {
9452            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9453        })
9454        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9455
9456    if let Some(callable_scope) = callable_scope {
9457        if let Some(receiver_type) = infer_java_like_local_receiver_type(
9458            callable_scope,
9459            &parsed.source,
9460            &reference.receiver,
9461            reference.line.max(1),
9462            lang,
9463        ) {
9464            return Some(receiver_type);
9465        }
9466    }
9467
9468    type_node.and_then(|node| {
9469        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9470    })
9471}
9472
9473fn infer_cpp_receiver_type(
9474    project_root: &Path,
9475    reference: &NameMatchRef,
9476    source_cache: &mut DispatchSourceCache,
9477) -> Option<String> {
9478    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9479        return None;
9480    }
9481
9482    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9483    let root = parsed.tree.root_node();
9484    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9485    infer_cpp_receiver_type_from_scope(
9486        scope,
9487        &parsed.source,
9488        &reference.receiver,
9489        reference.line.max(1),
9490    )
9491}
9492
9493fn find_enclosing_java_like_type_node<'tree>(
9494    root: tree_sitter::Node<'tree>,
9495    source: &str,
9496    reference: &NameMatchRef,
9497    lang: LangId,
9498) -> Option<tree_sitter::Node<'tree>> {
9499    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9500        .and_then(|name| simple_type_name(&name));
9501    let line = reference.line.max(1);
9502    let mut best = None;
9503    let mut stack = vec![root];
9504    while let Some(node) = stack.pop() {
9505        if !node_contains_line(node, line) {
9506            continue;
9507        }
9508        if is_java_like_type_kind(node.kind(), lang) {
9509            let name = declaration_name(node, source);
9510            if expected_type
9511                .as_deref()
9512                .is_none_or(|expected| name == Some(expected))
9513            {
9514                best = tighter_node(best, node);
9515            }
9516        }
9517        push_named_children(node, &mut stack);
9518    }
9519    best
9520}
9521
9522fn find_enclosing_java_like_callable_node<'tree>(
9523    root: tree_sitter::Node<'tree>,
9524    source: &str,
9525    reference: &NameMatchRef,
9526    lang: LangId,
9527) -> Option<tree_sitter::Node<'tree>> {
9528    let expected_name = reference.caller_symbol.rsplit("::").next();
9529    let line = reference.line.max(1);
9530    let mut best = None;
9531    let mut stack = vec![root];
9532    while let Some(node) = stack.pop() {
9533        if !node_contains_line(node, line) {
9534            continue;
9535        }
9536        if is_java_like_callable_kind(node.kind(), lang) {
9537            let name = declaration_name(node, source);
9538            if expected_name.is_none_or(|expected| name == Some(expected)) {
9539                best = tighter_node(best, node);
9540            }
9541        }
9542        push_named_children(node, &mut stack);
9543    }
9544    best
9545}
9546
9547fn find_enclosing_cpp_callable_node<'tree>(
9548    root: tree_sitter::Node<'tree>,
9549    _source: &str,
9550    reference: &NameMatchRef,
9551) -> Option<tree_sitter::Node<'tree>> {
9552    let line = reference.line.max(1);
9553    let mut best = None;
9554    let mut stack = vec![root];
9555    while let Some(node) = stack.pop() {
9556        if !node_contains_line(node, line) {
9557            continue;
9558        }
9559        if node.kind() == "function_definition" {
9560            best = tighter_node(best, node);
9561        }
9562        push_named_children(node, &mut stack);
9563    }
9564    best
9565}
9566
9567fn tighter_node<'tree>(
9568    current: Option<tree_sitter::Node<'tree>>,
9569    candidate: tree_sitter::Node<'tree>,
9570) -> Option<tree_sitter::Node<'tree>> {
9571    match current {
9572        Some(current)
9573            if current.start_byte() > candidate.start_byte()
9574                || (current.start_byte() == candidate.start_byte()
9575                    && current.end_byte() <= candidate.end_byte()) =>
9576        {
9577            Some(current)
9578        }
9579        _ => Some(candidate),
9580    }
9581}
9582
9583fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9584    let start = node.start_position().row as u32 + 1;
9585    let end = node.end_position().row as u32 + 1;
9586    start <= line && line <= end
9587}
9588
9589fn push_named_children<'tree>(
9590    node: tree_sitter::Node<'tree>,
9591    stack: &mut Vec<tree_sitter::Node<'tree>>,
9592) {
9593    for index in 0..node.named_child_count() {
9594        if let Some(child) = node.named_child(index as u32) {
9595            stack.push(child);
9596        }
9597    }
9598}
9599
9600fn declaration_name<'source>(
9601    node: tree_sitter::Node<'_>,
9602    source: &'source str,
9603) -> Option<&'source str> {
9604    node.child_by_field_name("name")
9605        .map(|name| node_text(name, source))
9606        .or_else(|| {
9607            first_named_child_text(
9608                node,
9609                source,
9610                &["identifier", "type_identifier", "simple_identifier"],
9611            )
9612        })
9613}
9614
9615fn first_named_child_text<'source>(
9616    node: tree_sitter::Node<'_>,
9617    source: &'source str,
9618    kinds: &[&str],
9619) -> Option<&'source str> {
9620    for index in 0..node.named_child_count() {
9621        let child = node.named_child(index as u32)?;
9622        if kinds.contains(&child.kind()) {
9623            return Some(node_text(child, source));
9624        }
9625    }
9626    None
9627}
9628
9629fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9630    &source[node.byte_range()]
9631}
9632
9633fn infer_java_like_field_receiver_type(
9634    type_node: tree_sitter::Node<'_>,
9635    source: &str,
9636    receiver: &str,
9637    lang: LangId,
9638) -> Option<String> {
9639    let mut stack = Vec::new();
9640    push_named_children(type_node, &mut stack);
9641    while let Some(node) = stack.pop() {
9642        if is_java_like_field_kind(node.kind(), lang) {
9643            if let Some(receiver_type) =
9644                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9645            {
9646                return Some(receiver_type);
9647            }
9648        }
9649        if is_java_like_type_kind(node.kind(), lang)
9650            || is_java_like_callable_kind(node.kind(), lang)
9651        {
9652            continue;
9653        }
9654        push_named_children(node, &mut stack);
9655    }
9656    None
9657}
9658
9659fn infer_java_like_local_receiver_type(
9660    callable_node: tree_sitter::Node<'_>,
9661    source: &str,
9662    receiver: &str,
9663    call_line: u32,
9664    lang: LangId,
9665) -> Option<String> {
9666    let mut best: Option<(u32, String)> = None;
9667    let mut stack = Vec::new();
9668    push_named_children(callable_node, &mut stack);
9669    while let Some(node) = stack.pop() {
9670        let start_line = node.start_position().row as u32 + 1;
9671        if start_line > call_line {
9672            continue;
9673        }
9674        if is_java_like_local_kind(node.kind(), lang) {
9675            if let Some(receiver_type) =
9676                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9677            {
9678                if best
9679                    .as_ref()
9680                    .is_none_or(|(best_line, _)| start_line >= *best_line)
9681                {
9682                    best = Some((start_line, receiver_type));
9683                }
9684            }
9685        }
9686        if is_java_like_type_kind(node.kind(), lang)
9687            || is_java_like_callable_kind(node.kind(), lang)
9688        {
9689            continue;
9690        }
9691        push_named_children(node, &mut stack);
9692    }
9693    best.map(|(_, receiver_type)| receiver_type)
9694}
9695
9696fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9697    match lang {
9698        LangId::Java => matches!(
9699            kind,
9700            "class_declaration"
9701                | "interface_declaration"
9702                | "enum_declaration"
9703                | "record_declaration"
9704                | "annotation_type_declaration"
9705        ),
9706        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9707        _ => false,
9708    }
9709}
9710
9711fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9712    match lang {
9713        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9714        LangId::Kotlin => kind == "function_declaration",
9715        _ => false,
9716    }
9717}
9718
9719fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9720    match lang {
9721        LangId::Java => kind == "field_declaration",
9722        LangId::Kotlin => kind == "property_declaration",
9723        _ => false,
9724    }
9725}
9726
9727fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9728    match lang {
9729        LangId::Java => kind == "local_variable_declaration",
9730        LangId::Kotlin => kind == "property_declaration",
9731        _ => false,
9732    }
9733}
9734
9735fn extract_java_like_declared_type(
9736    declaration: &str,
9737    receiver: &str,
9738    lang: LangId,
9739) -> Option<String> {
9740    match lang {
9741        LangId::Java => extract_java_declared_type(declaration, receiver),
9742        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9743        _ => None,
9744    }
9745}
9746
9747fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9748    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9749    let after = declaration[receiver_start + receiver.len()..].trim_start();
9750    if after
9751        .chars()
9752        .next()
9753        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9754    {
9755        return None;
9756    }
9757
9758    let before = declaration[..receiver_start].trim_end();
9759    if before.contains(',') {
9760        return None;
9761    }
9762    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9763}
9764
9765fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9766    loop {
9767        value = value.trim_start();
9768        if let Some(stripped) = strip_leading_java_annotation(value) {
9769            value = stripped;
9770            continue;
9771        }
9772        if let Some(stripped) = strip_leading_java_modifier(value) {
9773            value = stripped;
9774            continue;
9775        }
9776        return value.trim();
9777    }
9778}
9779
9780fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9781    let value = value.trim_start();
9782    let mut chars = value.char_indices();
9783    let (_, first) = chars.next()?;
9784    if first != '@' {
9785        return None;
9786    }
9787    let mut end = first.len_utf8();
9788    for (index, ch) in chars {
9789        if !(is_code_ident_char(ch) || ch == '.') {
9790            end = index;
9791            break;
9792        }
9793        end = index + ch.len_utf8();
9794    }
9795    let rest = value[end..].trim_start();
9796    if let Some(stripped) = rest.strip_prefix('(') {
9797        let mut depth = 1usize;
9798        for (index, ch) in stripped.char_indices() {
9799            match ch {
9800                '(' => depth += 1,
9801                ')' => {
9802                    depth = depth.saturating_sub(1);
9803                    if depth == 0 {
9804                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9805                    }
9806                }
9807                _ => {}
9808            }
9809        }
9810        return Some("");
9811    }
9812    Some(rest)
9813}
9814
9815fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9816    const MODIFIERS: &[&str] = &[
9817        "public",
9818        "protected",
9819        "private",
9820        "abstract",
9821        "static",
9822        "final",
9823        "transient",
9824        "volatile",
9825        "synchronized",
9826        "native",
9827        "strictfp",
9828    ];
9829    MODIFIERS
9830        .iter()
9831        .find_map(|modifier| strip_leading_word(value, modifier))
9832}
9833
9834fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9835    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9836    let before = &declaration[..receiver_start];
9837    if find_identifier_occurrence(before, "val").is_none()
9838        && find_identifier_occurrence(before, "var").is_none()
9839    {
9840        return None;
9841    }
9842
9843    let after = declaration[receiver_start + receiver.len()..].trim_start();
9844    if let Some(type_text) = after.strip_prefix(':') {
9845        return normalize_receiver_type_name(read_type_prefix(type_text));
9846    }
9847    after
9848        .strip_prefix('=')
9849        .and_then(infer_kotlin_constructor_type)
9850}
9851
9852fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9853    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9854    if rest.trim_start().starts_with('(') {
9855        normalize_receiver_type_name(head)
9856    } else {
9857        None
9858    }
9859}
9860
9861fn read_type_prefix(value: &str) -> &str {
9862    let mut angle_depth = 0usize;
9863    for (index, ch) in value.char_indices() {
9864        match ch {
9865            '<' => angle_depth += 1,
9866            '>' => angle_depth = angle_depth.saturating_sub(1),
9867            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9868                return value[..index].trim();
9869            }
9870            _ => {}
9871        }
9872    }
9873    value.trim()
9874}
9875
9876fn infer_cpp_receiver_type_from_scope(
9877    scope: tree_sitter::Node<'_>,
9878    source: &str,
9879    receiver: &str,
9880    call_line: u32,
9881) -> Option<String> {
9882    let lines = source.lines().collect::<Vec<_>>();
9883    if lines.is_empty() {
9884        return None;
9885    }
9886    let scope_start = scope.start_position().row as usize;
9887    let call_index = (call_line as usize)
9888        .saturating_sub(1)
9889        .min(lines.len().saturating_sub(1));
9890    for index in (scope_start..=call_index).rev() {
9891        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9892            return Some(receiver_type);
9893        }
9894    }
9895    None
9896}
9897
9898fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9899    for receiver_start in identifier_occurrences(line, receiver) {
9900        let after = line[receiver_start + receiver.len()..].trim_start();
9901        if after
9902            .chars()
9903            .next()
9904            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9905        {
9906            continue;
9907        }
9908        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9909        let normalized = normalize_cpp_type_name(type_text)?;
9910        if normalized == "auto" {
9911            if let Some(rhs) = after.strip_prefix('=') {
9912                return infer_cpp_auto_receiver_type(rhs);
9913            }
9914            continue;
9915        }
9916        return Some(normalized);
9917    }
9918    None
9919}
9920
9921fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9922    let candidate = prefix
9923        .rsplit([';', '{', '}', '('])
9924        .next()
9925        .unwrap_or(prefix)
9926        .trim();
9927    if candidate.is_empty() || candidate.ends_with(',') {
9928        None
9929    } else {
9930        Some(candidate)
9931    }
9932}
9933
9934fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9935    let without_templates = strip_angle_groups(type_text);
9936    let mut cleaned = String::with_capacity(without_templates.len());
9937    for token in without_templates.split_whitespace() {
9938        if matches!(
9939            token,
9940            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9941        ) {
9942            continue;
9943        }
9944        if !cleaned.is_empty() {
9945            cleaned.push(' ');
9946        }
9947        cleaned.push_str(token);
9948    }
9949    let token = cleaned
9950        .split_whitespace()
9951        .last()
9952        .unwrap_or(cleaned.trim())
9953        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9954        .trim_matches(['*', '&']);
9955    let simple = token.rsplit("::").next().unwrap_or(token).trim();
9956    if simple.is_empty() || cpp_non_type_token(simple) {
9957        None
9958    } else {
9959        Some(simple.to_string())
9960    }
9961}
9962
9963fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9964    let rhs = rhs.trim_start();
9965    if let Some(after_new) = rhs.strip_prefix("new ") {
9966        return infer_cpp_constructor_type(after_new);
9967    }
9968    infer_cpp_make_template_type(rhs)
9969        .or_else(|| infer_cpp_constructor_type(rhs))
9970        .or_else(|| infer_cpp_factory_type(rhs))
9971}
9972
9973fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9974    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9975    let normalized = normalize_cpp_type_name(head)?;
9976    if !normalized
9977        .chars()
9978        .next()
9979        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9980    {
9981        return None;
9982    }
9983    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9984        Some(normalized)
9985    } else {
9986        None
9987    }
9988}
9989
9990fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9991    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9992    if !rest.trim_start().starts_with('(') {
9993        return None;
9994    }
9995    let base = head.split('<').next().unwrap_or(head);
9996    let base_simple = base.rsplit("::").next().unwrap_or(base);
9997    if !matches!(base_simple, "make_unique" | "make_shared") {
9998        return None;
9999    }
10000    first_angle_arg(head).and_then(normalize_cpp_type_name)
10001}
10002
10003fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10004    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10005    if !rest.trim_start().starts_with('(') {
10006        return None;
10007    }
10008    let simple = head
10009        .split('<')
10010        .next()
10011        .unwrap_or(head)
10012        .rsplit("::")
10013        .next()
10014        .unwrap_or(head);
10015    for prefix in ["make", "create", "build"] {
10016        if let Some(suffix) = simple.strip_prefix(prefix) {
10017            if suffix
10018                .chars()
10019                .next()
10020                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10021            {
10022                return normalize_cpp_type_name(suffix);
10023            }
10024        }
10025    }
10026    None
10027}
10028
10029#[derive(Debug, Clone, Copy)]
10030enum JavaLikeInvocation {
10031    Kotlin,
10032    Cpp,
10033}
10034
10035fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10036    let value = value.trim_start();
10037    let mut end = 0usize;
10038    for (index, ch) in value.char_indices() {
10039        let allowed_separator = match flavor {
10040            JavaLikeInvocation::Kotlin => ch == '.',
10041            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10042        };
10043        if is_code_ident_char(ch) || allowed_separator {
10044            end = index + ch.len_utf8();
10045            continue;
10046        }
10047        break;
10048    }
10049    if end == 0 {
10050        return None;
10051    }
10052    let mut rest = &value[end..];
10053    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10054        let skipped = skip_balanced_angle(stripped)?;
10055        let rest_start = rest.len() - rest.trim_start().len();
10056        let angle_len = 1 + skipped;
10057        end += rest_start + angle_len;
10058        rest = &value[end..];
10059    }
10060    Some((value[..end].trim(), rest))
10061}
10062
10063fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10064    let mut depth = 1usize;
10065    for (index, ch) in value_after_open.char_indices() {
10066        match ch {
10067            '<' => depth += 1,
10068            '>' => {
10069                depth = depth.saturating_sub(1);
10070                if depth == 0 {
10071                    return Some(index + ch.len_utf8());
10072                }
10073            }
10074            _ => {}
10075        }
10076    }
10077    None
10078}
10079
10080fn first_angle_arg(value: &str) -> Option<&str> {
10081    let open = value.find('<')?;
10082    let inner_len = skip_balanced_angle(&value[open + 1..])?;
10083    let inner = &value[open + 1..open + inner_len];
10084    split_top_level_commas(inner).into_iter().next()
10085}
10086
10087fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10088    let without_generics = strip_angle_groups(type_text);
10089    let cleaned = without_generics
10090        .replace("[]", " ")
10091        .replace("...", " ")
10092        .replace(['?', '&', '*'], " ");
10093    let token = cleaned
10094        .split_whitespace()
10095        .last()
10096        .unwrap_or(cleaned.trim())
10097        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10098    let token = token.rsplit("::").next().unwrap_or(token);
10099    let simple = token.rsplit('.').next().unwrap_or(token).trim();
10100    if simple.is_empty()
10101        || java_like_primitive_type(simple)
10102        || !simple
10103            .chars()
10104            .next()
10105            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10106    {
10107        None
10108    } else {
10109        Some(simple.to_string())
10110    }
10111}
10112
10113fn simple_type_name(scoped_name: &str) -> Option<String> {
10114    scoped_name
10115        .rsplit("::")
10116        .find(|segment| !segment.is_empty())
10117        .and_then(normalize_receiver_type_name)
10118}
10119
10120fn strip_angle_groups(value: &str) -> String {
10121    let mut output = String::with_capacity(value.len());
10122    let mut depth = 0usize;
10123    for ch in value.chars() {
10124        match ch {
10125            '<' => {
10126                if depth == 0 {
10127                    output.push(' ');
10128                }
10129                depth += 1;
10130            }
10131            '>' => depth = depth.saturating_sub(1),
10132            _ if depth == 0 => output.push(ch),
10133            _ => {}
10134        }
10135    }
10136    output
10137}
10138
10139fn java_like_primitive_type(value: &str) -> bool {
10140    matches!(
10141        value,
10142        "boolean"
10143            | "byte"
10144            | "char"
10145            | "double"
10146            | "float"
10147            | "int"
10148            | "long"
10149            | "short"
10150            | "void"
10151            | "Boolean"
10152            | "Byte"
10153            | "Char"
10154            | "Double"
10155            | "Float"
10156            | "Int"
10157            | "Long"
10158            | "Short"
10159            | "Unit"
10160    )
10161}
10162
10163fn cpp_non_type_token(value: &str) -> bool {
10164    matches!(
10165        value,
10166        "return"
10167            | "if"
10168            | "else"
10169            | "for"
10170            | "while"
10171            | "do"
10172            | "switch"
10173            | "case"
10174            | "default"
10175            | "break"
10176            | "continue"
10177            | "goto"
10178            | "throw"
10179            | "new"
10180            | "delete"
10181            | "co_await"
10182            | "co_yield"
10183            | "co_return"
10184            | "static_cast"
10185            | "const_cast"
10186            | "dynamic_cast"
10187            | "reinterpret_cast"
10188            | "sizeof"
10189            | "alignof"
10190            | "typeid"
10191            | "and"
10192            | "or"
10193            | "not"
10194            | "xor"
10195    )
10196}
10197
10198fn receiver_is_bare_identifier(value: &str) -> bool {
10199    let mut chars = value.chars();
10200    let Some(first) = chars.next() else {
10201        return false;
10202    };
10203    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10204}
10205
10206fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10207    identifier_occurrences(value, needle).into_iter().next()
10208}
10209
10210fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10211    value
10212        .match_indices(needle)
10213        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10214        .collect()
10215}
10216
10217fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10218    let before = value[..start].chars().next_back();
10219    let after = value[start + len..].chars().next();
10220    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10221}
10222
10223fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10224    let stripped = value.strip_prefix(word)?;
10225    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10226        Some(stripped.trim_start())
10227    } else {
10228        None
10229    }
10230}
10231
10232fn is_code_ident_char(ch: char) -> bool {
10233    ch == '_' || ch.is_ascii_alphanumeric()
10234}
10235
10236fn infer_rust_receiver_type(
10237    project_root: &Path,
10238    reference: &NameMatchRef,
10239    source_cache: &mut DispatchSourceCache,
10240) -> ReceiverTypeInference {
10241    if matches!(reference.receiver.as_str(), "self" | "Self") {
10242        return enclosing_type_from_scoped_name(&reference.caller_symbol)
10243            .map(ReceiverTypeInference::Known)
10244            .unwrap_or(ReceiverTypeInference::Unknown);
10245    }
10246
10247    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10248        return ReceiverTypeInference::Known(reference.receiver.clone());
10249    }
10250
10251    if let Some(receiver_type) = reference
10252        .caller_signature
10253        .as_deref()
10254        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10255    {
10256        return ReceiverTypeInference::Known(receiver_type);
10257    }
10258
10259    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10260}
10261
10262fn infer_rust_direct_self_field_receiver_type(
10263    project_root: &Path,
10264    reference: &NameMatchRef,
10265    source_cache: &mut DispatchSourceCache,
10266) -> ReceiverTypeInference {
10267    if reference.colon_dispatch {
10268        return ReceiverTypeInference::Unknown;
10269    }
10270    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10271        return ReceiverTypeInference::Unknown;
10272    };
10273    if field_name != reference.receiver {
10274        return ReceiverTypeInference::Unknown;
10275    }
10276
10277    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10278        return ReceiverTypeInference::Unknown;
10279    };
10280    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10281        return ReceiverTypeInference::KnownButUnresolved;
10282    };
10283    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10284    else {
10285        return ReceiverTypeInference::Unknown;
10286    };
10287    let Some(impl_node) =
10288        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10289    else {
10290        return ReceiverTypeInference::Unknown;
10291    };
10292    if impl_node.child_by_field_name("trait").is_some()
10293        || impl_node.child_by_field_name("type_parameters").is_some()
10294    {
10295        return ReceiverTypeInference::KnownButUnresolved;
10296    }
10297    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10298        return ReceiverTypeInference::KnownButUnresolved;
10299    };
10300    if impl_target.kind() != "type_identifier"
10301        || node_text(impl_target, &parsed.source) != impl_type
10302    {
10303        return ReceiverTypeInference::KnownButUnresolved;
10304    }
10305
10306    let module_scope = rust_module_scope(impl_node);
10307    let Some(struct_node) = find_unique_rust_struct(
10308        parsed.tree.root_node(),
10309        &parsed.source,
10310        struct_name,
10311        &module_scope,
10312    ) else {
10313        return ReceiverTypeInference::KnownButUnresolved;
10314    };
10315    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10316    else {
10317        return ReceiverTypeInference::KnownButUnresolved;
10318    };
10319    if field_type.kind() != "type_identifier" {
10320        return ReceiverTypeInference::KnownButUnresolved;
10321    }
10322    let field_type_name = node_text(field_type, &parsed.source);
10323    if find_unique_rust_struct(
10324        parsed.tree.root_node(),
10325        &parsed.source,
10326        field_type_name,
10327        &module_scope,
10328    )
10329    .is_none()
10330    {
10331        return ReceiverTypeInference::KnownButUnresolved;
10332    }
10333
10334    ReceiverTypeInference::RustDirectSelfField {
10335        receiver_type: field_type_name.to_string(),
10336        declaration_file: reference.caller_file.clone(),
10337        module_scope,
10338    }
10339}
10340
10341fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10342    let (base, field) = receiver_expression.split_once('.')?;
10343    let base = base.trim();
10344    let field = field.trim();
10345    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10346}
10347
10348fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10349    let name = value.rsplit("::").next()?.trim();
10350    (!name.is_empty()
10351        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10352        && name.chars().all(is_rust_ident_char))
10353    .then_some(name)
10354}
10355
10356fn find_enclosing_rust_impl_node<'tree>(
10357    root: tree_sitter::Node<'tree>,
10358    line: u32,
10359) -> Option<tree_sitter::Node<'tree>> {
10360    let mut best = None;
10361    let mut stack = vec![root];
10362    while let Some(node) = stack.pop() {
10363        if !node_contains_line(node, line) {
10364            continue;
10365        }
10366        if node.kind() == "impl_item" {
10367            best = tighter_node(best, node);
10368        }
10369        push_named_children(node, &mut stack);
10370    }
10371    best
10372}
10373
10374fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10375    let mut scope = Vec::new();
10376    let mut current = node.parent();
10377    while let Some(parent) = current {
10378        if parent.kind() == "mod_item" {
10379            scope.push((parent.start_byte(), parent.end_byte()));
10380        }
10381        current = parent.parent();
10382    }
10383    scope.reverse();
10384    scope
10385}
10386
10387fn find_unique_rust_struct<'tree>(
10388    root: tree_sitter::Node<'tree>,
10389    source: &str,
10390    expected_name: &str,
10391    module_scope: &[(usize, usize)],
10392) -> Option<tree_sitter::Node<'tree>> {
10393    let mut found = None;
10394    let mut stack = vec![root];
10395    while let Some(node) = stack.pop() {
10396        if node.kind() == "struct_item"
10397            && rust_module_scope(node) == module_scope
10398            && node.child_by_field_name("type_parameters").is_none()
10399            && declaration_name(node, source) == Some(expected_name)
10400        {
10401            if found.is_some() {
10402                return None;
10403            }
10404            found = Some(node);
10405        }
10406        push_named_children(node, &mut stack);
10407    }
10408    found
10409}
10410
10411fn rust_struct_field_type_node<'tree>(
10412    struct_node: tree_sitter::Node<'tree>,
10413    source: &str,
10414    field_name: &str,
10415) -> Option<tree_sitter::Node<'tree>> {
10416    let fields = struct_node.child_by_field_name("body")?;
10417    if fields.kind() != "field_declaration_list" {
10418        return None;
10419    }
10420    for index in 0..fields.named_child_count() {
10421        let field = fields.named_child(index as u32)?;
10422        if field.kind() != "field_declaration"
10423            || declaration_name(field, source) != Some(field_name)
10424        {
10425            continue;
10426        }
10427        return field.child_by_field_name("type");
10428    }
10429    None
10430}
10431
10432fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10433    receiver
10434        .chars()
10435        .next()
10436        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10437}
10438
10439fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10440    scoped_name
10441        .rsplit_once("::")
10442        .map(|(enclosing, _)| enclosing)
10443        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10444        .map(ToString::to_string)
10445}
10446
10447fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10448    let params = signature_parameter_text(signature)?;
10449    for param in split_top_level_commas(params) {
10450        let Some((pattern, type_text)) = param.split_once(':') else {
10451            continue;
10452        };
10453        let Some(name) = rust_parameter_name(pattern) else {
10454            continue;
10455        };
10456        if name == receiver {
10457            return normalize_rust_receiver_type(type_text);
10458        }
10459    }
10460    None
10461}
10462
10463fn signature_parameter_text(signature: &str) -> Option<&str> {
10464    let open = signature.find('(')?;
10465    let mut depth = 0usize;
10466    for (offset, ch) in signature[open..].char_indices() {
10467        match ch {
10468            '(' => depth += 1,
10469            ')' => {
10470                depth = depth.saturating_sub(1);
10471                if depth == 0 {
10472                    return Some(&signature[open + 1..open + offset]);
10473                }
10474            }
10475            _ => {}
10476        }
10477    }
10478    None
10479}
10480
10481fn split_top_level_commas(value: &str) -> Vec<&str> {
10482    let mut parts = Vec::new();
10483    let mut start = 0usize;
10484    let mut angle_depth = 0usize;
10485    let mut paren_depth = 0usize;
10486    let mut bracket_depth = 0usize;
10487    for (index, ch) in value.char_indices() {
10488        match ch {
10489            '<' => angle_depth += 1,
10490            '>' => angle_depth = angle_depth.saturating_sub(1),
10491            '(' => paren_depth += 1,
10492            ')' => paren_depth = paren_depth.saturating_sub(1),
10493            '[' => bracket_depth += 1,
10494            ']' => bracket_depth = bracket_depth.saturating_sub(1),
10495            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10496                let part = value[start..index].trim();
10497                if !part.is_empty() {
10498                    parts.push(part);
10499                }
10500                start = index + ch.len_utf8();
10501            }
10502            _ => {}
10503        }
10504    }
10505    let part = value[start..].trim();
10506    if !part.is_empty() {
10507        parts.push(part);
10508    }
10509    parts
10510}
10511
10512fn rust_parameter_name(pattern: &str) -> Option<&str> {
10513    let mut pattern = pattern.trim();
10514    if let Some(stripped) = pattern.strip_prefix("mut ") {
10515        pattern = stripped.trim_start();
10516    }
10517    pattern
10518        .rsplit(|ch: char| !is_rust_ident_char(ch))
10519        .find(|part| !part.is_empty())
10520}
10521
10522fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10523    let mut ty = strip_leading_rust_type_modifiers(type_text);
10524    let owned_inner;
10525    if let Some(inner) = single_outer_generic_arg(ty) {
10526        owned_inner = inner.trim().to_string();
10527        ty = strip_leading_rust_type_modifiers(&owned_inner);
10528    }
10529    rust_base_type_ident(ty)
10530}
10531
10532fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10533    loop {
10534        ty = ty.trim_start();
10535        if let Some(stripped) = ty.strip_prefix('&') {
10536            ty = stripped.trim_start();
10537            if let Some(stripped) = strip_leading_lifetime(ty) {
10538                ty = stripped.trim_start();
10539            }
10540            if let Some(stripped) = ty.strip_prefix("mut ") {
10541                ty = stripped.trim_start();
10542            }
10543            continue;
10544        }
10545        if let Some(stripped) = ty.strip_prefix("mut ") {
10546            ty = stripped.trim_start();
10547            continue;
10548        }
10549        if let Some(stripped) = ty.strip_prefix("dyn ") {
10550            ty = stripped.trim_start();
10551            continue;
10552        }
10553        if let Some(stripped) = ty.strip_prefix("impl ") {
10554            ty = stripped.trim_start();
10555            continue;
10556        }
10557        break ty.trim();
10558    }
10559}
10560
10561fn strip_leading_lifetime(value: &str) -> Option<&str> {
10562    let mut chars = value.char_indices();
10563    let (_, first) = chars.next()?;
10564    if first != '\'' {
10565        return None;
10566    }
10567    for (index, ch) in chars {
10568        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10569            return Some(&value[index..]);
10570        }
10571    }
10572    Some("")
10573}
10574
10575fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10576    let ty = ty.trim();
10577    let open = ty.find('<')?;
10578    let mut depth = 0usize;
10579    let mut close = None;
10580    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10581        match ch {
10582            '<' => depth += 1,
10583            '>' => {
10584                depth = depth.saturating_sub(1);
10585                if depth == 0 {
10586                    close = Some(index);
10587                    break;
10588                }
10589            }
10590            _ => {}
10591        }
10592    }
10593    let close = close?;
10594    if !ty[close + 1..].trim().is_empty() {
10595        return None;
10596    }
10597    let inner = &ty[open + 1..close];
10598    let args = split_top_level_commas(inner);
10599    match args.as_slice() {
10600        [arg] => Some(*arg),
10601        _ => None,
10602    }
10603}
10604
10605fn rust_base_type_ident(ty: &str) -> Option<String> {
10606    let ty = ty.trim();
10607    let head = ty
10608        .split([' ', '+', '='])
10609        .find(|part| !part.is_empty())
10610        .unwrap_or(ty);
10611    let head = head.split('<').next().unwrap_or(head).trim();
10612    let ident = head
10613        .rsplit("::")
10614        .next()
10615        .unwrap_or(head)
10616        .trim_matches(|ch: char| !is_rust_ident_char(ch));
10617    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10618        None
10619    } else {
10620        Some(ident.to_string())
10621    }
10622}
10623
10624fn is_rust_ident_char(ch: char) -> bool {
10625    ch == '_' || ch.is_ascii_alphanumeric()
10626}
10627
10628fn select_rust_direct_self_field_candidate(
10629    project_root: &Path,
10630    reference: &NameMatchRef,
10631    candidates: &[NameMatchCandidate],
10632    receiver_type: &str,
10633    declaration_file: &str,
10634    declaration_scope: &[(usize, usize)],
10635    source_cache: &mut DispatchSourceCache,
10636) -> Option<NameMatchCandidate> {
10637    let eligible = candidates
10638        .iter()
10639        .filter(|candidate| candidate.node_id != reference.caller_node)
10640        .filter(|candidate| {
10641            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10642        })
10643        .filter(|candidate| {
10644            rust_direct_self_field_candidate_matches_scope(
10645                project_root,
10646                candidate,
10647                receiver_type,
10648                declaration_file,
10649                declaration_scope,
10650                source_cache,
10651            )
10652        })
10653        .collect::<Vec<_>>();
10654    match eligible.as_slice() {
10655        [candidate] => Some((**candidate).clone()),
10656        _ => None,
10657    }
10658}
10659
10660fn rust_direct_self_field_candidate_matches_scope(
10661    project_root: &Path,
10662    candidate: &NameMatchCandidate,
10663    receiver_type: &str,
10664    declaration_file: &str,
10665    declaration_scope: &[(usize, usize)],
10666    source_cache: &mut DispatchSourceCache,
10667) -> bool {
10668    if candidate.file_path != declaration_file {
10669        return false;
10670    }
10671    let Some(parsed) = parsed_dispatch_source_for_file(
10672        project_root,
10673        &candidate.file_path,
10674        "rust",
10675        LangId::Rust,
10676        source_cache,
10677    ) else {
10678        return false;
10679    };
10680    let Some(impl_node) =
10681        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10682    else {
10683        return false;
10684    };
10685    if impl_node.child_by_field_name("trait").is_some()
10686        || impl_node.child_by_field_name("type_parameters").is_some()
10687    {
10688        return false;
10689    }
10690    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10691        return false;
10692    };
10693    impl_target.kind() == "type_identifier"
10694        && node_text(impl_target, &parsed.source) == receiver_type
10695        && rust_module_scope(impl_node) == declaration_scope
10696}
10697
10698fn select_type_match_candidate(
10699    reference: &NameMatchRef,
10700    candidates: &[NameMatchCandidate],
10701    receiver_type: &str,
10702) -> Option<NameMatchCandidate> {
10703    let candidates = candidates
10704        .iter()
10705        .filter(|candidate| candidate.node_id != reference.caller_node)
10706        .filter(|candidate| {
10707            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10708        })
10709        .collect::<Vec<_>>();
10710    match candidates.as_slice() {
10711        [candidate] => Some((**candidate).clone()),
10712        _ => None,
10713    }
10714}
10715
10716fn type_candidate_matches(
10717    candidate: &NameMatchCandidate,
10718    receiver_type: &str,
10719    method_name: &str,
10720) -> bool {
10721    let normalized_type = receiver_type.replace('.', "::");
10722    let suffix = format!("{normalized_type}::{method_name}");
10723    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10724}
10725
10726fn select_name_match_candidate(
10727    reference: &NameMatchRef,
10728    candidates: &[NameMatchCandidate],
10729) -> Option<NameMatchCandidate> {
10730    let candidates = candidates
10731        .iter()
10732        .filter(|candidate| candidate.node_id != reference.caller_node)
10733        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10734        .collect::<Vec<_>>();
10735    match candidates.as_slice() {
10736        [] => None,
10737        [candidate] => Some((**candidate).clone()),
10738        _ => select_scored_name_match_candidate(reference, &candidates),
10739    }
10740}
10741
10742fn candidate_allowed_for_reference(
10743    reference: &NameMatchRef,
10744    candidate: &NameMatchCandidate,
10745) -> bool {
10746    if !reference.colon_dispatch {
10747        return true;
10748    }
10749
10750    candidate.kind == "method"
10751        && candidate
10752            .scoped_name
10753            .split("::")
10754            .any(|segment| segment == reference.receiver)
10755}
10756
10757fn select_scored_name_match_candidate(
10758    reference: &NameMatchRef,
10759    candidates: &[&NameMatchCandidate],
10760) -> Option<NameMatchCandidate> {
10761    let receiver_words = split_camel_case(&reference.receiver);
10762    if receiver_words.is_empty() {
10763        return None;
10764    }
10765
10766    let mut best: Option<(&NameMatchCandidate, f64)> = None;
10767    let mut tied_best = false;
10768    for candidate in candidates {
10769        let candidate_words = split_camel_case(&candidate.scoped_name);
10770        let overlap = receiver_words
10771            .iter()
10772            .filter(|receiver_word| {
10773                candidate_words
10774                    .iter()
10775                    .any(|candidate_word| candidate_word == *receiver_word)
10776            })
10777            .count() as f64;
10778        let score =
10779            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10780        match best {
10781            None => {
10782                best = Some((*candidate, score));
10783                tied_best = false;
10784            }
10785            Some((_, best_score)) if score > best_score => {
10786                best = Some((*candidate, score));
10787                tied_best = false;
10788            }
10789            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10790                tied_best = true;
10791            }
10792            _ => {}
10793        }
10794    }
10795
10796    let (candidate, score) = best?;
10797    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10798        Some(candidate.clone())
10799    } else {
10800        None
10801    }
10802}
10803
10804fn method_name_match_denylisted(method_name: &str) -> bool {
10805    matches!(
10806        method_name,
10807        "and_then"
10808            | "as_bytes"
10809            | "as_deref"
10810            | "as_mut"
10811            | "as_ref"
10812            | "as_str"
10813            | "borrow"
10814            | "borrow_mut"
10815            | "clear"
10816            | "clone"
10817            | "collect"
10818            | "contains"
10819            | "contains_key"
10820            | "count"
10821            | "dedup"
10822            | "default"
10823            | "drain"
10824            | "ends_with"
10825            | "entry"
10826            | "err"
10827            | "expect"
10828            | "extend"
10829            | "filter"
10830            | "filter_map"
10831            | "find"
10832            | "from"
10833            | "get"
10834            | "get_mut"
10835            | "insert"
10836            | "into"
10837            | "into_iter"
10838            | "is_empty"
10839            | "is_err"
10840            | "is_none"
10841            | "is_ok"
10842            | "is_some"
10843            | "iter"
10844            | "iter_mut"
10845            | "join"
10846            | "len"
10847            | "lock"
10848            | "map"
10849            | "map_err"
10850            | "max"
10851            | "min"
10852            | "new"
10853            | "next"
10854            | "ok"
10855            | "or_default"
10856            | "or_else"
10857            | "or_insert"
10858            | "or_insert_with"
10859            | "parse"
10860            | "pop"
10861            | "position"
10862            | "push"
10863            | "read"
10864            | "recv"
10865            | "remove"
10866            | "replace"
10867            | "retain"
10868            | "send"
10869            | "sort"
10870            | "sort_by"
10871            | "split"
10872            | "starts_with"
10873            | "sum"
10874            | "take"
10875            | "to_owned"
10876            | "to_string"
10877            | "trim"
10878            | "try_from"
10879            | "try_into"
10880            | "unwrap"
10881            | "unwrap_or"
10882            | "unwrap_or_default"
10883            | "unwrap_or_else"
10884            | "with_capacity"
10885            | "write"
10886    )
10887}
10888
10889fn split_camel_case(value: &str) -> Vec<String> {
10890    let chars = value.chars().collect::<Vec<_>>();
10891    let mut normalized = String::with_capacity(value.len() + 8);
10892    for (index, ch) in chars.iter().enumerate() {
10893        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10894        let next = chars.get(index + 1);
10895        let is_separator = ch.is_whitespace()
10896            || matches!(
10897                ch,
10898                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10899            );
10900        if is_separator {
10901            normalized.push(' ');
10902            continue;
10903        }
10904        let camel_boundary = previous.is_some_and(|prev| {
10905            (prev.is_lowercase() && ch.is_uppercase())
10906                || (prev.is_ascii_digit() && ch.is_alphabetic())
10907                || (prev.is_uppercase()
10908                    && ch.is_uppercase()
10909                    && next.is_some_and(|next| next.is_lowercase()))
10910        });
10911        if camel_boundary {
10912            normalized.push(' ');
10913        }
10914        normalized.push(*ch);
10915    }
10916
10917    normalized
10918        .split_whitespace()
10919        .filter(|word| word.len() > 1)
10920        .map(|word| word.to_ascii_lowercase())
10921        .collect()
10922}
10923
10924fn compute_path_proximity(left: &str, right: &str) -> f64 {
10925    let left_dirs = left
10926        .rsplit_once('/')
10927        .map(|(dir, _)| dir)
10928        .unwrap_or_default()
10929        .split('/')
10930        .filter(|part| !part.is_empty());
10931    let right_dirs = right
10932        .rsplit_once('/')
10933        .map(|(dir, _)| dir)
10934        .unwrap_or_default()
10935        .split('/')
10936        .filter(|part| !part.is_empty());
10937
10938    let shared = left_dirs
10939        .zip(right_dirs)
10940        .take_while(|(left, right)| left == right)
10941        .count();
10942    ((shared as f64) * 0.05).min(0.5)
10943}
10944
10945fn mark_backend_state(
10946    tx: &Transaction<'_>,
10947    project_root: &Path,
10948    rel_path: &str,
10949    content_hash: Option<&blake3::Hash>,
10950    status: &str,
10951) -> Result<()> {
10952    clear_backend_state_for_file(tx, project_root, rel_path)?;
10953    let hash = content_hash
10954        .map(|hash| hash_to_hex(*hash))
10955        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10956    tx.execute(
10957        "INSERT OR REPLACE INTO backend_file_state(
10958            backend, workspace_root, file_path, content_hash, status, updated_at
10959        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10960        params![
10961            BACKEND_TREESITTER,
10962            project_root.display().to_string(),
10963            rel_path,
10964            hash,
10965            status,
10966            unix_seconds_now(),
10967        ],
10968    )?;
10969    Ok(())
10970}
10971
10972fn clear_backend_state_for_file(
10973    tx: &Transaction<'_>,
10974    project_root: &Path,
10975    rel_path: &str,
10976) -> Result<()> {
10977    tx.execute(
10978        "DELETE FROM backend_file_state
10979         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10980        params![
10981            BACKEND_TREESITTER,
10982            project_root.display().to_string(),
10983            rel_path
10984        ],
10985    )?;
10986    Ok(())
10987}
10988
10989fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10990    tx.query_row(
10991        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10992        params![rel_path],
10993        |row| {
10994            let hash_text: String = row.get(1)?;
10995            Ok(FileRow {
10996                surface_fingerprint: row.get(0)?,
10997                freshness: FileFreshness {
10998                    content_hash: hash_from_hex(&hash_text)
10999                        .unwrap_or_else(cache_freshness::zero_hash),
11000                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11001                    size: row.get::<_, i64>(3)? as u64,
11002                },
11003            })
11004        },
11005    )
11006    .optional()
11007    .map_err(CallGraphStoreError::from)
11008}
11009
11010fn stored_node_ids_match_extract(
11011    tx: &Transaction<'_>,
11012    rel_path: &str,
11013    extract: &FileExtract,
11014) -> Result<bool> {
11015    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11016    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11017    let mut stored = BTreeSet::new();
11018    for row in rows {
11019        stored.insert(row?);
11020    }
11021    let extracted = extract
11022        .nodes
11023        .iter()
11024        .map(|node| node.id.clone())
11025        .collect::<BTreeSet<_>>();
11026    Ok(stored == extracted)
11027}
11028
11029/// Compare every persisted graph row that comes from this file before rewriting it.
11030/// Ranges and reference byte offsets are part of the key because queries expose
11031/// source locations; equal names and edges are not enough after a body shift.
11032fn stored_extract_matches(
11033    tx: &Transaction<'_>,
11034    rel_path: &str,
11035    extract: &FileExtract,
11036    index: &ProjectIndex<'_>,
11037) -> Result<bool> {
11038    let stored_file = tx
11039        .query_row(
11040            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11041            params![rel_path],
11042            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11043        )
11044        .optional()?;
11045    if stored_file
11046        != Some((
11047            lang_label(extract.lang).to_string(),
11048            extract.surface_fingerprint.clone(),
11049        ))
11050    {
11051        return Ok(false);
11052    }
11053
11054    let mut stored_nodes_stmt = tx.prepare(
11055        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11056                end_line, end_col, range_ordinal, signature, exported,
11057                is_default_export, is_type_like, is_callgraph_entry_point, provenance
11058         FROM nodes WHERE file_path = ?1",
11059    )?;
11060    let stored_nodes = stored_nodes_stmt
11061        .query_map(params![rel_path], |row| {
11062            Ok(serde_json::json!([
11063                row.get::<_, String>(0)?,
11064                row.get::<_, String>(1)?,
11065                row.get::<_, String>(2)?,
11066                row.get::<_, String>(3)?,
11067                row.get::<_, String>(4)?,
11068                row.get::<_, i64>(5)?,
11069                row.get::<_, i64>(6)?,
11070                row.get::<_, i64>(7)?,
11071                row.get::<_, i64>(8)?,
11072                row.get::<_, i64>(9)?,
11073                row.get::<_, Option<String>>(10)?,
11074                row.get::<_, i64>(11)?,
11075                row.get::<_, i64>(12)?,
11076                row.get::<_, i64>(13)?,
11077                row.get::<_, i64>(14)?,
11078                row.get::<_, String>(15)?,
11079            ])
11080            .to_string())
11081        })?
11082        .collect::<rusqlite::Result<Vec<_>>>()?;
11083    let expected_nodes = extract
11084        .nodes
11085        .iter()
11086        .map(|node| {
11087            serde_json::json!([
11088                node.id,
11089                node.file_path,
11090                node.name,
11091                node.scoped_name,
11092                node.kind,
11093                node.range.start_line,
11094                node.range.start_col,
11095                node.range.end_line,
11096                node.range.end_col,
11097                node.range_ordinal,
11098                node.signature,
11099                bool_int(node.exported),
11100                bool_int(node.is_default_export),
11101                bool_int(node.is_type_like),
11102                bool_int(node.is_callgraph_entry_point),
11103                PROVENANCE_TREESITTER,
11104            ])
11105            .to_string()
11106        })
11107        .collect::<Vec<_>>();
11108    let mut stored_nodes = stored_nodes;
11109    let mut expected_nodes = expected_nodes;
11110    stored_nodes.sort();
11111    expected_nodes.sort();
11112    if stored_nodes != expected_nodes {
11113        return Ok(false);
11114    }
11115
11116    let resolved_refs = extract
11117        .raw_refs
11118        .iter()
11119        .cloned()
11120        .map(|raw| resolve_ref(raw, index))
11121        .collect::<Result<Vec<_>>>()?;
11122    let mut stored_refs_stmt = tx.prepare(
11123        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11124                module_path, import_kind, local_name, requested_name, namespace_alias,
11125                wildcard, line, byte_start, byte_end, status, target_node,
11126                target_file, target_symbol, provenance
11127         FROM refs WHERE caller_file = ?1",
11128    )?;
11129    let stored_refs = stored_refs_stmt
11130        .query_map(params![rel_path], |row| {
11131            Ok(serde_json::json!([
11132                row.get::<_, String>(0)?,
11133                row.get::<_, Option<String>>(1)?,
11134                row.get::<_, String>(2)?,
11135                row.get::<_, String>(3)?,
11136                row.get::<_, Option<String>>(4)?,
11137                row.get::<_, Option<String>>(5)?,
11138                row.get::<_, Option<String>>(6)?,
11139                row.get::<_, Option<String>>(7)?,
11140                row.get::<_, Option<String>>(8)?,
11141                row.get::<_, Option<String>>(9)?,
11142                row.get::<_, Option<String>>(10)?,
11143                row.get::<_, i64>(11)?,
11144                row.get::<_, i64>(12)?,
11145                row.get::<_, i64>(13)?,
11146                row.get::<_, i64>(14)?,
11147                row.get::<_, String>(15)?,
11148                row.get::<_, Option<String>>(16)?,
11149                row.get::<_, Option<String>>(17)?,
11150                row.get::<_, Option<String>>(18)?,
11151                row.get::<_, String>(19)?,
11152            ])
11153            .to_string())
11154        })?
11155        .collect::<rusqlite::Result<Vec<_>>>()?;
11156    let expected_refs = resolved_refs
11157        .iter()
11158        .map(|resolved| {
11159            let raw = &resolved.raw;
11160            serde_json::json!([
11161                raw.ref_id,
11162                raw.caller_node,
11163                raw.caller_file,
11164                raw.kind,
11165                raw.short_name,
11166                raw.full_ref,
11167                raw.module_path,
11168                raw.import_kind,
11169                raw.local_name,
11170                raw.requested_name,
11171                raw.namespace_alias,
11172                bool_int(raw.wildcard),
11173                raw.line,
11174                raw.byte_start,
11175                raw.byte_end,
11176                resolved.status,
11177                resolved.target_node,
11178                resolved.target_file,
11179                resolved.target_symbol,
11180                PROVENANCE_TREESITTER,
11181            ])
11182            .to_string()
11183        })
11184        .collect::<Vec<_>>();
11185    let mut stored_refs = stored_refs;
11186    let mut expected_refs = expected_refs;
11187    stored_refs.sort();
11188    expected_refs.sort();
11189    if stored_refs != expected_refs {
11190        return Ok(false);
11191    }
11192
11193    let mut stored_edges_stmt = tx.prepare(
11194        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11195                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11196         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11197         WHERE r.caller_file = ?1 AND e.provenance = ?2",
11198    )?;
11199    let stored_edges = stored_edges_stmt
11200        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11201            Ok(serde_json::json!([
11202                row.get::<_, String>(0)?,
11203                row.get::<_, String>(1)?,
11204                row.get::<_, String>(2)?,
11205                row.get::<_, Option<String>>(3)?,
11206                row.get::<_, String>(4)?,
11207                row.get::<_, String>(5)?,
11208                row.get::<_, String>(6)?,
11209                row.get::<_, i64>(7)?,
11210                row.get::<_, String>(8)?,
11211            ])
11212            .to_string())
11213        })?
11214        .collect::<rusqlite::Result<Vec<_>>>()?;
11215    let expected_edges = resolved_refs
11216        .iter()
11217        .filter_map(|resolved| {
11218            resolved.edge.as_ref().map(|edge| {
11219                serde_json::json!([
11220                    edge.edge_id,
11221                    resolved.raw.ref_id,
11222                    edge.source_node,
11223                    edge.target_node,
11224                    edge.target_file,
11225                    edge.target_symbol,
11226                    edge.kind,
11227                    edge.line,
11228                    PROVENANCE_TREESITTER,
11229                ])
11230                .to_string()
11231            })
11232        })
11233        .collect::<Vec<_>>();
11234    let mut stored_edges = stored_edges;
11235    let mut expected_edges = expected_edges;
11236    stored_edges.sort();
11237    expected_edges.sort();
11238    if stored_edges != expected_edges {
11239        return Ok(false);
11240    }
11241
11242    let mut stored_dependencies_stmt =
11243        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11244    let stored_dependencies = stored_dependencies_stmt
11245        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11246        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11247    let expected_dependencies = extract
11248        .raw_refs
11249        .iter()
11250        .flat_map(|raw| raw.dependencies.iter().cloned())
11251        .collect::<BTreeSet<_>>();
11252    if stored_dependencies != expected_dependencies {
11253        return Ok(false);
11254    }
11255
11256    let mut stored_hints_stmt = tx.prepare(
11257        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11258         FROM dispatch_hints WHERE file = ?1",
11259    )?;
11260    let stored_hints = stored_hints_stmt
11261        .query_map(params![rel_path], |row| {
11262            Ok(serde_json::json!([
11263                row.get::<_, String>(0)?,
11264                row.get::<_, String>(1)?,
11265                row.get::<_, String>(2)?,
11266                row.get::<_, String>(3)?,
11267                row.get::<_, i64>(4)?,
11268                row.get::<_, i64>(5)?,
11269                row.get::<_, i64>(6)?,
11270                row.get::<_, String>(7)?,
11271            ])
11272            .to_string())
11273        })?
11274        .collect::<rusqlite::Result<Vec<_>>>()?;
11275    let expected_hints = extract
11276        .dispatch_hints
11277        .iter()
11278        .map(|hint| {
11279            serde_json::json!([
11280                hint.id,
11281                hint.method_name,
11282                hint.caller_node,
11283                hint.file,
11284                hint.line,
11285                hint.byte_start,
11286                hint.byte_end,
11287                PROVENANCE_TREESITTER,
11288            ])
11289            .to_string()
11290        })
11291        .collect::<Vec<_>>();
11292    let mut stored_hints = stored_hints;
11293    let mut expected_hints = expected_hints;
11294    stored_hints.sort();
11295    expected_hints.sort();
11296    Ok(stored_hints == expected_hints)
11297}
11298
11299fn update_file_fresh_metadata(
11300    tx: &Transaction<'_>,
11301    project_root: &Path,
11302    rel_path: &str,
11303    hash: &blake3::Hash,
11304    mtime: SystemTime,
11305    size: u64,
11306) -> Result<()> {
11307    tx.execute(
11308        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11309         WHERE path = ?1",
11310        params![
11311            rel_path,
11312            hash_to_hex(*hash),
11313            system_time_to_ns(mtime),
11314            size as i64,
11315            unix_seconds_now()
11316        ],
11317    )?;
11318    tx.execute(
11319        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11320         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11321        params![
11322            BACKEND_TREESITTER,
11323            rel_path,
11324            hash_to_hex(*hash),
11325            project_root.display().to_string(),
11326            unix_seconds_now(),
11327        ],
11328    )?;
11329    Ok(())
11330}
11331
11332#[derive(Debug, Clone, PartialEq, Eq)]
11333struct DependentRefSelection {
11334    ref_id: String,
11335    caller_file: String,
11336}
11337
11338fn ref_ids_depending_on(
11339    tx: &Transaction<'_>,
11340    project_root: &Path,
11341    rel_path: &str,
11342) -> Result<Vec<DependentRefSelection>> {
11343    let mut stmt = tx.prepare(
11344        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11345         FROM refs r
11346         WHERE r.caller_file IN (
11347             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11348         )
11349            OR r.target_file = ?1
11350         ORDER BY r.ref_id",
11351    )?;
11352    let rows = stmt.query_map(params![rel_path], |row| {
11353        Ok(RefDependencyRow {
11354            ref_id: row.get(0)?,
11355            kind: row.get(1)?,
11356            caller_file: row.get(2)?,
11357            module_path: row.get(3)?,
11358            target_file: row.get(4)?,
11359        })
11360    })?;
11361    let mut ids = Vec::new();
11362    for row in rows {
11363        let row = row?;
11364        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11365            ids.push(DependentRefSelection {
11366                ref_id: row.ref_id,
11367                caller_file: row.caller_file,
11368            });
11369        }
11370    }
11371    Ok(ids)
11372}
11373
11374fn record_dependent_refs(
11375    selected_ref_ids: &mut BTreeSet<String>,
11376    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11377    dependent_refs: Vec<DependentRefSelection>,
11378) {
11379    for dependent_ref in dependent_refs {
11380        let DependentRefSelection {
11381            ref_id,
11382            caller_file,
11383        } = dependent_ref;
11384        selected_ref_ids.insert(ref_id.clone());
11385        selected_refs_by_caller
11386            .entry(caller_file)
11387            .or_default()
11388            .insert(ref_id);
11389    }
11390}
11391
11392#[cfg(test)]
11393fn refs_by_caller_for_ref_ids(
11394    tx: &Transaction<'_>,
11395    ref_ids: &BTreeSet<String>,
11396) -> Result<BTreeMap<String, BTreeSet<String>>> {
11397    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11398    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11399    for ref_id in ref_ids {
11400        if let Some(caller) = stmt
11401            .query_row(params![ref_id], |row| row.get::<_, String>(0))
11402            .optional()?
11403        {
11404            by_caller.entry(caller).or_default().insert(ref_id.clone());
11405        }
11406    }
11407    Ok(by_caller)
11408}
11409
11410fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11411    tx.execute(
11412        "DELETE FROM file_dependencies WHERE file_path = ?1",
11413        params![rel_path],
11414    )?;
11415    delete_refs_for_caller(tx, rel_path)?;
11416    tx.execute(
11417        "DELETE FROM dispatch_hints WHERE file = ?1",
11418        params![rel_path],
11419    )?;
11420    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11421    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11422    Ok(())
11423}
11424
11425fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11426    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11427    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11428    let mut ids = BTreeSet::new();
11429    for row in rows {
11430        ids.insert(row?);
11431    }
11432    delete_ref_ids(tx, &ids)
11433}
11434
11435fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11436    for ref_id in ref_ids {
11437        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11438        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11439    }
11440    Ok(())
11441}
11442
11443fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11444    let mut stmt = conn.prepare(
11445        "SELECT source.file_path, source.scoped_name, edges.target_file,
11446                edges.target_symbol, edges.kind, edges.line
11447         FROM edges
11448         JOIN nodes AS source ON source.id = edges.source_node
11449         ORDER BY source.file_path, source.scoped_name, edges.target_file,
11450                  edges.target_symbol, edges.kind, edges.line",
11451    )?;
11452    let rows = stmt.query_map([], |row| {
11453        Ok(StoredEdge {
11454            source_file: row.get(0)?,
11455            source_symbol: row.get(1)?,
11456            target_file: row.get(2)?,
11457            target_symbol: row.get(3)?,
11458            kind: row.get(4)?,
11459            line: row.get::<_, i64>(5)? as u32,
11460        })
11461    })?;
11462    let mut edges = BTreeSet::new();
11463    for row in rows {
11464        edges.insert(row?);
11465    }
11466    Ok(edges)
11467}
11468
11469fn module_target_from_dependencies(
11470    project_root: &Path,
11471    dependencies: &BTreeSet<String>,
11472) -> Option<String> {
11473    dependencies.iter().find_map(|dep| {
11474        let path = project_root.join(dep);
11475        if path.is_file() {
11476            Some(relative_path(project_root, &canonicalize_path(&path)))
11477        } else {
11478            None
11479        }
11480    })
11481}
11482
11483fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11484    let mut named = HashMap::new();
11485    if let Some(full_ref) = &raw_ref.full_ref {
11486        named = parse_reexport_names(full_ref);
11487    }
11488    ReexportIndex {
11489        target_file,
11490        named,
11491        wildcard: raw_ref.wildcard,
11492    }
11493}
11494
11495fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11496    let mut names = HashMap::new();
11497    let Some(open) = statement.find('{') else {
11498        return names;
11499    };
11500    let Some(close) = statement[open + 1..]
11501        .find('}')
11502        .map(|offset| open + 1 + offset)
11503    else {
11504        return names;
11505    };
11506    for spec in statement[open + 1..close].split(',') {
11507        let spec = spec.trim();
11508        if spec.is_empty() {
11509            continue;
11510        }
11511        if let Some((source, local)) = spec.split_once(" as ") {
11512            names.insert(local.trim().to_string(), source.trim().to_string());
11513        } else {
11514            names.insert(spec.to_string(), spec.to_string());
11515        }
11516    }
11517    names
11518}
11519
11520#[derive(Debug)]
11521struct RefDependencyRow {
11522    ref_id: String,
11523    kind: String,
11524    caller_file: String,
11525    module_path: Option<String>,
11526    target_file: Option<String>,
11527}
11528
11529fn ref_dependency_row_depends_on(
11530    project_root: &Path,
11531    row: &RefDependencyRow,
11532    rel_path: &str,
11533) -> bool {
11534    if row.target_file.as_deref() == Some(rel_path) {
11535        return true;
11536    }
11537
11538    match row.kind.as_str() {
11539        "call" => true,
11540        "import" | "reexport" => row
11541            .module_path
11542            .as_deref()
11543            .map(|module_path| {
11544                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11545                    .contains(rel_path)
11546            })
11547            .unwrap_or(false),
11548        "export_alias" => false,
11549        _ => false,
11550    }
11551}
11552
11553fn module_dependencies_for_ref(
11554    project_root: &Path,
11555    caller_file: &str,
11556    module_path: &str,
11557) -> BTreeSet<String> {
11558    module_dependencies(project_root, &project_root.join(caller_file), module_path)
11559}
11560
11561fn import_dependencies(
11562    project_root: &Path,
11563    abs_path: &Path,
11564    imports: &[ImportStatement],
11565) -> BTreeSet<String> {
11566    let mut deps = BTreeSet::new();
11567    for import in imports {
11568        deps.extend(module_dependencies(
11569            project_root,
11570            abs_path,
11571            &import.module_path,
11572        ));
11573    }
11574    deps
11575}
11576
11577fn module_dependencies(
11578    project_root: &Path,
11579    abs_path: &Path,
11580    module_path: &str,
11581) -> BTreeSet<String> {
11582    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11583    let caller_dir = abs_path.parent().unwrap_or(project_root);
11584    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11585        deps.insert(relative_path(project_root, &resolved));
11586    }
11587    if module_path.starts_with('.') {
11588        let base = caller_dir.join(module_path);
11589        for candidate in relative_module_candidates(&base) {
11590            deps.insert(relative_path(project_root, &candidate));
11591        }
11592    }
11593    deps
11594}
11595
11596fn rust_module_dependencies(
11597    project_root: &Path,
11598    abs_path: &Path,
11599    module_path: &str,
11600) -> BTreeSet<String> {
11601    let mut deps = BTreeSet::new();
11602    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11603    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11604        return deps;
11605    };
11606    let src_prefix = rust_src_prefix(&rel_path);
11607    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11608    if !path_segments.is_empty() {
11609        rust_push_module_dependency_candidate(
11610            project_root,
11611            &mut deps,
11612            &src_prefix,
11613            &path_segments[..path_segments.len() - 1],
11614        );
11615    }
11616    deps
11617}
11618
11619fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11620    let path = rust_module_path_without_alias_or_use_list(module_path);
11621    let segments = path
11622        .split("::")
11623        .map(str::trim)
11624        .filter(|segment| !segment.is_empty())
11625        .collect::<Vec<_>>();
11626    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11627        return None;
11628    }
11629    rust_resolve_segments(rel_path, &segments)
11630}
11631
11632fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11633    let path = module_path
11634        .trim()
11635        .trim_end_matches(';')
11636        .split_once(" as ")
11637        .map(|(left, _)| left.trim())
11638        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11639    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11640}
11641
11642fn rust_push_module_dependency_candidate(
11643    project_root: &Path,
11644    deps: &mut BTreeSet<String>,
11645    src_prefix: &str,
11646    segments: &[String],
11647) {
11648    let candidates = if segments.is_empty() {
11649        vec![
11650            format!("{src_prefix}/lib.rs"),
11651            format!("{src_prefix}/main.rs"),
11652        ]
11653    } else {
11654        vec![
11655            format!("{}/{}.rs", src_prefix, segments.join("/")),
11656            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11657        ]
11658    };
11659    for candidate in candidates {
11660        if project_root.join(&candidate).is_file() {
11661            deps.insert(candidate);
11662        }
11663    }
11664}
11665
11666fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11667    let mut candidates = Vec::new();
11668    if base.extension().is_some() {
11669        candidates.push(base.to_path_buf());
11670        return candidates;
11671    }
11672    for ext in JS_TS_EXTENSIONS {
11673        candidates.push(base.with_extension(ext));
11674    }
11675    for ext in JS_TS_EXTENSIONS {
11676        candidates.push(base.join(format!("index.{ext}")));
11677    }
11678    candidates
11679}
11680
11681fn import_local_names(import: &ImportStatement) -> Vec<String> {
11682    let mut names = Vec::new();
11683    if let Some(default) = &import.default_import {
11684        names.push(default.clone());
11685    }
11686    if let Some(namespace) = &import.namespace_import {
11687        names.push(namespace.clone());
11688    }
11689    for name in &import.names {
11690        names.push(crate::imports::specifier_local_name(name).to_string());
11691    }
11692    names
11693}
11694
11695fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11696    import
11697        .names
11698        .iter()
11699        .map(|name| crate::imports::specifier_imported_name(name).to_string())
11700        .collect()
11701}
11702
11703fn import_is_wildcard(import: &ImportStatement) -> bool {
11704    import.namespace_import.is_some() || import.raw_text.contains('*')
11705}
11706
11707fn namespace_alias(full_ref: &str) -> Option<String> {
11708    full_ref
11709        .split_once('.')
11710        .map(|(namespace, _)| namespace.to_string())
11711}
11712
11713fn import_kind_label(kind: ImportKind) -> &'static str {
11714    match kind {
11715        ImportKind::Value => "value",
11716        ImportKind::Type => "type",
11717        ImportKind::SideEffect => "side_effect",
11718    }
11719}
11720
11721fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11722    match kind {
11723        SymbolKind::Function => "function",
11724        SymbolKind::Class => "class",
11725        SymbolKind::Method => "method",
11726        SymbolKind::Struct => "struct",
11727        SymbolKind::Interface => "interface",
11728        SymbolKind::Enum => "enum",
11729        SymbolKind::TypeAlias => "type_alias",
11730        SymbolKind::Variable => "variable",
11731        SymbolKind::Heading => "heading",
11732        SymbolKind::FileSummary => "file_summary",
11733    }
11734}
11735
11736fn is_type_like(kind: &SymbolKind) -> bool {
11737    matches!(
11738        kind,
11739        SymbolKind::Class
11740            | SymbolKind::Struct
11741            | SymbolKind::Interface
11742            | SymbolKind::Enum
11743            | SymbolKind::TypeAlias
11744    )
11745}
11746
11747fn lang_label(lang: LangId) -> &'static str {
11748    match lang {
11749        LangId::TypeScript => "typescript",
11750        LangId::Tsx => "tsx",
11751        LangId::JavaScript => "javascript",
11752        LangId::Python => "python",
11753        LangId::Rust => "rust",
11754        LangId::Go => "go",
11755        LangId::C => "c",
11756        LangId::Cpp => "cpp",
11757        LangId::Zig => "zig",
11758        LangId::CSharp => "csharp",
11759        LangId::Bash => "bash",
11760        LangId::Html => "html",
11761        LangId::Markdown => "markdown",
11762        LangId::Solidity => "solidity",
11763        LangId::Scss => "scss",
11764        LangId::Vue => "vue",
11765        LangId::Json => "json",
11766        LangId::Scala => "scala",
11767        LangId::Java => "java",
11768        LangId::Ruby => "ruby",
11769        LangId::Kotlin => "kotlin",
11770        LangId::Swift => "swift",
11771        LangId::Php => "php",
11772        LangId::Lua => "lua",
11773        LangId::Perl => "perl",
11774        LangId::Yaml => "yaml",
11775        LangId::Pascal => "pascal",
11776        LangId::R => "r",
11777        LangId::Groovy => "groovy",
11778        LangId::ObjC => "objc",
11779    }
11780}
11781
11782fn lang_from_label(label: &str) -> Option<LangId> {
11783    match label {
11784        "typescript" => Some(LangId::TypeScript),
11785        "tsx" => Some(LangId::Tsx),
11786        "javascript" => Some(LangId::JavaScript),
11787        "python" => Some(LangId::Python),
11788        "rust" => Some(LangId::Rust),
11789        "go" => Some(LangId::Go),
11790        "c" => Some(LangId::C),
11791        "cpp" => Some(LangId::Cpp),
11792        "zig" => Some(LangId::Zig),
11793        "csharp" => Some(LangId::CSharp),
11794        "bash" => Some(LangId::Bash),
11795        "html" => Some(LangId::Html),
11796        "markdown" => Some(LangId::Markdown),
11797        "solidity" => Some(LangId::Solidity),
11798        "scss" => Some(LangId::Scss),
11799        "vue" => Some(LangId::Vue),
11800        "json" => Some(LangId::Json),
11801        "scala" => Some(LangId::Scala),
11802        "java" => Some(LangId::Java),
11803        "ruby" => Some(LangId::Ruby),
11804        "kotlin" => Some(LangId::Kotlin),
11805        "swift" => Some(LangId::Swift),
11806        "php" => Some(LangId::Php),
11807        "lua" => Some(LangId::Lua),
11808        "perl" => Some(LangId::Perl),
11809        "yaml" => Some(LangId::Yaml),
11810        "pascal" => Some(LangId::Pascal),
11811        "r" => Some(LangId::R),
11812        "groovy" => Some(LangId::Groovy),
11813        "objc" => Some(LangId::ObjC),
11814        _ => None,
11815    }
11816}
11817
11818fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11819    let mut normalized = if files.is_empty() {
11820        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11821    } else {
11822        files
11823            .iter()
11824            .map(|path| normalize_file_path(project_root, path))
11825            .collect::<Result<Vec<_>>>()?
11826    };
11827    normalized.sort();
11828    normalized.dedup();
11829    Ok(normalized)
11830}
11831
11832fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11833    let full_path = if path.is_relative() {
11834        project_root.join(path)
11835    } else {
11836        path.to_path_buf()
11837    };
11838    Ok(canonicalize_path(&full_path))
11839}
11840
11841fn canonicalize_path(path: &Path) -> PathBuf {
11842    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11843}
11844
11845fn relative_path(project_root: &Path, path: &Path) -> String {
11846    if let Ok(stripped) = path.strip_prefix(project_root) {
11847        return stripped.to_string_lossy().replace('\\', "/");
11848    }
11849    let canon_root = canonicalize_path(project_root);
11850    let canon_path = canonicalize_path(path);
11851    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11852        return stripped.to_string_lossy().replace('\\', "/");
11853    }
11854    canon_path.to_string_lossy().replace('\\', "/")
11855}
11856
11857fn unqualified_name(scoped: &str) -> &str {
11858    if scoped == TOP_LEVEL_SYMBOL {
11859        return scoped;
11860    }
11861    scoped
11862        .rsplit("::")
11863        .next()
11864        .unwrap_or(scoped)
11865        .rsplit('.')
11866        .next()
11867        .unwrap_or(scoped)
11868        .rsplit('#')
11869        .next()
11870        .unwrap_or(scoped)
11871}
11872
11873fn ref_id(parts: &[&str]) -> String {
11874    let joined = parts.join("\0");
11875    hash_to_hex(blake3::hash(joined.as_bytes()))
11876}
11877
11878fn hash_to_hex(hash: blake3::Hash) -> String {
11879    hash.to_hex().to_string()
11880}
11881
11882fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11883    let bytes = hex_to_bytes(value)?;
11884    Some(blake3::Hash::from_bytes(bytes))
11885}
11886
11887fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11888    if value.len() != 64 {
11889        return None;
11890    }
11891    let mut bytes = [0u8; 32];
11892    for (index, slot) in bytes.iter_mut().enumerate() {
11893        let start = index * 2;
11894        let end = start + 2;
11895        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11896    }
11897    Some(bytes)
11898}
11899
11900#[derive(Debug, Clone)]
11901struct LineIndex {
11902    newline_offsets: Vec<usize>,
11903    source_len: usize,
11904}
11905
11906impl LineIndex {
11907    fn new(source: &str) -> Self {
11908        Self {
11909            newline_offsets: source
11910                .bytes()
11911                .enumerate()
11912                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11913                .collect(),
11914            source_len: source.len(),
11915        }
11916    }
11917
11918    fn byte_to_line(&self, byte_offset: usize) -> u32 {
11919        let byte_offset = byte_offset.min(self.source_len);
11920        self.newline_offsets
11921            .partition_point(|offset| *offset < byte_offset) as u32
11922            + 1
11923    }
11924}
11925
11926fn empty_to_none(value: String) -> Option<String> {
11927    if value.is_empty() {
11928        None
11929    } else {
11930        Some(value)
11931    }
11932}
11933
11934fn bool_int(value: bool) -> i64 {
11935    if value {
11936        1
11937    } else {
11938        0
11939    }
11940}
11941
11942fn system_time_to_ns(time: SystemTime) -> i64 {
11943    time.duration_since(UNIX_EPOCH)
11944        .unwrap_or_default()
11945        .as_nanos()
11946        .min(i64::MAX as u128) as i64
11947}
11948
11949fn ns_to_system_time(value: i64) -> SystemTime {
11950    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11951}
11952
11953fn unix_millis_now() -> u64 {
11954    SystemTime::now()
11955        .duration_since(UNIX_EPOCH)
11956        .unwrap_or_default()
11957        .as_millis()
11958        .min(u128::from(u64::MAX)) as u64
11959}
11960
11961fn unix_seconds_now() -> i64 {
11962    SystemTime::now()
11963        .duration_since(UNIX_EPOCH)
11964        .unwrap_or_default()
11965        .as_secs() as i64
11966}
11967
11968/// Serializes every test that drives the process-wide refresh worker
11969/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
11970/// worker down between another test's enqueue and its flush, deferring the
11971/// batch and zeroing that test's seam counts).
11972#[cfg(test)]
11973pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11974
11975#[cfg(test)]
11976mod refresh_worker_tests {
11977    use super::*;
11978    use std::fs;
11979    use tempfile::tempdir;
11980
11981    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11982        let temp = tempdir().unwrap();
11983        let root = temp.path().join("root");
11984        fs::create_dir_all(&root).unwrap();
11985        let artifact_key = crate::search_index::artifact_cache_key(&root);
11986        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11987        let callgraph_dir = temp
11988            .path()
11989            .join("storage")
11990            .join("callgraph")
11991            .join(artifact_key);
11992        let source = root.join("main.rs");
11993        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11994        let (store, _) = CallGraphStore::cold_build_with_lease(
11995            callgraph_dir.clone(),
11996            root.clone(),
11997            std::slice::from_ref(&source),
11998        )
11999        .unwrap();
12000        drop(store);
12001        (temp, root, callgraph_dir, source)
12002    }
12003
12004    fn pending_paths() -> PendingCallGraphStorePaths {
12005        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12006    }
12007
12008    fn wait_for_refresh_calls(root: &Path, expected: usize) {
12009        let deadline = Instant::now() + Duration::from_secs(12);
12010        while callgraph_refresh_worker_test_counts(root).0 < expected {
12011            assert!(
12012                Instant::now() < deadline,
12013                "timed out waiting for {expected} callgraph refresh worker call(s)"
12014            );
12015            std::thread::sleep(Duration::from_millis(5));
12016        }
12017    }
12018
12019    fn wait_for_refresh_worker_idle() {
12020        let deadline = Instant::now() + Duration::from_secs(12);
12021        loop {
12022            let worker = CALLGRAPH_REFRESH_WORKER
12023                .get_or_init(|| Mutex::new(None))
12024                .lock()
12025                .expect("callgraph refresh worker mutex poisoned")
12026                .clone();
12027            let idle = worker.is_none_or(|worker| {
12028                let queue = worker
12029                    .shared
12030                    .queue
12031                    .lock()
12032                    .expect("callgraph refresh queue mutex poisoned");
12033                queue.active.is_none() && queue.order.is_empty()
12034            });
12035            if idle {
12036                return;
12037            }
12038            assert!(
12039                Instant::now() < deadline,
12040                "timed out waiting for callgraph refresh worker to become idle"
12041            );
12042            std::thread::sleep(Duration::from_millis(5));
12043        }
12044    }
12045
12046    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12047        let temp = tempdir().unwrap();
12048        let root = temp.path().join("workspace");
12049        fs::create_dir_all(root.join("app/src")).unwrap();
12050        let artifact_key = crate::search_index::artifact_cache_key(&root);
12051        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12052        let callgraph_dir = temp
12053            .path()
12054            .join("storage")
12055            .join("callgraph")
12056            .join(artifact_key);
12057        fs::write(
12058            root.join("Cargo.toml"),
12059            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12060        )
12061        .unwrap();
12062        fs::write(
12063            root.join("app/Cargo.toml"),
12064            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12065        )
12066        .unwrap();
12067        let caller = root.join("app/src/lib.rs");
12068        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12069        let (store, _) = CallGraphStore::cold_build_with_lease(
12070            callgraph_dir.clone(),
12071            root.clone(),
12072            std::slice::from_ref(&caller),
12073        )
12074        .unwrap();
12075        drop(store);
12076        (temp, root, callgraph_dir, caller)
12077    }
12078
12079    #[test]
12080    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12081        let _guard = REFRESH_WORKER_TEST_LOCK
12082            .lock()
12083            .unwrap_or_else(std::sync::PoisonError::into_inner);
12084        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12085        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12086        reset_workspace_crate_prefix_build_count(&root);
12087        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12088
12089        for revision in ["first", "second"] {
12090            fs::write(
12091                &caller,
12092                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12093            )
12094            .unwrap();
12095            enqueue_callgraph_store_refresh(
12096                callgraph_dir.clone(),
12097                root.clone(),
12098                vec![caller.clone()],
12099                pending_paths(),
12100            );
12101            wait_for_refresh_worker_idle();
12102        }
12103
12104        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12105        assert!(flush_callgraph_store_refreshes_with_budget(
12106            Duration::from_secs(5)
12107        ));
12108        clear_callgraph_refresh_worker_test_seam(&root);
12109    }
12110
12111    #[test]
12112    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12113        let _guard = REFRESH_WORKER_TEST_LOCK
12114            .lock()
12115            .unwrap_or_else(std::sync::PoisonError::into_inner);
12116        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12117        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12118        reset_workspace_crate_prefix_build_count(&root);
12119        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12120
12121        fs::write(
12122            &caller,
12123            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12124        )
12125        .unwrap();
12126        enqueue_callgraph_store_refresh(
12127            callgraph_dir.clone(),
12128            root.clone(),
12129            vec![caller.clone()],
12130            pending_paths(),
12131        );
12132        wait_for_refresh_worker_idle();
12133        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12134
12135        let added_manifest = root.join("added/Cargo.toml");
12136        let added_source = root.join("added/src/lib.rs");
12137        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12138        fs::write(
12139            root.join("Cargo.toml"),
12140            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12141        )
12142        .unwrap();
12143        fs::write(
12144            &added_manifest,
12145            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12146        )
12147        .unwrap();
12148        fs::write(&added_source, "pub fn target() {}\n").unwrap();
12149        fs::write(
12150            &caller,
12151            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12152        )
12153        .unwrap();
12154
12155        enqueue_callgraph_store_refresh(
12156            callgraph_dir.clone(),
12157            root.clone(),
12158            vec![
12159                root.join("Cargo.toml"),
12160                added_manifest,
12161                added_source,
12162                caller,
12163            ],
12164            pending_paths(),
12165        );
12166        assert!(flush_callgraph_store_refreshes_with_budget(
12167            Duration::from_secs(12)
12168        ));
12169
12170        // This is the negative control for a permanently-static cache: without
12171        // manifest invalidation the build count stays at one and the call remains
12172        // unresolved because `added_crate` was absent when the map was primed.
12173        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12174        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12175            .unwrap()
12176            .expect("refreshed workspace store");
12177        let tree = store
12178            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12179            .unwrap();
12180        assert_eq!(tree.children.len(), 1);
12181        assert_eq!(tree.children[0].file, "added/src/lib.rs");
12182        assert_eq!(tree.children[0].name, "target");
12183        assert!(tree.children[0].resolved);
12184        clear_callgraph_refresh_worker_test_seam(&root);
12185    }
12186
12187    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12188        let temp = tempdir().unwrap();
12189        let main = temp.path().join("main");
12190        let worktree = temp.path().join("worktree");
12191        fs::create_dir_all(&main).unwrap();
12192        let mut git = std::process::Command::new("git");
12193        assert!(
12194            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12195                .status()
12196                .unwrap()
12197                .success()
12198        );
12199        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12200        for args in [
12201            vec![
12202                "-C",
12203                main.to_str().unwrap(),
12204                "config",
12205                "user.email",
12206                "test@example.com",
12207            ],
12208            vec![
12209                "-C",
12210                main.to_str().unwrap(),
12211                "config",
12212                "user.name",
12213                "AFT Test",
12214            ],
12215            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12216            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12217        ] {
12218            let mut command = std::process::Command::new("git");
12219            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12220                .status()
12221                .unwrap()
12222                .success());
12223        }
12224        let mut add_worktree = std::process::Command::new("git");
12225        assert!(crate::test_env::apply_hermetic_git_env(
12226            add_worktree
12227                .arg("-C")
12228                .arg(&main)
12229                .args(["worktree", "add", "--detach"])
12230                .arg(&worktree),
12231        )
12232        .status()
12233        .unwrap()
12234        .success());
12235        let main = fs::canonicalize(main).unwrap();
12236        let worktree = fs::canonicalize(worktree).unwrap();
12237        let project_key = crate::search_index::artifact_cache_key(&main);
12238        assert_eq!(
12239            crate::search_index::artifact_cache_key(&worktree),
12240            project_key
12241        );
12242        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12243        (temp, main, worktree, project_key, callgraph_dir)
12244    }
12245
12246    #[test]
12247    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12248        let _git_env = crate::test_env::hermetic_git_env_guard();
12249        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12250        crate::root_cache::configure_artifact_access(&root, &project_key, true);
12251        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12252        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12253        let publications_for_observer = Arc::clone(&publications);
12254        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12255            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12256        })));
12257        let source = root.join("lib.rs");
12258
12259        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12260            .expect_err("borrow-only writable open must remain unavailable");
12261        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12262        assert!(
12263            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12264                .unwrap()
12265                .is_none()
12266        );
12267        assert!(
12268            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12269                .unwrap()
12270                .is_none()
12271        );
12272        assert!(matches!(
12273            CallGraphStore::cold_build_with_lease(
12274                callgraph_dir.clone(),
12275                root.clone(),
12276                std::slice::from_ref(&source),
12277            ),
12278            Err(CallGraphStoreError::Unavailable(_))
12279        ));
12280        assert!(matches!(
12281            CallGraphStore::ensure_built_with_lease(
12282                callgraph_dir.clone(),
12283                root.clone(),
12284                std::slice::from_ref(&source),
12285            ),
12286            Err(CallGraphStoreError::Unavailable(_))
12287        ));
12288        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12289            callgraph_dir.clone(),
12290            root.clone(),
12291            &[source],
12292            1,
12293        )
12294        .expect_err("borrow-only forced rebuild must remain unsatisfied");
12295        set_cold_build_swap_observer(None);
12296
12297        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12298        assert_eq!(
12299            crate::root_cache::writer_lease_acquisition_count_for_test(
12300                crate::root_cache::RootCacheDomain::Callgraph,
12301                &project_key,
12302                &root,
12303            ),
12304            0
12305        );
12306        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12307        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12308    }
12309
12310    #[test]
12311    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12312        let _git_env = crate::test_env::hermetic_git_env_guard();
12313        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12314        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12315        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12316        let source = owner.join("lib.rs");
12317        let (store, _) = CallGraphStore::cold_build_with_lease(
12318            callgraph_dir.clone(),
12319            owner.clone(),
12320            std::slice::from_ref(&source),
12321        )
12322        .unwrap();
12323        let sqlite_path = store.sqlite_path().to_path_buf();
12324        drop(store);
12325
12326        let conn = Connection::open(&sqlite_path).unwrap();
12327        conn.execute(
12328            "UPDATE backend_file_state SET workspace_root = ?1",
12329            [worktree.display().to_string()],
12330        )
12331        .unwrap();
12332        drop(conn);
12333
12334        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12335        let publications_for_observer = Arc::clone(&publications);
12336        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12337            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12338        })));
12339        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12340
12341        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12342            .unwrap()
12343            .expect("owner should purge the storm-era worktree root");
12344        drop(repaired);
12345        for _ in 0..3 {
12346            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12347                .unwrap()
12348                .expect("linked worktree should borrow the owner generation");
12349            drop(borrower);
12350            assert!(
12351                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12352                    .unwrap()
12353                    .is_none()
12354            );
12355            let owner_store =
12356                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12357                    .unwrap()
12358                    .expect("owner generation should remain ready");
12359            drop(owner_store);
12360        }
12361        set_cold_build_swap_observer(None);
12362
12363        assert_eq!(
12364            publications.load(AtomicOrdering::SeqCst),
12365            1,
12366            "the owner performs one expected post-storm purge and alternation stays read-only"
12367        );
12368        assert_eq!(
12369            crate::root_cache::writer_lease_acquisition_count_for_test(
12370                crate::root_cache::RootCacheDomain::Callgraph,
12371                &project_key,
12372                &worktree,
12373            ),
12374            0
12375        );
12376    }
12377
12378    #[test]
12379    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12380        let temp = tempdir().unwrap();
12381        let root = temp.path().join("owner");
12382        let other_root = temp.path().join("other");
12383        fs::create_dir_all(&root).unwrap();
12384        fs::create_dir_all(&other_root).unwrap();
12385        let source = root.join("lib.rs");
12386        fs::write(&source, "pub fn marker() {}\n").unwrap();
12387        let project_key = crate::search_index::artifact_cache_key(&root);
12388        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12389        crate::root_cache::configure_artifact_access(&root, &project_key, false);
12390        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12391        rebuild_cooldown_records()
12392            .lock()
12393            .unwrap_or_else(std::sync::PoisonError::into_inner)
12394            .remove(&cooldown_key);
12395        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12396        let stale_epoch = epoch.current();
12397        epoch.next();
12398
12399        let failed = with_publish_epoch(epoch, stale_epoch, || {
12400            CallGraphStore::cold_build_with_lease(
12401                callgraph_dir.clone(),
12402                root.clone(),
12403                std::slice::from_ref(&source),
12404            )
12405        });
12406        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12407        assert!(
12408            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12409                .is_none()
12410        );
12411
12412        let (store, _) = CallGraphStore::cold_build_with_lease(
12413            callgraph_dir.clone(),
12414            root.clone(),
12415            std::slice::from_ref(&source),
12416        )
12417        .unwrap();
12418        drop(store);
12419        assert!(
12420            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12421                .is_none()
12422        );
12423
12424        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12425        assert!(
12426            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12427        );
12428    }
12429
12430    #[test]
12431    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12432        let _guard = REFRESH_WORKER_TEST_LOCK
12433            .lock()
12434            .unwrap_or_else(std::sync::PoisonError::into_inner);
12435        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12436        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12437        let pending = pending_paths();
12438        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12439
12440        let lifecycle = SubcLifecycleAdmission::default();
12441        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12442        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12443        let ticket = CallgraphRefreshTicket::new(
12444            lifecycle,
12445            Arc::clone(&generation),
12446            7,
12447            publish_epoch.clone(),
12448            publish_epoch.current(),
12449        );
12450        // Supersede before the worker runs: the batch must defer, not commit.
12451        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12452        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12453            .unwrap()
12454            .expect("ready store snapshot");
12455        let refresh_state = CallgraphRefreshState::new(
12456            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12457            Arc::new(AtomicBool::new(true)),
12458        );
12459
12460        enqueue_callgraph_store_refresh_fenced_with_state(
12461            callgraph_dir,
12462            root.clone(),
12463            vec![source.clone()],
12464            Arc::clone(&pending),
12465            refresh_state,
12466            ticket,
12467        );
12468        assert!(flush_callgraph_store_refreshes_with_budget(
12469            Duration::from_secs(5)
12470        ));
12471        assert_eq!(
12472            callgraph_refresh_worker_test_counts(&root).0,
12473            0,
12474            "superseded batch must not reach refresh_files or self-replay"
12475        );
12476        assert!(
12477            pending.lock().contains(&source),
12478            "superseded batch must defer its paths to the pending sink"
12479        );
12480        clear_callgraph_refresh_worker_test_seam(&root);
12481    }
12482
12483    #[test]
12484    fn superseded_open_failure_defers_without_self_replay() {
12485        let _guard = REFRESH_WORKER_TEST_LOCK
12486            .lock()
12487            .unwrap_or_else(std::sync::PoisonError::into_inner);
12488        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12489        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12490        let pending = pending_paths();
12491        let installed = Arc::new(
12492            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12493                .unwrap()
12494                .expect("ready store snapshot"),
12495        );
12496        let refresh_state = CallgraphRefreshState::new(
12497            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12498            Arc::new(AtomicBool::new(true)),
12499        );
12500        assert!(!installed.is_legacy_fallback());
12501        assert!(installed.is_current());
12502        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12503        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12504        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12505        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12506
12507        let lifecycle = SubcLifecycleAdmission::default();
12508        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12509        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12510        let ticket = CallgraphRefreshTicket::new(
12511            lifecycle,
12512            Arc::clone(&generation),
12513            7,
12514            publish_epoch.clone(),
12515            publish_epoch.current(),
12516        );
12517        enqueue_callgraph_store_refresh_fenced_with_state(
12518            callgraph_dir,
12519            root.clone(),
12520            vec![source.clone()],
12521            Arc::clone(&pending),
12522            refresh_state,
12523            ticket,
12524        );
12525        held_rx
12526            .recv_timeout(Duration::from_secs(12))
12527            .expect("refresh worker must hold after injected open failure");
12528
12529        // Mark the refresh request obsolete after the injected open failure,
12530        // then unblock the worker before its deferred retry can run.
12531        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12532        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12533        release_tx
12534            .send(())
12535            .expect("release superseded refresh worker");
12536        wait_for_refresh_worker_idle();
12537
12538        assert_eq!(
12539            callgraph_refresh_worker_test_counts(&root).0,
12540            1,
12541            "superseded open-failure batch must not self-replay"
12542        );
12543        assert_eq!(
12544            callgraph_refresh_worker_test_worker_calls(&root),
12545            1,
12546            "superseded open-failure batch must not create another worker call"
12547        );
12548        assert!(
12549            pending.lock().contains(&source),
12550            "superseded open-failure paths must remain in the pending sink"
12551        );
12552        let tree = installed
12553            .call_tree(Path::new("main.rs"), "entry", 1)
12554            .unwrap();
12555        assert_eq!(
12556            tree.children[0].name, "old_leaf",
12557            "superseded open-failure batch must not converge the store"
12558        );
12559        clear_callgraph_refresh_worker_test_seam(&root);
12560    }
12561
12562    #[test]
12563    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
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        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12571
12572        let lifecycle = SubcLifecycleAdmission::default();
12573        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12574        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12575        let expected_epoch = publish_epoch.current();
12576        let ticket = CallgraphRefreshTicket::new(
12577            lifecycle,
12578            generation,
12579            3,
12580            publish_epoch.clone(),
12581            expected_epoch,
12582        );
12583        // A cold build published a replacement generation after enqueue.
12584        publish_epoch.next();
12585
12586        enqueue_callgraph_store_refresh_fenced(
12587            callgraph_dir,
12588            root.clone(),
12589            vec![source.clone()],
12590            Arc::clone(&pending),
12591            ticket,
12592        );
12593        assert!(flush_callgraph_store_refreshes_with_budget(
12594            Duration::from_secs(5)
12595        ));
12596        assert_eq!(
12597            callgraph_refresh_worker_test_counts(&root).0,
12598            0,
12599            "epoch-superseded batch must not reach refresh_files"
12600        );
12601        assert!(
12602            pending.lock().contains(&source),
12603            "epoch-superseded batch must defer its paths to the pending sink"
12604        );
12605        clear_callgraph_refresh_worker_test_seam(&root);
12606    }
12607
12608    #[test]
12609    fn fenced_refresh_with_current_ticket_commits_normally() {
12610        let _guard = REFRESH_WORKER_TEST_LOCK
12611            .lock()
12612            .unwrap_or_else(std::sync::PoisonError::into_inner);
12613        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12614        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12615        let pending = pending_paths();
12616        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12617
12618        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12619
12620        let lifecycle = SubcLifecycleAdmission::default();
12621        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12622        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12623        let ticket = CallgraphRefreshTicket::new(
12624            lifecycle,
12625            generation,
12626            5,
12627            publish_epoch.clone(),
12628            publish_epoch.current(),
12629        );
12630
12631        enqueue_callgraph_store_refresh_fenced(
12632            callgraph_dir.clone(),
12633            root.clone(),
12634            vec![source.clone()],
12635            Arc::clone(&pending),
12636            ticket,
12637        );
12638        assert!(flush_callgraph_store_refreshes_with_budget(
12639            Duration::from_secs(5)
12640        ));
12641        assert_eq!(
12642            callgraph_refresh_worker_test_counts(&root).0,
12643            1,
12644            "current ticket must run the refresh"
12645        );
12646        assert!(
12647            pending.lock().is_empty(),
12648            "committed batch must not defer paths"
12649        );
12650
12651        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12652            .unwrap()
12653            .expect("published generation must remain readable");
12654        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12655        assert_eq!(
12656            tree.children[0].name, "new_leaf",
12657            "fenced commit must actually persist the refreshed content"
12658        );
12659        clear_callgraph_refresh_worker_test_seam(&root);
12660    }
12661
12662    #[test]
12663    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12664        let _guard = REFRESH_WORKER_TEST_LOCK
12665            .lock()
12666            .unwrap_or_else(std::sync::PoisonError::into_inner);
12667        // Generous pre-drain: the refresh worker is process-wide, so a prior
12668        // test's still-running batch (slow Windows CI) must fully settle
12669        // before this test enqueues, or its wait deadline absorbs the
12670        // leftover work. Idle workers return immediately.
12671        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12672        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12673        let pending = pending_paths();
12674        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12675
12676        enqueue_callgraph_store_refresh(
12677            callgraph_dir.clone(),
12678            root.clone(),
12679            vec![source.clone()],
12680            Arc::clone(&pending),
12681        );
12682        wait_for_refresh_calls(&root, 1);
12683        for _ in 0..3 {
12684            enqueue_callgraph_store_refresh(
12685                callgraph_dir.clone(),
12686                root.clone(),
12687                vec![source.clone()],
12688                Arc::clone(&pending),
12689            );
12690        }
12691
12692        assert!(flush_callgraph_store_refreshes_with_budget(
12693            Duration::from_secs(2)
12694        ));
12695        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12696        assert!(pending.lock().is_empty());
12697        clear_callgraph_refresh_worker_test_seam(&root);
12698    }
12699
12700    #[test]
12701    fn queued_refresh_opens_generation_published_after_enqueue() {
12702        let _guard = REFRESH_WORKER_TEST_LOCK
12703            .lock()
12704            .unwrap_or_else(std::sync::PoisonError::into_inner);
12705        // Generous pre-drain: the refresh worker is process-wide, so a prior
12706        // test's still-running batch (slow Windows CI) must fully settle
12707        // before this test enqueues, or its wait deadline absorbs the
12708        // leftover work. Idle workers return immediately.
12709        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12710        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12711        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12712        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12713        let (active_held_rx, active_release_tx) =
12714            install_callgraph_refresh_worker_test_gate(active_root.clone());
12715        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12716        enqueue_callgraph_store_refresh(
12717            active_dir,
12718            active_root.clone(),
12719            vec![active_source],
12720            pending_paths(),
12721        );
12722        active_held_rx
12723            .recv_timeout(Duration::from_secs(12))
12724            .expect("active refresh worker holds the queue");
12725
12726        fs::write(
12727            &target_source,
12728            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12729        )
12730        .unwrap();
12731        enqueue_callgraph_store_refresh(
12732            target_dir.clone(),
12733            target_root.clone(),
12734            vec![target_source.clone()],
12735            pending_paths(),
12736        );
12737        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12738            target_dir.clone(),
12739            target_root.clone(),
12740            std::slice::from_ref(&target_source),
12741        )
12742        .unwrap();
12743        fs::write(
12744            &target_source,
12745            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12746        )
12747        .unwrap();
12748        drop(new_generation);
12749
12750        active_release_tx
12751            .send(())
12752            .expect("release active refresh worker");
12753        wait_for_refresh_calls(&target_root, 1);
12754        assert!(flush_callgraph_store_refreshes_with_budget(
12755            Duration::from_secs(12)
12756        ));
12757        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12758            .unwrap()
12759            .expect("current callgraph generation");
12760        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12761        assert_eq!(tree.children[0].name, "worker_leaf");
12762        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12763        clear_callgraph_refresh_worker_test_seam(&active_root);
12764        clear_callgraph_refresh_worker_test_seam(&target_root);
12765    }
12766
12767    #[test]
12768    fn refresh_failure_marks_files_stale() {
12769        let _guard = REFRESH_WORKER_TEST_LOCK
12770            .lock()
12771            .unwrap_or_else(std::sync::PoisonError::into_inner);
12772        // Generous pre-drain: the refresh worker is process-wide, so a prior
12773        // test's still-running batch (slow Windows CI) must fully settle
12774        // before this test enqueues, or its wait deadline absorbs the
12775        // leftover work. Idle workers return immediately.
12776        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12777        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12778        let pending = pending_paths();
12779        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12780
12781        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12782        assert!(flush_callgraph_store_refreshes_with_budget(
12783            Duration::from_secs(2)
12784        ));
12785
12786        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12787        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12788            .unwrap()
12789            .expect("ready callgraph store");
12790        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12791        clear_callgraph_refresh_worker_test_seam(&root);
12792    }
12793
12794    #[test]
12795    fn idle_refresh_truncates_wal() {
12796        let _guard = REFRESH_WORKER_TEST_LOCK
12797            .lock()
12798            .unwrap_or_else(std::sync::PoisonError::into_inner);
12799        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12800        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12801        let generation = read_pointer(
12802            &callgraph_dir,
12803            &crate::search_index::artifact_cache_key(&root),
12804        )
12805        .expect("fixture publishes a generation");
12806        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12807        let pending = pending_paths();
12808        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12809
12810        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12811        enqueue_callgraph_store_refresh(
12812            callgraph_dir.clone(),
12813            root.clone(),
12814            vec![source.clone()],
12815            Arc::clone(&pending),
12816        );
12817        wait_for_refresh_calls(&root, 1);
12818        wait_for_refresh_worker_idle();
12819        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12820        while fs::metadata(&wal_path)
12821            .map(|metadata| metadata.len())
12822            .unwrap_or(0)
12823            != 0
12824        {
12825            assert!(
12826                Instant::now() < checkpoint_deadline,
12827                "idle checkpoint did not truncate WAL"
12828            );
12829            std::thread::sleep(Duration::from_millis(5));
12830        }
12831        assert_eq!(
12832            fs::metadata(&wal_path)
12833                .map(|metadata| metadata.len())
12834                .unwrap_or(0),
12835            0,
12836            "idle transition truncates the refresh WAL"
12837        );
12838
12839        clear_callgraph_refresh_worker_test_seam(&root);
12840    }
12841
12842    #[test]
12843    fn bounded_shutdown_defers_unprocessed_batches() {
12844        let _guard = REFRESH_WORKER_TEST_LOCK
12845            .lock()
12846            .unwrap_or_else(std::sync::PoisonError::into_inner);
12847        // Generous pre-drain: the refresh worker is process-wide, so a prior
12848        // test's still-running batch (slow Windows CI) must fully settle
12849        // before this test enqueues, or its wait deadline absorbs the
12850        // leftover work. Idle workers return immediately.
12851        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12852        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12853        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12854        let active_pending = pending_paths();
12855        let queued_pending = pending_paths();
12856        set_callgraph_refresh_worker_test_seam(
12857            active_root.clone(),
12858            Duration::from_millis(300),
12859            false,
12860        );
12861
12862        enqueue_callgraph_store_refresh(
12863            active_dir,
12864            active_root.clone(),
12865            vec![active_source.clone()],
12866            Arc::clone(&active_pending),
12867        );
12868        wait_for_refresh_calls(&active_root, 1);
12869        enqueue_callgraph_store_refresh(
12870            queued_dir,
12871            queued_root.clone(),
12872            vec![queued_source.clone()],
12873            Arc::clone(&queued_pending),
12874        );
12875
12876        assert!(!flush_callgraph_store_refreshes_with_budget(
12877            Duration::from_millis(20)
12878        ));
12879        assert!(active_pending.lock().contains(&active_source));
12880        assert!(queued_pending.lock().contains(&queued_source));
12881        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12882        clear_callgraph_refresh_worker_test_seam(&active_root);
12883    }
12884}
12885
12886#[cfg(test)]
12887mod cold_build_insert_tests {
12888    use super::*;
12889    use crate::imports::ImportBlock;
12890    use std::cell::Cell;
12891    use std::fs;
12892    use std::path::{Path, PathBuf};
12893    use tempfile::tempdir;
12894
12895    thread_local! {
12896        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12897        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12898        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12899    }
12900
12901    fn count_caller_traversal_selects(sql: &str) {
12902        let sql = sql.trim_start();
12903        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12904            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12905        }
12906        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12907            && sql.contains("e.target_file =")
12908        {
12909            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12910        }
12911        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
12912            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12913        }
12914    }
12915
12916    #[test]
12917    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12918        let dir = tempdir().unwrap();
12919        let previous_root = dir.path().join("previous-root");
12920        let current_root = dir.path().join("current-root");
12921        fs::create_dir_all(&previous_root).unwrap();
12922        fs::create_dir_all(&current_root).unwrap();
12923        fs::remove_dir(&previous_root).unwrap();
12924        let mut conn = Connection::open_in_memory().unwrap();
12925        initialize_schema(&conn).unwrap();
12926        conn.execute(
12927            "INSERT INTO backend_file_state(
12928                backend, workspace_root, file_path, content_hash, status, updated_at
12929             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12930            params![previous_root.display().to_string()],
12931        )
12932        .unwrap();
12933
12934        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
12935
12936        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12937        assert_eq!(
12938            stored_workspace_roots(&conn).unwrap(),
12939            vec![previous_root.display().to_string()]
12940        );
12941    }
12942
12943    #[test]
12944    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12945        assert_eq!(
12946            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12947            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12948        );
12949    }
12950
12951    #[test]
12952    fn legacy_migration_completion_log_has_operator_fields() {
12953        assert_eq!(
12954            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12955            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12956        );
12957    }
12958
12959    fn write_generation_with_age(
12960        dir: &Path,
12961        project_key: &str,
12962        ordinal: u64,
12963        age: Duration,
12964    ) -> String {
12965        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12966        let path = dir.join(&generation);
12967        fs::write(&path, b"sqlite placeholder").unwrap();
12968        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12969        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12970        generation
12971    }
12972
12973    #[test]
12974    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12975        let dir = tempfile::tempdir().unwrap();
12976        let project_key = "project";
12977        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12978        let previous =
12979            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12980        let pinned =
12981            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12982        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12983
12984        gc_old_generations(dir.path(), project_key, &current);
12985
12986        assert!(dir.path().join(&previous).is_file());
12987        assert!(dir.path().join(&pinned).is_file());
12988
12989        drop(marker);
12990        gc_old_generations(dir.path(), project_key, &current);
12991
12992        assert!(dir.path().join(&previous).is_file());
12993        assert!(!dir.path().join(&pinned).exists());
12994    }
12995
12996    #[test]
12997    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12998        let dir = tempfile::tempdir().unwrap();
12999        let project_key = "project";
13000        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13001        let _previous =
13002            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13003        let pinned =
13004            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13005        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13006        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13007
13008        gc_old_generations(dir.path(), project_key, &current);
13009
13010        assert!(dir.path().join(&pinned).is_file());
13011    }
13012
13013    #[test]
13014    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13015        let dir = tempfile::tempdir().unwrap();
13016        let project_key = "project";
13017        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13018        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13019        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13020        let old = write_generation_with_age(
13021            dir.path(),
13022            project_key,
13023            200,
13024            expired + Duration::from_secs(60),
13025        );
13026        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13027
13028        gc_old_generations(dir.path(), project_key, &current);
13029
13030        assert!(dir.path().join(&current).is_file());
13031        assert!(dir.path().join(&previous).is_file());
13032        assert!(!dir.path().join(&old).exists());
13033    }
13034
13035    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13036        let path = dir.join(name);
13037        fs::write(&path, b"temp placeholder").unwrap();
13038        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13039        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13040        path
13041    }
13042
13043    #[test]
13044    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13045        let dir = tempdir().unwrap();
13046        // One directory holds both an aged orphan (with its journal sidecar) and a
13047        // fresh temporary, so this proves the sweep SELECTS by age rather than
13048        // deleting everything in the directory.
13049        let aged = "project.g100.1.sqlite.tmp.1.200";
13050        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13051        let fresh = "project.g300.1.sqlite.tmp.1.400";
13052        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13053        write_build_temp_with_age(dir.path(), aged, aged_age);
13054        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13055        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13056
13057        sweep_orphaned_build_temps(dir.path());
13058
13059        assert!(
13060            !dir.path().join(aged).exists(),
13061            "aged orphan must be removed"
13062        );
13063        assert!(
13064            !dir.path().join(aged_journal).exists(),
13065            "aged journal sidecar must be removed"
13066        );
13067        assert!(
13068            dir.path().join(fresh).is_file(),
13069            "fresh temporary must survive"
13070        );
13071    }
13072
13073    #[test]
13074    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13075        let storage = tempdir().unwrap();
13076        let storage_root = storage.path();
13077        // The production shape: a legacy per-harness store whose root no longer
13078        // builds there — no `.current` pointer, no running build — so the per-root
13079        // cleanup never fires for it. A sibling root still building in the
13080        // root-keyed store triggers the store-wide sweep, which must reach into the
13081        // legacy directory and reclaim the orphan.
13082        let legacy_dir = storage_root.join("opencode").join("callgraph");
13083        fs::create_dir_all(&legacy_dir).unwrap();
13084        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13085        write_build_temp_with_age(
13086            &legacy_dir,
13087            orphan,
13088            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13089        );
13090        assert!(
13091            !legacy_dir.join("deadbeef.current").exists(),
13092            "the dead root has no current pointer"
13093        );
13094
13095        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13096        fs::create_dir_all(&root_keyed_dir).unwrap();
13097
13098        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13099
13100        assert!(
13101            !legacy_dir.join(orphan).exists(),
13102            "legacy orphan must be reclaimed by the store-wide sweep"
13103        );
13104    }
13105
13106    #[test]
13107    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13108        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
13109        // everything (min_age = 0) removes the fresh temporary that the real 24h
13110        // threshold spares in the test above. If a mutation to the age check leaves
13111        // the fresh file in place here, the predicate is no longer doing the
13112        // selection work the fresh-survives assertion relies on.
13113        let dir = tempdir().unwrap();
13114        let fresh = "project.g300.1.sqlite.tmp.1.400";
13115        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13116
13117        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13118
13119        assert!(
13120            !dir.path().join(fresh).exists(),
13121            "with the age predicate forced open, the fresh temporary is removed"
13122        );
13123    }
13124
13125    #[test]
13126    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13127        let dir = tempdir().unwrap();
13128        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
13129        // to be swept, plus a live read marker, is generation GC's jurisdiction.
13130        // The orphan sweep must not intersect it.
13131        let generation = write_generation_with_age(
13132            dir.path(),
13133            "project",
13134            400,
13135            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13136        );
13137        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13138
13139        sweep_orphaned_build_temps(dir.path());
13140
13141        assert!(
13142            dir.path().join(&generation).is_file(),
13143            "completed generation must survive the orphan sweep"
13144        );
13145        assert!(
13146            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13147            "read marker must survive the orphan sweep"
13148        );
13149    }
13150
13151    #[test]
13152    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13153        let dir = tempfile::tempdir().unwrap();
13154        let project_key = "project".to_string();
13155        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13156        let sqlite_path = dir.path().join(&generation);
13157        fs::remove_file(&sqlite_path).unwrap();
13158        let conn = Connection::open(&sqlite_path).unwrap();
13159        let store = CallGraphStore::from_connection(
13160            dir.path().to_path_buf(),
13161            project_key,
13162            sqlite_path,
13163            dir.path().to_path_buf(),
13164            false,
13165            Some(generation.clone()),
13166            None,
13167            None,
13168            conn,
13169        );
13170
13171        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13172        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13173
13174        drop(marker);
13175        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13176    }
13177
13178    #[test]
13179    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13180        let dir = tempdir().expect("temp dir");
13181        let file = dir.path().join("main.ts");
13182        fs::write(&file, "export function main() {}\n").expect("write fixture");
13183        let store = CallGraphStore::open(
13184            dir.path().join(".store-readiness-cache"),
13185            dir.path().to_path_buf(),
13186        )
13187        .expect("open store");
13188        {
13189            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13190            conn.trace(Some(count_caller_traversal_selects));
13191        }
13192
13193        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13194        assert!(store.indexed_file_count().is_err());
13195        assert!(store.indexed_file_count().is_err());
13196        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13197
13198        store
13199            .cold_build(std::slice::from_ref(&file))
13200            .expect("cold build");
13201        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13202        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13203        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13204        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13205
13206        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13207        conn.trace(None);
13208    }
13209
13210    #[test]
13211    fn direct_caller_frontier_chunks_sqlite_selects() {
13212        let dir = tempdir().expect("temp dir");
13213        let file = dir.path().join("main.ts");
13214        fs::write(
13215            &file,
13216            "export function caller() { target(); }\nexport function target() {}\n",
13217        )
13218        .expect("write fixture");
13219        let store = CallGraphStore::open(
13220            dir.path().join(".store-caller-frontier-query"),
13221            dir.path().to_path_buf(),
13222        )
13223        .expect("open store");
13224        store
13225            .cold_build(std::slice::from_ref(&file))
13226            .expect("cold build");
13227        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13228        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13229
13230        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13231        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13232        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13233        {
13234            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13235            conn.trace(Some(count_caller_traversal_selects));
13236        }
13237        let callers = store
13238            .direct_callers_for_symbols(&targets)
13239            .expect("batched callers");
13240        {
13241            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13242            conn.trace(None);
13243        }
13244
13245        assert_eq!(callers.len(), 1_000);
13246        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13247        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13248        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13249        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13250    }
13251
13252    #[test]
13253    fn callers_depth_boundary_batches_sqlite_counts() {
13254        const CALLER_COUNT: usize = 1_000;
13255
13256        let dir = tempdir().expect("temp dir");
13257        let file = dir.path().join("main.ts");
13258        let mut source = String::from("export function sharedHotHelper() {}\n");
13259        for index in 0..CALLER_COUNT {
13260            source.push_str(&format!(
13261                "export function caller{index}() {{ sharedHotHelper(); }}\n"
13262            ));
13263        }
13264        fs::write(&file, source).expect("write fixture");
13265
13266        let store = CallGraphStore::open(
13267            dir.path().join(".store-callers-query-fanout"),
13268            dir.path().to_path_buf(),
13269        )
13270        .expect("open store");
13271        store
13272            .cold_build(std::slice::from_ref(&file))
13273            .expect("cold build");
13274
13275        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13276        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13277        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13278        {
13279            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13280            conn.trace(Some(count_caller_traversal_selects));
13281        }
13282
13283        let started = Instant::now();
13284        let result = crate::commands::callgraph_store_adapter::callers_result(
13285            &store,
13286            Path::new("main.ts"),
13287            "sharedHotHelper",
13288            1,
13289            true,
13290        )
13291        .expect("callers result");
13292        let elapsed = started.elapsed();
13293
13294        {
13295            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13296            conn.trace(None);
13297        }
13298        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13299        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13300        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13301        eprintln!(
13302            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13303            result.total_callers,
13304            caller_queries,
13305            boundary_queries,
13306            total_selects,
13307            elapsed.as_secs_f64() * 1_000.0
13308        );
13309
13310        assert_eq!(result.total_callers, CALLER_COUNT);
13311        assert_eq!(caller_queries, 1);
13312        assert_eq!(boundary_queries, 3);
13313        assert_eq!(total_selects, 9);
13314    }
13315
13316    #[test]
13317    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13318        let dir = tempdir().expect("temp dir");
13319        let file = dir.path().join("main.ts");
13320        fs::write(
13321            &file,
13322            r#"export function topA() {
13323  root();
13324}
13325
13326export function topB() {
13327  root();
13328}
13329
13330export function root() {
13331  leaf();
13332  missing();
13333}
13334
13335export function leaf() {}
13336"#,
13337        )
13338        .expect("write fixture");
13339
13340        let store = CallGraphStore::open(
13341            dir.path().join(".store-depth-boundary-counts"),
13342            dir.path().to_path_buf(),
13343        )
13344        .expect("open store");
13345        store
13346            .cold_build(std::slice::from_ref(&file))
13347            .expect("cold build");
13348
13349        let root = store
13350            .node_for(Path::new("main.ts"), "root")
13351            .expect("root node");
13352        let leaf = store
13353            .node_for(Path::new("main.ts"), "leaf")
13354            .expect("leaf node");
13355
13356        let (full_forward_len, full_direct_len) = {
13357            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13358            conn.execute(
13359                "INSERT INTO edges (
13360                    edge_id, ref_id, source_node, target_node, target_file,
13361                    target_symbol, kind, line, provenance
13362                 ) VALUES (
13363                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13364                    ?2, ?3, 'call', 98, ?4
13365                 )",
13366                rusqlite::params![
13367                    &root.node_id,
13368                    &leaf.file,
13369                    &leaf.symbol,
13370                    PROVENANCE_TREESITTER
13371                ],
13372            )
13373            .expect("insert dangling forward edge");
13374            conn.execute(
13375                "INSERT INTO edges (
13376                    edge_id, ref_id, source_node, target_node, target_file,
13377                    target_symbol, kind, line, provenance
13378                 ) VALUES (
13379                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13380                    ?1, ?2, ?3, 'call', 99, ?4
13381                 )",
13382                rusqlite::params![
13383                    &root.node_id,
13384                    &root.file,
13385                    &root.symbol,
13386                    PROVENANCE_TREESITTER
13387                ],
13388            )
13389            .expect("insert dangling direct-caller edge");
13390
13391            let full_forward_len = forward_calls_for_node(&conn, &root)
13392                .expect("full forward calls")
13393                .len();
13394            let counted_forward_len =
13395                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13396            assert_eq!(
13397                counted_forward_len, full_forward_len,
13398                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13399            );
13400
13401            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13402                .expect("full direct callers");
13403            let full_direct_len = full_direct.len();
13404            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13405                .expect("counted direct callers");
13406            assert_eq!(
13407                counted_direct_len, full_direct_len,
13408                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13409            );
13410
13411            let distinct_direct_len = full_direct
13412                .iter()
13413                .map(|site| {
13414                    (
13415                        site.caller.file.clone(),
13416                        site.line,
13417                        site.target_file.clone(),
13418                        site.target_symbol.clone(),
13419                    )
13420                })
13421                .collect::<BTreeSet<_>>()
13422                .len();
13423            let batch_counts = direct_caller_counts_for_tuples(
13424                &conn,
13425                &[
13426                    (root.file.clone(), root.symbol.clone()),
13427                    (root.file.clone(), root.symbol.clone()),
13428                    (leaf.file.clone(), leaf.symbol.clone()),
13429                ],
13430            )
13431            .expect("batched direct-caller counts");
13432            assert_eq!(batch_counts.len(), 2);
13433            assert_eq!(
13434                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13435                Some(&distinct_direct_len)
13436            );
13437
13438            (full_forward_len, full_direct_len)
13439        };
13440
13441        assert_eq!(
13442            full_forward_len, 2,
13443            "fixture root should have one resolved and one unresolved outgoing call"
13444        );
13445        assert_eq!(
13446            full_direct_len, 2,
13447            "fixture root should have two real direct callers"
13448        );
13449
13450        let tree = store
13451            .call_tree(Path::new("main.ts"), "root", 0)
13452            .expect("call tree");
13453        assert!(tree.depth_limited);
13454        assert_eq!(tree.children.len(), 0);
13455        assert_eq!(
13456            tree.truncated, full_forward_len,
13457            "call_tree depth boundary must report the full forward-call list length"
13458        );
13459
13460        let callers = store
13461            .callers_of(Path::new("main.ts"), "leaf", 0)
13462            .expect("callers");
13463        assert!(callers.depth_limited);
13464        assert_eq!(callers.callers.len(), 1);
13465        assert_eq!(callers.callers[0].caller.symbol, "root");
13466        assert_eq!(
13467            callers.truncated, full_direct_len,
13468            "callers depth boundary must report the full direct-caller list length"
13469        );
13470    }
13471
13472    #[test]
13473    fn source_freshness_matches_cache_collect_for_same_bytes() {
13474        let dir = tempdir().expect("temp dir");
13475        let path = dir.path().join("fixture.ts");
13476        let source = "export function main() { return helper(); }\n";
13477        fs::write(&path, source).expect("write fixture");
13478
13479        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13480        let actual =
13481            collect_source_freshness(&path, source).expect("collect freshness from source");
13482
13483        assert_eq!(actual, expected);
13484    }
13485
13486    #[test]
13487    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13488        let root = tempfile::tempdir().unwrap();
13489        let callgraph_dir = tempfile::tempdir().unwrap();
13490        let source_dir = root.path().join("src");
13491        std::fs::create_dir_all(&source_dir).unwrap();
13492        let source = source_dir.join("lib.rs");
13493        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13494        let files = vec![source.clone()];
13495        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13496        let old_epoch = epoch.next();
13497        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13498        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13499        let old_epoch_flag = epoch.clone();
13500        let old_dir = callgraph_dir.path().to_path_buf();
13501        let old_root = root.path().to_path_buf();
13502        let old_files = files.clone();
13503        let old = std::thread::spawn(move || {
13504            set_cold_build_before_publish_observer(Some(Arc::new(move || {
13505                reached_tx.send(()).unwrap();
13506                release_rx.recv().unwrap();
13507            })));
13508            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13509                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13510            });
13511            set_cold_build_before_publish_observer(None);
13512            result
13513        });
13514        // Positive wait: the older build runs a real cold build (git probe +
13515        // SQLite schema init) before the barrier, which can exceed 5s on a
13516        // contended Windows CI runner. Only negative waits stay short.
13517        reached_rx
13518            .recv_timeout(Duration::from_secs(30))
13519            .expect("older build did not reach its publication barrier");
13520
13521        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13522        let new_epoch = epoch.next();
13523        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13524            CallGraphStore::cold_build_with_lease(
13525                callgraph_dir.path().to_path_buf(),
13526                root.path().to_path_buf(),
13527                &files,
13528            )
13529        })
13530        .expect("newer build should publish");
13531        drop(new_store);
13532
13533        release_tx.send(()).unwrap();
13534        assert!(matches!(
13535            old.join().unwrap(),
13536            Err(CallGraphStoreError::Superseded)
13537        ));
13538
13539        let current = CallGraphStore::open_readonly(
13540            callgraph_dir.path().to_path_buf(),
13541            root.path().to_path_buf(),
13542        )
13543        .unwrap()
13544        .expect("current callgraph generation");
13545        assert_eq!(
13546            current
13547                .nodes_matching("new_generation_marker")
13548                .unwrap()
13549                .len(),
13550            1
13551        );
13552        assert!(current
13553            .nodes_matching("old_generation_marker")
13554            .unwrap()
13555            .is_empty());
13556    }
13557
13558    #[test]
13559    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13560        let dir = tempdir().expect("temp dir");
13561        let project_root = dir.path();
13562        let extract = fixture_extract(project_root);
13563        let resolved = fixture_resolved(&extract);
13564
13565        let reference = build_reference_connection(project_root, &extract, &resolved);
13566        let optimized = build_optimized_connection(project_root, &extract, &resolved);
13567
13568        for table in [
13569            "files",
13570            "nodes",
13571            "file_dependencies",
13572            "dispatch_hints",
13573            "refs",
13574            "edges",
13575        ] {
13576            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
13577            // the reference and optimized builds run sequentially and can straddle a
13578            // one-second tick under load, so it is legitimately allowed to differ.
13579            // This mirrors the existing exclusions of `backend_file_state.updated_at`
13580            // and the chunked-vs-unchunked sibling test. The check is for structural
13581            // row equivalence of the optimized bulk insert, not wall-clock equality.
13582            let excluded: &[&str] = if table == "files" {
13583                &["indexed_at"]
13584            } else {
13585                &[]
13586            };
13587            assert_eq!(
13588                table_rows_without(&reference, table, excluded),
13589                table_rows_without(&optimized, table, excluded),
13590                "table `{table}` rows must match apart from wall-clock columns"
13591            );
13592        }
13593        assert_eq!(
13594            backend_state_rows(&reference),
13595            backend_state_rows(&optimized),
13596            "backend freshness rows must match apart from updated_at"
13597        );
13598        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13599    }
13600
13601    #[test]
13602    fn cold_build_chunked_matches_unchunked_logical_rows() {
13603        let dir = tempdir().expect("temp dir");
13604        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13605        write_chunked_equivalence_fixture(&project_root);
13606        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13607        assert!(
13608            files.len() > 6,
13609            "fixture should be large enough to split into multiple chunks"
13610        );
13611
13612        let unchunked = CallGraphStore::open(
13613            project_root.join(".store-unchunked"),
13614            project_root.to_path_buf(),
13615        )
13616        .expect("open unchunked store");
13617        let unchunked_stats = unchunked
13618            .cold_build_chunked(&files, 0)
13619            .expect("unchunked cold build");
13620
13621        let chunked = CallGraphStore::open(
13622            project_root.join(".store-chunked"),
13623            project_root.to_path_buf(),
13624        )
13625        .expect("open chunked store");
13626        let chunked_stats = chunked
13627            .cold_build_chunked(&files, 3)
13628            .expect("chunked cold build");
13629
13630        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13631        assert_eq!(
13632            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13633            chunked.edge_snapshot().expect("chunked edge snapshot"),
13634            "public edge snapshots must match"
13635        );
13636
13637        let dispatch_edges = {
13638            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13639            conn.query_row(
13640                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13641                [],
13642                |row| row.get::<_, i64>(0),
13643            )
13644            .expect("count dispatch edges")
13645        };
13646        assert!(
13647            dispatch_edges > 0,
13648            "fixture must exercise method-dispatch edge insertion"
13649        );
13650
13651        for table in [
13652            "edges",
13653            "refs",
13654            "nodes",
13655            "file_dependencies",
13656            "dispatch_hints",
13657        ] {
13658            assert_eq!(
13659                graph_table_rows(&unchunked, table),
13660                graph_table_rows(&chunked, table),
13661                "chunked cold build must match unchunked rows for {table}"
13662            );
13663        }
13664        assert_eq!(
13665            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13666            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13667            "files rows must match apart from indexed_at"
13668        );
13669        assert_eq!(
13670            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13671            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13672            "backend freshness rows must match apart from updated_at"
13673        );
13674
13675        let published_dir = project_root.join(".store-published");
13676        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13677            published_dir.clone(),
13678            project_root.to_path_buf(),
13679            &files,
13680            0,
13681        )
13682        .expect("published unchunked cold build");
13683        assert!(
13684            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13685                .expect("needs_cold_build after publish"),
13686            "published store should be ready"
13687        );
13688        drop(_published);
13689        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13690            published_dir,
13691            project_root.to_path_buf(),
13692            &files,
13693            3,
13694        )
13695        .expect("ensure with a different chunk size");
13696        assert!(
13697            rebuild_stats.is_none(),
13698            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13699        );
13700    }
13701
13702    // Perf A/B bench (not a gate): measures cold_build wall time at a given
13703    // chunk size against a real repo. Driven by env so the same binary can A/B
13704    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
13705    // memory work. Run:
13706    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
13707    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
13708    #[test]
13709    #[ignore]
13710    fn bench_cold_build_chunk() {
13711        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13712        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13713            .expect("AFT_PERF_CHUNK")
13714            .parse()
13715            .expect("AFT_PERF_CHUNK must be a non-negative integer");
13716        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13717        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13718        let dir = tempdir().expect("temp dir");
13719        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13720            .expect("open store");
13721        let started = Instant::now();
13722        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13723        let ms = started.elapsed().as_millis();
13724        println!(
13725            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13726            stats.files, stats.nodes, stats.refs, stats.edges
13727        );
13728    }
13729
13730    #[test]
13731    fn persisted_workspace_reexport_selects_its_package_dependency() {
13732        let root = tempdir().expect("temp dir");
13733        let dependencies = BTreeSet::from([
13734            "packages/aft-bridge/src/index.ts".to_string(),
13735            "packages/opencode-plugin/src/types.ts".to_string(),
13736        ]);
13737        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13738
13739        assert_eq!(
13740            stored_dependencies_for_module(
13741                root.path(),
13742                "packages/opencode-plugin/src/shared/bash-hints.ts",
13743                "@cortexkit/aft-bridge",
13744                &dependencies,
13745                &indexed_files,
13746            ),
13747            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13748        );
13749    }
13750
13751    #[test]
13752    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13753        let dir = tempdir().expect("temp dir");
13754        let project_root = dir.path();
13755        let files =
13756            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13757        let index_path = project_root.join("src/index.ts");
13758
13759        let store = CallGraphStore::open(
13760            project_root.join(".store-incremental-barrel"),
13761            project_root.to_path_buf(),
13762        )
13763        .expect("open incremental store");
13764        store.cold_build(&files).expect("initial cold build");
13765
13766        {
13767            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13768            let tx = conn.transaction().expect("dependency transaction");
13769            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13770                .expect("dependent refs for barrel");
13771            let selected_ref_ids = dependent_refs
13772                .iter()
13773                .map(|dependent_ref| dependent_ref.ref_id.clone())
13774                .collect::<BTreeSet<_>>();
13775            let mut threaded_ref_ids = BTreeSet::new();
13776            let mut threaded_by_caller = BTreeMap::new();
13777            record_dependent_refs(
13778                &mut threaded_ref_ids,
13779                &mut threaded_by_caller,
13780                dependent_refs,
13781            );
13782            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13783                .expect("old per-ref caller lookup");
13784
13785            assert_eq!(threaded_ref_ids, selected_ref_ids);
13786            assert_eq!(threaded_by_caller, old_by_caller);
13787            for consumer in [
13788                "src/consumer_a.ts",
13789                "src/consumer_b.ts",
13790                "src/consumer_c.ts",
13791            ] {
13792                assert!(
13793                    threaded_by_caller.contains_key(consumer),
13794                    "barrel edit should select dependent refs from {consumer}"
13795                );
13796            }
13797        }
13798
13799        fs::write(
13800            &index_path,
13801            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13802        )
13803        .expect("edit barrel");
13804        let stats = store
13805            .refresh_files(std::slice::from_ref(&index_path))
13806            .expect("incremental refresh");
13807        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13808        assert!(
13809            stats.dependency_selected_refs > 0,
13810            "barrel surface edit should select dependent refs"
13811        );
13812
13813        let cold_store = CallGraphStore::open(
13814            project_root.join(".store-cold-barrel"),
13815            project_root.to_path_buf(),
13816        )
13817        .expect("open cold rebuild store");
13818        cold_store
13819            .cold_build(&files)
13820            .expect("comparison cold build");
13821
13822        for table in [
13823            "nodes",
13824            "refs",
13825            "file_dependencies",
13826            "edges",
13827            "dispatch_hints",
13828        ] {
13829            assert_eq!(
13830                graph_table_rows(&store, table),
13831                graph_table_rows(&cold_store, table),
13832                "incremental refresh {table} rows must match cold rebuild"
13833            );
13834        }
13835
13836        let consumer_path = project_root.join("src/consumer_a.ts");
13837        fs::write(
13838            &consumer_path,
13839            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13840        )
13841        .expect("edit barrel consumer");
13842        store
13843            .refresh_files(std::slice::from_ref(&consumer_path))
13844            .expect("refresh consumer through unchanged barrel");
13845        cold_store
13846            .cold_build(&files)
13847            .expect("comparison cold rebuild after consumer refresh");
13848        for table in [
13849            "nodes",
13850            "refs",
13851            "file_dependencies",
13852            "edges",
13853            "dispatch_hints",
13854        ] {
13855            assert_eq!(
13856                graph_table_rows(&store, table),
13857                graph_table_rows(&cold_store, table),
13858                "refresh through a persisted barrel must preserve cold-build {table} rows"
13859            );
13860        }
13861    }
13862
13863    fn build_reference_connection(
13864        project_root: &Path,
13865        extract: &FileExtract,
13866        resolved: &ResolvedRef,
13867    ) -> Connection {
13868        let mut conn = Connection::open_in_memory().expect("open reference db");
13869        configure_build_connection(&conn).expect("configure reference db");
13870        initialize_schema(&conn).expect("initialize reference schema");
13871        {
13872            let tx = conn.transaction().expect("reference transaction");
13873            clear_tables(&tx).expect("reference clear");
13874            insert_meta(&tx).expect("reference meta");
13875            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13876            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13877            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13878                .expect("reference dispatch edges");
13879            assert_eq!(supplemental, 0);
13880            tx.commit().expect("reference commit");
13881        }
13882        conn
13883    }
13884
13885    fn build_optimized_connection(
13886        project_root: &Path,
13887        extract: &FileExtract,
13888        resolved: &ResolvedRef,
13889    ) -> Connection {
13890        let mut conn = Connection::open_in_memory().expect("open optimized db");
13891        configure_build_connection(&conn).expect("configure optimized db");
13892        initialize_schema(&conn).expect("initialize optimized schema");
13893        {
13894            let tx = conn.transaction().expect("optimized transaction");
13895            clear_tables(&tx).expect("optimized clear");
13896            insert_meta(&tx).expect("optimized meta");
13897            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13898            {
13899                let workspace_root = project_root.display().to_string();
13900                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13901                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13902                    .expect("optimized file extract");
13903                insert_resolved_ref_prepared(&mut inserts, resolved)
13904                    .expect("optimized resolved ref");
13905            }
13906            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13907            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13908                .expect("optimized dispatch edges");
13909            assert_eq!(supplemental, 0);
13910            tx.commit().expect("optimized commit");
13911        }
13912        conn
13913    }
13914
13915    fn fixture_extract(_project_root: &Path) -> FileExtract {
13916        let rel_path = "src/main.ts".to_string();
13917        let target_path = "src/helper.ts".to_string();
13918        let node = NodeRecord {
13919            id: "node-main".to_string(),
13920            file_path: rel_path.clone(),
13921            name: "main".to_string(),
13922            scoped_name: "main".to_string(),
13923            kind: "function".to_string(),
13924            range: Range {
13925                start_line: 0,
13926                start_col: 0,
13927                end_line: 0,
13928                end_col: 32,
13929            },
13930            range_ordinal: 0,
13931            signature: Some("export function main()".to_string()),
13932            exported: true,
13933            is_default_export: false,
13934            is_type_like: false,
13935            is_callgraph_entry_point: true,
13936        };
13937        let mut dependencies = BTreeSet::new();
13938        dependencies.insert(target_path.clone());
13939        let raw_ref = RawRef {
13940            ref_id: "ref-main-helper".to_string(),
13941            caller_node: Some(node.id.clone()),
13942            caller_symbol: Some(node.scoped_name.clone()),
13943            caller_file: rel_path.clone(),
13944            kind: "call".to_string(),
13945            short_name: Some("helper".to_string()),
13946            full_ref: Some("helper".to_string()),
13947            module_path: None,
13948            import_kind: None,
13949            local_name: Some("helper".to_string()),
13950            requested_name: Some("helper".to_string()),
13951            namespace_alias: None,
13952            wildcard: false,
13953            line: 1,
13954            byte_start: 24,
13955            byte_end: 32,
13956            dependencies,
13957        };
13958        FileExtract {
13959            rel_path,
13960            freshness: FileFreshness {
13961                mtime: UNIX_EPOCH + Duration::from_secs(123),
13962                size: 40,
13963                content_hash: cache_freshness::hash_bytes(b"fixture source"),
13964            },
13965            lang: LangId::TypeScript,
13966            data: FileCallData {
13967                calls_by_symbol: HashMap::new(),
13968                exported_symbols: Vec::new(),
13969                symbol_metadata: HashMap::new(),
13970                default_export_symbol: None,
13971                import_block: ImportBlock::empty(),
13972                lang: LangId::TypeScript,
13973            },
13974            nodes: vec![node.clone()],
13975            raw_refs: vec![raw_ref],
13976            dispatch_hints: vec![DispatchHint {
13977                id: "dispatch-main-helper".to_string(),
13978                method_name: "helper".to_string(),
13979                caller_node: node.id,
13980                file: "src/main.ts".to_string(),
13981                line: 1,
13982                byte_start: 24,
13983                byte_end: 32,
13984            }],
13985            surface_fingerprint: "surface".to_string(),
13986        }
13987    }
13988
13989    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13990        let raw = extract.raw_refs[0].clone();
13991        let mut dependencies = raw.dependencies.clone();
13992        dependencies.insert("src/helper.ts".to_string());
13993        ResolvedRef {
13994            edge: Some(EdgeRecord {
13995                edge_id: "edge-main-helper".to_string(),
13996                source_node: raw.caller_node.clone().expect("caller node"),
13997                target_node: Some("node-helper".to_string()),
13998                target_file: "src/helper.ts".to_string(),
13999                target_symbol: "helper".to_string(),
14000                kind: "call".to_string(),
14001                line: raw.line,
14002            }),
14003            raw,
14004            status: "resolved".to_string(),
14005            target_node: Some("node-helper".to_string()),
14006            target_file: Some("src/helper.ts".to_string()),
14007            target_symbol: Some("helper".to_string()),
14008            dependencies,
14009        }
14010    }
14011
14012    fn write_chunked_equivalence_fixture(project_root: &Path) {
14013        let ts_dir = project_root.join("ts");
14014        fs::create_dir_all(&ts_dir).expect("create ts dir");
14015        fs::write(
14016            ts_dir.join("leaf.ts"),
14017            "export function leaf(value: number) {\n  return value + 1;\n}\n",
14018        )
14019        .expect("write ts leaf");
14020        fs::write(
14021            ts_dir.join("mid.ts"),
14022            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
14023        )
14024        .expect("write ts mid");
14025        fs::write(
14026            ts_dir.join("entry.ts"),
14027            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
14028        )
14029        .expect("write ts entry");
14030        fs::write(
14031            ts_dir.join("worker.ts"),
14032            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
14033        )
14034        .expect("write ts worker");
14035        for idx in 0..4 {
14036            fs::write(
14037                ts_dir.join(format!("extra_{idx}.ts")),
14038                format!(
14039                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
14040                ),
14041            )
14042            .expect("write ts extra");
14043        }
14044
14045        let rust_dir = project_root.join("src");
14046        let commands_dir = rust_dir.join("commands");
14047        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14048        fs::write(
14049            rust_dir.join("context.rs"),
14050            r#"pub struct AppContext;
14051
14052impl AppContext {
14053    pub fn callgraph_store_for_ops(&self) -> usize {
14054        1
14055    }
14056}
14057"#,
14058        )
14059        .expect("write rust context");
14060        fs::write(
14061            rust_dir.join("lib.rs"),
14062            "pub mod context;\npub mod commands;\n",
14063        )
14064        .expect("write rust lib");
14065        fs::write(
14066            commands_dir.join("mod.rs"),
14067            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14068        )
14069        .expect("write rust commands mod");
14070        for name in ["callers", "impact", "trace_to"] {
14071            fs::write(
14072                commands_dir.join(format!("{name}.rs")),
14073                format!(
14074                    r#"use crate::context::AppContext;
14075
14076pub fn handle_{name}(ctx: &AppContext) -> usize {{
14077    ctx.callgraph_store_for_ops()
14078}}
14079"#
14080                ),
14081            )
14082            .expect("write rust command");
14083        }
14084    }
14085
14086    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14087        let src_dir = project_root.join("src");
14088        fs::create_dir_all(&src_dir).expect("create src dir");
14089
14090        let target_path = src_dir.join("target.ts");
14091        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
14092            .expect("write target");
14093
14094        let index_path = src_dir.join("index.ts");
14095        fs::write(&index_path, barrel_source).expect("write barrel");
14096
14097        let mut files = vec![target_path, index_path];
14098        for (file_name, function_name) in [
14099            ("consumer_a.ts", "consumerA"),
14100            ("consumer_b.ts", "consumerB"),
14101            ("consumer_c.ts", "consumerC"),
14102        ] {
14103            let path = src_dir.join(file_name);
14104            fs::write(
14105                &path,
14106                format!(
14107                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
14108                ),
14109            )
14110            .expect("write consumer");
14111            files.push(path);
14112        }
14113        files
14114    }
14115
14116    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14117        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14118        table_rows(&conn, table)
14119    }
14120
14121    fn graph_table_rows_without(
14122        store: &CallGraphStore,
14123        table: &str,
14124        excluded_columns: &[&str],
14125    ) -> Vec<String> {
14126        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14127        table_rows_without(&conn, table, excluded_columns)
14128    }
14129
14130    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14131        table_rows_without(conn, table, &[])
14132    }
14133
14134    fn table_rows_without(
14135        conn: &Connection,
14136        table: &str,
14137        excluded_columns: &[&str],
14138    ) -> Vec<String> {
14139        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14140        let columns: Vec<String> = conn
14141            .prepare(&format!("PRAGMA table_info({table})"))
14142            .expect("prepare table_info")
14143            .query_map([], |row| row.get::<_, String>(1))
14144            .expect("query table_info")
14145            .collect::<std::result::Result<Vec<String>, _>>()
14146            .expect("collect columns")
14147            .into_iter()
14148            .filter(|column| !excluded_columns.contains(column.as_str()))
14149            .collect();
14150        let sql = format!(
14151            "SELECT {} FROM {table} ORDER BY {}",
14152            columns.join(", "),
14153            columns.join(", ")
14154        );
14155        conn.prepare(&sql)
14156            .expect("prepare table rows")
14157            .query_map([], |row| row_to_strings(row, columns.len()))
14158            .expect("query table rows")
14159            .collect::<std::result::Result<_, _>>()
14160            .expect("collect table rows")
14161    }
14162
14163    fn assert_cold_build_stats_match_except_elapsed(
14164        expected: &ColdBuildStats,
14165        actual: &ColdBuildStats,
14166    ) {
14167        assert_eq!(actual.files, expected.files, "file counts must match");
14168        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14169        assert_eq!(actual.refs, expected.refs, "ref counts must match");
14170        assert_eq!(actual.edges, expected.edges, "edge counts must match");
14171        assert_eq!(
14172            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14173            expected
14174                .failed_files
14175                .iter()
14176                .cloned()
14177                .collect::<BTreeSet<_>>(),
14178            "failed file sets must match"
14179        );
14180    }
14181
14182    fn backend_state_rows(conn: &Connection) -> Vec<String> {
14183        conn.prepare(
14184            "SELECT backend, workspace_root, file_path, content_hash, status
14185             FROM backend_file_state
14186             ORDER BY backend, workspace_root, file_path, content_hash, status",
14187        )
14188        .expect("prepare backend rows")
14189        .query_map([], |row| row_to_strings(row, 5))
14190        .expect("query backend rows")
14191        .collect::<std::result::Result<_, _>>()
14192        .expect("collect backend rows")
14193    }
14194
14195    fn secondary_indexes(conn: &Connection) -> Vec<String> {
14196        let mut indexes = Vec::new();
14197        for table in [
14198            "files",
14199            "nodes",
14200            "refs",
14201            "file_dependencies",
14202            "edges",
14203            "dispatch_hints",
14204            "type_ref_names",
14205            "backend_file_state",
14206            "meta",
14207        ] {
14208            let sql = format!("PRAGMA index_list({table})");
14209            let mut stmt = conn.prepare(&sql).expect("prepare index list");
14210            let rows = stmt
14211                .query_map([], |row| row.get::<_, String>(1))
14212                .expect("query index list");
14213            for name in rows {
14214                let name = name.expect("index name");
14215                if name.starts_with("idx_") {
14216                    indexes.push(format!("{table}:{name}"));
14217                }
14218            }
14219        }
14220        indexes.sort();
14221        indexes
14222    }
14223
14224    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14225        let mut values = Vec::with_capacity(len);
14226        for index in 0..len {
14227            let value = row.get_ref(index)?;
14228            values.push(match value {
14229                rusqlite::types::ValueRef::Null => "NULL".to_string(),
14230                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14231                rusqlite::types::ValueRef::Real(value) => value.to_string(),
14232                rusqlite::types::ValueRef::Text(value) => {
14233                    String::from_utf8_lossy(value).into_owned()
14234                }
14235                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14236            });
14237        }
14238        Ok(values.join("\u{1f}"))
14239    }
14240}
14241
14242#[cfg(test)]
14243mod rust_resolution_tests {
14244    use super::*;
14245    use crate::inspect::job::CallgraphSnapshot;
14246    use std::fs;
14247    use tempfile::tempdir;
14248
14249    #[test]
14250    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14251        let dir = tempdir().expect("tempdir");
14252        let root = dir.path();
14253        write_rust_manifest(root, "scoped-alias-fixture");
14254        write_file(
14255            root,
14256            "src/lib.rs",
14257            r#"pub mod finalization_contract;
14258
14259pub fn run_alias() {
14260    use crate::finalization_contract as fc;
14261    fc::check_mason_contract();
14262}
14263"#,
14264        );
14265        write_file(
14266            root,
14267            "src/finalization_contract.rs",
14268            r#"pub fn check_mason_contract() {}
14269fn planted_dead() {}
14270"#,
14271        );
14272
14273        let (store, snapshot) = cold_build_twice(root);
14274        assert_direct_caller(
14275            &store,
14276            "src/finalization_contract.rs",
14277            "check_mason_contract",
14278            "src/lib.rs",
14279            "run_alias",
14280        );
14281        assert_projected_call(
14282            root,
14283            &snapshot,
14284            "src/finalization_contract.rs",
14285            "check_mason_contract",
14286        );
14287        assert_no_projected_call(
14288            root,
14289            &snapshot,
14290            "src/finalization_contract.rs",
14291            "planted_dead",
14292        );
14293        assert!(
14294            store
14295                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14296                .expect("planted dead callers")
14297                .is_empty(),
14298            "planted-dead guard should stay without callers"
14299        );
14300    }
14301
14302    #[test]
14303    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14304        let dir = tempdir().expect("tempdir");
14305        let root = dir.path();
14306        write_rust_manifest(root, "inline-module-fixture");
14307        write_file(
14308            root,
14309            "src/lib.rs",
14310            r#"mod work_graph { fn operations() {} }
14311mod manifest { fn operations() {} }
14312mod audit { fn operations() {} }
14313mod dispatch { fn operations() {} }
14314mod finalization { fn operations() {} }
14315
14316pub fn run_inline_operations() {
14317    work_graph::operations();
14318    manifest::operations();
14319    audit::operations();
14320    dispatch::operations();
14321    finalization::operations();
14322}
14323
14324fn planted_dead() {}
14325"#,
14326        );
14327
14328        let (store, snapshot) = cold_build_twice(root);
14329        for module in [
14330            "work_graph",
14331            "manifest",
14332            "audit",
14333            "dispatch",
14334            "finalization",
14335        ] {
14336            assert_direct_caller(
14337                &store,
14338                "src/lib.rs",
14339                &format!("{module}::operations"),
14340                "src/lib.rs",
14341                "run_inline_operations",
14342            );
14343        }
14344        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14345        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14346    }
14347
14348    #[test]
14349    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14350        let dir = tempdir().expect("tempdir");
14351        let root = dir.path();
14352        fs::write(
14353            root.join("Cargo.toml"),
14354            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14355        )
14356        .expect("write workspace manifest");
14357        write_file(
14358            root,
14359            "crates/but-action/Cargo.toml",
14360            r#"[package]
14361name = "but-action"
14362version = "0.1.0"
14363edition = "2021"
14364"#,
14365        );
14366        write_file(
14367            root,
14368            "crates/but-action/src/lib.rs",
14369            "mod action;\npub use action::{list_actions};\n",
14370        );
14371        write_file(
14372            root,
14373            "crates/but-action/src/action.rs",
14374            "pub fn list_actions() {}\nfn planted_dead() {}\n",
14375        );
14376        write_file(
14377            root,
14378            "crates/app/Cargo.toml",
14379            r#"[package]
14380name = "app"
14381version = "0.1.0"
14382edition = "2021"
14383"#,
14384        );
14385        write_file(
14386            root,
14387            "crates/app/src/lib.rs",
14388            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
14389        );
14390
14391        let (store, snapshot) = cold_build_twice(root);
14392        assert_direct_caller(
14393            &store,
14394            "crates/but-action/src/action.rs",
14395            "list_actions",
14396            "crates/app/src/lib.rs",
14397            "run_actions",
14398        );
14399        assert!(
14400            store
14401                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14402                .expect("lib reexport callers")
14403                .is_empty(),
14404            "call should target the reexported source function, not lib.rs"
14405        );
14406        assert_projected_call(
14407            root,
14408            &snapshot,
14409            "crates/but-action/src/action.rs",
14410            "list_actions",
14411        );
14412        assert_no_projected_call(
14413            root,
14414            &snapshot,
14415            "crates/but-action/src/action.rs",
14416            "planted_dead",
14417        );
14418    }
14419
14420    #[test]
14421    fn rust_generic_self_turbofish_method_dispatch_resolves() {
14422        let dir = tempdir().expect("tempdir");
14423        let root = dir.path();
14424        write_rust_manifest(root, "generic-self-fixture");
14425        write_file(
14426            root,
14427            "src/lib.rs",
14428            r#"pub struct Matcher;
14429
14430impl Matcher {
14431    pub fn run(&self) -> bool {
14432        self.fuzzy_match_optimal::<usize>("needle")
14433    }
14434
14435    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14436        let _ = std::marker::PhantomData::<T>;
14437        true
14438    }
14439
14440    fn planted_dead(&self) {}
14441}
14442
14443pub fn entry() -> bool {
14444    let matcher = Matcher;
14445    matcher.run()
14446}
14447"#,
14448        );
14449
14450        let (store, snapshot) = cold_build_twice(root);
14451        assert_direct_caller(
14452            &store,
14453            "src/lib.rs",
14454            "Matcher::fuzzy_match_optimal",
14455            "src/lib.rs",
14456            "Matcher::run",
14457        );
14458        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14459        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14460    }
14461
14462    #[test]
14463    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14464        let dir = tempdir().expect("tempdir");
14465        let root = dir.path();
14466        write_rust_manifest(root, "manifest-operations-fixture");
14467        write_file(
14468            root,
14469            "src/main.rs",
14470            r#"mod dispatch;
14471use dispatch::{manifest_operations};
14472
14473fn main() {
14474    manifest_operations();
14475}
14476"#,
14477        );
14478        write_file(
14479            root,
14480            "src/dispatch.rs",
14481            r#"mod work_graph { fn operations() {} }
14482mod manifest { fn operations() {} }
14483mod audit { fn operations() {} }
14484mod descriptor { fn operations() {} }
14485mod writer { fn operations() {} }
14486
14487pub fn manifest_operations() {
14488    manifest::operations();
14489}
14490
14491pub fn work_graph_operations() {
14492    work_graph::operations();
14493}
14494
14495pub fn audit_operations() {
14496    audit::operations();
14497}
14498
14499pub fn descriptor_operations() {
14500    descriptor::operations();
14501}
14502
14503pub fn writer_operations() {
14504    writer::operations();
14505}
14506
14507fn planted_dead() {}
14508"#,
14509        );
14510
14511        let (store, snapshot) = cold_build_twice(root);
14512        assert_direct_caller(
14513            &store,
14514            "src/dispatch.rs",
14515            "manifest_operations",
14516            "src/main.rs",
14517            "main",
14518        );
14519        assert_direct_caller(
14520            &store,
14521            "src/dispatch.rs",
14522            "manifest::operations",
14523            "src/dispatch.rs",
14524            "manifest_operations",
14525        );
14526        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14527        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14528        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14529    }
14530
14531    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14532        let files = rust_files(root);
14533        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14534            .expect("open first store");
14535        first.cold_build(&files).expect("first cold build");
14536        let first_snapshot =
14537            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14538
14539        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14540            .expect("open second store");
14541        second.cold_build(&files).expect("second cold build");
14542        let second_snapshot =
14543            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14544
14545        assert_eq!(
14546            projection_rows(&first_snapshot),
14547            projection_rows(&second_snapshot),
14548            "cold-build projection should be deterministic"
14549        );
14550        (first, first_snapshot)
14551    }
14552
14553    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14554        let mut rows = Vec::new();
14555        for export in &snapshot.exported_symbols {
14556            rows.push(format!(
14557                "export\t{}\t{}\t{}\t{}",
14558                export.file.display(),
14559                export.symbol,
14560                export.kind,
14561                export.line
14562            ));
14563        }
14564        for call in &snapshot.outbound_calls {
14565            rows.push(format!(
14566                "call\t{}\t{}\t{}\t{}\t{}",
14567                call.caller_file.display(),
14568                call.caller_symbol,
14569                call.target,
14570                call.line,
14571                call.provenance
14572            ));
14573        }
14574        for file in &snapshot.entry_points {
14575            rows.push(format!("entry_file\t{}", file.display()));
14576        }
14577        for (file, symbols) in &snapshot.entry_point_symbols {
14578            for symbol in symbols {
14579                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14580            }
14581        }
14582        rows.sort();
14583        rows
14584    }
14585
14586    fn assert_direct_caller(
14587        store: &CallGraphStore,
14588        target_rel: &str,
14589        target_symbol: &str,
14590        caller_rel: &str,
14591        caller_symbol: &str,
14592    ) {
14593        let callers = store
14594            .direct_callers_of(Path::new(target_rel), target_symbol)
14595            .unwrap_or_else(|error| {
14596                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14597            });
14598        assert!(
14599            callers.iter().any(|site| {
14600                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14601            }),
14602            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14603        );
14604    }
14605
14606    fn assert_projected_call(
14607        root: &Path,
14608        snapshot: &CallgraphSnapshot,
14609        target_rel: &str,
14610        symbol: &str,
14611    ) {
14612        let target = projected_target(root, target_rel, symbol);
14613        assert!(
14614            snapshot.outbound_calls.iter().any(|call| {
14615                call.target == target
14616                    || call.target.starts_with(&format!(
14617                        "{target}{}",
14618                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14619                    ))
14620            }),
14621            "expected projected call to {target}; calls: {:#?}",
14622            snapshot.outbound_calls
14623        );
14624    }
14625
14626    fn assert_no_projected_call(
14627        root: &Path,
14628        snapshot: &CallgraphSnapshot,
14629        target_rel: &str,
14630        symbol: &str,
14631    ) {
14632        let target = projected_target(root, target_rel, symbol);
14633        assert!(
14634            snapshot.outbound_calls.iter().all(|call| {
14635                call.target != target
14636                    && !call.target.starts_with(&format!(
14637                        "{target}{}",
14638                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14639                    ))
14640            }),
14641            "did not expect projected call to {target}; calls: {:#?}",
14642            snapshot.outbound_calls
14643        );
14644    }
14645
14646    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14647        // Projection targets carry the normalized (verbatim-stripped)
14648        // canonical form; bare fs::canonicalize diverges on Windows.
14649        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14650        format!("{}::{symbol}", path.display())
14651    }
14652
14653    fn write_rust_manifest(root: &Path, name: &str) {
14654        write_file(
14655            root,
14656            "Cargo.toml",
14657            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14658        );
14659    }
14660
14661    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14662        let path = root.join(rel_path);
14663        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14664        fs::write(&path, source).expect("write fixture file");
14665        path
14666    }
14667
14668    fn rust_files(root: &Path) -> Vec<PathBuf> {
14669        let mut files = Vec::new();
14670        collect_rust_files(root, &mut files);
14671        files.sort();
14672        files
14673    }
14674
14675    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14676        for entry in fs::read_dir(dir).expect("read fixture dir") {
14677            let entry = entry.expect("read fixture entry");
14678            let path = entry.path();
14679            if path.is_dir() {
14680                let name = path
14681                    .file_name()
14682                    .and_then(|name| name.to_str())
14683                    .unwrap_or("");
14684                if !name.starts_with(".store") {
14685                    collect_rust_files(&path, files);
14686                }
14687            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14688                files.push(path);
14689            }
14690        }
14691    }
14692}
14693
14694#[cfg(test)]
14695mod build_pool_tests {
14696    use super::build_pool_size;
14697
14698    #[test]
14699    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14700        let size = build_pool_size();
14701        // Never zero, never the full core count, never above the 8 cap — this is
14702        // the starvation guard for the cold-build's all-cores tree-sitter pass.
14703        assert!(size >= 1, "pool size must be at least 1");
14704        assert!(size <= 8, "pool size must be capped at 8, got {size}");
14705
14706        let cores = std::thread::available_parallelism()
14707            .map(|p| p.get())
14708            .unwrap_or(1);
14709        let expected = cores.div_ceil(2).clamp(1, 8);
14710        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14711    }
14712}
14713
14714#[cfg(test)]
14715mod reexport_resolution_tests {
14716    use super::*;
14717
14718    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14719        ProjectIndex {
14720            project_root: PathBuf::from("/fixture"),
14721            files: files.into_iter().collect(),
14722            caller_data: HashMap::new(),
14723            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14724        }
14725    }
14726
14727    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14728        DbFileIndex {
14729            lang: None,
14730            exports: HashSet::new(),
14731            default_export: None,
14732            export_aliases: HashMap::new(),
14733            node_by_scoped: HashMap::new(),
14734            node_by_bare: HashMap::new(),
14735            module_targets: HashMap::new(),
14736            reexports: reexport_targets
14737                .iter()
14738                .map(|target| ReexportIndex {
14739                    target_file: Some((*target).to_string()),
14740                    named: HashMap::new(),
14741                    wildcard: true,
14742                })
14743                .collect(),
14744        }
14745    }
14746
14747    /// A dense wildcard re-export cycle (barrel files re-exporting each
14748    /// other) must resolve in O(files), not O(branching^depth). Without the
14749    /// resolver's memoization, resolving a MISSING symbol through this
14750    /// 12-file complete digraph explores ~11^16 paths and this test never
14751    /// finishes: the depth cap bounds path length, not path count, and one
14752    /// such resolution can pin a worker thread at 100% CPU indefinitely.
14753    #[test]
14754    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14755        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14756        let files = names
14757            .iter()
14758            .map(|name| {
14759                let targets: Vec<&str> = names
14760                    .iter()
14761                    .filter(|other| *other != name)
14762                    .map(String::as_str)
14763                    .collect();
14764                (name.clone(), barrel_file(&targets))
14765            })
14766            .collect();
14767        let index = barrel_index(files);
14768
14769        assert_eq!(
14770            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14771            None
14772        );
14773    }
14774
14775    /// Depth-dominance counterexample: the walk first reaches `shared` down a
14776    /// 16-hop chain (no budget left for its leaf), then reaches it again
14777    /// directly at depth 1. Plain visited-set pruning would skip the second
14778    /// visit and lose a resolution the capped resolver finds; the
14779    /// depth-dominance memo revisits because the second arrival is shallower.
14780    #[test]
14781    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14782        let mut leaf = barrel_file(&[]);
14783        leaf.exports.insert("deep_symbol".to_string());
14784        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14785        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
14786        // entry's SECOND reexport goes straight to shared.
14787        files.push((
14788            "src/entry.ts".to_string(),
14789            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14790        ));
14791        for i in 0..15 {
14792            let next = if i == 14 {
14793                "src/shared.ts".to_string()
14794            } else {
14795                format!("src/chain{}.ts", i + 1)
14796            };
14797            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14798        }
14799        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14800        files.push(("src/leaf.ts".to_string(), leaf));
14801        let index = barrel_index(files);
14802
14803        assert_eq!(
14804            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14805            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14806            "a shallower re-visit must not be pruned by a deeper capped visit"
14807        );
14808    }
14809
14810    #[test]
14811    fn symbol_reachable_through_reexport_cycle_still_resolves() {
14812        let mut leaf = barrel_file(&[]);
14813        leaf.exports.insert("real_symbol".to_string());
14814        let index = barrel_index(vec![
14815            (
14816                "src/a.ts".to_string(),
14817                barrel_file(&["src/b.ts", "src/a.ts"]),
14818            ),
14819            (
14820                "src/b.ts".to_string(),
14821                barrel_file(&["src/a.ts", "src/leaf.ts"]),
14822            ),
14823            ("src/leaf.ts".to_string(), leaf),
14824        ]);
14825
14826        assert_eq!(
14827            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14828            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14829        );
14830    }
14831}
14832
14833#[cfg(test)]
14834mod method_dispatch_inference_tests {
14835    use super::*;
14836    use std::fs;
14837    use tempfile::tempdir;
14838
14839    #[test]
14840    fn java_field_receiver_type_selects_declared_class_method() {
14841        let source = r#"class EntryPoint {
14842    private UserService userService;
14843
14844    void handle() {
14845        userService.find();
14846    }
14847}
14848
14849class UserService {
14850    void find() {}
14851}
14852
14853class AuditService {
14854    void find() {}
14855}
14856"#;
14857        let dir = tempdir().expect("temp dir");
14858        let root = dir.path();
14859        write_fixture(root, "src/EntryPoint.java", source);
14860        let reference = reference(
14861            "java",
14862            "src/EntryPoint.java",
14863            "EntryPoint::handle",
14864            "userService",
14865            "find",
14866            line_of(source, "userService.find()"),
14867        );
14868        let mut cache = DispatchSourceCache::new();
14869
14870        let receiver_type =
14871            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14872        assert_eq!(receiver_type, "UserService");
14873
14874        let candidates = vec![
14875            method_candidate("audit", "AuditService::find"),
14876            method_candidate("user", "UserService::find"),
14877        ];
14878        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14879            .expect("type candidate");
14880        assert_eq!(selected.scoped_name, "UserService::find");
14881
14882        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14883        assert!(
14884            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14885        );
14886    }
14887
14888    #[test]
14889    fn kotlin_property_and_local_value_types_are_inferred() {
14890        let source = r#"class Handler {
14891    private val auditService: AuditService = AuditService()
14892
14893    fun handle() {
14894        auditService.find()
14895        val userService: UserService = UserService()
14896        userService.find()
14897        val billingService = BillingService()
14898        billingService.find()
14899    }
14900}
14901
14902class UserService { fun find() {} }
14903class AuditService { fun find() {} }
14904class BillingService { fun find() {} }
14905"#;
14906        let dir = tempdir().expect("temp dir");
14907        let root = dir.path();
14908        write_fixture(root, "src/Handler.kt", source);
14909        let mut cache = DispatchSourceCache::new();
14910
14911        let audit_ref = reference(
14912            "kotlin",
14913            "src/Handler.kt",
14914            "Handler::handle",
14915            "auditService",
14916            "find",
14917            line_of(source, "auditService.find()"),
14918        );
14919        assert_eq!(
14920            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14921            Some("AuditService")
14922        );
14923
14924        let user_ref = reference(
14925            "kotlin",
14926            "src/Handler.kt",
14927            "Handler::handle",
14928            "userService",
14929            "find",
14930            line_of(source, "userService.find()"),
14931        );
14932        assert_eq!(
14933            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14934            Some("UserService")
14935        );
14936
14937        let billing_ref = reference(
14938            "kotlin",
14939            "src/Handler.kt",
14940            "Handler::handle",
14941            "billingService",
14942            "find",
14943            line_of(source, "billingService.find()"),
14944        );
14945        assert_eq!(
14946            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14947            Some("BillingService")
14948        );
14949    }
14950
14951    #[test]
14952    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14953        let source = r#"struct Foo { void run(); };
14954struct PointerFoo { void run(); };
14955struct FactoryFoo { void run(); };
14956FactoryFoo makeFactoryFoo();
14957
14958void handle() {
14959    Foo foo;
14960    foo.run();
14961    PointerFoo* pointerFoo = nullptr;
14962    pointerFoo->run();
14963    auto factoryFoo = makeFactoryFoo();
14964    factoryFoo.run();
14965}
14966"#;
14967        let dir = tempdir().expect("temp dir");
14968        let root = dir.path();
14969        write_fixture(root, "src/fixture.cpp", source);
14970        let mut cache = DispatchSourceCache::new();
14971
14972        let foo_ref = reference(
14973            "cpp",
14974            "src/fixture.cpp",
14975            "handle",
14976            "foo",
14977            "run",
14978            line_of(source, "foo.run()"),
14979        );
14980        assert_eq!(
14981            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14982            Some("Foo")
14983        );
14984
14985        let pointer_ref = reference(
14986            "cpp",
14987            "src/fixture.cpp",
14988            "handle",
14989            "pointerFoo",
14990            "run",
14991            line_of(source, "pointerFoo->run()"),
14992        );
14993        assert_eq!(
14994            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14995            Some("PointerFoo")
14996        );
14997
14998        let factory_ref = reference(
14999            "cpp",
15000            "src/fixture.cpp",
15001            "handle",
15002            "factoryFoo",
15003            "run",
15004            line_of(source, "factoryFoo.run()"),
15005        );
15006        assert_eq!(
15007            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15008            Some("FactoryFoo")
15009        );
15010    }
15011
15012    #[test]
15013    fn rust_direct_self_field_name_trims_separator_whitespace() {
15014        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15015            assert_eq!(
15016                rust_direct_self_field_name(receiver_expression),
15017                Some("engine")
15018            );
15019        }
15020    }
15021
15022    #[test]
15023    fn rust_direct_self_field_receiver_type_is_conservative() {
15024        let source = r#"struct Engine;
15025
15026struct Car {
15027    engine: Engine,
15028}
15029
15030impl Car {
15031    fn run(&self) {
15032        self.engine.start();
15033    }
15034}
15035
15036struct NestedCar {
15037    engine: Engine,
15038}
15039
15040impl NestedCar {
15041    fn run(&self) {
15042        self.inner.engine.start();
15043    }
15044}
15045
15046struct WrappedCar {
15047    engine: Option<Engine>,
15048}
15049
15050impl WrappedCar {
15051    fn run(&self) {
15052        self.engine.start(); // wrapped
15053    }
15054}
15055
15056struct GenericCar<T> {
15057    engine: T,
15058}
15059
15060impl<T> GenericCar<T> {
15061    fn run(&self) {
15062        self.engine.start(); // generic
15063    }
15064}
15065
15066type EngineAlias = Engine;
15067
15068struct AliasCar {
15069    engine: EngineAlias,
15070}
15071
15072impl AliasCar {
15073    fn run(&self) {
15074        self.engine.start(); // alias
15075    }
15076}
15077"#;
15078        let dir = tempdir().expect("temp dir");
15079        let root = dir.path();
15080        write_fixture(root, "src/lib.rs", source);
15081        let mut cache = DispatchSourceCache::new();
15082
15083        let mut direct = reference(
15084            "rust",
15085            "src/lib.rs",
15086            "Car::run",
15087            "engine",
15088            "start",
15089            line_of(source, "self.engine.start()"),
15090        );
15091        direct.receiver_expression = "self.engine".to_string();
15092        assert_eq!(
15093            infer_receiver_type(root, &direct, &mut cache).as_deref(),
15094            Some("Engine")
15095        );
15096
15097        let mut mismatched_impl_target = direct.clone();
15098        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15099        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15100
15101        let mut nested = reference(
15102            "rust",
15103            "src/lib.rs",
15104            "NestedCar::run",
15105            "engine",
15106            "start",
15107            line_of(source, "self.inner.engine.start()"),
15108        );
15109        nested.receiver_expression = "self.inner.engine".to_string();
15110        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15111
15112        let mut wrapped = reference(
15113            "rust",
15114            "src/lib.rs",
15115            "WrappedCar::run",
15116            "engine",
15117            "start",
15118            line_of(source, "self.engine.start(); // wrapped"),
15119        );
15120        wrapped.receiver_expression = "self.engine".to_string();
15121        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15122
15123        let mut generic = reference(
15124            "rust",
15125            "src/lib.rs",
15126            "GenericCar::run",
15127            "engine",
15128            "start",
15129            line_of(source, "self.engine.start(); // generic"),
15130        );
15131        generic.receiver_expression = "self.engine".to_string();
15132        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15133
15134        let mut alias = reference(
15135            "rust",
15136            "src/lib.rs",
15137            "AliasCar::run",
15138            "engine",
15139            "start",
15140            line_of(source, "self.engine.start(); // alias"),
15141        );
15142        alias.receiver_expression = "self.engine".to_string();
15143        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15144    }
15145
15146    #[test]
15147    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15148        let source = r#"struct Engine;
15149
15150struct Car {
15151    engine: &'static Engine,
15152}
15153
15154impl Car {
15155    fn run(&self) {
15156        self.engine.start();
15157    }
15158}
15159"#;
15160        let dir = tempdir().expect("temp dir");
15161        let root = dir.path();
15162        write_fixture(root, "src/lib.rs", source);
15163        let mut cache = DispatchSourceCache::new();
15164        let mut reference = reference(
15165            "rust",
15166            "src/lib.rs",
15167            "Car::run",
15168            "engine",
15169            "start",
15170            line_of(source, "self.engine.start()"),
15171        );
15172        reference.receiver_expression = "self.engine".to_string();
15173
15174        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15175    }
15176
15177    #[test]
15178    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15179        let source = r#"trait Drive {
15180    fn run(&self);
15181}
15182
15183struct Engine;
15184
15185struct Car {
15186    engine: Engine,
15187}
15188
15189impl Drive for Car {
15190    fn run(&self) {
15191        self.engine.start();
15192    }
15193}
15194"#;
15195        let dir = tempdir().expect("temp dir");
15196        let root = dir.path();
15197        write_fixture(root, "src/lib.rs", source);
15198        let mut cache = DispatchSourceCache::new();
15199        let mut reference = reference(
15200            "rust",
15201            "src/lib.rs",
15202            "Car::run",
15203            "engine",
15204            "start",
15205            line_of(source, "self.engine.start()"),
15206        );
15207        reference.receiver_expression = "self.engine".to_string();
15208
15209        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15210    }
15211
15212    #[test]
15213    fn rust_self_field_does_not_bind_struct_from_another_module() {
15214        let source = r#"struct Engine;
15215
15216mod unrelated {
15217    struct Car {
15218        engine: Engine,
15219    }
15220}
15221
15222impl Car {
15223    fn run(&self) {
15224        self.engine.start();
15225    }
15226}
15227"#;
15228        let dir = tempdir().expect("temp dir");
15229        let root = dir.path();
15230        write_fixture(root, "src/lib.rs", source);
15231        let mut cache = DispatchSourceCache::new();
15232        let mut reference = reference(
15233            "rust",
15234            "src/lib.rs",
15235            "Car::run",
15236            "engine",
15237            "start",
15238            line_of(source, "self.engine.start()"),
15239        );
15240        reference.receiver_expression = "self.engine".to_string();
15241
15242        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15243    }
15244
15245    #[test]
15246    fn unknown_java_receiver_still_uses_name_match_fallback() {
15247        let source = r#"class EntryPoint {
15248    void handle() {
15249        service.runSpecial();
15250    }
15251}
15252
15253class OnlyService {
15254    void runSpecial() {}
15255}
15256"#;
15257        let dir = tempdir().expect("temp dir");
15258        let root = dir.path();
15259        write_fixture(root, "src/EntryPoint.java", source);
15260        let reference = reference(
15261            "java",
15262            "src/EntryPoint.java",
15263            "EntryPoint::handle",
15264            "service",
15265            "runSpecial",
15266            line_of(source, "service.runSpecial()"),
15267        );
15268        let mut cache = DispatchSourceCache::new();
15269
15270        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15271        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15272        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15273        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15274    }
15275
15276    fn reference(
15277        lang: &str,
15278        caller_file: &str,
15279        caller_symbol: &str,
15280        receiver: &str,
15281        method_name: &str,
15282        line: u32,
15283    ) -> NameMatchRef {
15284        NameMatchRef {
15285            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15286            caller_node: format!("{caller_symbol}:node"),
15287            caller_file: caller_file.to_string(),
15288            caller_symbol: caller_symbol.to_string(),
15289            caller_signature: None,
15290            receiver_expression: receiver.to_string(),
15291            receiver: receiver.to_string(),
15292            method_name: method_name.to_string(),
15293            colon_dispatch: false,
15294            line,
15295            lang: lang.to_string(),
15296        }
15297    }
15298
15299    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15300        NameMatchCandidate {
15301            node_id: node_id.to_string(),
15302            file_path: "src/targets.fixture".to_string(),
15303            scoped_name: scoped_name.to_string(),
15304            kind: "method".to_string(),
15305            start_line: 1,
15306        }
15307    }
15308
15309    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15310        let path = root.join(rel_path);
15311        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15312        fs::write(path, source).expect("write fixture");
15313    }
15314
15315    fn line_of(source: &str, needle: &str) -> u32 {
15316        source
15317            .lines()
15318            .position(|line| line.contains(needle))
15319            .map(|index| index as u32 + 1)
15320            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15321    }
15322}