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_caller_counts_of(
1536        &self,
1537        targets: &[(String, String)],
1538    ) -> Result<HashMap<(String, String), usize>>;
1539    fn outgoing_calls_for_symbols(
1540        &self,
1541        sources: &[(String, String)],
1542    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1543    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1544        -> Result<StoreCallersResult>;
1545    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1546    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1547    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1548    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1549    fn call_tree(
1550        &self,
1551        file_rel: &Path,
1552        symbol: &str,
1553        depth: usize,
1554    ) -> Result<callgraph::CallTreeNode>;
1555    fn trace_to(
1556        &self,
1557        file_rel: &Path,
1558        symbol: &str,
1559        max_depth: usize,
1560    ) -> Result<callgraph::TraceToResult>;
1561    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1562    fn trace_to_symbol(
1563        &self,
1564        file_rel: &Path,
1565        symbol: &str,
1566        to_symbol: &str,
1567        to_file: Option<&Path>,
1568        max_depth: usize,
1569    ) -> Result<callgraph::TraceToSymbolResult>;
1570}
1571
1572#[derive(Debug, Clone, PartialEq, Eq)]
1573enum OpenRootRepair {
1574    None,
1575    ReRooted,
1576    NeedsRebuild {
1577        previous_roots: Vec<String>,
1578        current_root: String,
1579        reason: String,
1580    },
1581}
1582
1583struct OpenedStore {
1584    store: CallGraphStore,
1585    root_repair: OpenRootRepair,
1586}
1587
1588#[derive(Clone, Debug)]
1589struct LegacyCallgraphPartition {
1590    harness: String,
1591    dir: PathBuf,
1592    key: String,
1593    bytes: u64,
1594    freshness: Option<SystemTime>,
1595}
1596
1597#[derive(Clone, Debug)]
1598struct LegacyCallgraphTarget {
1599    partition: LegacyCallgraphPartition,
1600    sqlite_path: PathBuf,
1601    generation: Option<String>,
1602    source_bytes: u64,
1603    source_blake3: String,
1604}
1605
1606#[derive(Clone, Debug)]
1607struct SourceFingerprint {
1608    bytes: u64,
1609    blake3: String,
1610}
1611
1612#[derive(Clone, Debug)]
1613struct PublishedLegacyMigration {
1614    generation: String,
1615    migrated_bytes: u64,
1616}
1617
1618#[derive(Debug, Clone)]
1619pub struct ColdBuildStats {
1620    pub files: usize,
1621    pub nodes: usize,
1622    pub refs: usize,
1623    pub edges: usize,
1624    pub failed_files: Vec<String>,
1625    pub elapsed_ms: u128,
1626}
1627
1628#[derive(Debug, Clone)]
1629pub struct IncrementalStats {
1630    pub changed_files: Vec<String>,
1631    pub surface_changed: Vec<String>,
1632    pub deleted_files: Vec<String>,
1633    pub dependency_selected_refs: usize,
1634    pub refreshed_own_files: usize,
1635    pub unchanged_extract_files: usize,
1636}
1637
1638/// Phase timings for the copy-based incremental refresh benchmark.
1639#[doc(hidden)]
1640#[derive(Debug, Clone, Default, PartialEq, Eq)]
1641pub struct RefreshFilesProfile {
1642    pub parse: Duration,
1643    pub dependency_selection: Duration,
1644    pub row_deletes: Duration,
1645    pub row_inserts: Duration,
1646    pub dependent_parse: Duration,
1647    pub index_load: Duration,
1648    pub ref_resolution: Duration,
1649    pub method_dispatch: Duration,
1650    pub commit: Duration,
1651    pub total: Duration,
1652}
1653
1654impl RefreshFilesProfile {
1655    pub fn report(&self) -> String {
1656        format!(
1657            "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",
1658            self.parse.as_millis(),
1659            self.dependency_selection.as_millis(),
1660            self.row_deletes.as_millis(),
1661            self.row_inserts.as_millis(),
1662            self.dependent_parse.as_millis(),
1663            self.index_load.as_millis(),
1664            self.ref_resolution.as_millis(),
1665            self.method_dispatch.as_millis(),
1666            self.commit.as_millis(),
1667            self.total.as_millis(),
1668        )
1669    }
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1673pub struct StoredEdge {
1674    pub source_file: String,
1675    pub source_symbol: String,
1676    pub target_file: String,
1677    pub target_symbol: String,
1678    pub kind: String,
1679    pub line: u32,
1680}
1681
1682#[derive(Debug, Clone, PartialEq, Eq)]
1683pub struct StoreNode {
1684    node_id: String,
1685    pub file: String,
1686    pub symbol: String,
1687    pub name: String,
1688    pub kind: String,
1689    pub line: u32,
1690    pub end_line: u32,
1691    pub signature: Option<String>,
1692    pub exported: bool,
1693    pub is_entry_point: bool,
1694    pub lang: LangId,
1695}
1696
1697#[cfg(test)]
1698impl StoreNode {
1699    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1700        Self {
1701            node_id: format!("{file}:{symbol}"),
1702            file: file.to_string(),
1703            symbol: symbol.to_string(),
1704            name: symbol.to_string(),
1705            kind: "function".to_string(),
1706            line: 1,
1707            end_line: 1,
1708            signature: None,
1709            exported: is_entry_point,
1710            is_entry_point,
1711            lang: LangId::TypeScript,
1712        }
1713    }
1714}
1715
1716#[derive(Debug, Clone, PartialEq, Eq)]
1717pub struct StoreCallSite {
1718    pub caller: StoreNode,
1719    pub target_file: String,
1720    pub target_symbol: String,
1721    pub target: Option<StoreNode>,
1722    pub line: u32,
1723    pub byte_start: usize,
1724    pub byte_end: usize,
1725    pub resolved: bool,
1726    pub provenance: String,
1727}
1728
1729impl StoreCallSite {
1730    pub fn approximate(&self) -> bool {
1731        self.provenance == PROVENANCE_NAME_MATCH
1732    }
1733
1734    pub fn resolved_by(&self) -> &str {
1735        &self.provenance
1736    }
1737
1738    pub fn supplemental_resolution(&self) -> Option<&str> {
1739        match self.provenance.as_str() {
1740            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1741            _ => None,
1742        }
1743    }
1744}
1745
1746#[derive(Debug, Clone, PartialEq, Eq)]
1747pub struct StoreUnresolvedCall {
1748    pub caller: StoreNode,
1749    pub symbol: String,
1750    pub full_ref: Option<String>,
1751    pub line: u32,
1752    pub byte_start: usize,
1753    pub byte_end: usize,
1754}
1755
1756#[derive(Debug, Clone, PartialEq, Eq)]
1757pub struct StoreCallersResult {
1758    pub target: StoreNode,
1759    pub callers: Vec<StoreCallSite>,
1760    pub scanned_files: usize,
1761    pub depth_limited: bool,
1762    pub truncated: usize,
1763}
1764
1765#[derive(Debug, Clone, PartialEq, Eq)]
1766pub struct StoreImpactCaller {
1767    pub site: StoreCallSite,
1768    pub signature: Option<String>,
1769    pub is_entry_point: bool,
1770    pub call_expression: Option<String>,
1771    pub parameters: Vec<String>,
1772}
1773
1774#[derive(Debug, Clone, PartialEq, Eq)]
1775pub struct StoreImpactResult {
1776    pub target: StoreNode,
1777    pub parameters: Vec<String>,
1778    pub callers: Vec<StoreImpactCaller>,
1779    pub depth_limited: bool,
1780    pub truncated: usize,
1781}
1782
1783#[derive(Debug, Clone)]
1784struct ExtractFailure {
1785    rel_path: String,
1786    freshness: Option<FileFreshness>,
1787}
1788
1789#[derive(Debug, Clone)]
1790struct BuildExtractsResult {
1791    extracts: Vec<FileExtract>,
1792    failures: Vec<ExtractFailure>,
1793}
1794
1795#[derive(Debug, Clone)]
1796enum StoreForwardCall {
1797    Resolved(StoreCallSite),
1798    Unresolved(StoreUnresolvedCall),
1799}
1800
1801impl StoreForwardCall {
1802    fn byte_start(&self) -> usize {
1803        match self {
1804            Self::Resolved(site) => site.byte_start,
1805            Self::Unresolved(call) => call.byte_start,
1806        }
1807    }
1808
1809    fn line(&self) -> u32 {
1810        match self {
1811            Self::Resolved(site) => site.line,
1812            Self::Unresolved(call) => call.line,
1813        }
1814    }
1815}
1816
1817#[derive(Debug, Clone)]
1818struct FileExtract {
1819    rel_path: String,
1820    freshness: FileFreshness,
1821    lang: LangId,
1822    data: FileCallData,
1823    nodes: Vec<NodeRecord>,
1824    raw_refs: Vec<RawRef>,
1825    dispatch_hints: Vec<DispatchHint>,
1826    surface_fingerprint: String,
1827}
1828
1829#[derive(Debug, Clone)]
1830struct NodeRecord {
1831    id: String,
1832    file_path: String,
1833    name: String,
1834    scoped_name: String,
1835    kind: String,
1836    range: Range,
1837    range_ordinal: u32,
1838    signature: Option<String>,
1839    exported: bool,
1840    is_default_export: bool,
1841    is_type_like: bool,
1842    is_callgraph_entry_point: bool,
1843}
1844
1845#[derive(Debug, Clone)]
1846struct RawRef {
1847    ref_id: String,
1848    caller_node: Option<String>,
1849    caller_symbol: Option<String>,
1850    caller_file: String,
1851    kind: String,
1852    short_name: Option<String>,
1853    full_ref: Option<String>,
1854    module_path: Option<String>,
1855    import_kind: Option<String>,
1856    local_name: Option<String>,
1857    requested_name: Option<String>,
1858    namespace_alias: Option<String>,
1859    wildcard: bool,
1860    line: u32,
1861    byte_start: usize,
1862    byte_end: usize,
1863    dependencies: BTreeSet<String>,
1864}
1865
1866#[derive(Debug, Clone)]
1867struct ResolvedRef {
1868    raw: RawRef,
1869    status: String,
1870    target_node: Option<String>,
1871    target_file: Option<String>,
1872    target_symbol: Option<String>,
1873    dependencies: BTreeSet<String>,
1874    edge: Option<EdgeRecord>,
1875}
1876
1877#[derive(Debug, Clone)]
1878struct EdgeRecord {
1879    edge_id: String,
1880    source_node: String,
1881    target_node: Option<String>,
1882    target_file: String,
1883    target_symbol: String,
1884    kind: String,
1885    line: u32,
1886}
1887
1888#[derive(Debug, Clone)]
1889struct DispatchHint {
1890    id: String,
1891    method_name: String,
1892    caller_node: String,
1893    file: String,
1894    line: u32,
1895    byte_start: usize,
1896    byte_end: usize,
1897}
1898
1899#[derive(Debug, Clone)]
1900struct NameMatchRef {
1901    ref_id: String,
1902    caller_node: String,
1903    caller_file: String,
1904    caller_symbol: String,
1905    caller_signature: Option<String>,
1906    receiver_expression: String,
1907    receiver: String,
1908    method_name: String,
1909    colon_dispatch: bool,
1910    line: u32,
1911    lang: String,
1912}
1913
1914#[derive(Debug, Clone)]
1915struct NameMatchCandidate {
1916    node_id: String,
1917    file_path: String,
1918    scoped_name: String,
1919    kind: String,
1920    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
1921    start_line: u32,
1922}
1923
1924#[derive(Debug, Clone)]
1925struct FileRow {
1926    surface_fingerprint: String,
1927    freshness: FileFreshness,
1928}
1929
1930#[derive(Debug, Clone)]
1931struct DbFileIndex {
1932    lang: Option<LangId>,
1933    exports: HashSet<String>,
1934    default_export: Option<String>,
1935    export_aliases: HashMap<String, String>,
1936    node_by_scoped: HashMap<String, String>,
1937    node_by_bare: HashMap<String, String>,
1938    module_targets: HashMap<String, Option<String>>,
1939    reexports: Vec<ReexportIndex>,
1940}
1941
1942#[derive(Debug, Clone)]
1943struct ReexportIndex {
1944    target_file: Option<String>,
1945    named: HashMap<String, String>,
1946    wildcard: bool,
1947}
1948
1949#[derive(Debug, Clone)]
1950struct ProjectIndex<'a> {
1951    project_root: PathBuf,
1952    files: HashMap<String, DbFileIndex>,
1953    caller_data: HashMap<String, &'a FileCallData>,
1954    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1955    /// watcher events replace the cache before another batch can resolve refs.
1956    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1957    /// its own workspace mapping.
1958    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1959}
1960
1961impl ProjectIndex<'_> {
1962    /// Resolve a crate name to its `src` prefix, building the workspace map on
1963    /// first use. Refresh-worker indexes for the same root share this cache.
1964    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1965        self.workspace_crate_prefixes
1966            .0
1967            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1968            .get(crate_name)
1969            .cloned()
1970    }
1971}
1972
1973impl CallGraphStore {
1974    pub fn open_if_enabled(
1975        options: CallGraphStoreOptions,
1976        callgraph_dir: PathBuf,
1977        project_root: PathBuf,
1978    ) -> Result<Option<Self>> {
1979        if !options.enabled {
1980            return Ok(None);
1981        }
1982        Self::open(callgraph_dir, project_root).map(Some)
1983    }
1984
1985    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1986        let project_key = crate::search_index::artifact_cache_key(&project_root);
1987        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1988        else {
1989            return Err(CallGraphStoreError::Unavailable(
1990                "writer capability denied; use the read-only callgraph opener".to_string(),
1991            ));
1992        };
1993        std::fs::create_dir_all(&callgraph_dir)?;
1994        // Resolve the current generation via the pointer (falling back to the
1995        // legacy single-file DB). If nothing is published yet, open the legacy
1996        // path so a brand-new store still gets a writable DB + schema.
1997        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1998            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1999        let OpenedStore { store, root_repair } = Self::open_at_path(
2000            project_root.clone(),
2001            project_key,
2002            sqlite_path,
2003            generation,
2004            true,
2005            Some(Arc::clone(&writer_lease)),
2006            None,
2007        )?;
2008        match root_repair {
2009            OpenRootRepair::NeedsRebuild { .. } => {
2010                log_root_repair_rebuild(&root_repair);
2011                drop(store);
2012                drop(writer_lease);
2013                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2014                let (store, _stats) =
2015                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2016                Ok(store)
2017            }
2018            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2019        }
2020    }
2021
2022    pub fn open_readonly(
2023        callgraph_dir: PathBuf,
2024        project_root: PathBuf,
2025    ) -> Result<Option<ReadonlyCallGraphStore>> {
2026        let project_key = crate::search_index::artifact_cache_key(&project_root);
2027        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2028        {
2029            let conn = open_readonly_connection(&sqlite_path)?;
2030            if !database_ready(&conn).unwrap_or(false) {
2031                return Ok(None);
2032            }
2033            let marker_label = generation.as_deref().unwrap_or("legacy");
2034            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2035            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2036                Self::from_connection(
2037                    project_root,
2038                    project_key,
2039                    sqlite_path,
2040                    callgraph_dir,
2041                    false,
2042                    generation,
2043                    None,
2044                    Some(read_marker),
2045                    conn,
2046                ),
2047            )));
2048        }
2049
2050        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2051            return Ok(None);
2052        };
2053        crate::slog_warn!(
2054            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2055            target.partition.harness,
2056            target.sqlite_path.display()
2057        );
2058        let conn = open_readonly_connection(&target.sqlite_path)?;
2059        if !database_ready(&conn).unwrap_or(false) {
2060            return Ok(None);
2061        }
2062        let marker_label =
2063            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2064        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2065        Ok(Some(ReadonlyCallGraphStore::from_inner(
2066            Self::from_connection(
2067                project_root,
2068                project_key,
2069                target.sqlite_path,
2070                callgraph_dir,
2071                true,
2072                target.generation,
2073                None,
2074                Some(read_marker),
2075                conn,
2076            ),
2077        )))
2078    }
2079
2080    /// Open the currently-published ready store with write access so moved-root
2081    /// metadata can be repaired before projection readers consume it. Unlike
2082    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2083    /// ready generation exists, it returns `Ok(None)` instead of creating an
2084    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2085    pub fn open_ready_repairing(
2086        callgraph_dir: PathBuf,
2087        project_root: PathBuf,
2088    ) -> Result<Option<Self>> {
2089        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2090    }
2091
2092    /// Open a ready store for bounded maintenance work without repairing root
2093    /// metadata or starting a cold rebuild. A store that needs either action is
2094    /// reported as unavailable so a background build can own that work.
2095    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2096        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2097    }
2098
2099    pub fn open_ready_no_rebuild(
2100        callgraph_dir: PathBuf,
2101        project_root: PathBuf,
2102    ) -> Result<Option<Self>> {
2103        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2104    }
2105
2106    fn open_ready_with_rebuild_policy(
2107        callgraph_dir: PathBuf,
2108        project_root: PathBuf,
2109        allow_cold_build: bool,
2110        allow_root_repair: bool,
2111    ) -> Result<Option<Self>> {
2112        let project_key = crate::search_index::artifact_cache_key(&project_root);
2113        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2114        else {
2115            return Ok(None);
2116        };
2117        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2118        else {
2119            return Ok(None);
2120        };
2121        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2122            project_root.clone(),
2123            project_key.clone(),
2124            sqlite_path,
2125            generation,
2126            true,
2127            Some(Arc::clone(&writer_lease)),
2128            None,
2129            allow_root_repair,
2130        )?;
2131        match root_repair {
2132            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2133                log_root_repair_rebuild(&root_repair);
2134                drop(store);
2135                drop(writer_lease);
2136                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2137                let (store, _stats) =
2138                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2139                Ok(Some(store))
2140            }
2141            OpenRootRepair::NeedsRebuild { .. } => {
2142                if let Some(message) = note_repair_entry(&project_key) {
2143                    crate::slog_warn!("{message}");
2144                }
2145                Ok(None)
2146            }
2147            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2148        }
2149    }
2150
2151    pub fn cold_build_with_lease(
2152        callgraph_dir: PathBuf,
2153        project_root: PathBuf,
2154        files: &[PathBuf],
2155    ) -> Result<(Self, ColdBuildStats)> {
2156        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2157    }
2158
2159    pub fn cold_build_with_lease_chunked(
2160        callgraph_dir: PathBuf,
2161        project_root: PathBuf,
2162        files: &[PathBuf],
2163        chunk_size: usize,
2164    ) -> Result<(Self, ColdBuildStats)> {
2165        Self::cold_build_with_lease_chunked_inner(
2166            callgraph_dir,
2167            project_root,
2168            files,
2169            chunk_size,
2170            false,
2171        )
2172    }
2173
2174    pub(crate) fn force_cold_build_with_lease_chunked(
2175        callgraph_dir: PathBuf,
2176        project_root: PathBuf,
2177        files: &[PathBuf],
2178        chunk_size: usize,
2179    ) -> Result<(Self, ColdBuildStats)> {
2180        Self::cold_build_with_lease_chunked_inner(
2181            callgraph_dir,
2182            project_root,
2183            files,
2184            chunk_size,
2185            true,
2186        )
2187    }
2188
2189    fn cold_build_with_lease_chunked_inner(
2190        callgraph_dir: PathBuf,
2191        project_root: PathBuf,
2192        files: &[PathBuf],
2193        chunk_size: usize,
2194        require_new_publication: bool,
2195    ) -> Result<(Self, ColdBuildStats)> {
2196        let project_key = crate::search_index::artifact_cache_key(&project_root);
2197        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2198        else {
2199            let operation = if require_new_publication {
2200                "forced rebuild"
2201            } else {
2202                "cold build"
2203            };
2204            return Err(CallGraphStoreError::Unavailable(format!(
2205                "{operation} could not acquire writer capability"
2206            )));
2207        };
2208        std::fs::create_dir_all(&callgraph_dir)?;
2209        let (stats, generation) = Self::cold_build_publish_locked(
2210            &callgraph_dir,
2211            &project_root,
2212            &project_key,
2213            files,
2214            chunk_size,
2215            Arc::clone(&writer_lease),
2216        )?;
2217        let store = Self::open_generation(
2218            &callgraph_dir,
2219            project_root,
2220            project_key,
2221            generation,
2222            writer_lease,
2223        )?;
2224        Ok((store, stats))
2225    }
2226
2227    pub fn ensure_built_with_lease(
2228        callgraph_dir: PathBuf,
2229        project_root: PathBuf,
2230        files: &[PathBuf],
2231    ) -> Result<(Self, Option<ColdBuildStats>)> {
2232        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2233    }
2234
2235    pub fn ensure_built_with_lease_chunked(
2236        callgraph_dir: PathBuf,
2237        project_root: PathBuf,
2238        files: &[PathBuf],
2239        chunk_size: usize,
2240    ) -> Result<(Self, Option<ColdBuildStats>)> {
2241        let project_key = crate::search_index::artifact_cache_key(&project_root);
2242        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2243        else {
2244            return Err(CallGraphStoreError::Unavailable(
2245                "callgraph ensure could not acquire writer capability".to_string(),
2246            ));
2247        };
2248        std::fs::create_dir_all(&callgraph_dir)?;
2249        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2250        // Another process may have published a ready generation while we waited
2251        // for the lock — open it instead of rebuilding. If that generation is
2252        // from this same project at an older filesystem root, repair the root
2253        // metadata in-place while still holding the build lease. If data rows
2254        // contain absolute paths, publish a fresh generation under this lease
2255        // rather than recursively reacquiring the same lock.
2256        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2257        {
2258            let OpenedStore { store, root_repair } = Self::open_at_path(
2259                project_root.clone(),
2260                project_key.clone(),
2261                sqlite_path,
2262                generation,
2263                true,
2264                Some(Arc::clone(&writer_lease)),
2265                None,
2266            )?;
2267            match root_repair {
2268                OpenRootRepair::NeedsRebuild { .. } => {
2269                    log_root_repair_rebuild(&root_repair);
2270                    drop(store);
2271                    let (stats, generation) = Self::cold_build_publish_locked(
2272                        &callgraph_dir,
2273                        &project_root,
2274                        &project_key,
2275                        files,
2276                        chunk_size,
2277                        Arc::clone(&writer_lease),
2278                    )?;
2279                    let store = Self::open_generation(
2280                        &callgraph_dir,
2281                        project_root,
2282                        project_key,
2283                        generation,
2284                        writer_lease,
2285                    )?;
2286                    return Ok((store, Some(stats)));
2287                }
2288                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2289                    return Ok((store, None));
2290                }
2291            }
2292        }
2293        if let Some(store) = try_legacy_migration_or_fallback(
2294            &callgraph_dir,
2295            &project_root,
2296            &project_key,
2297            Arc::clone(&writer_lease),
2298        )? {
2299            return Ok((store, None));
2300        }
2301        let (stats, generation) = Self::cold_build_publish_locked(
2302            &callgraph_dir,
2303            &project_root,
2304            &project_key,
2305            files,
2306            chunk_size,
2307            Arc::clone(&writer_lease),
2308        )?;
2309        let store = Self::open_generation(
2310            &callgraph_dir,
2311            project_root,
2312            project_key,
2313            generation,
2314            writer_lease,
2315        )?;
2316        Ok((store, Some(stats)))
2317    }
2318
2319    /// Migrate a legacy harness-partition store without falling through to a
2320    /// cold build. This is used after a query has already opened a read-only
2321    /// fallback: the caller runs it on the same limited background lane as cold
2322    /// builds while queries continue using that fallback. Public so crash/retry
2323    /// tests can drive the migration synchronously on a thread where the
2324    /// thread-local failure seams apply.
2325    pub fn migrate_legacy_with_lease(
2326        callgraph_dir: PathBuf,
2327        project_root: PathBuf,
2328    ) -> Result<Option<Self>> {
2329        let project_key = crate::search_index::artifact_cache_key(&project_root);
2330        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2331        else {
2332            return Ok(None);
2333        };
2334        std::fs::create_dir_all(&callgraph_dir)?;
2335        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2336
2337        // Another writer may have completed the migration while this worker was
2338        // waiting for the lease. Adopt its root-keyed generation rather than
2339        // copying the legacy source a second time.
2340        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2341        {
2342            let OpenedStore { store, root_repair } = Self::open_at_path(
2343                project_root,
2344                project_key,
2345                sqlite_path,
2346                generation,
2347                true,
2348                Some(writer_lease),
2349                None,
2350            )?;
2351            return match root_repair {
2352                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2353                OpenRootRepair::NeedsRebuild { reason, .. } => {
2354                    Err(CallGraphStoreError::Unavailable(format!(
2355                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2356                    )))
2357                }
2358            };
2359        }
2360
2361        let store = try_legacy_migration_or_fallback(
2362            &callgraph_dir,
2363            &project_root,
2364            &project_key,
2365            writer_lease,
2366        )?;
2367        // A disk-floor or backup-budget failure returns a readable legacy store.
2368        // Keep the already-resident fallback instead of sending this duplicate
2369        // reader through the background-install channel.
2370        Ok(store.filter(|store| !store.is_legacy_fallback()))
2371    }
2372
2373    /// Build a fresh DB and publish it as a new generation, then atomically flip
2374    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2375    /// succeeds even when other processes hold an older generation open (the
2376    /// multi-TUI Windows case). The builder owns the temp + generation files
2377    /// exclusively (unique pid+nanos names), so it can rename/replace them
2378    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2379    ///
2380    /// Returns the published generation file name so callers open exactly the
2381    /// generation they built (avoiding a race where a concurrent build's flip
2382    /// would otherwise reopen a different generation).
2383    fn cold_build_publish_locked(
2384        callgraph_dir: &Path,
2385        project_root: &Path,
2386        project_key: &str,
2387        files: &[PathBuf],
2388        chunk_size: usize,
2389        writer_lease: Arc<crate::root_cache::WriterLease>,
2390    ) -> Result<(ColdBuildStats, String)> {
2391        if let Some((previous_root, remaining)) =
2392            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2393        {
2394            return Err(CallGraphStoreError::Unavailable(format!(
2395                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2396                previous_root.display(),
2397                remaining.as_millis()
2398            )));
2399        }
2400        let generation = generation_file_name(project_key);
2401        let gen_path = callgraph_dir.join(&generation);
2402        let temp_path = callgraph_dir.join(format!(
2403            "{generation}.tmp.{}.{}",
2404            std::process::id(),
2405            now_nanos()
2406        ));
2407        remove_sqlite_file_set(&temp_path);
2408
2409        let stats = {
2410            let temp_store = Self::open_at_path(
2411                project_root.to_path_buf(),
2412                project_key.to_string(),
2413                temp_path.clone(),
2414                None,
2415                false,
2416                Some(Arc::clone(&writer_lease)),
2417                None,
2418            )?
2419            .store;
2420            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2421            let _ = temp_store.checkpoint_wal_truncate();
2422            temp_store.prepare_for_atomic_swap()?;
2423            stats
2424        };
2425
2426        notify_cold_build_before_publish_observer();
2427        let publication = publish_if_current(|| {
2428            verify_writer_lease(&writer_lease)?;
2429            // Move the finished build to its final generation path. This target is
2430            // brand-new and owned by us, so the rename never hits an open file.
2431            remove_sqlite_file_set(&gen_path);
2432            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2433            crate::fs_lock::sync_parent(&gen_path);
2434            remove_sqlite_sidecars(&gen_path);
2435
2436            notify_cold_build_swap_observer(&temp_path, &gen_path);
2437
2438            // Atomically publish the new generation, then best-effort GC old ones.
2439            verify_writer_lease(&writer_lease)?;
2440            publish_pointer(callgraph_dir, project_key, &generation)?;
2441            gc_old_generations(callgraph_dir, project_key, &generation);
2442            // Store-wide orphan sweep on the same cadence: reclaims aged build
2443            // temps for roots that no longer build here, which the per-root GC
2444            // above never reaches.
2445            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2446            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2447                let inspect_root =
2448                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2449                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2450                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2451            }
2452            Ok(())
2453        });
2454        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2455            remove_sqlite_file_set(&temp_path);
2456        }
2457        publication?;
2458        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2459        Ok((stats, generation))
2460    }
2461
2462    /// Open a specific just-published generation (read-write, WAL) so a builder
2463    /// returns a store pinned to exactly what it built.
2464    fn open_generation(
2465        callgraph_dir: &Path,
2466        project_root: PathBuf,
2467        project_key: String,
2468        generation: String,
2469        writer_lease: Arc<crate::root_cache::WriterLease>,
2470    ) -> Result<Self> {
2471        let gen_path = callgraph_dir.join(&generation);
2472        Ok(Self::open_at_path(
2473            project_root,
2474            project_key,
2475            gen_path,
2476            Some(generation),
2477            true,
2478            Some(writer_lease),
2479            None,
2480        )?
2481        .store)
2482    }
2483
2484    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2485        let project_key = crate::search_index::artifact_cache_key(project_root);
2486        // A cold build is needed unless a ready generation (or ready legacy DB)
2487        // is currently published.
2488        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2489    }
2490
2491    fn open_at_path(
2492        project_root: PathBuf,
2493        project_key: String,
2494        sqlite_path: PathBuf,
2495        generation: Option<String>,
2496        use_wal: bool,
2497        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2498        read_marker: Option<crate::root_cache::ReadMarker>,
2499    ) -> Result<OpenedStore> {
2500        Self::open_at_path_with_root_repair(
2501            project_root,
2502            project_key,
2503            sqlite_path,
2504            generation,
2505            use_wal,
2506            writer_lease,
2507            read_marker,
2508            true,
2509        )
2510    }
2511
2512    fn open_at_path_with_root_repair(
2513        project_root: PathBuf,
2514        project_key: String,
2515        sqlite_path: PathBuf,
2516        generation: Option<String>,
2517        use_wal: bool,
2518        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2519        read_marker: Option<crate::root_cache::ReadMarker>,
2520        allow_root_repair: bool,
2521    ) -> Result<OpenedStore> {
2522        if let Some(lease) = writer_lease.as_ref() {
2523            verify_writer_lease(lease)?;
2524        }
2525        if let Some(parent) = sqlite_path.parent() {
2526            std::fs::create_dir_all(parent)?;
2527        }
2528        let mut conn = Connection::open(&sqlite_path)?;
2529        if use_wal {
2530            configure_connection(&conn)?;
2531        } else {
2532            configure_build_connection(&conn)?;
2533        }
2534        if let Some(lease) = writer_lease.as_ref() {
2535            verify_writer_lease(lease)?;
2536        }
2537        initialize_schema(&conn)?;
2538        if let Some(lease) = writer_lease.as_ref() {
2539            verify_writer_lease(lease)?;
2540        }
2541        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2542        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2543            (Some(marker), _, _) => Some(marker),
2544            (None, Some(label), Some(cache_dir)) => {
2545                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2546            }
2547            (None, _, _) => None,
2548        };
2549        let publication_dir = sqlite_path
2550            .parent()
2551            .map(Path::to_path_buf)
2552            .unwrap_or_default();
2553        let store = Self::from_connection(
2554            project_root,
2555            project_key,
2556            sqlite_path,
2557            publication_dir,
2558            false,
2559            generation,
2560            writer_lease,
2561            read_marker,
2562            conn,
2563        );
2564        Ok(OpenedStore { store, root_repair })
2565    }
2566
2567    fn prepare_for_atomic_swap(&self) -> Result<()> {
2568        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2569        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2570        Ok(())
2571    }
2572
2573    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2574        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2575            self.sqlite_path
2576                .parent()
2577                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2578        });
2579        if protected_reader {
2580            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2581        } else {
2582            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2583        }
2584    }
2585
2586    fn from_connection(
2587        project_root: PathBuf,
2588        project_key: String,
2589        sqlite_path: PathBuf,
2590        publication_dir: PathBuf,
2591        legacy_fallback: bool,
2592        generation: Option<String>,
2593        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2594        read_marker: Option<crate::root_cache::ReadMarker>,
2595        conn: Connection,
2596    ) -> Self {
2597        let write_metrics = callgraph_write_metrics_for_key(&project_key);
2598        Self {
2599            project_root,
2600            project_key,
2601            sqlite_path,
2602            publication_dir,
2603            legacy_fallback,
2604            generation,
2605            writer_lease,
2606            read_marker,
2607            database_ready: AtomicBool::new(false),
2608            write_metrics,
2609            conn: Mutex::new(conn),
2610        }
2611    }
2612
2613    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2614        if self.database_ready.load(AtomicOrdering::Acquire) {
2615            return Ok(());
2616        }
2617        ensure_database_ready(conn)?;
2618        self.database_ready.store(true, AtomicOrdering::Release);
2619        Ok(())
2620    }
2621
2622    pub fn project_root(&self) -> &Path {
2623        &self.project_root
2624    }
2625
2626    pub fn project_key(&self) -> &str {
2627        &self.project_key
2628    }
2629
2630    pub fn sqlite_path(&self) -> &Path {
2631        &self.sqlite_path
2632    }
2633
2634    /// The generation file named by the publication pointer when this store opened.
2635    pub(crate) fn projection_generation(&self) -> Option<&str> {
2636        self.generation.as_deref()
2637    }
2638
2639    /// Read the durable revision that changes in the same transaction as graph writes.
2640    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2641        self.refresh_read_marker()?;
2642        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2643        self.ensure_ready(&conn)?;
2644        projection_write_revision(&conn)
2645    }
2646
2647    /// Whether this store is reading from a legacy harness partition because
2648    /// the root-keyed store has not published a generation yet.
2649    pub fn is_legacy_fallback(&self) -> bool {
2650        self.legacy_fallback
2651    }
2652
2653    pub(crate) fn is_legacy_migration(&self) -> bool {
2654        self.generation.as_deref().is_some_and(|generation| {
2655            migration_generation_requires_manifest(generation)
2656                && migration_manifest_valid(&self.publication_dir, generation)
2657        })
2658    }
2659
2660    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2661        self.writer_lease.as_ref().map(|lease| lease.epoch())
2662    }
2663
2664    fn verify_writer_lease(&self) -> Result<()> {
2665        let Some(lease) = self.writer_lease.as_ref() else {
2666            return Err(CallGraphStoreError::Unavailable(
2667                "callgraph store opened read-only; write API is unavailable".to_string(),
2668            ));
2669        };
2670        verify_writer_lease(lease)
2671    }
2672
2673    fn refresh_read_marker(&self) -> Result<()> {
2674        if let Some(marker) = self.read_marker.as_ref() {
2675            marker.touch_if_due()?;
2676        }
2677        Ok(())
2678    }
2679
2680    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2681        self.write_metrics
2682            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2683    }
2684
2685    fn checkpoint_wal_truncate(&self) -> bool {
2686        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2687        checkpoint_wal_truncate(&conn)
2688    }
2689
2690    /// True if this store still reflects the currently-published generation.
2691    /// Cheap (one small pointer-file read). When false, another process (or a
2692    /// local cold rebuild) has published a newer generation and the holder
2693    /// should drop this store and reopen via the pointer to converge. A missing
2694    /// pointer keeps the current store (legacy DB still valid, or transient).
2695    pub fn is_current(&self) -> bool {
2696        let _ = self.refresh_read_marker();
2697        match (
2698            read_pointer(&self.publication_dir, &self.project_key),
2699            &self.generation,
2700        ) {
2701            // Even when both generations happen to have the same filename, the
2702            // root-keyed pointer names a different directory from the fallback.
2703            (Some(_), _) if self.legacy_fallback => false,
2704            (Some(published), Some(opened)) => &published == opened,
2705            // A generation now supersedes the legacy single-file DB we opened.
2706            (Some(_), None) => false,
2707            // No pointer: keep serving (legacy DB, or an anomalous pointer
2708            // removal where our open generation file is still valid).
2709            (None, _) => true,
2710        }
2711    }
2712
2713    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2714        self.cold_build_chunked(files, 0)
2715    }
2716
2717    pub fn cold_build_chunked(
2718        &self,
2719        files: &[PathBuf],
2720        chunk_size: usize,
2721    ) -> Result<ColdBuildStats> {
2722        let started = Instant::now();
2723        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2724        macro_rules! phase {
2725            ($label:expr, $t:expr) => {
2726                if bench {
2727                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2728                    let _ = std::io::Write::flush(&mut std::io::stderr());
2729                }
2730            };
2731        }
2732        let files = normalize_file_list(&self.project_root, files)?;
2733
2734        if chunk_size == 0 {
2735            let t = Instant::now();
2736            let build = build_extracts_parallel(&self.project_root, &files);
2737            phase!("extract_parallel", t);
2738            let extracts = build.extracts;
2739            let failures = build.failures;
2740            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2741
2742            let t = Instant::now();
2743            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2744            phase!("build_index", t);
2745            let t = Instant::now();
2746            let mut resolved_refs = Vec::new();
2747            for extract in &extracts {
2748                for raw_ref in &extract.raw_refs {
2749                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2750                }
2751            }
2752            phase!("resolve_refs", t);
2753            let ref_count = resolved_refs.len();
2754            let edge_count = resolved_refs
2755                .iter()
2756                .filter(|item| item.edge.is_some())
2757                .count();
2758
2759            let t = Instant::now();
2760            self.verify_writer_lease()?;
2761            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2762            let total_changes_before = conn.total_changes();
2763            let tx = conn.transaction()?;
2764            clear_tables(&tx)?;
2765            insert_meta(&tx)?;
2766            drop_cold_build_secondary_indexes(&tx)?;
2767            {
2768                let workspace_root = self.project_root.display().to_string();
2769                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2770                for extract in &extracts {
2771                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2772                }
2773                for failure in &failures {
2774                    insert_backend_state_prepared(
2775                        &mut inserts.backend_state,
2776                        &workspace_root,
2777                        &failure.rel_path,
2778                        failure
2779                            .freshness
2780                            .as_ref()
2781                            .map(|freshness| &freshness.content_hash),
2782                        "stale",
2783                    )?;
2784                }
2785                for resolved in &resolved_refs {
2786                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2787                }
2788            }
2789            create_cold_build_secondary_indexes(&tx)?;
2790            let supplemental_edge_count =
2791                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2792            set_meta_ready(&tx, true)?;
2793            tx.commit()?;
2794            self.record_commit(total_changes_before, &conn);
2795            phase!("sqlite_insert", t);
2796
2797            let elapsed_ms = started.elapsed().as_millis();
2798            crate::slog_info!(
2799                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2800                extracts.len(),
2801                node_count,
2802                ref_count,
2803                edge_count + supplemental_edge_count,
2804                elapsed_ms
2805            );
2806            return Ok(ColdBuildStats {
2807                files: extracts.len(),
2808                nodes: node_count,
2809                refs: ref_count,
2810                edges: edge_count + supplemental_edge_count,
2811                failed_files: failures
2812                    .into_iter()
2813                    .map(|failure| failure.rel_path)
2814                    .collect(),
2815                elapsed_ms,
2816            });
2817        }
2818
2819        // Chunked implementation: parse and resolve in batches to reduce peak
2820        // memory during cold build without changing the persisted graph.
2821        let t = Instant::now();
2822        self.verify_writer_lease()?;
2823        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2824        let total_changes_before = conn.total_changes();
2825        let tx = conn.transaction()?;
2826        clear_tables(&tx)?;
2827        insert_meta(&tx)?;
2828        drop_cold_build_secondary_indexes(&tx)?;
2829
2830        let mut all_raw_refs = Vec::new();
2831        let mut failures = Vec::new();
2832        let mut node_count = 0;
2833        let mut files_parsed = 0;
2834
2835        let mut persistent_call_data = Vec::new();
2836        let mut file_to_call_data_index = HashMap::new();
2837        let mut files_index = HashMap::new();
2838
2839        let workspace_root = self.project_root.display().to_string();
2840
2841        {
2842            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2843            for chunk in files.chunks(chunk_size) {
2844                let build = build_extracts_parallel(&self.project_root, chunk);
2845                failures.extend(build.failures.clone());
2846
2847                for extract in build.extracts {
2848                    files_parsed += 1;
2849                    node_count += extract.nodes.len();
2850                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2851
2852                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2853                    files_index.insert(extract.rel_path.clone(), db_file_index);
2854
2855                    persistent_call_data.push(extract.data);
2856                    let idx = persistent_call_data.len() - 1;
2857                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2858
2859                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2860                }
2861                for failure in &build.failures {
2862                    insert_backend_state_prepared(
2863                        &mut inserts.backend_state,
2864                        &workspace_root,
2865                        &failure.rel_path,
2866                        failure
2867                            .freshness
2868                            .as_ref()
2869                            .map(|freshness| &freshness.content_hash),
2870                        "stale",
2871                    )?;
2872                }
2873            }
2874        }
2875
2876        let mut caller_data = HashMap::new();
2877        for (rel_path, idx) in &file_to_call_data_index {
2878            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2879        }
2880        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2881        let index = ProjectIndex::from_parts(
2882            &self.project_root,
2883            files_index,
2884            caller_data,
2885            WorkspaceCratePrefixCache::default(),
2886        );
2887
2888        let mut resolved_refs = Vec::new();
2889        for (_, raw_refs) in all_raw_refs {
2890            for raw_ref in raw_refs {
2891                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2892            }
2893        }
2894
2895        let ref_count = resolved_refs.len();
2896        let edge_count = resolved_refs
2897            .iter()
2898            .filter(|item| item.edge.is_some())
2899            .count();
2900
2901        {
2902            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2903            for resolved in &resolved_refs {
2904                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2905            }
2906        }
2907        create_cold_build_secondary_indexes(&tx)?;
2908        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2909            &tx,
2910            &self.project_root,
2911            &indexed_caller_files,
2912            chunk_size,
2913        )?;
2914        set_meta_ready(&tx, true)?;
2915        bump_projection_write_revision(&tx)?;
2916        tx.commit()?;
2917        self.record_commit(total_changes_before, &conn);
2918        phase!("sqlite_insert", t);
2919
2920        let elapsed_ms = started.elapsed().as_millis();
2921        crate::slog_info!(
2922            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2923            files_parsed,
2924            node_count,
2925            ref_count,
2926            edge_count + supplemental_edge_count,
2927            elapsed_ms
2928        );
2929        Ok(ColdBuildStats {
2930            files: files_parsed,
2931            nodes: node_count,
2932            refs: ref_count,
2933            edges: edge_count + supplemental_edge_count,
2934            failed_files: failures
2935                .into_iter()
2936                .map(|failure| failure.rel_path)
2937                .collect(),
2938            elapsed_ms,
2939        })
2940    }
2941
2942    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2943        self.refresh_files_with_workspace_crate_prefix_cache(
2944            changed_files,
2945            WorkspaceCratePrefixCache::default(),
2946        )
2947    }
2948
2949    fn refresh_files_with_workspace_crate_prefix_cache(
2950        &self,
2951        changed_files: &[PathBuf],
2952        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2953    ) -> Result<IncrementalStats> {
2954        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2955            changed_files,
2956            workspace_crate_prefixes,
2957        )?;
2958        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2959            eprintln!("refresh_files phases: {}", profile.report());
2960        }
2961        Ok(stats)
2962    }
2963
2964    /// Run an incremental refresh and return phase timings for an offline store copy.
2965    #[doc(hidden)]
2966    pub fn refresh_files_profiled(
2967        &self,
2968        changed_files: &[PathBuf],
2969    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2970        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2971            changed_files,
2972            WorkspaceCratePrefixCache::default(),
2973        )
2974    }
2975
2976    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2977        &self,
2978        changed_files: &[PathBuf],
2979        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2980    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2981        let total_started = Instant::now();
2982        let mut profile = RefreshFilesProfile::default();
2983        self.verify_writer_lease()?;
2984        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2985        let total_changes_before = conn.total_changes();
2986        let tx = conn.transaction()?;
2987        ensure_database_ready(&tx)?;
2988        let mut changed = Vec::new();
2989        let mut surface_changed = BTreeSet::new();
2990        let mut deleted = BTreeSet::new();
2991        let mut own_refresh = BTreeSet::new();
2992        let mut candidate_own_refresh = BTreeSet::new();
2993        let mut unchanged_extracts = 0usize;
2994        let mut selected_ref_ids = BTreeSet::new();
2995        let mut selected_refs_by_caller = BTreeMap::new();
2996        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2997
2998        for input in changed_files {
2999            let abs_path = normalize_file_path(&self.project_root, input)?;
3000            let rel_path = relative_path(&self.project_root, &abs_path);
3001            changed.push(rel_path.clone());
3002            let old_row = load_file_row(&tx, &rel_path)?;
3003            if !abs_path.exists() {
3004                if old_row.is_some() {
3005                    surface_changed.insert(rel_path.clone());
3006                    deleted.insert(rel_path.clone());
3007                    let started = Instant::now();
3008                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3009                    profile.dependency_selection += started.elapsed();
3010                    record_dependent_refs(
3011                        &mut selected_ref_ids,
3012                        &mut selected_refs_by_caller,
3013                        dependent_refs,
3014                    );
3015                    let started = Instant::now();
3016                    delete_file_rows(&tx, &rel_path)?;
3017                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3018                    profile.row_deletes += started.elapsed();
3019                }
3020                continue;
3021            }
3022
3023            if let Some(row) = &old_row {
3024                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3025                    FreshnessVerdict::HotFresh => continue,
3026                    FreshnessVerdict::ContentFresh {
3027                        new_mtime,
3028                        new_size,
3029                    } => {
3030                        update_file_fresh_metadata(
3031                            &tx,
3032                            &self.project_root,
3033                            &rel_path,
3034                            &row.freshness.content_hash,
3035                            new_mtime,
3036                            new_size,
3037                        )?;
3038                        continue;
3039                    }
3040                    FreshnessVerdict::Deleted => {
3041                        surface_changed.insert(rel_path.clone());
3042                        deleted.insert(rel_path.clone());
3043                        let started = Instant::now();
3044                        let dependent_refs =
3045                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3046                        profile.dependency_selection += started.elapsed();
3047                        record_dependent_refs(
3048                            &mut selected_ref_ids,
3049                            &mut selected_refs_by_caller,
3050                            dependent_refs,
3051                        );
3052                        let started = Instant::now();
3053                        delete_file_rows(&tx, &rel_path)?;
3054                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3055                        profile.row_deletes += started.elapsed();
3056                        continue;
3057                    }
3058                    FreshnessVerdict::Stale => {}
3059                }
3060            }
3061
3062            let started = Instant::now();
3063            let extract = build_file_extract(&self.project_root, &abs_path)?;
3064            profile.parse += started.elapsed();
3065            let surface_is_changed = old_row
3066                .as_ref()
3067                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3068                .unwrap_or(true);
3069            if surface_is_changed {
3070                surface_changed.insert(rel_path.clone());
3071                let started = Instant::now();
3072                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3073                profile.dependency_selection += started.elapsed();
3074                record_dependent_refs(
3075                    &mut selected_ref_ids,
3076                    &mut selected_refs_by_caller,
3077                    dependent_refs,
3078                );
3079            }
3080            candidate_own_refresh.insert(rel_path.clone());
3081            changed_extracts.insert(rel_path, extract);
3082        }
3083
3084        let dependency_selected_refs = selected_ref_ids.len();
3085        let mut touched_callers: BTreeSet<String> =
3086            selected_refs_by_caller.keys().cloned().collect();
3087        touched_callers.extend(candidate_own_refresh.iter().cloned());
3088
3089        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3090        for rel_path in &touched_callers {
3091            if deleted.contains(rel_path) {
3092                continue;
3093            }
3094            if let Some(extract) = changed_extracts.get(rel_path) {
3095                caller_extracts.insert(rel_path.clone(), extract.clone());
3096                continue;
3097            }
3098            let abs_path = self.project_root.join(rel_path);
3099            if abs_path.exists() {
3100                let started = Instant::now();
3101                let extract = build_file_extract(&self.project_root, &abs_path)?;
3102                profile.dependent_parse += started.elapsed();
3103                caller_extracts.insert(rel_path.clone(), extract);
3104            }
3105        }
3106
3107        let started = Instant::now();
3108        let index = ProjectIndex::from_db_and_callers(
3109            &tx,
3110            &self.project_root,
3111            &caller_extracts,
3112            workspace_crate_prefixes,
3113        )?;
3114        profile.index_load += started.elapsed();
3115
3116        for rel_path in &candidate_own_refresh {
3117            let Some(extract) = changed_extracts.get(rel_path) else {
3118                continue;
3119            };
3120            if !write_amplification_baseline_enabled()
3121                && stored_extract_matches(&tx, rel_path, extract, &index)?
3122            {
3123                unchanged_extracts += 1;
3124                update_file_fresh_metadata(
3125                    &tx,
3126                    &self.project_root,
3127                    rel_path,
3128                    &extract.freshness.content_hash,
3129                    extract.freshness.mtime,
3130                    extract.freshness.size,
3131                )?;
3132                continue;
3133            }
3134
3135            own_refresh.insert(rel_path.clone());
3136            let started = Instant::now();
3137            delete_file_rows(&tx, rel_path)?;
3138            profile.row_deletes += started.elapsed();
3139            let started = Instant::now();
3140            insert_file_extract(&tx, &self.project_root, extract)?;
3141            profile.row_inserts += started.elapsed();
3142        }
3143
3144        let dependency_callers = touched_callers
3145            .iter()
3146            .filter(|rel_path| {
3147                !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3148            })
3149            .cloned()
3150            .collect::<Vec<_>>();
3151        for rel_path in dependency_callers {
3152            let Some(extract) = caller_extracts.get(&rel_path) else {
3153                continue;
3154            };
3155            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3156                continue;
3157            }
3158
3159            own_refresh.insert(rel_path.clone());
3160            let started = Instant::now();
3161            delete_file_rows(&tx, &rel_path)?;
3162            profile.row_deletes += started.elapsed();
3163            let started = Instant::now();
3164            insert_file_extract(&tx, &self.project_root, extract)?;
3165            profile.row_inserts += started.elapsed();
3166        }
3167        let started = Instant::now();
3168        for rel_path in &touched_callers {
3169            if deleted.contains(rel_path) {
3170                continue;
3171            }
3172            let Some(extract) = caller_extracts.get(rel_path) else {
3173                continue;
3174            };
3175            if own_refresh.contains(rel_path) {
3176                delete_refs_for_caller(&tx, rel_path)?;
3177                for raw_ref in &extract.raw_refs {
3178                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3179                    insert_resolved_ref(&tx, &resolved)?;
3180                }
3181                continue;
3182            }
3183
3184            let selected_for_caller = selected_refs_by_caller
3185                .get(rel_path)
3186                .cloned()
3187                .unwrap_or_default();
3188            delete_ref_ids(&tx, &selected_for_caller)?;
3189            for raw_ref in &extract.raw_refs {
3190                if selected_for_caller.contains(&raw_ref.ref_id) {
3191                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3192                    insert_resolved_ref(&tx, &resolved)?;
3193                }
3194            }
3195        }
3196        profile.ref_resolution += started.elapsed();
3197
3198        let started = Instant::now();
3199        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3200        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3201        profile.method_dispatch += started.elapsed();
3202
3203        bump_projection_write_revision(&tx)?;
3204        let started = Instant::now();
3205        commit_incremental_if_current(tx)?;
3206        self.record_commit(total_changes_before, &conn);
3207        profile.commit += started.elapsed();
3208        profile.total = total_started.elapsed();
3209        Ok((
3210            IncrementalStats {
3211                changed_files: changed,
3212                surface_changed: surface_changed.into_iter().collect(),
3213                deleted_files: deleted.into_iter().collect(),
3214                dependency_selected_refs,
3215                refreshed_own_files: own_refresh.len(),
3216                unchanged_extract_files: unchanged_extracts,
3217            },
3218            profile,
3219        ))
3220    }
3221
3222    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3223        self.cold_build(current_files)
3224    }
3225
3226    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3227        self.verify_writer_lease()?;
3228        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3229        let total_changes_before = conn.total_changes();
3230        let tx = conn.transaction()?;
3231        let mut marked = Vec::new();
3232        for path in files {
3233            let abs_path = normalize_file_path(&self.project_root, path)?;
3234            let rel_path = relative_path(&self.project_root, &abs_path);
3235            let freshness = cache_freshness::collect(&abs_path).ok();
3236            mark_backend_state(
3237                &tx,
3238                &self.project_root,
3239                &rel_path,
3240                freshness.as_ref().map(|freshness| &freshness.content_hash),
3241                "stale",
3242            )?;
3243            marked.push(rel_path);
3244        }
3245        bump_projection_write_revision(&tx)?;
3246        tx.commit()?;
3247        self.record_commit(total_changes_before, &conn);
3248        marked.sort();
3249        marked.dedup();
3250        Ok(marked)
3251    }
3252
3253    pub fn stale_files(&self) -> Result<Vec<String>> {
3254        self.refresh_read_marker()?;
3255        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3256        let mut stmt = conn.prepare(
3257            "SELECT DISTINCT file_path FROM backend_file_state
3258             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3259             ORDER BY file_path",
3260        )?;
3261        let rows = stmt.query_map(
3262            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3263            |row| row.get::<_, String>(0),
3264        )?;
3265        rows.collect::<std::result::Result<Vec<_>, _>>()
3266            .map_err(Into::into)
3267    }
3268
3269    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3270        self.refresh_read_marker()?;
3271        let rel_path = relative_path(
3272            &self.project_root,
3273            &normalize_file_path(&self.project_root, file)?,
3274        );
3275        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3276        conn.query_row(
3277            "SELECT status FROM backend_file_state
3278             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3279             ORDER BY updated_at DESC LIMIT 1",
3280            params![
3281                BACKEND_TREESITTER,
3282                self.project_root.display().to_string(),
3283                rel_path
3284            ],
3285            |row| row.get(0),
3286        )
3287        .optional()
3288        .map_err(Into::into)
3289    }
3290
3291    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3292        self.refresh_read_marker()?;
3293        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3294        self.ensure_ready(&conn)?;
3295        edge_snapshot_with_conn(&conn)
3296    }
3297
3298    pub fn indexed_file_count(&self) -> Result<usize> {
3299        self.refresh_read_marker()?;
3300        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3301        self.ensure_ready(&conn)?;
3302        indexed_file_count(&conn)
3303    }
3304
3305    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3306        self.refresh_read_marker()?;
3307        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3308        let rel_path = relative_path(&self.project_root, &abs_path);
3309        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3310        self.ensure_ready(&conn)?;
3311        resolve_node_for_rel(&conn, &rel_path, symbol)
3312    }
3313
3314    /// Return all positional nodes matching a legacy symbol query in a file.
3315    ///
3316    /// Consumers that need legacy compatibility can collapse these by
3317    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3318    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<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        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3325    }
3326
3327    /// Return all positional nodes matching a symbol query anywhere in the store.
3328    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3329        self.refresh_read_marker()?;
3330        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3331        self.ensure_ready(&conn)?;
3332        nodes_matching_symbol(&conn, symbol)
3333    }
3334
3335    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3336    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3337        self.refresh_read_marker()?;
3338        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3339        let rel_path = relative_path(&self.project_root, &abs_path);
3340        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3341        self.ensure_ready(&conn)?;
3342        direct_callers_for_tuple(&conn, &rel_path, symbol)
3343    }
3344
3345    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3346    pub fn direct_caller_counts_of(
3347        &self,
3348        targets: &[(String, String)],
3349    ) -> Result<HashMap<(String, String), usize>> {
3350        if targets.is_empty() {
3351            return Ok(HashMap::new());
3352        }
3353        self.refresh_read_marker()?;
3354        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3355        self.ensure_ready(&conn)?;
3356        direct_caller_counts_for_tuples(&conn, targets)
3357    }
3358
3359    pub fn callers_of(
3360        &self,
3361        file_rel: &Path,
3362        symbol: &str,
3363        depth: usize,
3364    ) -> Result<StoreCallersResult> {
3365        let target = self.node_for(file_rel, symbol)?;
3366        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3367        self.ensure_ready(&conn)?;
3368        let effective_depth = depth.max(1);
3369        let mut visited = HashSet::new();
3370        let mut callers = Vec::new();
3371        let mut depth_limited = false;
3372        let mut truncated = 0usize;
3373        collect_callers_recursive(
3374            &conn,
3375            &target.file,
3376            &target.symbol,
3377            effective_depth,
3378            0,
3379            &mut visited,
3380            &mut callers,
3381            &mut depth_limited,
3382            &mut truncated,
3383        )?;
3384        Ok(StoreCallersResult {
3385            target,
3386            callers,
3387            scanned_files: indexed_file_count(&conn)?,
3388            depth_limited,
3389            truncated,
3390        })
3391    }
3392
3393    pub fn impact_of(
3394        &self,
3395        file_rel: &Path,
3396        symbol: &str,
3397        depth: usize,
3398    ) -> Result<StoreImpactResult> {
3399        let callers = self.callers_of(file_rel, symbol, depth)?;
3400        let target_parameters = callers
3401            .target
3402            .signature
3403            .as_deref()
3404            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3405            .unwrap_or_default();
3406        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3407        for site in &callers.callers {
3408            source_lines_by_file
3409                .entry(site.caller.file.clone())
3410                .or_insert_with(|| {
3411                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3412                });
3413        }
3414        let enriched = callers
3415            .callers
3416            .iter()
3417            .map(|site| StoreImpactCaller {
3418                site: site.clone(),
3419                signature: site.caller.signature.clone(),
3420                is_entry_point: site.caller.is_entry_point,
3421                call_expression: source_lines_by_file
3422                    .get(&site.caller.file)
3423                    .and_then(|lines| lines.as_ref())
3424                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3425                    .cloned(),
3426                parameters: site
3427                    .caller
3428                    .signature
3429                    .as_deref()
3430                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3431                    .unwrap_or_default(),
3432            })
3433            .collect();
3434        Ok(StoreImpactResult {
3435            target: callers.target,
3436            parameters: target_parameters,
3437            callers: enriched,
3438            depth_limited: callers.depth_limited,
3439            truncated: callers.truncated,
3440        })
3441    }
3442
3443    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3444        self.refresh_read_marker()?;
3445        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3446        self.ensure_ready(&conn)?;
3447        outgoing_calls_for_node(&conn, node)
3448    }
3449
3450    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3451    pub fn outgoing_calls_for_symbols(
3452        &self,
3453        sources: &[(String, String)],
3454    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3455        if sources.is_empty() {
3456            return Ok(HashMap::new());
3457        }
3458        self.refresh_read_marker()?;
3459        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3460        self.ensure_ready(&conn)?;
3461        outgoing_calls_for_symbol_tuples(&conn, sources)
3462    }
3463
3464    /// Return resolved direct self-call refs suppressed from the general edge table.
3465    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3466        self.refresh_read_marker()?;
3467        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3468        self.ensure_ready(&conn)?;
3469        resolved_self_calls_for_node(&conn, node)
3470    }
3471
3472    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3473        self.refresh_read_marker()?;
3474        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3475        self.ensure_ready(&conn)?;
3476        unresolved_calls_for_node(&conn, node)
3477    }
3478
3479    pub fn call_tree(
3480        &self,
3481        file_rel: &Path,
3482        symbol: &str,
3483        max_depth: usize,
3484    ) -> Result<callgraph::CallTreeNode> {
3485        let node = self.node_for(file_rel, symbol)?;
3486        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3487        self.ensure_ready(&conn)?;
3488        let mut visited = HashSet::new();
3489        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3490    }
3491
3492    pub fn trace_to(
3493        &self,
3494        file_rel: &Path,
3495        symbol: &str,
3496        max_depth: usize,
3497    ) -> Result<callgraph::TraceToResult> {
3498        let target = self.node_for(file_rel, symbol)?;
3499        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3500        self.ensure_ready(&conn)?;
3501        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3502
3503        #[derive(Clone)]
3504        struct PathElem {
3505            node: StoreNode,
3506        }
3507
3508        let initial = vec![PathElem {
3509            node: target.clone(),
3510        }];
3511        let mut complete_paths = Vec::new();
3512        if target.is_entry_point {
3513            complete_paths.push(initial.clone());
3514        }
3515
3516        let mut queue = vec![(initial, 0usize)];
3517        let mut max_depth_reached = false;
3518        let mut truncated_paths = 0usize;
3519
3520        while let Some((path, depth)) = queue.pop() {
3521            if depth >= effective_max {
3522                max_depth_reached = true;
3523                continue;
3524            }
3525            let Some(current) = path.last() else {
3526                continue;
3527            };
3528            let callers =
3529                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3530            if callers.is_empty() {
3531                if path.len() > 1 {
3532                    truncated_paths += 1;
3533                }
3534                continue;
3535            }
3536
3537            let mut has_new_path = false;
3538            for site in callers {
3539                if path.iter().any(|elem| {
3540                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3541                }) {
3542                    continue;
3543                }
3544                has_new_path = true;
3545                let mut new_path = path.clone();
3546                new_path.push(PathElem {
3547                    node: site.caller.clone(),
3548                });
3549                if site.caller.is_entry_point {
3550                    complete_paths.push(new_path.clone());
3551                }
3552                queue.push((new_path, depth + 1));
3553            }
3554            if !has_new_path && path.len() > 1 {
3555                truncated_paths += 1;
3556            }
3557        }
3558
3559        let mut paths: Vec<callgraph::TracePath> = complete_paths
3560            .into_iter()
3561            .map(|mut elems| {
3562                elems.reverse();
3563                let hops = elems
3564                    .iter()
3565                    .enumerate()
3566                    .map(|(index, elem)| callgraph::TraceHop {
3567                        symbol: elem.node.symbol.clone(),
3568                        file: elem.node.file.clone(),
3569                        line: elem.node.line,
3570                        signature: elem.node.signature.clone(),
3571                        is_entry_point: index == 0 && elem.node.is_entry_point,
3572                    })
3573                    .collect();
3574                callgraph::TracePath { hops }
3575            })
3576            .collect();
3577        paths.sort_by(|left, right| {
3578            let left_entry = left
3579                .hops
3580                .first()
3581                .map(|hop| hop.symbol.as_str())
3582                .unwrap_or("");
3583            let right_entry = right
3584                .hops
3585                .first()
3586                .map(|hop| hop.symbol.as_str())
3587                .unwrap_or("");
3588            left_entry
3589                .cmp(right_entry)
3590                .then(left.hops.len().cmp(&right.hops.len()))
3591        });
3592        let entry_points_found = paths
3593            .iter()
3594            .filter_map(|path| path.hops.first())
3595            .filter(|hop| hop.is_entry_point)
3596            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3597            .collect::<HashSet<_>>()
3598            .len();
3599
3600        Ok(callgraph::TraceToResult {
3601            target_symbol: target.symbol,
3602            target_file: target.file,
3603            total_paths: paths.len(),
3604            paths,
3605            entry_points_found,
3606            max_depth_reached,
3607            truncated_paths,
3608        })
3609    }
3610
3611    pub fn trace_to_symbol_candidates(
3612        &self,
3613        to_symbol: &str,
3614    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3615        self.refresh_read_marker()?;
3616        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3617        self.ensure_ready(&conn)?;
3618        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3619        for node in nodes_matching_symbol(&conn, to_symbol)? {
3620            candidates_by_file
3621                .entry(node.file)
3622                .and_modify(|line| *line = (*line).min(node.line))
3623                .or_insert(node.line);
3624        }
3625        let mut candidates: Vec<_> = candidates_by_file
3626            .into_iter()
3627            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3628            .collect();
3629        candidates
3630            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3631        Ok(candidates)
3632    }
3633
3634    pub fn trace_to_symbol(
3635        &self,
3636        file_rel: &Path,
3637        symbol: &str,
3638        to_symbol: &str,
3639        to_file: Option<&Path>,
3640        max_depth: usize,
3641    ) -> Result<callgraph::TraceToSymbolResult> {
3642        let origin = self.node_for(file_rel, symbol)?;
3643        let target_file = to_file
3644            .map(|path| normalize_file_path(&self.project_root, path))
3645            .transpose()?
3646            .map(|path| relative_path(&self.project_root, &path));
3647        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3648        self.ensure_ready(&conn)?;
3649        let effective_max = if max_depth == 0 {
3650            10
3651        } else {
3652            max_depth.min(16)
3653        };
3654
3655        let start_hop = trace_to_symbol_hop(&origin);
3656        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3657            return Ok(callgraph::TraceToSymbolResult {
3658                path: Some(vec![start_hop]),
3659                complete: true,
3660                reason: None,
3661            });
3662        }
3663
3664        let mut queue = VecDeque::new();
3665        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3666        let mut visited = HashSet::new();
3667        visited.insert((origin.file.clone(), origin.symbol.clone()));
3668        let mut max_depth_exhausted = false;
3669
3670        while let Some((current, path, depth)) = queue.pop_front() {
3671            let callees = outgoing_calls_for_node(&conn, &current)?
3672                .into_iter()
3673                .filter_map(|site| site.target)
3674                .collect::<Vec<_>>();
3675
3676            if depth >= effective_max {
3677                if callees
3678                    .iter()
3679                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3680                {
3681                    max_depth_exhausted = true;
3682                }
3683                continue;
3684            }
3685
3686            for callee in callees {
3687                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3688                    continue;
3689                }
3690                let mut next_path = path.clone();
3691                next_path.push(trace_to_symbol_hop(&callee));
3692                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3693                    return Ok(callgraph::TraceToSymbolResult {
3694                        path: Some(next_path),
3695                        complete: true,
3696                        reason: None,
3697                    });
3698                }
3699                queue.push_back((callee, next_path, depth + 1));
3700            }
3701        }
3702
3703        if max_depth_exhausted {
3704            Ok(callgraph::TraceToSymbolResult {
3705                path: None,
3706                complete: false,
3707                reason: Some("max_depth_exhausted".to_string()),
3708            })
3709        } else {
3710            Ok(callgraph::TraceToSymbolResult {
3711                path: None,
3712                complete: true,
3713                reason: Some("no_path_found".to_string()),
3714            })
3715        }
3716    }
3717}
3718
3719impl ReadonlyCallGraphStore {
3720    fn from_inner(inner: CallGraphStore) -> Self {
3721        Self { inner }
3722    }
3723
3724    pub fn project_root(&self) -> &Path {
3725        self.inner.project_root()
3726    }
3727
3728    pub fn project_key(&self) -> &str {
3729        self.inner.project_key()
3730    }
3731
3732    pub fn sqlite_path(&self) -> &Path {
3733        self.inner.sqlite_path()
3734    }
3735
3736    pub(crate) fn projection_generation(&self) -> Option<&str> {
3737        self.inner.projection_generation()
3738    }
3739
3740    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3741        self.inner.projection_write_revision()
3742    }
3743
3744    /// Report the open generation handle. SQLite-owned allocations are measured
3745    /// once by the process-wide SQLite allocator counters.
3746    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3747        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3748    }
3749
3750    /// Whether this reader is temporarily serving a legacy harness partition.
3751    pub fn is_legacy_fallback(&self) -> bool {
3752        self.inner.is_legacy_fallback()
3753    }
3754
3755    pub fn is_current(&self) -> bool {
3756        self.inner.is_current()
3757    }
3758
3759    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3760        self.inner.edge_snapshot()
3761    }
3762
3763    pub fn indexed_file_count(&self) -> Result<usize> {
3764        self.inner.indexed_file_count()
3765    }
3766
3767    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3768        self.inner.node_for(file_rel, symbol)
3769    }
3770
3771    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3772        self.inner.nodes_for(file_rel, symbol)
3773    }
3774
3775    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3776        self.inner.nodes_matching(symbol)
3777    }
3778
3779    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3780        self.inner.direct_callers_of(file_rel, symbol)
3781    }
3782
3783    pub fn direct_caller_counts_of(
3784        &self,
3785        targets: &[(String, String)],
3786    ) -> Result<HashMap<(String, String), usize>> {
3787        self.inner.direct_caller_counts_of(targets)
3788    }
3789
3790    pub fn callers_of(
3791        &self,
3792        file_rel: &Path,
3793        symbol: &str,
3794        depth: usize,
3795    ) -> Result<StoreCallersResult> {
3796        self.inner.callers_of(file_rel, symbol, depth)
3797    }
3798
3799    pub fn impact_of(
3800        &self,
3801        file_rel: &Path,
3802        symbol: &str,
3803        depth: usize,
3804    ) -> Result<StoreImpactResult> {
3805        self.inner.impact_of(file_rel, symbol, depth)
3806    }
3807
3808    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3809        self.inner.outgoing_calls_of(node)
3810    }
3811
3812    pub fn outgoing_calls_for_symbols(
3813        &self,
3814        sources: &[(String, String)],
3815    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3816        self.inner.outgoing_calls_for_symbols(sources)
3817    }
3818
3819    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3820        self.inner.resolved_self_calls_of(node)
3821    }
3822
3823    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3824        self.inner.unresolved_calls_of(node)
3825    }
3826
3827    pub fn call_tree(
3828        &self,
3829        file_rel: &Path,
3830        symbol: &str,
3831        depth: usize,
3832    ) -> Result<callgraph::CallTreeNode> {
3833        self.inner.call_tree(file_rel, symbol, depth)
3834    }
3835
3836    pub fn trace_to(
3837        &self,
3838        file_rel: &Path,
3839        symbol: &str,
3840        max_depth: usize,
3841    ) -> Result<callgraph::TraceToResult> {
3842        self.inner.trace_to(file_rel, symbol, max_depth)
3843    }
3844
3845    pub fn trace_to_symbol_candidates(
3846        &self,
3847        to_symbol: &str,
3848    ) -> Result<Vec<TraceToSymbolCandidate>> {
3849        self.inner.trace_to_symbol_candidates(to_symbol)
3850    }
3851
3852    pub fn trace_to_symbol(
3853        &self,
3854        file_rel: &Path,
3855        symbol: &str,
3856        to_symbol: &str,
3857        to_file: Option<&Path>,
3858        max_depth: usize,
3859    ) -> Result<callgraph::TraceToSymbolResult> {
3860        self.inner
3861            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3862    }
3863}
3864
3865impl CallGraphRead for CallGraphStore {
3866    fn project_root(&self) -> &Path {
3867        CallGraphStore::project_root(self)
3868    }
3869    fn project_key(&self) -> &str {
3870        CallGraphStore::project_key(self)
3871    }
3872    fn sqlite_path(&self) -> &Path {
3873        CallGraphStore::sqlite_path(self)
3874    }
3875    fn is_current(&self) -> bool {
3876        CallGraphStore::is_current(self)
3877    }
3878    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3879        CallGraphStore::edge_snapshot(self)
3880    }
3881    fn indexed_file_count(&self) -> Result<usize> {
3882        CallGraphStore::indexed_file_count(self)
3883    }
3884    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3885        CallGraphStore::node_for(self, file_rel, symbol)
3886    }
3887    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3888        CallGraphStore::nodes_for(self, file_rel, symbol)
3889    }
3890    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3891        CallGraphStore::nodes_matching(self, symbol)
3892    }
3893    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3894        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3895    }
3896    fn direct_caller_counts_of(
3897        &self,
3898        targets: &[(String, String)],
3899    ) -> Result<HashMap<(String, String), usize>> {
3900        CallGraphStore::direct_caller_counts_of(self, targets)
3901    }
3902    fn callers_of(
3903        &self,
3904        file_rel: &Path,
3905        symbol: &str,
3906        depth: usize,
3907    ) -> Result<StoreCallersResult> {
3908        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3909    }
3910    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3911        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3912    }
3913    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3914        CallGraphStore::outgoing_calls_of(self, node)
3915    }
3916    fn outgoing_calls_for_symbols(
3917        &self,
3918        sources: &[(String, String)],
3919    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3920        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3921    }
3922    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3923        CallGraphStore::resolved_self_calls_of(self, node)
3924    }
3925    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3926        CallGraphStore::unresolved_calls_of(self, node)
3927    }
3928    fn call_tree(
3929        &self,
3930        file_rel: &Path,
3931        symbol: &str,
3932        depth: usize,
3933    ) -> Result<callgraph::CallTreeNode> {
3934        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3935    }
3936    fn trace_to(
3937        &self,
3938        file_rel: &Path,
3939        symbol: &str,
3940        max_depth: usize,
3941    ) -> Result<callgraph::TraceToResult> {
3942        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3943    }
3944    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3945        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3946    }
3947    fn trace_to_symbol(
3948        &self,
3949        file_rel: &Path,
3950        symbol: &str,
3951        to_symbol: &str,
3952        to_file: Option<&Path>,
3953        max_depth: usize,
3954    ) -> Result<callgraph::TraceToSymbolResult> {
3955        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3956    }
3957}
3958
3959impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3960    fn project_root(&self) -> &Path {
3961        (**self).project_root()
3962    }
3963    fn project_key(&self) -> &str {
3964        (**self).project_key()
3965    }
3966    fn sqlite_path(&self) -> &Path {
3967        (**self).sqlite_path()
3968    }
3969    fn is_current(&self) -> bool {
3970        (**self).is_current()
3971    }
3972    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3973        (**self).edge_snapshot()
3974    }
3975    fn indexed_file_count(&self) -> Result<usize> {
3976        (**self).indexed_file_count()
3977    }
3978    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3979        (**self).node_for(file_rel, symbol)
3980    }
3981    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3982        (**self).nodes_for(file_rel, symbol)
3983    }
3984    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3985        (**self).nodes_matching(symbol)
3986    }
3987    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3988        (**self).direct_callers_of(file_rel, symbol)
3989    }
3990    fn direct_caller_counts_of(
3991        &self,
3992        targets: &[(String, String)],
3993    ) -> Result<HashMap<(String, String), usize>> {
3994        (**self).direct_caller_counts_of(targets)
3995    }
3996    fn callers_of(
3997        &self,
3998        file_rel: &Path,
3999        symbol: &str,
4000        depth: usize,
4001    ) -> Result<StoreCallersResult> {
4002        (**self).callers_of(file_rel, symbol, depth)
4003    }
4004    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4005        (**self).impact_of(file_rel, symbol, depth)
4006    }
4007    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4008        (**self).outgoing_calls_of(node)
4009    }
4010    fn outgoing_calls_for_symbols(
4011        &self,
4012        sources: &[(String, String)],
4013    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4014        (**self).outgoing_calls_for_symbols(sources)
4015    }
4016    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4017        (**self).resolved_self_calls_of(node)
4018    }
4019    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4020        (**self).unresolved_calls_of(node)
4021    }
4022    fn call_tree(
4023        &self,
4024        file_rel: &Path,
4025        symbol: &str,
4026        depth: usize,
4027    ) -> Result<callgraph::CallTreeNode> {
4028        (**self).call_tree(file_rel, symbol, depth)
4029    }
4030    fn trace_to(
4031        &self,
4032        file_rel: &Path,
4033        symbol: &str,
4034        max_depth: usize,
4035    ) -> Result<callgraph::TraceToResult> {
4036        (**self).trace_to(file_rel, symbol, max_depth)
4037    }
4038    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4039        (**self).trace_to_symbol_candidates(to_symbol)
4040    }
4041    fn trace_to_symbol(
4042        &self,
4043        file_rel: &Path,
4044        symbol: &str,
4045        to_symbol: &str,
4046        to_file: Option<&Path>,
4047        max_depth: usize,
4048    ) -> Result<callgraph::TraceToSymbolResult> {
4049        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4050    }
4051}
4052
4053impl CallGraphRead for ReadonlyCallGraphStore {
4054    fn project_root(&self) -> &Path {
4055        self.project_root()
4056    }
4057    fn project_key(&self) -> &str {
4058        self.project_key()
4059    }
4060    fn sqlite_path(&self) -> &Path {
4061        self.sqlite_path()
4062    }
4063    fn is_current(&self) -> bool {
4064        self.is_current()
4065    }
4066    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4067        self.edge_snapshot()
4068    }
4069    fn indexed_file_count(&self) -> Result<usize> {
4070        self.indexed_file_count()
4071    }
4072    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4073        self.node_for(file_rel, symbol)
4074    }
4075    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4076        self.nodes_for(file_rel, symbol)
4077    }
4078    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4079        self.nodes_matching(symbol)
4080    }
4081    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4082        self.direct_callers_of(file_rel, symbol)
4083    }
4084    fn direct_caller_counts_of(
4085        &self,
4086        targets: &[(String, String)],
4087    ) -> Result<HashMap<(String, String), usize>> {
4088        self.direct_caller_counts_of(targets)
4089    }
4090    fn callers_of(
4091        &self,
4092        file_rel: &Path,
4093        symbol: &str,
4094        depth: usize,
4095    ) -> Result<StoreCallersResult> {
4096        self.callers_of(file_rel, symbol, depth)
4097    }
4098    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4099        self.impact_of(file_rel, symbol, depth)
4100    }
4101    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4102        self.outgoing_calls_of(node)
4103    }
4104    fn outgoing_calls_for_symbols(
4105        &self,
4106        sources: &[(String, String)],
4107    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4108        self.outgoing_calls_for_symbols(sources)
4109    }
4110    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4111        self.resolved_self_calls_of(node)
4112    }
4113    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4114        self.unresolved_calls_of(node)
4115    }
4116    fn call_tree(
4117        &self,
4118        file_rel: &Path,
4119        symbol: &str,
4120        depth: usize,
4121    ) -> Result<callgraph::CallTreeNode> {
4122        self.call_tree(file_rel, symbol, depth)
4123    }
4124    fn trace_to(
4125        &self,
4126        file_rel: &Path,
4127        symbol: &str,
4128        max_depth: usize,
4129    ) -> Result<callgraph::TraceToResult> {
4130        self.trace_to(file_rel, symbol, max_depth)
4131    }
4132    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4133        self.trace_to_symbol_candidates(to_symbol)
4134    }
4135    fn trace_to_symbol(
4136        &self,
4137        file_rel: &Path,
4138        symbol: &str,
4139        to_symbol: &str,
4140        to_file: Option<&Path>,
4141        max_depth: usize,
4142    ) -> Result<callgraph::TraceToSymbolResult> {
4143        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4144    }
4145}
4146
4147fn indexed_file_count(conn: &Connection) -> Result<usize> {
4148    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4149    Ok(count.max(0) as usize)
4150}
4151
4152fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4153    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4154    match candidates.as_slice() {
4155        [candidate] => Ok(candidate.clone()),
4156        [] => Err(AftError::SymbolNotFound {
4157            name: symbol.to_string(),
4158            file: rel_path.to_string(),
4159        }
4160        .into()),
4161        _ => Err(AftError::AmbiguousSymbol {
4162            name: symbol.to_string(),
4163            candidates: candidates
4164                .iter()
4165                .map(|candidate| candidate.symbol.clone())
4166                .collect(),
4167        }
4168        .into()),
4169    }
4170}
4171
4172fn nodes_for_file_matching_symbol(
4173    conn: &Connection,
4174    rel_path: &str,
4175    symbol: &str,
4176) -> Result<Vec<StoreNode>> {
4177    let qualified_query = symbol.contains("::");
4178    let sql = if qualified_query {
4179        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4180                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4181         FROM nodes n JOIN files f ON f.path = n.file_path
4182         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4183         ORDER BY n.scoped_name, n.start_line, n.start_col"
4184    } else {
4185        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4186                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4187         FROM nodes n JOIN files f ON f.path = n.file_path
4188         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4189         ORDER BY n.scoped_name, n.start_line, n.start_col"
4190    };
4191    let mut stmt = conn.prepare(sql)?;
4192    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4193    rows.collect::<std::result::Result<Vec<_>, _>>()
4194        .map_err(Into::into)
4195}
4196
4197fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4198    let qualified_query = symbol.contains("::");
4199    let sql = if qualified_query {
4200        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4201                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4202         FROM nodes n JOIN files f ON f.path = n.file_path
4203         WHERE n.scoped_name = ?1
4204         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4205    } else {
4206        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4207                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4208         FROM nodes n JOIN files f ON f.path = n.file_path
4209         WHERE n.scoped_name = ?1 OR n.name = ?1
4210         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4211    };
4212    let mut stmt = conn.prepare(sql)?;
4213    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4214    rows.collect::<std::result::Result<Vec<_>, _>>()
4215        .map_err(Into::into)
4216}
4217
4218fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4219    store_node_from_row_at(row, 0)
4220}
4221
4222fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4223    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4224    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4225    let lang_label_value: String = row.get(offset + 10)?;
4226    Ok(StoreNode {
4227        node_id: row.get(offset)?,
4228        file: row.get(offset + 1)?,
4229        symbol: row.get(offset + 2)?,
4230        name: row.get(offset + 3)?,
4231        kind: row.get(offset + 4)?,
4232        line: start_line.saturating_add(1),
4233        end_line: end_line.saturating_add(1),
4234        signature: row.get(offset + 7)?,
4235        exported: row.get::<_, i64>(offset + 8)? != 0,
4236        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4237        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4238    })
4239}
4240
4241fn optional_store_node_from_row_at(
4242    row: &rusqlite::Row<'_>,
4243    offset: usize,
4244) -> rusqlite::Result<Option<StoreNode>> {
4245    if row.get::<_, Option<String>>(offset)?.is_some() {
4246        store_node_from_row_at(row, offset).map(Some)
4247    } else {
4248        Ok(None)
4249    }
4250}
4251
4252#[allow(clippy::too_many_arguments)]
4253fn collect_callers_recursive(
4254    conn: &Connection,
4255    file: &str,
4256    symbol: &str,
4257    max_depth: usize,
4258    current_depth: usize,
4259    visited: &mut HashSet<(String, String)>,
4260    result: &mut Vec<StoreCallSite>,
4261    depth_limited: &mut bool,
4262    truncated: &mut usize,
4263) -> Result<()> {
4264    if current_depth >= max_depth {
4265        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4266        if omitted > 0 {
4267            *depth_limited = true;
4268            *truncated += omitted;
4269        }
4270        return Ok(());
4271    }
4272
4273    if !visited.insert((file.to_string(), symbol.to_string())) {
4274        return Ok(());
4275    }
4276
4277    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4278    for site in sites {
4279        result.push(site.clone());
4280        if current_depth + 1 < max_depth {
4281            collect_callers_recursive(
4282                conn,
4283                &site.caller.file,
4284                &site.caller.symbol,
4285                max_depth,
4286                current_depth + 1,
4287                visited,
4288                result,
4289                depth_limited,
4290                truncated,
4291            )?;
4292        } else {
4293            let omitted =
4294                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4295            if omitted > 0 {
4296                *depth_limited = true;
4297                *truncated += omitted;
4298            }
4299        }
4300    }
4301    Ok(())
4302}
4303
4304// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4305const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
4306
4307fn direct_caller_counts_for_tuples(
4308    conn: &Connection,
4309    targets: &[(String, String)],
4310) -> Result<HashMap<(String, String), usize>> {
4311    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4312    let mut counts = unique_targets
4313        .iter()
4314        .cloned()
4315        .map(|target| (target, 0usize))
4316        .collect::<HashMap<_, _>>();
4317
4318    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4319    for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
4320        let requested_values = (0..chunk.len())
4321            .map(|_| "(?, ?)")
4322            .collect::<Vec<_>>()
4323            .join(", ");
4324        let sql = format!(
4325            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4326             deduped AS (
4327                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4328                 FROM requested requested
4329                 JOIN edges e
4330                   ON e.target_file = requested.target_file
4331                  AND e.target_symbol = requested.target_symbol
4332                  AND e.kind = 'call'
4333                 JOIN refs r ON r.ref_id = e.ref_id
4334                 JOIN nodes src ON src.id = e.source_node
4335                 JOIN files src_file ON src_file.path = src.file_path
4336                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4337             )
4338             SELECT target_file, target_symbol, COUNT(*)
4339             FROM deduped
4340             GROUP BY target_file, target_symbol"
4341        );
4342        let bindings = chunk
4343            .iter()
4344            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4345        let mut stmt = conn.prepare(&sql)?;
4346        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4347            Ok((
4348                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4349                row.get::<_, i64>(2)?,
4350            ))
4351        })?;
4352        for row in rows {
4353            let (target, count) = row?;
4354            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4355        }
4356    }
4357
4358    Ok(counts)
4359}
4360
4361fn direct_caller_count_for_tuple(
4362    conn: &Connection,
4363    target_file: &str,
4364    target_symbol: &str,
4365) -> Result<usize> {
4366    let count: i64 = conn.query_row(
4367        "SELECT COUNT(*)
4368         FROM edges e
4369         JOIN refs r ON r.ref_id = e.ref_id
4370         JOIN nodes src ON src.id = e.source_node
4371         JOIN files src_file ON src_file.path = src.file_path
4372         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4373        params![target_file, target_symbol],
4374        |row| row.get(0),
4375    )?;
4376    Ok(usize::try_from(count).unwrap_or(usize::MAX))
4377}
4378
4379fn direct_callers_for_tuple(
4380    conn: &Connection,
4381    target_file: &str,
4382    target_symbol: &str,
4383) -> Result<Vec<StoreCallSite>> {
4384    let mut stmt = conn.prepare(
4385        "SELECT e.target_file, e.target_symbol, e.line,
4386                r.byte_start, r.byte_end, r.status, e.provenance,
4387                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4388                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4389                src_file.lang,
4390                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4391                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4392                tgt_file.lang
4393         FROM edges e
4394         JOIN refs r ON r.ref_id = e.ref_id
4395         JOIN nodes src ON src.id = e.source_node
4396         JOIN files src_file ON src_file.path = src.file_path
4397         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4398             ON tgt.id = e.target_node
4399         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4400         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4401    )?;
4402    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
4403        let caller = store_node_from_row_at(row, 7)?;
4404        let target = optional_store_node_from_row_at(row, 18)?;
4405        Ok(StoreCallSite {
4406            caller,
4407            target_file: row.get(0)?,
4408            target_symbol: row.get(1)?,
4409            target,
4410            line: row.get::<_, i64>(2)?.max(0) as u32,
4411            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4412            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4413            resolved: row.get::<_, String>(5)? == "resolved",
4414            provenance: row.get(6)?,
4415        })
4416    })?;
4417    rows.collect::<std::result::Result<Vec<_>, _>>()
4418        .map_err(Into::into)
4419}
4420
4421// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4422const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4423// Outgoing-edge batches bind one source node per parameter.
4424const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4425
4426fn outgoing_calls_for_symbol_tuples(
4427    conn: &Connection,
4428    sources: &[(String, String)],
4429) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4430    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4431    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4432    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4433    let source_nodes = unique_sources
4434        .iter()
4435        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4436        .cloned()
4437        .collect::<Vec<_>>();
4438    let source_nodes_by_id = source_nodes
4439        .iter()
4440        .cloned()
4441        .map(|node| (node.node_id.clone(), node))
4442        .collect::<HashMap<_, _>>();
4443    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4444
4445    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4446        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4447        let sql = format!(
4448            "SELECT e.source_node,
4449                    e.target_file, e.target_symbol, e.line,
4450                    r.byte_start, r.byte_end, r.status, e.provenance,
4451                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4452                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4453                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4454                    tgt_file.lang
4455             FROM edges e
4456             JOIN refs r ON r.ref_id = e.ref_id
4457             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4458             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4459             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4460             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4461        );
4462        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4463        let mut stmt = conn.prepare(&sql)?;
4464        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4465            let source_node_id = row.get::<_, String>(0)?;
4466            let caller = source_nodes_by_id
4467                .get(&source_node_id)
4468                .expect("batched outgoing row belongs to a requested source node")
4469                .clone();
4470            let target = optional_store_node_from_row_at(row, 8)?;
4471            Ok((
4472                source_node_id,
4473                StoreCallSite {
4474                    caller,
4475                    target_file: row.get(1)?,
4476                    target_symbol: row.get(2)?,
4477                    target,
4478                    line: row.get::<_, i64>(3)?.max(0) as u32,
4479                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4480                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4481                    resolved: row.get::<_, String>(6)? == "resolved",
4482                    provenance: row.get(7)?,
4483                },
4484            ))
4485        })?;
4486        for row in rows {
4487            let (source_node_id, call) = row?;
4488            calls_by_node.entry(source_node_id).or_default().push(call);
4489        }
4490    }
4491
4492    let mut calls_by_source = HashMap::new();
4493    for source in &unique_sources {
4494        let mut calls = Vec::new();
4495        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4496            for node in nodes {
4497                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4498                    calls.extend(node_calls);
4499                }
4500            }
4501        }
4502        calls_by_source.insert(source.clone(), calls);
4503    }
4504
4505    // Resolve each logical target once for the whole frontier. Keeping this separate
4506    // preserves positional-symbol representatives without a correlated lookup per edge.
4507    let target_tuples = calls_by_source
4508        .values()
4509        .flatten()
4510        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4511        .collect::<Vec<_>>();
4512    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4513    for calls in calls_by_source.values_mut() {
4514        for call in calls {
4515            if let Some(target) = target_nodes
4516                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4517                .and_then(|nodes| nodes.first())
4518            {
4519                call.target = Some(target.clone());
4520            }
4521        }
4522    }
4523
4524    Ok(calls_by_source)
4525}
4526
4527fn nodes_for_symbol_tuples(
4528    conn: &Connection,
4529    symbols: &[(String, String)],
4530) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4531    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4532    let mut nodes_by_symbol = unique_symbols
4533        .iter()
4534        .cloned()
4535        .map(|symbol| (symbol, Vec::new()))
4536        .collect::<HashMap<_, _>>();
4537    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4538
4539    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4540        let requested_values = (0..chunk.len())
4541            .map(|_| "(?, ?)")
4542            .collect::<Vec<_>>()
4543            .join(", ");
4544        let sql = format!(
4545            "WITH requested(file, symbol) AS (VALUES {requested_values})
4546             SELECT requested.file, requested.symbol,
4547                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4548                    node.start_line, node.end_line, node.signature, node.exported,
4549                    node.is_callgraph_entry_point, node_file.lang
4550             FROM requested
4551             JOIN nodes node INDEXED BY idx_nodes_file
4552               ON node.file_path = requested.file
4553              AND node.scoped_name = requested.symbol
4554             JOIN files node_file ON node_file.path = node.file_path
4555             ORDER BY requested.file, requested.symbol,
4556                      node.scoped_name, node.start_line, node.end_line,
4557                      node.start_col, node.range_ordinal"
4558        );
4559        let bindings = chunk
4560            .iter()
4561            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4562        let mut stmt = conn.prepare(&sql)?;
4563        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4564            Ok((
4565                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4566                store_node_from_row_at(row, 2)?,
4567            ))
4568        })?;
4569        for row in rows {
4570            let (symbol, node) = row?;
4571            nodes_by_symbol.entry(symbol).or_default().push(node);
4572        }
4573    }
4574
4575    Ok(nodes_by_symbol)
4576}
4577
4578fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4579    let mut stmt = conn.prepare(
4580        "SELECT e.target_file, e.target_symbol, e.line,
4581                r.byte_start, r.byte_end, r.status, e.provenance,
4582                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4583                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4584                tgt_file.lang
4585         FROM edges e
4586         JOIN refs r ON r.ref_id = e.ref_id
4587         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4588             ON tgt.id = e.target_node
4589         WHERE e.kind = 'call' AND e.source_node = ?1
4590         ORDER BY r.byte_start, r.line, r.ref_id",
4591    )?;
4592    let rows = stmt.query_map(params![node.node_id], |row| {
4593        let target = optional_store_node_from_row_at(row, 7)?;
4594        Ok(StoreCallSite {
4595            caller: node.clone(),
4596            target_file: row.get(0)?,
4597            target_symbol: row.get(1)?,
4598            target,
4599            line: row.get::<_, i64>(2)?.max(0) as u32,
4600            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4601            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4602            resolved: row.get::<_, String>(5)? == "resolved",
4603            provenance: row.get(6)?,
4604        })
4605    })?;
4606    rows.collect::<std::result::Result<Vec<_>, _>>()
4607        .map_err(Into::into)
4608}
4609
4610fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4611    let mut stmt = conn.prepare(
4612        "SELECT r.target_file, r.target_symbol, r.line,
4613                r.byte_start, r.byte_end, r.status, r.provenance,
4614                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4615                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4616                tgt_file.lang
4617         FROM refs r
4618         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4619             ON tgt.id = r.target_node
4620         WHERE r.caller_node = ?1
4621           AND r.kind = 'call'
4622           AND r.status <> 'unresolved'
4623           AND r.target_file = ?2
4624           AND r.target_symbol = ?3
4625           AND r.provenance = ?4
4626           AND NOT EXISTS (
4627               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4628           )
4629         ORDER BY r.byte_start, r.line, r.ref_id",
4630    )?;
4631    let rows = stmt.query_map(
4632        params![
4633            &node.node_id,
4634            &node.file,
4635            &node.symbol,
4636            PROVENANCE_TREESITTER
4637        ],
4638        |row| {
4639            let target = optional_store_node_from_row_at(row, 7)?;
4640            Ok(StoreCallSite {
4641                caller: node.clone(),
4642                target_file: row.get(0)?,
4643                target_symbol: row.get(1)?,
4644                target,
4645                line: row.get::<_, i64>(2)?.max(0) as u32,
4646                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4647                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4648                resolved: row.get::<_, String>(5)? == "resolved",
4649                provenance: row.get(6)?,
4650            })
4651        },
4652    )?;
4653    rows.collect::<std::result::Result<Vec<_>, _>>()
4654        .map_err(Into::into)
4655}
4656
4657fn unresolved_calls_for_node(
4658    conn: &Connection,
4659    node: &StoreNode,
4660) -> Result<Vec<StoreUnresolvedCall>> {
4661    let mut stmt = conn.prepare(
4662        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4663         FROM refs
4664         WHERE caller_node = ?1
4665           AND kind = 'call'
4666           AND status = 'unresolved'
4667           AND NOT EXISTS (
4668               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4669           )
4670         ORDER BY byte_start, line, ref_id",
4671    )?;
4672    let rows = stmt.query_map(params![node.node_id], |row| {
4673        Ok(StoreUnresolvedCall {
4674            caller: node.clone(),
4675            symbol: row.get(0)?,
4676            full_ref: row.get(1)?,
4677            line: row.get::<_, i64>(2)?.max(0) as u32,
4678            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4679            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4680        })
4681    })?;
4682    rows.collect::<std::result::Result<Vec<_>, _>>()
4683        .map_err(Into::into)
4684}
4685
4686fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4687    let mut calls = Vec::new();
4688    calls.extend(
4689        outgoing_calls_for_node(conn, node)?
4690            .into_iter()
4691            .map(StoreForwardCall::Resolved),
4692    );
4693    calls.extend(
4694        unresolved_calls_for_node(conn, node)?
4695            .into_iter()
4696            .map(StoreForwardCall::Unresolved),
4697    );
4698    calls.sort_by(|left, right| {
4699        left.byte_start()
4700            .cmp(&right.byte_start())
4701            .then(left.line().cmp(&right.line()))
4702    });
4703    Ok(calls)
4704}
4705
4706fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4707    let resolved_count: i64 = conn.query_row(
4708        "SELECT COUNT(*)
4709         FROM edges e
4710         JOIN refs r ON r.ref_id = e.ref_id
4711         WHERE e.kind = 'call' AND e.source_node = ?1",
4712        params![&node.node_id],
4713        |row| row.get(0),
4714    )?;
4715    let unresolved_count: i64 = conn.query_row(
4716        "SELECT COUNT(*)
4717         FROM refs
4718         WHERE caller_node = ?1
4719           AND kind = 'call'
4720           AND status = 'unresolved'
4721           AND NOT EXISTS (
4722               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4723           )",
4724        params![&node.node_id],
4725        |row| row.get(0),
4726    )?;
4727    let total = resolved_count.saturating_add(unresolved_count);
4728    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4729}
4730
4731fn call_tree_inner(
4732    conn: &Connection,
4733    node: &StoreNode,
4734    max_depth: usize,
4735    current_depth: usize,
4736    visited: &mut HashSet<(String, String)>,
4737) -> Result<callgraph::CallTreeNode> {
4738    let visit_key = (node.file.clone(), node.symbol.clone());
4739    if visited.contains(&visit_key) {
4740        return Ok(callgraph::CallTreeNode {
4741            name: node.symbol.clone(),
4742            file: node.file.clone(),
4743            line: node.line,
4744            signature: node.signature.clone(),
4745            resolved: true,
4746            children: Vec::new(),
4747            depth_limited: false,
4748            truncated: 0,
4749        });
4750    }
4751    visited.insert(visit_key.clone());
4752
4753    let mut children = Vec::new();
4754    let mut depth_limited = false;
4755    let mut truncated = 0usize;
4756
4757    if current_depth < max_depth {
4758        let calls = forward_calls_for_node(conn, node)?;
4759        for call in calls {
4760            match call {
4761                StoreForwardCall::Resolved(site) => {
4762                    if let Some(target) = site.target {
4763                        let child =
4764                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4765                        depth_limited |= child.depth_limited;
4766                        truncated += child.truncated;
4767                        children.push(child);
4768                    } else {
4769                        children.push(callgraph::CallTreeNode {
4770                            name: site.target_symbol,
4771                            file: site.target_file,
4772                            line: site.line,
4773                            signature: None,
4774                            resolved: false,
4775                            children: Vec::new(),
4776                            depth_limited: false,
4777                            truncated: 0,
4778                        });
4779                    }
4780                }
4781                StoreForwardCall::Unresolved(call) => {
4782                    children.push(callgraph::CallTreeNode {
4783                        name: call.symbol,
4784                        file: call.caller.file,
4785                        line: call.line,
4786                        signature: None,
4787                        resolved: false,
4788                        children: Vec::new(),
4789                        depth_limited: false,
4790                        truncated: 0,
4791                    });
4792                }
4793            }
4794        }
4795    } else {
4796        truncated = forward_call_count_for_node(conn, node)?;
4797        depth_limited = truncated > 0;
4798    }
4799
4800    visited.remove(&visit_key);
4801    Ok(callgraph::CallTreeNode {
4802        name: node.symbol.clone(),
4803        file: node.file.clone(),
4804        line: node.line,
4805        signature: node.signature.clone(),
4806        resolved: true,
4807        children,
4808        depth_limited,
4809        truncated,
4810    })
4811}
4812
4813fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4814    callgraph::TraceToSymbolHop {
4815        symbol: node.symbol.clone(),
4816        file: node.file.clone(),
4817        line: node.line,
4818    }
4819}
4820
4821fn trace_to_symbol_matches_target(
4822    node: &StoreNode,
4823    to_symbol: &str,
4824    to_file: Option<&str>,
4825) -> bool {
4826    if !symbol_query_matches(&node.symbol, to_symbol) {
4827        return false;
4828    }
4829    match to_file {
4830        Some(file) => node.file == file,
4831        None => true,
4832    }
4833}
4834
4835fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4836    symbol == query || unqualified_name(symbol) == query
4837}
4838
4839fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4840    let source = std::fs::read_to_string(path).ok()?;
4841    Some(source.lines().map(|line| line.trim().to_string()).collect())
4842}
4843
4844#[doc(hidden)]
4845pub fn live_callgraph_edge_snapshot(
4846    project_root: &Path,
4847    files: &[PathBuf],
4848) -> Result<BTreeSet<StoredEdge>> {
4849    let files = normalize_file_list(project_root, files)?;
4850    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4851    let mut file_data = Vec::new();
4852    for file in &files {
4853        let canon = canonicalize_path(file);
4854        let data = graph.build_file(&canon)?.clone();
4855        file_data.push((canon, data));
4856    }
4857
4858    let mut edges = BTreeSet::new();
4859    for (caller_file, data) in &file_data {
4860        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4861            for call_site in call_sites {
4862                let resolution = graph.resolve_cross_file_edge(
4863                    &call_site.full_callee,
4864                    &call_site.callee_name,
4865                    caller_file,
4866                    &data.import_block,
4867                );
4868                let (target_file, target_symbol) = match resolution {
4869                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4870                    EdgeResolution::Unresolved { callee_name } => {
4871                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4872                            continue;
4873                        }
4874                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4875                            data,
4876                            caller_file,
4877                            &callee_name,
4878                        ) else {
4879                            continue;
4880                        };
4881                        (caller_file.clone(), target_symbol)
4882                    }
4883                };
4884                if target_file == *caller_file && target_symbol == *caller_symbol {
4885                    continue;
4886                }
4887                edges.insert(StoredEdge {
4888                    source_file: relative_path(project_root, caller_file),
4889                    source_symbol: caller_symbol.clone(),
4890                    target_file: relative_path(project_root, &target_file),
4891                    target_symbol,
4892                    kind: "call".to_string(),
4893                    line: call_site.line,
4894                });
4895            }
4896        }
4897    }
4898    Ok(edges)
4899}
4900
4901fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
4902{
4903    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
4904}
4905
4906fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
4907    RebuildCooldownKey {
4908        callgraph_dir: std::fs::canonicalize(callgraph_dir)
4909            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
4910        project_key: project_key.to_string(),
4911    }
4912}
4913
4914fn rebuild_cooldown_denial(
4915    callgraph_dir: &Path,
4916    project_key: &str,
4917    project_root: &Path,
4918    now: Instant,
4919) -> Option<(PathBuf, Duration)> {
4920    let key = rebuild_cooldown_key(callgraph_dir, project_key);
4921    let records = rebuild_cooldown_records()
4922        .lock()
4923        .unwrap_or_else(std::sync::PoisonError::into_inner);
4924    let record = records.get(&key)?;
4925    if record.project_root == project_root || !record.cross_root_cooldown_armed {
4926        return None;
4927    }
4928    let elapsed = now.saturating_duration_since(record.published_at);
4929    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
4930}
4931
4932fn record_successful_rebuild(
4933    callgraph_dir: &Path,
4934    project_key: &str,
4935    project_root: &Path,
4936    published_at: Instant,
4937) {
4938    let key = rebuild_cooldown_key(callgraph_dir, project_key);
4939    let mut records = rebuild_cooldown_records()
4940        .lock()
4941        .unwrap_or_else(std::sync::PoisonError::into_inner);
4942    if records.len() >= 4_096 && !records.contains_key(&key) {
4943        if let Some(evict) = records.keys().next().cloned() {
4944            records.remove(&evict);
4945        }
4946    }
4947    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
4948        previous.cross_root_cooldown_armed || previous.project_root != project_root
4949    });
4950    records.insert(
4951        key,
4952        RebuildCooldownRecord {
4953            project_root: project_root.to_path_buf(),
4954            published_at,
4955            cross_root_cooldown_armed,
4956        },
4957    );
4958}
4959
4960fn acquire_writer_lease(
4961    callgraph_dir: &Path,
4962    project_key: &str,
4963    project_root: &Path,
4964) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4965    crate::root_cache::WriterLease::acquire_shared(
4966        crate::root_cache::RootCacheDomain::Callgraph,
4967        callgraph_dir,
4968        project_key,
4969        project_root,
4970    )
4971    .map_err(CallGraphStoreError::from)
4972}
4973
4974fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4975    if lease.verify()? {
4976        Ok(())
4977    } else {
4978        Err(CallGraphStoreError::Unavailable(format!(
4979            "callgraph writer lease for key {} lost epoch {}; aborting write",
4980            lease.key(),
4981            lease.epoch()
4982        )))
4983    }
4984}
4985
4986fn legacy_migration_completion_line(
4987    project_key: &str,
4988    method: &str,
4989    legacy_bytes: u64,
4990    migrated_bytes: u64,
4991) -> String {
4992    format!(
4993        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4994    )
4995}
4996
4997fn log_legacy_migration_completion(
4998    project_key: &str,
4999    method: &str,
5000    legacy_bytes: u64,
5001    migrated_bytes: u64,
5002) {
5003    crate::slog_info!(
5004        "{}",
5005        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5006    );
5007}
5008
5009fn try_legacy_migration_or_fallback(
5010    callgraph_dir: &Path,
5011    project_root: &Path,
5012    project_key: &str,
5013    writer_lease: Arc<crate::root_cache::WriterLease>,
5014) -> Result<Option<CallGraphStore>> {
5015    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5016    if partitions.is_empty() {
5017        return Ok(None);
5018    }
5019
5020    for partition in &partitions {
5021        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5022            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5023                return open_legacy_fallback_store(
5024                    callgraph_dir,
5025                    project_root,
5026                    project_key,
5027                    &partitions,
5028                );
5029            }
5030            match publish_generation_copy_migration(
5031                callgraph_dir,
5032                project_key,
5033                &source,
5034                Arc::clone(&writer_lease),
5035            ) {
5036                Ok(published) => {
5037                    log_legacy_migration_completion(
5038                        project_key,
5039                        "generation_copy",
5040                        source.source_bytes,
5041                        published.migrated_bytes,
5042                    );
5043                    return CallGraphStore::open_generation(
5044                        callgraph_dir,
5045                        project_root.to_path_buf(),
5046                        project_key.to_string(),
5047                        published.generation,
5048                        writer_lease,
5049                    )
5050                    .map(Some);
5051                }
5052                Err(error) => {
5053                    crate::slog_warn!(
5054                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5055                        source.sqlite_path.display(),
5056                        error
5057                    );
5058                    return open_legacy_fallback_store(
5059                        callgraph_dir,
5060                        project_root,
5061                        project_key,
5062                        &partitions,
5063                    );
5064                }
5065            }
5066        }
5067
5068        if let Some(source) = current_legacy_generation(partition)? {
5069            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5070                return open_legacy_fallback_store(
5071                    callgraph_dir,
5072                    project_root,
5073                    project_key,
5074                    &partitions,
5075                );
5076            }
5077            match publish_backup_migration(
5078                callgraph_dir,
5079                project_key,
5080                &source,
5081                Arc::clone(&writer_lease),
5082            ) {
5083                Ok(published) => {
5084                    log_legacy_migration_completion(
5085                        project_key,
5086                        "sqlite_backup",
5087                        source.source_bytes,
5088                        published.migrated_bytes,
5089                    );
5090                    return CallGraphStore::open_generation(
5091                        callgraph_dir,
5092                        project_root.to_path_buf(),
5093                        project_key.to_string(),
5094                        published.generation,
5095                        writer_lease,
5096                    )
5097                    .map(Some);
5098                }
5099                Err(error) => {
5100                    crate::slog_warn!(
5101                        "root-keyed callgraph backup migration failed from {}: {}",
5102                        source.sqlite_path.display(),
5103                        error
5104                    );
5105                    return open_legacy_fallback_store(
5106                        callgraph_dir,
5107                        project_root,
5108                        project_key,
5109                        &partitions,
5110                    );
5111                }
5112            }
5113        }
5114    }
5115
5116    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5117}
5118
5119fn open_legacy_fallback_store(
5120    callgraph_dir: &Path,
5121    project_root: &Path,
5122    project_key: &str,
5123    partitions: &[LegacyCallgraphPartition],
5124) -> Result<Option<CallGraphStore>> {
5125    let Some(target) = first_ready_legacy_target(partitions)? else {
5126        return Ok(None);
5127    };
5128    crate::slog_warn!(
5129        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5130        target.partition.harness,
5131        target.sqlite_path.display()
5132    );
5133    let conn = open_readonly_connection(&target.sqlite_path)?;
5134    if !database_ready(&conn).unwrap_or(false) {
5135        return Ok(None);
5136    }
5137    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5138    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5139    Ok(Some(CallGraphStore::from_connection(
5140        project_root.to_path_buf(),
5141        project_key.to_string(),
5142        target.sqlite_path,
5143        callgraph_dir.to_path_buf(),
5144        true,
5145        target.generation,
5146        None,
5147        Some(read_marker),
5148        conn,
5149    )))
5150}
5151
5152fn migration_disk_floor_allows(
5153    source: &LegacyCallgraphTarget,
5154    callgraph_dir: &Path,
5155) -> Result<bool> {
5156    let available = migration_available_disk(callgraph_dir)?;
5157    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5158        source.source_bytes,
5159        available,
5160    );
5161    if decision.should_skip_copy() {
5162        crate::slog_warn!(
5163            "{}",
5164            decision.warning_message(&source.sqlite_path, callgraph_dir)
5165        );
5166        return Ok(false);
5167    }
5168    Ok(true)
5169}
5170
5171fn migration_available_disk(path: &Path) -> Result<u64> {
5172    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5173        return Ok(bytes);
5174    }
5175    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5176}
5177
5178fn legacy_callgraph_partitions(
5179    callgraph_dir: &Path,
5180    project_key: &str,
5181) -> Result<Vec<LegacyCallgraphPartition>> {
5182    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5183        return Ok(Vec::new());
5184    };
5185    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5186    let mut partitions = inventory
5187        .into_iter()
5188        .filter(|entry| {
5189            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5190                && entry.key == project_key
5191        })
5192        .map(|entry| {
5193            let dir = if entry.path.is_dir() {
5194                entry.path.clone()
5195            } else {
5196                entry
5197                    .path
5198                    .parent()
5199                    .map(Path::to_path_buf)
5200                    .unwrap_or_else(|| entry.path.clone())
5201            };
5202            LegacyCallgraphPartition {
5203                harness: entry.harness,
5204                dir,
5205                key: entry.key,
5206                bytes: entry.bytes,
5207                freshness: entry.callgraph_pointer_mtime,
5208            }
5209        })
5210        .collect::<Vec<_>>();
5211    partitions.sort_by(|left, right| {
5212        right
5213            .freshness
5214            .cmp(&left.freshness)
5215            .then_with(|| right.bytes.cmp(&left.bytes))
5216            .then_with(|| left.harness.cmp(&right.harness))
5217    });
5218    Ok(partitions)
5219}
5220
5221fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5222    let domain_dir = callgraph_dir.parent()?;
5223    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5224        return None;
5225    }
5226    domain_dir.parent().map(Path::to_path_buf)
5227}
5228
5229pub(crate) fn all_legacy_partitions_migrated_for_keys(
5230    callgraph_dir: &Path,
5231    configured_keys: &BTreeSet<String>,
5232) -> Result<bool> {
5233    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5234        return Ok(false);
5235    };
5236    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5237        .into_iter()
5238        .filter(|entry| {
5239            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5240                && configured_keys.contains(&entry.key)
5241        })
5242        .map(|entry| entry.key)
5243        .collect::<BTreeSet<_>>();
5244    if legacy_keys.is_empty() {
5245        return Ok(false);
5246    }
5247
5248    for key in legacy_keys {
5249        let migrated_dir = storage_root.join("callgraph").join(&key);
5250        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5251            return Ok(false);
5252        };
5253        if !migration_generation_requires_manifest(&generation)
5254            || !migration_manifest_valid(&migrated_dir, &generation)
5255        {
5256            return Ok(false);
5257        }
5258    }
5259    Ok(true)
5260}
5261
5262fn newest_superseded_legacy_generation(
5263    partition: &LegacyCallgraphPartition,
5264) -> Result<Option<LegacyCallgraphTarget>> {
5265    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5266        return Ok(None);
5267    };
5268    let prefix = format!("{}.g", partition.key);
5269    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5270        return Ok(None);
5271    };
5272    let mut candidates = Vec::new();
5273    for entry in entries.flatten() {
5274        let name = entry.file_name().to_string_lossy().to_string();
5275        if name == current
5276            || name.contains(".tmp.")
5277            || !name.starts_with(&prefix)
5278            || !name.ends_with(".sqlite")
5279        {
5280            continue;
5281        }
5282        let path = entry.path();
5283        if !db_path_ready(&path) {
5284            continue;
5285        }
5286        let modified = entry
5287            .metadata()
5288            .and_then(|metadata| metadata.modified())
5289            .unwrap_or(SystemTime::UNIX_EPOCH);
5290        candidates.push((modified, path, name));
5291    }
5292    candidates.sort_by(|left, right| right.0.cmp(&left.0));
5293    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5294        return Ok(None);
5295    };
5296    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5297    Ok(Some(LegacyCallgraphTarget {
5298        partition: partition.clone(),
5299        sqlite_path,
5300        generation: Some(generation),
5301        source_bytes,
5302        source_blake3: String::new(),
5303    }))
5304}
5305
5306fn current_legacy_generation(
5307    partition: &LegacyCallgraphPartition,
5308) -> Result<Option<LegacyCallgraphTarget>> {
5309    let Some(target) = ready_legacy_target(partition)? else {
5310        return Ok(None);
5311    };
5312    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5313    if has_superseded {
5314        return Ok(None);
5315    }
5316    Ok(Some(target))
5317}
5318
5319fn freshest_legacy_fallback_target(
5320    callgraph_dir: &Path,
5321    project_key: &str,
5322) -> Result<Option<LegacyCallgraphTarget>> {
5323    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5324    first_ready_legacy_target(&partitions)
5325}
5326
5327fn first_ready_legacy_target(
5328    partitions: &[LegacyCallgraphPartition],
5329) -> Result<Option<LegacyCallgraphTarget>> {
5330    for partition in partitions {
5331        if let Some(target) = ready_legacy_target(partition)? {
5332            return Ok(Some(target));
5333        }
5334    }
5335    Ok(None)
5336}
5337
5338fn ready_legacy_target(
5339    partition: &LegacyCallgraphPartition,
5340) -> Result<Option<LegacyCallgraphTarget>> {
5341    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5342        let sqlite_path = partition.dir.join(&generation);
5343        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5344            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5345            return Ok(Some(LegacyCallgraphTarget {
5346                partition: partition.clone(),
5347                sqlite_path,
5348                generation: Some(generation),
5349                source_bytes,
5350                source_blake3: String::new(),
5351            }));
5352        }
5353    }
5354
5355    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5356    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5357        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5358        return Ok(Some(LegacyCallgraphTarget {
5359            partition: partition.clone(),
5360            sqlite_path,
5361            generation: None,
5362            source_bytes,
5363            source_blake3: String::new(),
5364        }));
5365    }
5366    Ok(None)
5367}
5368
5369fn publish_generation_copy_migration(
5370    callgraph_dir: &Path,
5371    project_key: &str,
5372    source: &LegacyCallgraphTarget,
5373    writer_lease: Arc<crate::root_cache::WriterLease>,
5374) -> Result<PublishedLegacyMigration> {
5375    let generation = migration_generation_file_name(project_key, "copy");
5376    let temp_path = migration_temp_path(callgraph_dir, &generation);
5377    remove_sqlite_file_set(&temp_path);
5378    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5379    fail_after_temp_copy_for_test()?;
5380
5381    let mut source = source.clone();
5382    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5383    source.source_blake3 = fingerprint.blake3;
5384    let generation = publish_migrated_generation(
5385        callgraph_dir,
5386        project_key,
5387        &generation,
5388        &temp_path,
5389        &source,
5390        fingerprint.bytes,
5391        writer_lease,
5392        "generation_copy",
5393    )?;
5394    Ok(PublishedLegacyMigration {
5395        generation,
5396        migrated_bytes: fingerprint.bytes,
5397    })
5398}
5399
5400fn publish_backup_migration(
5401    callgraph_dir: &Path,
5402    project_key: &str,
5403    source: &LegacyCallgraphTarget,
5404    writer_lease: Arc<crate::root_cache::WriterLease>,
5405) -> Result<PublishedLegacyMigration> {
5406    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5407        return Err(CallGraphStoreError::Unavailable(
5408            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5409        ));
5410    }
5411
5412    let generation = migration_generation_file_name(project_key, "backup");
5413    let temp_path = migration_temp_path(callgraph_dir, &generation);
5414    remove_sqlite_file_set(&temp_path);
5415
5416    let source_conn = open_readonly_connection(&source.sqlite_path)?;
5417    let mut destination = Connection::open(&temp_path)?;
5418    destination.busy_timeout(Duration::from_secs(5))?;
5419    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5420    let started = Instant::now();
5421    let mut retries = 0;
5422    loop {
5423        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5424            rusqlite::backup::StepResult::Done => break,
5425            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5426            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5427                retries += 1;
5428                if retries > MIGRATION_BACKUP_RETRY_BUDGET
5429                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5430                {
5431                    return Err(CallGraphStoreError::Unavailable(format!(
5432                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5433                    )));
5434                }
5435                std::thread::sleep(Duration::from_millis(20));
5436            }
5437            _ => {
5438                return Err(CallGraphStoreError::Unavailable(
5439                    "legacy callgraph backup returned an unknown step result".to_string(),
5440                ));
5441            }
5442        }
5443    }
5444    drop(backup);
5445
5446    let integrity: String =
5447        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5448    if integrity != "ok" {
5449        return Err(CallGraphStoreError::Unavailable(format!(
5450            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5451        )));
5452    }
5453    if !database_ready(&destination)? {
5454        return Err(CallGraphStoreError::Unavailable(
5455            "legacy callgraph backup produced a database without ready metadata".to_string(),
5456        ));
5457    }
5458    destination.execute_batch("PRAGMA optimize;")?;
5459    drop(destination);
5460    sync_file(&temp_path)?;
5461    fail_after_temp_copy_for_test()?;
5462
5463    let mut source = source.clone();
5464    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5465    source.source_blake3 = fingerprint.blake3;
5466    let generation = publish_migrated_generation(
5467        callgraph_dir,
5468        project_key,
5469        &generation,
5470        &temp_path,
5471        &source,
5472        fingerprint.bytes,
5473        writer_lease,
5474        "sqlite_backup",
5475    )?;
5476    Ok(PublishedLegacyMigration {
5477        generation,
5478        migrated_bytes: fingerprint.bytes,
5479    })
5480}
5481
5482fn publish_migrated_generation(
5483    callgraph_dir: &Path,
5484    project_key: &str,
5485    generation: &str,
5486    temp_path: &Path,
5487    source: &LegacyCallgraphTarget,
5488    migrated_bytes: u64,
5489    writer_lease: Arc<crate::root_cache::WriterLease>,
5490    method: &str,
5491) -> Result<String> {
5492    let gen_path = callgraph_dir.join(generation);
5493    checkpoint_sqlite_before_publication(temp_path);
5494    let publication = publish_if_current(|| {
5495        verify_writer_lease(&writer_lease)?;
5496        remove_sqlite_file_set(&gen_path);
5497        rename_sqlite_file_set(temp_path, &gen_path)?;
5498        crate::fs_lock::sync_parent(&gen_path);
5499
5500        verify_writer_lease(&writer_lease)?;
5501        publish_pointer(callgraph_dir, project_key, generation)?;
5502        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5503        Ok(generation.to_string())
5504    });
5505    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5506        remove_sqlite_file_set(temp_path);
5507    }
5508    publication
5509}
5510
5511fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5512    if let Some(parent) = destination.parent() {
5513        std::fs::create_dir_all(parent)?;
5514    }
5515    for suffix in SQLITE_FILE_SET_SUFFIXES {
5516        let source_path = sqlite_file_set_path(source, suffix);
5517        if !source_path.is_file() {
5518            continue;
5519        }
5520        let destination_path = sqlite_file_set_path(destination, suffix);
5521        std::fs::copy(&source_path, &destination_path)?;
5522        sync_file(&destination_path)?;
5523    }
5524    Ok(())
5525}
5526
5527fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5528    for suffix in SQLITE_FILE_SET_SUFFIXES {
5529        let source_path = sqlite_file_set_path(source, suffix);
5530        if !source_path.exists() {
5531            continue;
5532        }
5533        let destination_path = sqlite_file_set_path(destination, suffix);
5534        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5535            let _ = std::fs::remove_file(&source_path);
5536            return Err(error.into());
5537        }
5538    }
5539    Ok(())
5540}
5541
5542fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5543    let mut bytes = 0_u64;
5544    for suffix in SQLITE_FILE_SET_SUFFIXES {
5545        let member = sqlite_file_set_path(path, suffix);
5546        if !member.is_file() {
5547            continue;
5548        }
5549        bytes = bytes.saturating_add(member.metadata()?.len());
5550    }
5551    Ok(bytes)
5552}
5553
5554fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5555    let mut hasher = blake3::Hasher::new();
5556    let mut bytes = 0_u64;
5557    let mut buffer = [0_u8; 64 * 1024];
5558    for suffix in SQLITE_FILE_SET_SUFFIXES {
5559        let member = sqlite_file_set_path(path, suffix);
5560        if !member.is_file() {
5561            continue;
5562        }
5563        hasher.update(suffix.as_bytes());
5564        let mut file = std::fs::File::open(&member)?;
5565        loop {
5566            let read = file.read(&mut buffer)?;
5567            if read == 0 {
5568                break;
5569            }
5570            bytes = bytes.saturating_add(read as u64);
5571            hasher.update(&buffer[..read]);
5572        }
5573    }
5574    Ok(SourceFingerprint {
5575        bytes,
5576        blake3: hash_to_hex(hasher.finalize()),
5577    })
5578}
5579
5580fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5581    if suffix.is_empty() {
5582        path.to_path_buf()
5583    } else {
5584        PathBuf::from(format!("{}{suffix}", path.display()))
5585    }
5586}
5587
5588fn sync_file(path: &Path) -> Result<()> {
5589    let file = std::fs::OpenOptions::new()
5590        .read(true)
5591        .write(true)
5592        .open(path)?;
5593    file.sync_all()?;
5594    Ok(())
5595}
5596
5597fn fail_after_temp_copy_for_test() -> Result<()> {
5598    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5599        return Err(CallGraphStoreError::Unavailable(
5600            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5601        ));
5602    }
5603    Ok(())
5604}
5605
5606fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5607    format!(
5608        "{project_key}.g{}.{}{}{}.sqlite",
5609        now_nanos(),
5610        std::process::id(),
5611        MIGRATION_GENERATION_TAG,
5612        method
5613    )
5614}
5615
5616fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5617    callgraph_dir.join(format!(
5618        "{generation}.tmp.{}.{}",
5619        std::process::id(),
5620        now_nanos()
5621    ))
5622}
5623
5624fn write_migration_manifest(
5625    callgraph_dir: &Path,
5626    generation: &str,
5627    source: &LegacyCallgraphTarget,
5628    migrated_bytes: u64,
5629    method: &str,
5630) -> Result<()> {
5631    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5632    let temp_path = manifest_path.with_extension(format!(
5633        "migration.json.tmp.{}.{}",
5634        std::process::id(),
5635        now_nanos()
5636    ));
5637    let manifest = serde_json::json!({
5638        "version": MIGRATION_MANIFEST_VERSION,
5639        "method": method,
5640        "target_generation": generation,
5641        "source_harness": source.partition.harness,
5642        "source_path": source.sqlite_path.display().to_string(),
5643        "source_generation": source.generation,
5644        "source_bytes": source.source_bytes,
5645        "source_blake3": source.source_blake3,
5646        "migrated_bytes": migrated_bytes,
5647    });
5648    {
5649        use std::io::Write as _;
5650        let mut file = std::fs::File::create(&temp_path)?;
5651        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5652        file.write_all(b"\n")?;
5653        file.sync_all()?;
5654    }
5655    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5656        let _ = std::fs::remove_file(&temp_path);
5657        return Err(error.into());
5658    }
5659    crate::fs_lock::sync_parent(&manifest_path);
5660    Ok(())
5661}
5662
5663fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5664    callgraph_dir.join(format!("{generation}.migration.json"))
5665}
5666
5667fn migration_generation_requires_manifest(generation: &str) -> bool {
5668    generation.contains(MIGRATION_GENERATION_TAG)
5669}
5670
5671fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5672    if !migration_generation_requires_manifest(generation) {
5673        return true;
5674    }
5675    let path = migration_manifest_path(callgraph_dir, generation);
5676    let Ok(bytes) = std::fs::read(path) else {
5677        return false;
5678    };
5679    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5680        return false;
5681    };
5682    value.get("version").and_then(serde_json::Value::as_u64)
5683        == Some(MIGRATION_MANIFEST_VERSION as u64)
5684        && value
5685            .get("target_generation")
5686            .and_then(serde_json::Value::as_str)
5687            == Some(generation)
5688        && value
5689            .get("source_bytes")
5690            .and_then(serde_json::Value::as_u64)
5691            .is_some_and(|bytes| bytes > 0)
5692        && value
5693            .get("source_blake3")
5694            .and_then(serde_json::Value::as_str)
5695            .is_some_and(|hash| hash.len() == 64)
5696}
5697
5698fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5699    let pointer_generation = read_pointer(callgraph_dir, project_key);
5700    if let Some(generation) = pointer_generation.as_deref() {
5701        if migration_generation_requires_manifest(generation)
5702            && !migration_manifest_valid(callgraph_dir, generation)
5703        {
5704            let path = callgraph_dir.join(generation);
5705            remove_sqlite_file_set(&path);
5706            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5707            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5708        }
5709    }
5710
5711    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5712        return;
5713    };
5714    for entry in entries.flatten() {
5715        let name = entry.file_name().to_string_lossy().to_string();
5716        let path = entry.path();
5717        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5718            let _ = std::fs::remove_file(path);
5719            continue;
5720        }
5721        if name.starts_with(&format!("{project_key}.g"))
5722            && name.ends_with(".sqlite")
5723            && name.contains(MIGRATION_GENERATION_TAG)
5724            && pointer_generation.as_deref() != Some(&name)
5725            && !migration_manifest_valid(callgraph_dir, &name)
5726        {
5727            remove_sqlite_file_set(&path);
5728            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5729        }
5730    }
5731    crate::fs_lock::sync_parent(callgraph_dir);
5732}
5733
5734fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5735    let mut hasher = blake3::Hasher::new();
5736    hasher.update(path.to_string_lossy().as_bytes());
5737    if let Some(generation) = generation {
5738        hasher.update(generation.as_bytes());
5739    }
5740    let digest = hash_to_hex(hasher.finalize());
5741    format!("legacy-{}", &digest[..16])
5742}
5743
5744fn open_readonly_connection(path: &Path) -> Result<Connection> {
5745    let uri = sqlite_readonly_uri(path);
5746    let conn = Connection::open_with_flags(
5747        &uri,
5748        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5749    )?;
5750    conn.pragma_update(
5751        None,
5752        "synchronous",
5753        if write_amplification_baseline_enabled() {
5754            "FULL"
5755        } else {
5756            "NORMAL"
5757        },
5758    )?;
5759    conn.busy_timeout(reader_busy_timeout())?;
5760    conn.execute_batch("PRAGMA query_only=ON;")?;
5761    Ok(conn)
5762}
5763
5764fn reader_busy_timeout() -> Duration {
5765    let jitter = (now_nanos() % 500) as u64;
5766    Duration::from_millis(250 + jitter)
5767}
5768
5769fn sqlite_readonly_uri(path: &Path) -> String {
5770    let raw = path.to_string_lossy().replace('\\', "/");
5771    let encoded = percent_encode_sqlite_uri_path(&raw);
5772    if raw.starts_with('/') {
5773        format!("file://{encoded}?mode=ro")
5774    } else if raw.as_bytes().get(1) == Some(&b':') {
5775        format!("file:///{encoded}?mode=ro")
5776    } else {
5777        format!("file:{encoded}?mode=ro")
5778    }
5779}
5780
5781fn percent_encode_sqlite_uri_path(path: &str) -> String {
5782    let mut encoded = String::with_capacity(path.len());
5783    for byte in path.bytes() {
5784        match byte {
5785            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5786                encoded.push(byte as char)
5787            }
5788            _ => encoded.push_str(&format!("%{byte:02X}")),
5789        }
5790    }
5791    encoded
5792}
5793
5794fn configure_connection(conn: &Connection) -> Result<()> {
5795    conn.pragma_update(None, "journal_mode", "WAL")?;
5796    let baseline = write_amplification_baseline_enabled();
5797    conn.pragma_update(
5798        None,
5799        "synchronous",
5800        if baseline { "FULL" } else { "NORMAL" },
5801    )?;
5802    conn.pragma_update(
5803        None,
5804        "wal_autocheckpoint",
5805        if baseline {
5806            1_000
5807        } else {
5808            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5809        },
5810    )?;
5811    conn.pragma_update(None, "busy_timeout", 5_000)?;
5812    Ok(())
5813}
5814
5815fn configure_build_connection(conn: &Connection) -> Result<()> {
5816    conn.pragma_update(None, "journal_mode", "DELETE")?;
5817    conn.pragma_update(
5818        None,
5819        "synchronous",
5820        if write_amplification_baseline_enabled() {
5821            "FULL"
5822        } else {
5823            "NORMAL"
5824        },
5825    )?;
5826    conn.pragma_update(None, "busy_timeout", 5_000)?;
5827    Ok(())
5828}
5829
5830/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
5831/// private temporary copy before publishing it; a busy reader is harmless because
5832/// the next publication or cleanup pass can retry without affecting the source.
5833fn checkpoint_sqlite_before_publication(path: &Path) {
5834    let Ok(conn) = Connection::open(path) else {
5835        return;
5836    };
5837    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5838    let _ = conn.busy_timeout(Duration::from_secs(5));
5839    let _ = checkpoint_wal_truncate(&conn);
5840}
5841
5842fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5843    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5844        row.get::<_, i64>(0)
5845    }) {
5846        Ok(0) => true,
5847        Ok(_) => false,
5848        Err(rusqlite::Error::SqliteFailure(error, _))
5849            if matches!(
5850                error.code,
5851                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5852            ) =>
5853        {
5854            false
5855        }
5856        Err(error) => {
5857            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5858            false
5859        }
5860    }
5861}
5862
5863fn initialize_schema(conn: &Connection) -> Result<()> {
5864    conn.execute_batch(
5865        "CREATE TABLE IF NOT EXISTS files (
5866            path                TEXT PRIMARY KEY,
5867            content_hash        TEXT NOT NULL,
5868            mtime_ns            INTEGER NOT NULL,
5869            size                INTEGER NOT NULL,
5870            lang                TEXT NOT NULL,
5871            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5872            is_public_api       INTEGER NOT NULL DEFAULT 0,
5873            surface_fingerprint TEXT NOT NULL,
5874            indexed_at          INTEGER NOT NULL
5875        );
5876
5877        CREATE TABLE IF NOT EXISTS nodes (
5878            id                         TEXT PRIMARY KEY,
5879            file_path                  TEXT NOT NULL,
5880            name                       TEXT NOT NULL,
5881            scoped_name                TEXT NOT NULL,
5882            kind                       TEXT NOT NULL,
5883            start_line                 INTEGER NOT NULL,
5884            start_col                  INTEGER NOT NULL,
5885            end_line                   INTEGER NOT NULL,
5886            end_col                    INTEGER NOT NULL,
5887            range_ordinal              INTEGER NOT NULL,
5888            signature                  TEXT,
5889            exported                   INTEGER NOT NULL,
5890            is_default_export          INTEGER NOT NULL,
5891            is_type_like               INTEGER NOT NULL,
5892            is_callgraph_entry_point   INTEGER NOT NULL,
5893            provenance                 TEXT NOT NULL,
5894            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5895        );
5896        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5897        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5898        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5899
5900        CREATE TABLE IF NOT EXISTS refs (
5901            ref_id          TEXT PRIMARY KEY,
5902            caller_node     TEXT,
5903            caller_file     TEXT NOT NULL,
5904            kind            TEXT NOT NULL,
5905            short_name      TEXT,
5906            full_ref        TEXT,
5907            module_path     TEXT,
5908            import_kind     TEXT,
5909            local_name      TEXT,
5910            requested_name  TEXT,
5911            namespace_alias TEXT,
5912            wildcard        INTEGER NOT NULL DEFAULT 0,
5913            line            INTEGER NOT NULL,
5914            byte_start      INTEGER NOT NULL,
5915            byte_end        INTEGER NOT NULL,
5916            status          TEXT NOT NULL,
5917            target_node     TEXT,
5918            target_file     TEXT,
5919            target_symbol   TEXT,
5920            provenance      TEXT NOT NULL
5921        );
5922        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5923        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5924        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5925        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5926        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5927
5928        CREATE TABLE IF NOT EXISTS file_dependencies (
5929            file_path   TEXT NOT NULL,
5930            dep_file    TEXT NOT NULL,
5931            PRIMARY KEY(file_path, dep_file)
5932        );
5933        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5934
5935        CREATE TABLE IF NOT EXISTS edges (
5936            edge_id       TEXT PRIMARY KEY,
5937            ref_id        TEXT NOT NULL,
5938            source_node   TEXT NOT NULL,
5939            target_node   TEXT,
5940            target_file   TEXT NOT NULL,
5941            target_symbol TEXT NOT NULL,
5942            kind          TEXT NOT NULL,
5943            line          INTEGER NOT NULL,
5944            provenance    TEXT NOT NULL
5945        );
5946        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5947        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5948        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5949        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5950
5951        CREATE TABLE IF NOT EXISTS dispatch_hints (
5952            id           TEXT PRIMARY KEY,
5953            method_name  TEXT NOT NULL,
5954            caller_node  TEXT NOT NULL,
5955            file         TEXT NOT NULL,
5956            line         INTEGER NOT NULL,
5957            byte_start   INTEGER NOT NULL,
5958            byte_end     INTEGER NOT NULL,
5959            provenance   TEXT NOT NULL
5960        );
5961        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5962
5963        CREATE TABLE IF NOT EXISTS type_ref_names (
5964            name TEXT PRIMARY KEY
5965        );
5966
5967        CREATE TABLE IF NOT EXISTS backend_file_state (
5968            backend        TEXT NOT NULL,
5969            workspace_root TEXT NOT NULL,
5970            file_path      TEXT NOT NULL,
5971            content_hash   TEXT NOT NULL,
5972            status         TEXT NOT NULL,
5973            updated_at     INTEGER NOT NULL,
5974            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5975        );
5976        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5977
5978        CREATE TABLE IF NOT EXISTS meta (
5979            k TEXT PRIMARY KEY,
5980            v TEXT NOT NULL
5981        );",
5982    )?;
5983    insert_meta(conn)?;
5984    Ok(())
5985}
5986
5987fn insert_meta(conn: &Connection) -> Result<()> {
5988    conn.execute(
5989        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5990        params![SCHEMA_VERSION.to_string()],
5991    )?;
5992    conn.execute(
5993        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5994        params![schema_fingerprint()],
5995    )?;
5996    conn.execute(
5997        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
5998        [],
5999    )?;
6000    Ok(())
6001}
6002
6003/// Return the durable revision paired atomically with graph mutations. Stores
6004/// created by older binaries lack the revision row, so callers cannot detect
6005/// in-place graph changes and must not cache their snapshots.
6006fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6007    let revision: Option<String> = conn
6008        .query_row(
6009            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6010            [],
6011            |row| row.get(0),
6012        )
6013        .optional()?;
6014    revision
6015        .map(|revision| {
6016            revision.parse::<u64>().map_err(|error| {
6017                CallGraphStoreError::Unavailable(format!(
6018                    "callgraph projection write revision is invalid: {error}"
6019                ))
6020            })
6021        })
6022        .transpose()
6023}
6024
6025/// Advance the projection revision inside the graph mutation transaction so a
6026/// cached snapshot never survives an in-place refresh.
6027fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6028    tx.execute(
6029        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6030         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6031        [],
6032    )?;
6033    Ok(())
6034}
6035
6036fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6037    conn.execute(
6038        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6039        params![if ready { "1" } else { "0" }],
6040    )?;
6041    Ok(())
6042}
6043
6044fn database_ready(conn: &Connection) -> Result<bool> {
6045    let schema_version: Option<String> = conn
6046        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6047            row.get(0)
6048        })
6049        .optional()?;
6050    let fingerprint: Option<String> = conn
6051        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6052            row.get(0)
6053        })
6054        .optional()?;
6055    let ready: Option<String> = conn
6056        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6057        .optional()?;
6058
6059    let expected_schema = SCHEMA_VERSION.to_string();
6060    let expected_fingerprint = schema_fingerprint();
6061    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6062        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6063        && ready.as_deref() == Some("1"))
6064}
6065
6066fn ensure_database_ready(conn: &Connection) -> Result<()> {
6067    if database_ready(conn)? {
6068        Ok(())
6069    } else {
6070        Err(CallGraphStoreError::Unavailable(
6071            "database is missing, stale, or mid-build".to_string(),
6072        ))
6073    }
6074}
6075
6076fn schema_fingerprint() -> String {
6077    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6078    // edge sources, broader call extraction) even if the table SHAPE is
6079    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6080    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6081    let input =
6082        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6083    hash_to_hex(blake3::hash(input.as_bytes()))
6084}
6085
6086fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6087    tx.execute_batch(
6088        "DELETE FROM edges;
6089         DELETE FROM file_dependencies;
6090         DELETE FROM refs;
6091         DELETE FROM dispatch_hints;
6092         DELETE FROM type_ref_names;
6093         DELETE FROM backend_file_state;
6094         DELETE FROM nodes;
6095         DELETE FROM files;",
6096    )?;
6097    Ok(())
6098}
6099
6100fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6101    tx.execute_batch(
6102        "DROP INDEX IF EXISTS idx_nodes_file;
6103         DROP INDEX IF EXISTS idx_nodes_name;
6104         DROP INDEX IF EXISTS idx_nodes_scoped;
6105         DROP INDEX IF EXISTS idx_refs_short_name;
6106         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6107         DROP INDEX IF EXISTS idx_refs_caller_file;
6108         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6109         DROP INDEX IF EXISTS idx_refs_target_file;
6110         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6111         DROP INDEX IF EXISTS idx_edges_source_kind;
6112         DROP INDEX IF EXISTS idx_edges_target_kind;
6113         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6114         DROP INDEX IF EXISTS idx_edges_ref_id;
6115         DROP INDEX IF EXISTS idx_dispatch_hints_method;
6116         DROP INDEX IF EXISTS idx_backend_file_state_file;",
6117    )?;
6118    Ok(())
6119}
6120
6121fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6122    tx.execute_batch(
6123        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6124         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6125         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6126         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6127         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6128         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6129         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6130         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6131         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6132         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6133         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6134         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6135         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6136         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6137         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6138    )?;
6139    Ok(())
6140}
6141
6142const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6143    ("files", "path"),
6144    ("nodes", "file_path"),
6145    ("refs", "caller_file"),
6146    ("refs", "target_file"),
6147    ("file_dependencies", "file_path"),
6148    ("file_dependencies", "dep_file"),
6149    ("edges", "target_file"),
6150    ("dispatch_hints", "file"),
6151    ("backend_file_state", "file_path"),
6152];
6153
6154/// Reconcile `backend_file_state.workspace_root` when the opener's project root
6155/// differs from what is stored. The store key is the git-root commit hash, so
6156/// multiple live checkouts/clones share one on-disk generation.
6157///
6158/// Cheap in-place re-root is only safe when every previously stored root path is
6159/// gone from disk (true move/rename). If any stale root still exists, another
6160/// clone is still alive and rewriting metadata would ping-pong relative rows
6161/// between trees (possibly on different branches). We then return
6162/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
6163/// opener. That can make each clone rebuild on open when they alternate — bounded
6164/// by open frequency — but each rebuild is correct for its opener, unlike silent
6165/// cross-clone corruption.
6166fn reconcile_workspace_roots(
6167    conn: &mut Connection,
6168    project_root: &Path,
6169    allow_repair: bool,
6170) -> Result<OpenRootRepair> {
6171    let roots = stored_workspace_roots(conn)?;
6172    let current_root = project_root.display().to_string();
6173    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6174        return Ok(OpenRootRepair::None);
6175    }
6176
6177    if let Some(sample) = sample_absolute_data_path(conn)? {
6178        return Ok(OpenRootRepair::NeedsRebuild {
6179            previous_roots: roots,
6180            current_root,
6181            reason: format!("absolute store data path row {sample}"),
6182        });
6183    }
6184
6185    for stored_root in roots.iter() {
6186        if stored_root == &current_root {
6187            continue;
6188        }
6189        if Path::new(stored_root).exists() {
6190            let reason = format!(
6191                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6192            );
6193            return Ok(OpenRootRepair::NeedsRebuild {
6194                previous_roots: roots,
6195                current_root,
6196                reason,
6197            });
6198        }
6199    }
6200
6201    if !allow_repair {
6202        return Ok(OpenRootRepair::NeedsRebuild {
6203            previous_roots: roots,
6204            current_root,
6205            reason: "workspace root metadata requires deferred repair".to_string(),
6206        });
6207    }
6208
6209    publish_if_current(|| {
6210        let tx = conn.transaction()?;
6211        tx.execute(
6212            "UPDATE OR IGNORE backend_file_state
6213             SET workspace_root = ?1
6214             WHERE workspace_root <> ?1",
6215            params![&current_root],
6216        )?;
6217        tx.execute(
6218            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6219            params![&current_root],
6220        )?;
6221        tx.commit()?;
6222        Ok(())
6223    })?;
6224
6225    crate::slog_info!(
6226        "callgraph store re-rooted from {} to {}",
6227        roots.join(", "),
6228        current_root
6229    );
6230    Ok(OpenRootRepair::ReRooted)
6231}
6232
6233fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6234    let mut stmt = conn.prepare(
6235        "SELECT DISTINCT workspace_root
6236         FROM backend_file_state
6237         ORDER BY workspace_root",
6238    )?;
6239    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6240    rows.collect::<std::result::Result<Vec<_>, _>>()
6241        .map_err(Into::into)
6242}
6243
6244fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6245    for (table, column) in STORE_DATA_PATH_COLUMNS {
6246        let sql = format!(
6247            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6248        );
6249        let mut stmt = conn.prepare(&sql)?;
6250        let mut rows = stmt.query([])?;
6251        while let Some(row) = rows.next()? {
6252            let value: String = row.get(0)?;
6253            if stored_path_is_absolute(&value) {
6254                return Ok(Some(format!("{table}.{column}={value}")));
6255            }
6256        }
6257    }
6258    Ok(None)
6259}
6260
6261fn stored_path_is_absolute(value: &str) -> bool {
6262    if value.is_empty() {
6263        return false;
6264    }
6265    if Path::new(value).is_absolute() || value.starts_with('/') {
6266        return true;
6267    }
6268    let bytes = value.as_bytes();
6269    if bytes.len() >= 3
6270        && bytes[1] == b':'
6271        && (bytes[2] == b'/' || bytes[2] == b'\\')
6272        && bytes[0].is_ascii_alphabetic()
6273    {
6274        return true;
6275    }
6276    value.starts_with("\\\\") || value.starts_with("//")
6277}
6278
6279fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6280    if let OpenRootRepair::NeedsRebuild {
6281        previous_roots,
6282        current_root,
6283        reason,
6284    } = repair
6285    {
6286        crate::slog_info!(
6287            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6288            previous_roots.join(", "),
6289            current_root,
6290            reason
6291        );
6292    }
6293}
6294
6295/// Nanosecond clock used to make temp/generation file names unique.
6296fn now_nanos() -> u128 {
6297    SystemTime::now()
6298        .duration_since(UNIX_EPOCH)
6299        .unwrap_or(Duration::ZERO)
6300        .as_nanos()
6301}
6302
6303/// The pointer file `<dir>/<key>.current`. Its single line names the current
6304/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
6305/// can always be atomically replaced via rename even on Windows — Rust opens
6306/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
6307fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6308    callgraph_dir.join(format!("{project_key}.current"))
6309}
6310
6311/// The legacy single-file DB path used before the generation scheme. Still read
6312/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
6313/// build publishes a generation.
6314fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6315    callgraph_dir.join(format!("{project_key}.sqlite"))
6316}
6317
6318/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
6319/// cold build writes a brand-new generation file, so publishing NEVER replaces
6320/// a file another process holds open (the root Windows fix).
6321fn generation_file_name(project_key: &str) -> String {
6322    format!(
6323        "{project_key}.g{}.{}.sqlite",
6324        now_nanos(),
6325        std::process::id()
6326    )
6327}
6328
6329/// Read the pointer; returns the generation file name if present and non-empty.
6330fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6331    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6332    let name = text.trim();
6333    if name.is_empty() {
6334        None
6335    } else {
6336        Some(name.to_string())
6337    }
6338}
6339
6340/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
6341/// `ready` flag). Uses a throwaway read-only connection.
6342fn db_path_ready(path: &Path) -> bool {
6343    (|| -> Result<bool> {
6344        let conn = open_readonly_connection(path)?;
6345        database_ready(&conn)
6346    })()
6347    .unwrap_or(false)
6348}
6349
6350/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
6351/// where `generation` is `Some(name)` for a pointer-published generation or
6352/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
6353/// published (caller treats that as "needs cold build").
6354///
6355/// Handles the GC race (the pointer names a generation that was just deleted) by
6356/// re-reading the pointer and retrying a few times.
6357fn resolve_ready_target(
6358    callgraph_dir: &Path,
6359    project_key: &str,
6360) -> Option<(PathBuf, Option<String>)> {
6361    for _ in 0..5 {
6362        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6363            let gen_path = callgraph_dir.join(&generation);
6364            if gen_path.is_file() {
6365                return (migration_manifest_valid(callgraph_dir, &generation)
6366                    && db_path_ready(&gen_path))
6367                .then_some((gen_path, Some(generation)));
6368            }
6369            // Pointer names a missing generation (a GC/publish race): re-read the
6370            // pointer and retry rather than failing the reader.
6371            std::thread::sleep(Duration::from_millis(5));
6372            continue;
6373        }
6374        // No pointer: fall back to the legacy single-file DB if it is ready.
6375        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6376        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6377    }
6378    None
6379}
6380
6381/// Atomically publish `generation` as the current store by flipping the pointer
6382/// file. Writes a temp file, fsyncs, then renames over the pointer — never
6383/// replacing an open DB file, so it succeeds cross-platform.
6384fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6385    let pointer = pointer_path(callgraph_dir, project_key);
6386    let tmp = callgraph_dir.join(format!(
6387        "{project_key}.current.tmp.{}.{}",
6388        std::process::id(),
6389        now_nanos()
6390    ));
6391    {
6392        use std::io::Write as _;
6393        let mut file = std::fs::File::create(&tmp)?;
6394        file.write_all(generation.as_bytes())?;
6395        file.write_all(b"\n")?;
6396        file.sync_all()?;
6397    }
6398    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6399        let _ = std::fs::remove_file(&tmp);
6400        return Err(error.into());
6401    }
6402    crate::fs_lock::sync_parent(&pointer);
6403    Ok(())
6404}
6405
6406#[derive(Clone, Debug)]
6407struct GenerationGcCandidate {
6408    name: String,
6409    path: PathBuf,
6410    modified: SystemTime,
6411}
6412
6413/// Best-effort GC of superseded generation files. The current pointer target and
6414/// newest previous generation are always retained. Older generations are removed
6415/// when they have no protected read marker, or after the absolute retention TTL
6416/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
6417/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
6418fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6419    let temp_grace = Duration::from_secs(60);
6420    let now = SystemTime::now();
6421    let pointer_current =
6422        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6423    let gen_prefix = format!("{project_key}.g");
6424    let tmp_prefixes = [
6425        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
6426        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
6427        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
6428    ];
6429    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6430        return;
6431    };
6432    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6433    for entry in entries.flatten() {
6434        let name = entry.file_name();
6435        let name = name.to_string_lossy().to_string();
6436        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6437        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6438
6439        // Orphaned temp files from a crashed build/publish: remove once aged out.
6440        if name.contains(".tmp.") {
6441            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6442                let _ = std::fs::remove_file(entry.path());
6443            }
6444            continue;
6445        }
6446
6447        // Superseded legacy single-file DB: best-effort delete once a generation
6448        // is published (ignored if another process still holds it open).
6449        if name == format!("{project_key}.sqlite") {
6450            remove_sqlite_file_set(&entry.path());
6451            continue;
6452        }
6453
6454        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6455            gens.push(GenerationGcCandidate {
6456                name,
6457                path: entry.path(),
6458                modified: mtime,
6459            });
6460        }
6461    }
6462
6463    let mut superseded = gens
6464        .iter()
6465        .filter(|generation| generation.name != pointer_current)
6466        .collect::<Vec<_>>();
6467    superseded.sort_by(|left, right| {
6468        right
6469            .modified
6470            .cmp(&left.modified)
6471            .then_with(|| right.name.cmp(&left.name))
6472    });
6473    let previous = superseded.first().map(|generation| generation.name.clone());
6474
6475    for generation in gens {
6476        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6477        if generation.name == pointer_current
6478            || Some(generation.name.as_str()) == previous.as_deref()
6479        {
6480            continue;
6481        }
6482
6483        let age = now
6484            .duration_since(generation.modified)
6485            .unwrap_or(Duration::ZERO);
6486        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6487            continue;
6488        }
6489
6490        remove_sqlite_file_set(&generation.path);
6491        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6492        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6493            callgraph_dir,
6494            &generation.name,
6495        ));
6496    }
6497}
6498
6499fn remove_sqlite_file_set(path: &Path) {
6500    let _ = std::fs::remove_file(path);
6501    remove_sqlite_sidecars(path);
6502}
6503
6504fn remove_sqlite_sidecars(path: &Path) {
6505    let path_text = path.to_string_lossy();
6506    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6507    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6508    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6509}
6510
6511/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
6512///
6513/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
6514/// place on success; a build that dies (process kill, crash, host restart) leaves
6515/// the temporary behind. The largest observed cold build finishes well under a
6516/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
6517/// never rename. A live build's temporary is minutes old at most.
6518///
6519/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
6520/// liveness check read false-positive on exactly the oldest files — the ones most
6521/// worth deleting: in production an orphan's embedded pid had been recycled to an
6522/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
6523/// cannot lie that way, so it is the honest orphan predicate.
6524const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6525
6526/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
6527/// same cadence as [`gc_old_generations`] (after a generation is published) but,
6528/// unlike it, is not scoped to the building root: it covers every directory in the
6529/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
6530///
6531/// That last case is the production hole this fixes. The per-root cleanup in
6532/// [`gc_old_generations`] only fires when a root actually builds, so when activity
6533/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
6534/// store's orphans become permanent — gigabytes accumulated in a legacy store
6535/// whose roots no longer built there, while the active store stayed clean. A
6536/// sibling root that still builds triggers this pass and cleans both layouts.
6537fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6538    sweep_orphaned_build_temps(callgraph_dir);
6539    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6540        return;
6541    };
6542    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6543
6544    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
6545    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6546        for entry in entries.flatten() {
6547            if entry.path().is_dir() {
6548                sweep_orphaned_build_temps(&entry.path());
6549            }
6550        }
6551    }
6552
6553    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
6554    if let Ok(entries) = std::fs::read_dir(&storage_root) {
6555        for entry in entries.flatten() {
6556            let legacy_dir = entry.path().join(domain);
6557            if legacy_dir.is_dir() {
6558                sweep_orphaned_build_temps(&legacy_dir);
6559            }
6560        }
6561    }
6562}
6563
6564/// Sweep one callgraph directory, removing build temporaries older than
6565/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
6566fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6567    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6568}
6569
6570/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
6571/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
6572fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6573    let now = SystemTime::now();
6574    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6575        return;
6576    };
6577    let mut removed_any = false;
6578    for entry in entries.flatten() {
6579        let name = entry.file_name().to_string_lossy().to_string();
6580        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
6581        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
6582        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
6583        // without that substring — a completed `.sqlite` generation, a pointer, a
6584        // read-marker dir — is left alone: those belong to generation GC.
6585        if !name.contains(".sqlite.tmp.") {
6586            continue;
6587        }
6588        let mtime = entry
6589            .metadata()
6590            .and_then(|meta| meta.modified())
6591            .unwrap_or(now);
6592        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6593            continue;
6594        }
6595        // Deletion races a concurrent build finishing: that build renames the temp
6596        // into place, so the file is gone by the time we unlink. The 24h age makes
6597        // this overlap practically impossible, but treat a missing file as success
6598        // (the rename won) rather than an error, and never touch a path that does
6599        // not match the temporary shape above.
6600        match std::fs::remove_file(entry.path()) {
6601            Ok(()) => removed_any = true,
6602            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6603            Err(_) => {}
6604        }
6605    }
6606    if removed_any {
6607        crate::fs_lock::sync_parent(callgraph_dir);
6608    }
6609}
6610
6611/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6612/// the global all-cores rayon pool. The store cold-build is the heaviest
6613/// background pass (parse-dominated) and runs on a separate thread off the
6614/// single-threaded request loop; left unbounded it monopolizes every core and
6615/// starves the bridge so interactive tools time out (the same starvation the
6616/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6617/// match the main thread, since the extract walks tree-sitter ASTs.
6618fn build_pool_size() -> usize {
6619    std::thread::available_parallelism()
6620        .map(|parallelism| parallelism.get())
6621        .unwrap_or(1)
6622        .div_ceil(2)
6623        .clamp(1, 8)
6624}
6625
6626fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6627    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6628        Ok(extract) => Ok(extract),
6629        Err(error) => {
6630            let abs_path =
6631                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6632            let rel_path = relative_path(project_root, &abs_path);
6633            let freshness = cache_freshness::collect(&abs_path).ok();
6634            log::debug!(
6635                "callgraph store: skipping {} during cold build: {}",
6636                abs_path.display(),
6637                error
6638            );
6639            Err(ExtractFailure {
6640                rel_path,
6641                freshness,
6642            })
6643        }
6644    };
6645
6646    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6647        files.par_iter().map(extract_one).collect()
6648    };
6649
6650    // Run inside a dedicated bounded pool when one builds; fall back to the
6651    // global pool only if the bounded pool can't be constructed.
6652    let results = match rayon::ThreadPoolBuilder::new()
6653        .num_threads(build_pool_size())
6654        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6655        .stack_size(8 * 1024 * 1024)
6656        .build()
6657    {
6658        Ok(pool) => pool.install(run),
6659        Err(error) => {
6660            log::warn!(
6661                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6662            );
6663            run()
6664        }
6665    };
6666
6667    let mut extracts = Vec::new();
6668    let mut failures = Vec::new();
6669    for result in results {
6670        match result {
6671            Ok(extract) => extracts.push(extract),
6672            Err(failure) => failures.push(failure),
6673        }
6674    }
6675    BuildExtractsResult { extracts, failures }
6676}
6677
6678fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6679    let metadata = std::fs::metadata(path)?;
6680    let size = metadata.len();
6681    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6682        cache_freshness::zero_hash()
6683    } else if source.len() as u64 == size {
6684        cache_freshness::hash_bytes(source.as_bytes())
6685    } else {
6686        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6687    };
6688    Ok(FileFreshness {
6689        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6690        size,
6691        content_hash,
6692    })
6693}
6694
6695fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6696    let abs_path = normalize_file_path(project_root, path)?;
6697    let rel_path = relative_path(project_root, &abs_path);
6698    let source = std::fs::read_to_string(&abs_path)?;
6699    let freshness = collect_source_freshness(&abs_path, &source)?;
6700    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6701    let lang = data.lang;
6702    if lang == LangId::Rust {
6703        extend_rust_imports_with_nested_uses(&source, &mut data);
6704    }
6705    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6706    let node_by_scoped: HashMap<String, String> = nodes
6707        .iter()
6708        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6709        .collect();
6710    let import_dependencies =
6711        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6712    let line_index = LineIndex::new(&source);
6713    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6714    let rust_reexports = if lang == LangId::Rust {
6715        collect_rust_pub_use_reexport_refs(
6716            project_root,
6717            &abs_path,
6718            &rel_path,
6719            &data.import_block.imports,
6720            &line_index,
6721        )
6722    } else {
6723        ReexportRefs {
6724            raw_refs: Vec::new(),
6725            surface_parts: Vec::new(),
6726        }
6727    };
6728    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6729    let mut raw_refs = Vec::new();
6730    raw_refs.extend(build_call_refs(
6731        &rel_path,
6732        &data,
6733        &node_by_scoped,
6734        &import_dependencies,
6735    ));
6736    raw_refs.extend(build_import_refs(
6737        project_root,
6738        &abs_path,
6739        &rel_path,
6740        &data.import_block.imports,
6741        &line_index,
6742    ));
6743    let mut surface_parts = reexports.surface_parts;
6744    surface_parts.extend(rust_reexports.surface_parts);
6745    surface_parts.extend(source_less_exports.surface_parts);
6746    raw_refs.extend(reexports.raw_refs);
6747    raw_refs.extend(rust_reexports.raw_refs);
6748    raw_refs.extend(source_less_exports.raw_refs);
6749    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6750    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6751
6752    Ok(FileExtract {
6753        rel_path,
6754        freshness,
6755        lang,
6756        data,
6757        nodes,
6758        raw_refs,
6759        dispatch_hints,
6760        surface_fingerprint,
6761    })
6762}
6763
6764fn build_node_records(
6765    rel_path: &str,
6766    source: &str,
6767    data: &FileCallData,
6768) -> Result<Vec<NodeRecord>> {
6769    let mut records = Vec::new();
6770    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6771    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6772    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6773
6774    for (scoped_name, meta) in metadata {
6775        let name = unqualified_name(scoped_name).to_string();
6776        let range = selection_range(source, scoped_name, &name, &meta.range);
6777        let range_key = (
6778            range.start_line,
6779            range.start_col,
6780            range.end_line,
6781            range.end_col,
6782        );
6783        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6784        let range_ordinal = *ordinal;
6785        *ordinal += 1;
6786        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6787        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6788        let is_default_export = data
6789            .default_export_symbol
6790            .as_deref()
6791            .map(|default| default == scoped_name || default == name)
6792            .unwrap_or(false);
6793        records.push(NodeRecord {
6794            id,
6795            file_path: rel_path.to_string(),
6796            name: name.clone(),
6797            scoped_name: scoped_name.clone(),
6798            kind: symbol_kind_label(&meta.kind).to_string(),
6799            range,
6800            range_ordinal,
6801            signature: meta.signature.clone(),
6802            exported,
6803            is_default_export,
6804            is_type_like: is_type_like(&meta.kind),
6805            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6806                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6807        });
6808    }
6809
6810    Ok(records)
6811}
6812
6813fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6814    if scoped_name == TOP_LEVEL_SYMBOL {
6815        return Range {
6816            start_line: 0,
6817            start_col: 0,
6818            end_line: 0,
6819            end_col: 0,
6820        };
6821    }
6822    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6823        return fallback.clone();
6824    };
6825    let start_col = fallback.start_col as usize;
6826    let search_start = start_col.min(line.len());
6827    if let Some(offset) = line[search_start..].find(name) {
6828        let col = search_start + offset;
6829        return Range {
6830            start_line: fallback.start_line,
6831            start_col: col as u32,
6832            end_line: fallback.start_line,
6833            end_col: (col + name.len()) as u32,
6834        };
6835    }
6836    if let Some(offset) = line.find(name) {
6837        return Range {
6838            start_line: fallback.start_line,
6839            start_col: offset as u32,
6840            end_line: fallback.start_line,
6841            end_col: (offset + name.len()) as u32,
6842        };
6843    }
6844    Range {
6845        start_line: fallback.start_line,
6846        start_col: fallback.start_col,
6847        end_line: fallback.start_line,
6848        end_col: fallback.start_col.saturating_add(name.len() as u32),
6849    }
6850}
6851
6852fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6853    if scoped_name == TOP_LEVEL_SYMBOL {
6854        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6855    }
6856    let input = format!(
6857        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6858        range.start_line, range.start_col, range.end_line, range.end_col
6859    );
6860    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6861}
6862
6863fn build_call_refs(
6864    rel_path: &str,
6865    data: &FileCallData,
6866    node_by_scoped: &HashMap<String, String>,
6867    import_dependencies: &BTreeSet<String>,
6868) -> Vec<RawRef> {
6869    let mut refs = Vec::new();
6870    let mut ordinal = 0usize;
6871    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6872    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6873    for (caller_symbol, call_sites) in symbols {
6874        let caller_node = node_by_scoped.get(caller_symbol).cloned();
6875        for call_site in call_sites {
6876            ordinal += 1;
6877            let ref_id = ref_id(&[
6878                rel_path,
6879                "call",
6880                caller_symbol,
6881                &call_site.line.to_string(),
6882                &call_site.byte_start.to_string(),
6883                &call_site.byte_end.to_string(),
6884                &call_site.full_callee,
6885                &ordinal.to_string(),
6886            ]);
6887            refs.push(RawRef {
6888                ref_id,
6889                caller_node: caller_node.clone(),
6890                caller_symbol: Some(caller_symbol.clone()),
6891                caller_file: rel_path.to_string(),
6892                kind: "call".to_string(),
6893                short_name: Some(call_site.callee_name.clone()),
6894                full_ref: Some(call_site.full_callee.clone()),
6895                module_path: None,
6896                import_kind: None,
6897                local_name: Some(call_site.callee_name.clone()),
6898                requested_name: Some(call_site.callee_name.clone()),
6899                namespace_alias: namespace_alias(&call_site.full_callee),
6900                wildcard: false,
6901                line: call_site.line,
6902                byte_start: call_site.byte_start,
6903                byte_end: call_site.byte_end,
6904                dependencies: import_dependencies.clone(),
6905            });
6906        }
6907    }
6908    refs
6909}
6910
6911fn build_import_refs(
6912    project_root: &Path,
6913    abs_path: &Path,
6914    rel_path: &str,
6915    imports: &[ImportStatement],
6916    line_index: &LineIndex,
6917) -> Vec<RawRef> {
6918    let mut refs = Vec::new();
6919    for (index, import) in imports.iter().enumerate() {
6920        let import_kind = import_kind_label(import.kind).to_string();
6921        let local_name = import_local_names(import).join(",");
6922        let requested_name = import_requested_names(import).join(",");
6923        let ref_id = ref_id(&[
6924            rel_path,
6925            "import",
6926            &import.byte_range.start.to_string(),
6927            &import.byte_range.end.to_string(),
6928            &import.module_path,
6929            &index.to_string(),
6930        ]);
6931        refs.push(RawRef {
6932            ref_id,
6933            caller_node: None,
6934            caller_symbol: None,
6935            caller_file: rel_path.to_string(),
6936            kind: "import".to_string(),
6937            short_name: None,
6938            full_ref: Some(import.raw_text.clone()),
6939            module_path: Some(import.module_path.clone()),
6940            import_kind: Some(import_kind),
6941            local_name: empty_to_none(local_name),
6942            requested_name: empty_to_none(requested_name),
6943            namespace_alias: import.namespace_import.clone(),
6944            wildcard: import_is_wildcard(import),
6945            line: line_index.byte_to_line(import.byte_range.start),
6946            byte_start: import.byte_range.start,
6947            byte_end: import.byte_range.end,
6948            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6949        });
6950    }
6951    refs
6952}
6953
6954fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6955    let grammar = grammar_for(LangId::Rust);
6956    let mut parser = Parser::new();
6957    if parser.set_language(&grammar).is_err() {
6958        return;
6959    }
6960    let Some(tree) = parser.parse(source, None) else {
6961        return;
6962    };
6963
6964    let mut seen = data
6965        .import_block
6966        .imports
6967        .iter()
6968        .map(|import| (import.byte_range.start, import.byte_range.end))
6969        .collect::<HashSet<_>>();
6970    let mut nested_imports = Vec::new();
6971    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6972    if nested_imports.is_empty() {
6973        return;
6974    }
6975
6976    data.import_block.imports.extend(nested_imports);
6977    data.import_block
6978        .imports
6979        .sort_by_key(|import| import.byte_range.start);
6980    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6981}
6982
6983fn collect_rust_use_imports(
6984    source: &str,
6985    node: Node<'_>,
6986    seen: &mut HashSet<(usize, usize)>,
6987    imports: &mut Vec<ImportStatement>,
6988) {
6989    if node.kind() == "use_declaration" {
6990        let range = node.byte_range();
6991        if seen.insert((range.start, range.end)) {
6992            if let Some(import) = rust_import_from_use_node(source, node) {
6993                imports.push(import);
6994            }
6995        }
6996    }
6997
6998    let mut cursor = node.walk();
6999    if !cursor.goto_first_child() {
7000        return;
7001    }
7002    loop {
7003        collect_rust_use_imports(source, cursor.node(), seen, imports);
7004        if !cursor.goto_next_sibling() {
7005            break;
7006        }
7007    }
7008}
7009
7010fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7011    let raw_text = source[node.byte_range()].to_string();
7012    let body = rust_use_body(&raw_text)?.to_string();
7013    let visibility = rust_use_visibility(&raw_text);
7014    let names = rust_use_list_names(&body);
7015    let group = classify_rust_import_group(&body);
7016    let byte_range = node.byte_range();
7017
7018    Some(ImportStatement {
7019        module_path: body,
7020        names: names.clone(),
7021        default_import: visibility.clone(),
7022        namespace_import: None,
7023        kind: ImportKind::Value,
7024        group,
7025        byte_range,
7026        raw_text,
7027        form: ImportForm::RustUse {
7028            visibility,
7029            named: names,
7030        },
7031    })
7032}
7033
7034fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7035    let start = imports.iter().map(|import| import.byte_range.start).min()?;
7036    let end = imports.iter().map(|import| import.byte_range.end).max()?;
7037    Some(start..end)
7038}
7039
7040fn rust_use_visibility(raw_text: &str) -> Option<String> {
7041    let use_pos = raw_text.find("use ")?;
7042    let prefix = raw_text[..use_pos].trim();
7043    if prefix.is_empty() {
7044        None
7045    } else {
7046        Some(prefix.to_string())
7047    }
7048}
7049
7050fn rust_use_body(raw_text: &str) -> Option<&str> {
7051    let use_pos = raw_text.find("use ")?;
7052    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7053}
7054
7055fn rust_use_list_names(body: &str) -> Vec<String> {
7056    let Some(open) = body.find("::{") else {
7057        return Vec::new();
7058    };
7059    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7060        return Vec::new();
7061    };
7062    body[open + 3..close]
7063        .split(',')
7064        .filter_map(|spec| {
7065            let spec = spec.trim();
7066            if spec.is_empty() {
7067                None
7068            } else {
7069                Some(spec.to_string())
7070            }
7071        })
7072        .collect()
7073}
7074
7075fn classify_rust_import_group(body: &str) -> ImportGroup {
7076    let first = body
7077        .split("::")
7078        .next()
7079        .unwrap_or(body)
7080        .split_whitespace()
7081        .next()
7082        .unwrap_or(body);
7083    match first.trim() {
7084        "std" | "core" | "alloc" => ImportGroup::Stdlib,
7085        "crate" | "self" | "super" => ImportGroup::Internal,
7086        _ => ImportGroup::External,
7087    }
7088}
7089
7090#[derive(Debug, Clone)]
7091struct ReexportRefs {
7092    raw_refs: Vec<RawRef>,
7093    surface_parts: Vec<String>,
7094}
7095
7096fn collect_reexport_refs(
7097    project_root: &Path,
7098    abs_path: &Path,
7099    rel_path: &str,
7100    source: &str,
7101) -> ReexportRefs {
7102    let mut raw_refs = Vec::new();
7103    let mut surface_parts = Vec::new();
7104    let mut search_start = 0usize;
7105    let mut ordinal = 0usize;
7106    while let Some(export_offset) = source[search_start..].find("export") {
7107        let start = search_start + export_offset;
7108        let Some(statement_end_offset) = source[start..].find(';') else {
7109            break;
7110        };
7111        let end = start + statement_end_offset + 1;
7112        let statement = &source[start..end];
7113        search_start = end;
7114        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7115            continue;
7116        }
7117        let Some(module_path) = quoted_module_path(statement) else {
7118            continue;
7119        };
7120        ordinal += 1;
7121        let wildcard = statement.contains('*');
7122        let line = source[..start]
7123            .bytes()
7124            .filter(|byte| *byte == b'\n')
7125            .count() as u32
7126            + 1;
7127        let ref_id = ref_id(&[
7128            rel_path,
7129            "reexport",
7130            &start.to_string(),
7131            &end.to_string(),
7132            &module_path,
7133            &ordinal.to_string(),
7134        ]);
7135        surface_parts.push(format!("reexport\t{statement}"));
7136        raw_refs.push(RawRef {
7137            ref_id,
7138            caller_node: None,
7139            caller_symbol: None,
7140            caller_file: rel_path.to_string(),
7141            kind: "reexport".to_string(),
7142            short_name: None,
7143            full_ref: Some(statement.to_string()),
7144            module_path: Some(module_path.clone()),
7145            import_kind: Some("reexport".to_string()),
7146            local_name: None,
7147            requested_name: None,
7148            namespace_alias: None,
7149            wildcard,
7150            line,
7151            byte_start: start,
7152            byte_end: end,
7153            dependencies: module_dependencies(project_root, abs_path, &module_path),
7154        });
7155    }
7156    ReexportRefs {
7157        raw_refs,
7158        surface_parts,
7159    }
7160}
7161
7162fn collect_rust_pub_use_reexport_refs(
7163    project_root: &Path,
7164    abs_path: &Path,
7165    rel_path: &str,
7166    imports: &[ImportStatement],
7167    line_index: &LineIndex,
7168) -> ReexportRefs {
7169    let mut raw_refs = Vec::new();
7170    let mut surface_parts = Vec::new();
7171    let mut ordinal = 0usize;
7172
7173    for import in imports {
7174        let Some(visibility) = &import.default_import else {
7175            continue;
7176        };
7177        if !visibility.starts_with("pub") {
7178            continue;
7179        }
7180        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7181            continue;
7182        };
7183        ordinal += 1;
7184        let ref_id = ref_id(&[
7185            rel_path,
7186            "rust_reexport",
7187            &import.byte_range.start.to_string(),
7188            &import.byte_range.end.to_string(),
7189            &module_path,
7190            &ordinal.to_string(),
7191        ]);
7192        surface_parts.push(format!("reexport\t{}", import.raw_text));
7193        raw_refs.push(RawRef {
7194            ref_id,
7195            caller_node: None,
7196            caller_symbol: None,
7197            caller_file: rel_path.to_string(),
7198            kind: "reexport".to_string(),
7199            short_name: None,
7200            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7201            module_path: Some(module_path.clone()),
7202            import_kind: Some("reexport".to_string()),
7203            local_name: None,
7204            requested_name: None,
7205            namespace_alias: None,
7206            wildcard,
7207            line: line_index.byte_to_line(import.byte_range.start),
7208            byte_start: import.byte_range.start,
7209            byte_end: import.byte_range.end,
7210            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7211        });
7212    }
7213
7214    ReexportRefs {
7215        raw_refs,
7216        surface_parts,
7217    }
7218}
7219
7220fn rust_pub_use_reexport_parts(
7221    import: &ImportStatement,
7222) -> Option<(String, HashMap<String, String>, bool)> {
7223    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7224    let body = body.trim();
7225    if let Some(module_path) = body.strip_suffix("::*") {
7226        return Some((module_path.trim().to_string(), HashMap::new(), true));
7227    }
7228
7229    if let Some(brace_start) = body.find("::{") {
7230        let module_path = body[..brace_start].trim().to_string();
7231        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7232        if names.is_empty() {
7233            return None;
7234        }
7235        return Some((module_path, names, false));
7236    }
7237
7238    let (module_path, spec) = body.rsplit_once("::")?;
7239    let names = rust_reexport_names_from_specs(spec);
7240    if names.is_empty() {
7241        return None;
7242    }
7243    Some((module_path.trim().to_string(), names, false))
7244}
7245
7246fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7247    let mut names = HashMap::new();
7248    for spec in specs.split(',') {
7249        let spec = spec.trim();
7250        if spec.is_empty() || spec == "self" {
7251            continue;
7252        }
7253        if let Some((source, local)) = spec.split_once(" as ") {
7254            let source = source.trim();
7255            let local = local.trim();
7256            if !source.is_empty() && !local.is_empty() && source != "self" {
7257                names.insert(local.to_string(), source.to_string());
7258            }
7259        } else {
7260            names.insert(spec.to_string(), spec.to_string());
7261        }
7262    }
7263    names
7264}
7265
7266fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7267    if named.is_empty() {
7268        return fallback.to_string();
7269    }
7270    let mut specs = named
7271        .iter()
7272        .map(|(local, source)| {
7273            if local == source {
7274                source.clone()
7275            } else {
7276                format!("{source} as {local}")
7277            }
7278        })
7279        .collect::<Vec<_>>();
7280    specs.sort();
7281    format!("pub use {{{}}};", specs.join(", "))
7282}
7283
7284fn quoted_module_path(statement: &str) -> Option<String> {
7285    let quote = match (statement.find('\''), statement.find('"')) {
7286        (Some(single), Some(double)) if single < double => '\'',
7287        (Some(_), Some(_)) => '"',
7288        (Some(_), None) => '\'',
7289        (None, Some(_)) => '"',
7290        (None, None) => return None,
7291    };
7292    let start = statement.find(quote)? + 1;
7293    let end = statement[start..].find(quote)? + start;
7294    Some(statement[start..end].to_string())
7295}
7296
7297#[derive(Debug, Clone)]
7298struct SourceLessExportRefs {
7299    raw_refs: Vec<RawRef>,
7300    surface_parts: Vec<String>,
7301}
7302
7303fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7304    let mut raw_refs = Vec::new();
7305    let mut surface_parts = Vec::new();
7306    let mut search_start = 0usize;
7307    let mut ordinal = 0usize;
7308    while let Some(export_offset) = source[search_start..].find("export") {
7309        let start = search_start + export_offset;
7310        let Some(statement_end_offset) = source[start..].find(';') else {
7311            break;
7312        };
7313        let end = start + statement_end_offset + 1;
7314        let statement = &source[start..end];
7315        search_start = end;
7316        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7317            continue;
7318        }
7319        let aliases = parse_reexport_names(statement);
7320        if aliases.is_empty() {
7321            continue;
7322        }
7323        let line = source[..start]
7324            .bytes()
7325            .filter(|byte| *byte == b'\n')
7326            .count() as u32
7327            + 1;
7328        for (exported, source_symbol) in aliases {
7329            ordinal += 1;
7330            let ref_id = ref_id(&[
7331                rel_path,
7332                "export_alias",
7333                &start.to_string(),
7334                &end.to_string(),
7335                &exported,
7336                &source_symbol,
7337                &ordinal.to_string(),
7338            ]);
7339            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7340            raw_refs.push(RawRef {
7341                ref_id,
7342                caller_node: None,
7343                caller_symbol: None,
7344                caller_file: rel_path.to_string(),
7345                kind: "export_alias".to_string(),
7346                short_name: None,
7347                full_ref: Some(statement.to_string()),
7348                module_path: None,
7349                import_kind: Some("export_alias".to_string()),
7350                local_name: Some(exported),
7351                requested_name: Some(source_symbol),
7352                namespace_alias: None,
7353                wildcard: false,
7354                line,
7355                byte_start: start,
7356                byte_end: end,
7357                dependencies: BTreeSet::new(),
7358            });
7359        }
7360    }
7361    SourceLessExportRefs {
7362        raw_refs,
7363        surface_parts,
7364    }
7365}
7366
7367fn build_dispatch_hints(
7368    rel_path: &str,
7369    data: &FileCallData,
7370    node_by_scoped: &HashMap<String, String>,
7371) -> Vec<DispatchHint> {
7372    let mut hints = Vec::new();
7373    let mut ordinal = 0usize;
7374    for (caller_symbol, call_sites) in &data.calls_by_symbol {
7375        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7376            continue;
7377        };
7378        for call_site in call_sites {
7379            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7380                continue;
7381            }
7382            ordinal += 1;
7383            hints.push(DispatchHint {
7384                id: ref_id(&[
7385                    rel_path,
7386                    "dispatch",
7387                    caller_symbol,
7388                    &call_site.line.to_string(),
7389                    &call_site.byte_start.to_string(),
7390                    &call_site.byte_end.to_string(),
7391                    &ordinal.to_string(),
7392                ]),
7393                method_name: call_site.callee_name.clone(),
7394                caller_node: caller_node.clone(),
7395                file: rel_path.to_string(),
7396                line: call_site.line,
7397                byte_start: call_site.byte_start,
7398                byte_end: call_site.byte_end,
7399            });
7400        }
7401    }
7402    hints
7403}
7404
7405fn surface_fingerprint(
7406    nodes: &mut [NodeRecord],
7407    data: &FileCallData,
7408    reexport_parts: &[String],
7409) -> String {
7410    nodes.sort_by(|left, right| {
7411        (left.file_path.as_str(), left.scoped_name.as_str())
7412            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7413    });
7414    let mut parts = Vec::new();
7415    for node in nodes.iter() {
7416        parts.push(format!(
7417            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7418            node.scoped_name,
7419            node.name,
7420            node.kind,
7421            node.exported,
7422            node.range.start_line,
7423            node.range.start_col,
7424            node.range.end_line,
7425            node.range.end_col,
7426            node.range_ordinal,
7427            node.signature.as_deref().unwrap_or("")
7428        ));
7429    }
7430    let mut exports = data.exported_symbols.clone();
7431    exports.sort();
7432    for export in exports {
7433        parts.push(format!("export\t{export}"));
7434    }
7435    if let Some(default_export) = &data.default_export_symbol {
7436        parts.push(format!("default\t{default_export}"));
7437    }
7438    let mut imports: Vec<String> = data
7439        .import_block
7440        .imports
7441        .iter()
7442        .map(|import| {
7443            format!(
7444                "import\t{}\t{:?}\t{}",
7445                import.module_path, import.form, import.raw_text
7446            )
7447        })
7448        .collect();
7449    imports.sort();
7450    parts.extend(imports);
7451    parts.extend(reexport_parts.iter().cloned());
7452    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7453}
7454
7455fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7456    if raw.kind != "call" {
7457        return Ok(ResolvedRef {
7458            dependencies: raw.dependencies.clone(),
7459            raw,
7460            status: "unresolved".to_string(),
7461            target_node: None,
7462            target_file: None,
7463            target_symbol: None,
7464            edge: None,
7465        });
7466    }
7467
7468    let caller_file = raw.caller_file.clone();
7469    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7470        CallGraphStoreError::MissingCallerData {
7471            file: caller_file.clone(),
7472        }
7473    })?;
7474    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7475    let short_name = raw.short_name.as_deref().unwrap_or_default();
7476    let mut dependencies = raw.dependencies.clone();
7477
7478    let resolved = match index.lang_for(&caller_file) {
7479        Some(LangId::Rust) => {
7480            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7481        }
7482        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7483            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7484        }
7485        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7486    };
7487
7488    let Some((status, target_file, target_symbol)) = resolved else {
7489        return Ok(ResolvedRef {
7490            raw,
7491            status: "unresolved".to_string(),
7492            target_node: None,
7493            target_file: None,
7494            target_symbol: None,
7495            dependencies,
7496            edge: None,
7497        });
7498    };
7499
7500    dependencies.insert(target_file.clone());
7501    let target_node = index.node_for_symbol(&target_file, &target_symbol);
7502    let source_node = raw.caller_node.clone();
7503    let edge = if let Some(source_node) = source_node {
7504        if target_file == caller_file
7505            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7506        {
7507            None
7508        } else {
7509            Some(EdgeRecord {
7510                edge_id: ref_id(&[&raw.ref_id, "edge"]),
7511                source_node,
7512                target_node: target_node.clone(),
7513                target_file: target_file.clone(),
7514                target_symbol: target_symbol.clone(),
7515                kind: "call".to_string(),
7516                line: raw.line,
7517            })
7518        }
7519    } else {
7520        None
7521    };
7522
7523    Ok(ResolvedRef {
7524        raw,
7525        status,
7526        target_node,
7527        target_file: Some(target_file),
7528        target_symbol: Some(target_symbol),
7529        dependencies,
7530        edge,
7531    })
7532}
7533
7534fn resolve_js_ts_target(
7535    index: &ProjectIndex<'_>,
7536    caller_file: &str,
7537    full_ref: &str,
7538    short_name: &str,
7539    caller_data: &FileCallData,
7540) -> Option<(String, String, String)> {
7541    if let Some((namespace, member)) = full_ref.split_once('.') {
7542        for import in &caller_data.import_block.imports {
7543            if import.namespace_import.as_deref() == Some(namespace) {
7544                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7545                    if let Some((file, symbol)) =
7546                        resolve_exported_symbol(index, &target_file, member, 0)
7547                    {
7548                        return Some(("resolved".to_string(), file, symbol));
7549                    }
7550                }
7551            }
7552        }
7553    }
7554
7555    for import in &caller_data.import_block.imports {
7556        for spec in &import.names {
7557            if crate::imports::specifier_local_name(spec) == short_name {
7558                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7559                    let requested = crate::imports::specifier_imported_name(spec);
7560                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7561                        .unwrap_or_else(|| (target_file, requested.to_string()));
7562                    return Some(("resolved".to_string(), file, symbol));
7563                }
7564            }
7565        }
7566
7567        if import.default_import.as_deref() == Some(short_name) {
7568            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7569                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7570                    .or_else(|| {
7571                        index
7572                            .files
7573                            .get(&target_file)
7574                            .and_then(|file| file.default_export.clone())
7575                            .map(|symbol| (target_file.clone(), symbol))
7576                    })
7577                    .unwrap_or_else(|| {
7578                        let file_name = Path::new(&target_file)
7579                            .file_name()
7580                            .and_then(|name| name.to_str())
7581                            .unwrap_or("unknown")
7582                            .to_string();
7583                        (target_file, format!("<default:{file_name}>"))
7584                    });
7585                return Some(("resolved".to_string(), file, symbol));
7586            }
7587        }
7588    }
7589
7590    for import in &caller_data.import_block.imports {
7591        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7592            if index
7593                .files
7594                .get(&target_file)
7595                .map(|file| file.exports.contains(short_name))
7596                .unwrap_or(false)
7597            {
7598                return Some(("resolved".to_string(), target_file, short_name.to_string()));
7599            }
7600        }
7601    }
7602
7603    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7604}
7605
7606fn resolve_exported_symbol(
7607    index: &ProjectIndex<'_>,
7608    file: &str,
7609    requested: &str,
7610    depth: usize,
7611) -> Option<(String, String)> {
7612    let mut visited = std::collections::HashMap::new();
7613    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7614}
7615
7616/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7617/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7618/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7619/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7620/// (file, symbol) pair — but only when the earlier visit had at least as much
7621/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7622/// first visit had to cut off at the cap, so plain visited-set pruning would
7623/// lose resolutions the capped walk finds).
7624fn resolve_exported_symbol_inner(
7625    index: &ProjectIndex<'_>,
7626    file: &str,
7627    requested: &str,
7628    depth: usize,
7629    visited: &mut std::collections::HashMap<(String, String), usize>,
7630) -> Option<(String, String)> {
7631    if depth > 16 {
7632        return None;
7633    }
7634    if requested != "default" {
7635        if let Some(source_symbol) = index
7636            .files
7637            .get(file)
7638            .and_then(|item| item.export_aliases.get(requested))
7639        {
7640            return Some((file.to_string(), source_symbol.clone()));
7641        }
7642        if index
7643            .files
7644            .get(file)
7645            .map(|item| item.exports.contains(requested))
7646            .unwrap_or(false)
7647        {
7648            return Some((file.to_string(), requested.to_string()));
7649        }
7650    } else if let Some(default) = index
7651        .files
7652        .get(file)
7653        .and_then(|item| item.default_export.clone())
7654    {
7655        return Some((file.to_string(), default));
7656    }
7657
7658    // Memo check sits after the local-export fast paths: the common direct
7659    // hit never allocates the key, and a hit through the memo would have
7660    // returned above anyway.
7661    match visited.entry((file.to_string(), requested.to_string())) {
7662        std::collections::hash_map::Entry::Occupied(mut seen) => {
7663            if *seen.get() <= depth {
7664                return None;
7665            }
7666            seen.insert(depth);
7667        }
7668        std::collections::hash_map::Entry::Vacant(slot) => {
7669            slot.insert(depth);
7670        }
7671    }
7672
7673    for reexport in index.reexports_for(file) {
7674        let mut next_requested = requested.to_string();
7675        let matches = if reexport.wildcard {
7676            true
7677        } else if let Some(source_name) = reexport.named.get(requested) {
7678            next_requested = source_name.clone();
7679            true
7680        } else {
7681            false
7682        };
7683        if !matches {
7684            continue;
7685        }
7686        if let Some(target_file) = &reexport.target_file {
7687            if let Some(target) = resolve_exported_symbol_inner(
7688                index,
7689                target_file,
7690                &next_requested,
7691                depth + 1,
7692                visited,
7693            ) {
7694                return Some(target);
7695            }
7696        }
7697    }
7698    None
7699}
7700
7701fn resolve_rust_target(
7702    index: &ProjectIndex<'_>,
7703    caller_file: &str,
7704    full_ref: &str,
7705    short_name: &str,
7706    caller_data: &FileCallData,
7707    raw: &RawRef,
7708) -> Option<(String, String, String)> {
7709    if full_ref.contains("::") {
7710        if let Some((target_file, target_symbol)) =
7711            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7712        {
7713            return Some(("resolved".to_string(), target_file, target_symbol));
7714        }
7715    }
7716
7717    for import in &caller_data.import_block.imports {
7718        if let Some((target_file, target_symbol)) =
7719            rust_target_for_use(index, caller_file, import, short_name)
7720        {
7721            return Some(("resolved".to_string(), target_file, target_symbol));
7722        }
7723    }
7724
7725    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7726}
7727
7728fn rust_target_for_qualified(
7729    index: &ProjectIndex<'_>,
7730    caller_file: &str,
7731    full_ref: &str,
7732    short_name: &str,
7733    caller_data: &FileCallData,
7734    raw: &RawRef,
7735) -> Option<(String, String)> {
7736    let mut segments: Vec<&str> = full_ref.split("::").collect();
7737    if segments.len() < 2 {
7738        return None;
7739    }
7740    segments.pop();
7741    let requested_symbol = rust_target_symbol(full_ref, short_name);
7742
7743    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7744        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7745        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7746            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7747                return Some(rust_resolve_reexport_if_symbol_missing(
7748                    index,
7749                    target_file,
7750                    requested_symbol.clone(),
7751                ));
7752            }
7753        }
7754
7755        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7756        if let Some(target) =
7757            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7758        {
7759            return Some(target);
7760        }
7761        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7762            return Some(rust_resolve_reexport_if_symbol_missing(
7763                index,
7764                target_file,
7765                requested_symbol.clone(),
7766            ));
7767        }
7768    }
7769    None
7770}
7771
7772fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7773    full_ref
7774        .rsplit("::")
7775        .next()
7776        .filter(|name| !name.is_empty())
7777        .unwrap_or(short_name)
7778        .to_string()
7779}
7780
7781fn rust_resolve_reexport_if_symbol_missing(
7782    index: &ProjectIndex<'_>,
7783    target_file: String,
7784    target_symbol: String,
7785) -> (String, String) {
7786    if index
7787        .node_for_symbol(&target_file, &target_symbol)
7788        .is_some()
7789    {
7790        return (target_file, target_symbol);
7791    }
7792    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7793        resolved
7794    } else {
7795        (target_file, target_symbol)
7796    }
7797}
7798
7799fn rust_module_path_candidates(
7800    segments: &[&str],
7801    caller_data: &FileCallData,
7802    raw: &RawRef,
7803) -> Vec<Vec<String>> {
7804    let mut candidates = Vec::new();
7805    if let Some(first) = segments.first().copied() {
7806        for import in &caller_data.import_block.imports {
7807            if !rust_import_is_visible_to_call(import, raw) {
7808                continue;
7809            }
7810            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7811                continue;
7812            };
7813            if local_name == first {
7814                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7815                rust_push_unique_path_candidate(&mut candidates, path_segments);
7816            }
7817        }
7818    }
7819    rust_push_unique_path_candidate(
7820        &mut candidates,
7821        segments
7822            .iter()
7823            .map(|segment| (*segment).to_string())
7824            .collect(),
7825    );
7826    candidates
7827}
7828
7829fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7830    if !candidates.iter().any(|existing| existing == &candidate) {
7831        candidates.push(candidate);
7832    }
7833}
7834
7835fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7836    import.byte_range.start <= raw.byte_start
7837}
7838
7839fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7840    let path = import.module_path.trim().trim_end_matches(';').trim();
7841    if path.contains("::{") || path.contains('{') || path.contains('*') {
7842        return None;
7843    }
7844    let (path_without_alias, alias) = path
7845        .split_once(" as ")
7846        .map(|(left, right)| (left.trim(), Some(right.trim())))
7847        .unwrap_or((path, None));
7848    let segments = path_without_alias
7849        .split("::")
7850        .map(str::trim)
7851        .filter(|segment| !segment.is_empty())
7852        .collect::<Vec<_>>();
7853    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7854    if local_name.chars().next().is_some_and(char::is_uppercase) {
7855        return None;
7856    }
7857    Some((
7858        local_name,
7859        segments
7860            .into_iter()
7861            .map(|segment| segment.to_string())
7862            .collect(),
7863    ))
7864}
7865
7866fn rust_inline_scoped_target(
7867    index: &ProjectIndex<'_>,
7868    caller_file: &str,
7869    module_segments: &[String],
7870    short_name: &str,
7871) -> Option<(String, String)> {
7872    let src_prefix = rust_src_prefix(caller_file);
7873    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7874    file_paths.sort();
7875    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7876        let caller = file_paths.remove(position);
7877        file_paths.insert(0, caller);
7878    }
7879
7880    for file_path in file_paths {
7881        if index.lang_for(&file_path) != Some(LangId::Rust)
7882            || rust_src_prefix(&file_path) != src_prefix
7883        {
7884            continue;
7885        }
7886        let file_module_segments = rust_module_segments_for_rel(&file_path);
7887        if !module_segments.starts_with(&file_module_segments) {
7888            continue;
7889        }
7890        let scoped_segments = &module_segments[file_module_segments.len()..];
7891        if scoped_segments.is_empty() {
7892            continue;
7893        }
7894        let mut scoped_symbol = scoped_segments.join("::");
7895        scoped_symbol.push_str("::");
7896        scoped_symbol.push_str(short_name);
7897        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7898            return Some((file_path, scoped_symbol));
7899        }
7900    }
7901    None
7902}
7903
7904fn rust_target_for_use(
7905    index: &ProjectIndex<'_>,
7906    caller_file: &str,
7907    import: &ImportStatement,
7908    short_name: &str,
7909) -> Option<(String, String)> {
7910    let path = import.module_path.trim().trim_end_matches(';');
7911    if let Some(brace_start) = path.find("::{") {
7912        let prefix = &path[..brace_start];
7913        if import.names.iter().any(|name| name == short_name) {
7914            let prefix_segments: Vec<&str> = prefix.split("::").collect();
7915            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7916            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7917            return Some((file, short_name.to_string()));
7918        }
7919        return None;
7920    }
7921
7922    let (path_without_alias, alias) = path
7923        .split_once(" as ")
7924        .map(|(left, right)| (left.trim(), Some(right.trim())))
7925        .unwrap_or((path, None));
7926    let segments: Vec<&str> = path_without_alias.split("::").collect();
7927    let imported = alias.or_else(|| segments.last().copied())?;
7928    if imported != short_name {
7929        return None;
7930    }
7931    if segments.len() < 2 {
7932        return None;
7933    }
7934    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7935    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7936    Some((file, segments.last().unwrap_or(&short_name).to_string()))
7937}
7938
7939fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7940    let crate_name = segments.first().copied()?;
7941    let src_prefix = index.crate_src_prefix(crate_name)?;
7942    let module_segments = segments[1..]
7943        .iter()
7944        .map(|segment| segment.to_string())
7945        .collect::<Vec<_>>();
7946    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7947}
7948
7949#[cfg(test)]
7950static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7951    OnceLock::new();
7952
7953#[cfg(test)]
7954fn note_workspace_crate_prefix_build(project_root: &Path) {
7955    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7956        .get_or_init(|| Mutex::new(HashMap::new()))
7957        .lock()
7958        .expect("workspace crate prefix build counts mutex poisoned");
7959    *counts.entry(project_root.to_path_buf()).or_default() += 1;
7960}
7961
7962#[cfg(not(test))]
7963fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7964
7965#[cfg(test)]
7966fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7967    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7968        .get_or_init(|| Mutex::new(HashMap::new()))
7969        .lock()
7970        .expect("workspace crate prefix build counts mutex poisoned")
7971        .remove(project_root);
7972}
7973
7974#[cfg(test)]
7975fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7976    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7977        .get_or_init(|| Mutex::new(HashMap::new()))
7978        .lock()
7979        .expect("workspace crate prefix build counts mutex poisoned")
7980        .get(project_root)
7981        .copied()
7982        .unwrap_or(0)
7983}
7984
7985/// Walk the project tree once and map every Rust crate name (package name with
7986/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
7987/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
7988/// longer re-walks the filesystem once per ref.
7989fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7990    note_workspace_crate_prefix_build(project_root);
7991    let mut prefixes = HashMap::new();
7992    let mut stack = vec![project_root.to_path_buf()];
7993    while let Some(dir) = stack.pop() {
7994        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7995        if matches!(name, "target" | "node_modules" | ".git") {
7996            continue;
7997        }
7998        let manifest = dir.join("Cargo.toml");
7999        if manifest.is_file() {
8000            let crate_names = rust_manifest_crate_names(&manifest);
8001            if !crate_names.is_empty() {
8002                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8003                for crate_name in crate_names {
8004                    prefixes
8005                        .entry(crate_name)
8006                        .or_insert_with(|| src_prefix.clone());
8007                }
8008            }
8009        }
8010        let Ok(entries) = std::fs::read_dir(&dir) else {
8011            continue;
8012        };
8013        for entry in entries.flatten() {
8014            let path = entry.path();
8015            if path.is_dir() {
8016                stack.push(path);
8017            }
8018        }
8019    }
8020    prefixes
8021}
8022
8023/// Extract the crate names a manifest defines: the normalized package name
8024/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
8025/// reachable by either spelling, matching the previous match semantics.
8026fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8027    let Ok(source) = std::fs::read_to_string(manifest) else {
8028        return Vec::new();
8029    };
8030    let mut in_lib = false;
8031    let mut package_name = None;
8032    let mut lib_name = None;
8033    for line in source.lines() {
8034        let trimmed = line.trim();
8035        if trimmed.starts_with('[') {
8036            in_lib = trimmed == "[lib]";
8037            continue;
8038        }
8039        let Some((key, value)) = trimmed.split_once('=') else {
8040            continue;
8041        };
8042        let key = key.trim();
8043        let value = value.trim().trim_matches('"');
8044        if in_lib && key == "name" {
8045            lib_name = Some(value.to_string());
8046        } else if !in_lib && key == "name" && package_name.is_none() {
8047            package_name = Some(value.to_string());
8048        }
8049    }
8050    let mut names = Vec::new();
8051    if let Some(lib) = lib_name {
8052        names.push(lib);
8053    }
8054    if let Some(package) = package_name {
8055        let normalized = package.replace('-', "_");
8056        if !names.contains(&normalized) {
8057            names.push(normalized);
8058        }
8059    }
8060    names
8061}
8062
8063fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8064    if segments.is_empty() {
8065        return Some(Vec::new());
8066    }
8067    let caller_segments = rust_module_segments_for_rel(caller_file);
8068    match segments[0] {
8069        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8070        "self" => {
8071            let mut resolved = caller_segments;
8072            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8073            Some(resolved)
8074        }
8075        "super" => {
8076            let mut resolved = caller_segments;
8077            resolved.pop();
8078            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8079            Some(resolved)
8080        }
8081        _ => {
8082            let mut resolved = caller_segments;
8083            resolved.pop();
8084            resolved.extend(segments.iter().map(|item| item.to_string()));
8085            Some(resolved)
8086        }
8087    }
8088}
8089
8090fn rust_file_for_segments(
8091    index: &ProjectIndex<'_>,
8092    caller_file: &str,
8093    segments: &[String],
8094) -> Option<String> {
8095    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8096}
8097
8098fn rust_file_for_src_prefix(
8099    index: &ProjectIndex<'_>,
8100    src_prefix: &str,
8101    segments: &[String],
8102) -> Option<String> {
8103    let candidate = if segments.is_empty() {
8104        [src_prefix, "lib.rs"].join("/")
8105    } else {
8106        format!("{}/{}.rs", src_prefix, segments.join("/"))
8107    };
8108    if index.files.contains_key(&candidate) {
8109        return Some(candidate);
8110    }
8111    if !segments.is_empty() {
8112        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8113        if index.files.contains_key(&mod_candidate) {
8114            return Some(mod_candidate);
8115        }
8116    }
8117    None
8118}
8119
8120fn rust_src_prefix(rel_path: &str) -> String {
8121    rel_path
8122        .split_once("/src/")
8123        .map(|(prefix, _)| format!("{prefix}/src"))
8124        .unwrap_or_else(|| "src".to_string())
8125}
8126
8127fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8128    let after_src = rel_path
8129        .split_once("/src/")
8130        .map(|(_, rest)| rest)
8131        .or_else(|| rel_path.strip_prefix("src/"))
8132        .unwrap_or(rel_path);
8133    if matches!(after_src, "lib.rs" | "main.rs") {
8134        return Vec::new();
8135    }
8136    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8137        return prefix.split('/').map(|item| item.to_string()).collect();
8138    }
8139    after_src
8140        .strip_suffix(".rs")
8141        .unwrap_or(after_src)
8142        .split('/')
8143        .map(|item| item.to_string())
8144        .collect()
8145}
8146
8147fn resolve_local_target(
8148    _index: &ProjectIndex<'_>,
8149    caller_file: &str,
8150    full_ref: &str,
8151    short_name: &str,
8152    caller_data: &FileCallData,
8153) -> Option<(String, String, String)> {
8154    if !callgraph::is_bare_callee(full_ref, short_name) {
8155        return None;
8156    }
8157    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8158        .ok()
8159        .map(|symbol| {
8160            (
8161                "resolved_local".to_string(),
8162                caller_file.to_string(),
8163                symbol,
8164            )
8165        })
8166}
8167
8168impl<'a> ProjectIndex<'a> {
8169    fn from_parts(
8170        project_root: &Path,
8171        files: HashMap<String, DbFileIndex>,
8172        caller_data: HashMap<String, &'a FileCallData>,
8173        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8174    ) -> Self {
8175        Self {
8176            project_root: project_root.to_path_buf(),
8177            files,
8178            caller_data,
8179            workspace_crate_prefixes,
8180        }
8181    }
8182
8183    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8184        let mut files = HashMap::new();
8185        let mut caller_data = HashMap::new();
8186        for extract in extracts {
8187            let index = DbFileIndex::from_extract(project_root, extract);
8188            caller_data.insert(extract.rel_path.clone(), &extract.data);
8189            files.insert(extract.rel_path.clone(), index);
8190        }
8191        Self::from_parts(
8192            project_root,
8193            files,
8194            caller_data,
8195            WorkspaceCratePrefixCache::default(),
8196        )
8197    }
8198
8199    fn from_db_and_callers(
8200        tx: &Transaction<'_>,
8201        project_root: &Path,
8202        caller_extracts: &'a HashMap<String, FileExtract>,
8203        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8204    ) -> Result<Self> {
8205        let mut files = load_db_file_indexes(tx, project_root)?;
8206        let mut caller_data = HashMap::new();
8207        for (rel_path, extract) in caller_extracts {
8208            files.insert(
8209                rel_path.clone(),
8210                DbFileIndex::from_extract(project_root, extract),
8211            );
8212            caller_data.insert(rel_path.clone(), &extract.data);
8213        }
8214        Ok(Self::from_parts(
8215            project_root,
8216            files,
8217            caller_data,
8218            workspace_crate_prefixes,
8219        ))
8220    }
8221
8222    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8223        self.files.get(rel_path).and_then(|file| file.lang)
8224    }
8225
8226    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8227        self.files
8228            .get(caller_file)
8229            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8230    }
8231
8232    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8233        self.files
8234            .get(rel_path)
8235            .map(|file| file.reexports.as_slice())
8236            .unwrap_or(&[])
8237    }
8238
8239    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8240        self.files.get(rel_path).and_then(|file| {
8241            file.node_by_scoped
8242                .get(symbol)
8243                .cloned()
8244                .or_else(|| file.node_by_bare.get(symbol).cloned())
8245        })
8246    }
8247}
8248
8249impl DbFileIndex {
8250    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8251        let mut node_by_scoped = HashMap::new();
8252        let mut node_by_bare = HashMap::new();
8253        for node in &extract.nodes {
8254            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8255            node_by_bare
8256                .entry(node.name.clone())
8257                .or_insert(node.id.clone());
8258        }
8259        let mut export_aliases = HashMap::new();
8260        for raw_ref in &extract.raw_refs {
8261            if raw_ref.kind == "export_alias" {
8262                if let (Some(exported), Some(source_symbol)) =
8263                    (&raw_ref.local_name, &raw_ref.requested_name)
8264                {
8265                    export_aliases.insert(exported.clone(), source_symbol.clone());
8266                }
8267            }
8268        }
8269        let mut module_targets = HashMap::new();
8270        let mut reexports = Vec::new();
8271        for raw_ref in &extract.raw_refs {
8272            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8273                continue;
8274            }
8275            let Some(module_path) = &raw_ref.module_path else {
8276                continue;
8277            };
8278            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8279            module_targets
8280                .entry(module_path.clone())
8281                .or_insert_with(|| target_file.clone());
8282            if raw_ref.kind == "reexport" {
8283                reexports.push(reexport_index_from_raw(raw_ref, target_file));
8284            }
8285        }
8286        Self {
8287            lang: Some(extract.lang),
8288            exports: extract.data.exported_symbols.iter().cloned().collect(),
8289            default_export: extract.data.default_export_symbol.clone(),
8290            export_aliases,
8291            node_by_scoped,
8292            node_by_bare,
8293            module_targets,
8294            reexports,
8295        }
8296    }
8297}
8298
8299fn load_db_file_indexes(
8300    tx: &Transaction<'_>,
8301    project_root: &Path,
8302) -> Result<HashMap<String, DbFileIndex>> {
8303    let mut files = HashMap::new();
8304    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8305    let rows = stmt.query_map([], |row| {
8306        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8307    })?;
8308    for row in rows {
8309        let (rel_path, lang) = row?;
8310        files.insert(
8311            rel_path.clone(),
8312            DbFileIndex {
8313                lang: lang_from_label(&lang),
8314                exports: HashSet::new(),
8315                default_export: None,
8316                export_aliases: HashMap::new(),
8317                node_by_scoped: HashMap::new(),
8318                node_by_bare: HashMap::new(),
8319                module_targets: HashMap::new(),
8320                reexports: Vec::new(),
8321            },
8322        );
8323    }
8324
8325    let mut node_stmt = tx.prepare(
8326        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8327    )?;
8328    let nodes = node_stmt.query_map([], |row| {
8329        Ok((
8330            row.get::<_, String>(0)?,
8331            row.get::<_, String>(1)?,
8332            row.get::<_, String>(2)?,
8333            row.get::<_, String>(3)?,
8334            row.get::<_, i64>(4)? != 0,
8335            row.get::<_, i64>(5)? != 0,
8336        ))
8337    })?;
8338    for row in nodes {
8339        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8340        let file = files
8341            .entry(file_path.clone())
8342            .or_insert_with(|| DbFileIndex {
8343                lang: None,
8344                exports: HashSet::new(),
8345                default_export: None,
8346                export_aliases: HashMap::new(),
8347                node_by_scoped: HashMap::new(),
8348                node_by_bare: HashMap::new(),
8349                module_targets: HashMap::new(),
8350                reexports: Vec::new(),
8351            });
8352        if exported {
8353            file.exports.insert(name.clone());
8354            file.exports.insert(scoped_name.clone());
8355        }
8356        if is_default_export {
8357            file.default_export = Some(scoped_name.clone());
8358        }
8359        file.node_by_scoped.insert(scoped_name, id.clone());
8360        file.node_by_bare.entry(name).or_insert(id);
8361    }
8362    let file_keys: HashSet<String> = files.keys().cloned().collect();
8363    // Persisted caller extracts supply import targets. Only reexports from other
8364    // files need dependency reconstruction, and their caller dependencies are
8365    // loaded once instead of issuing repeated SQLite queries per reference.
8366    let dependencies_by_file = load_file_dependencies_index(tx)?;
8367    let mut ref_stmt = tx.prepare(
8368        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8369         FROM refs WHERE kind IN ('reexport', 'export_alias')",
8370    )?;
8371    let ref_rows = ref_stmt.query_map([], |row| {
8372        Ok((
8373            row.get::<_, String>(0)?,
8374            row.get::<_, String>(1)?,
8375            row.get::<_, String>(2)?,
8376            row.get::<_, Option<String>>(3)?,
8377            row.get::<_, Option<String>>(4)?,
8378            row.get::<_, i64>(5)? != 0,
8379            row.get::<_, Option<String>>(6)?,
8380            row.get::<_, Option<String>>(7)?,
8381        ))
8382    })?;
8383    for row in ref_rows {
8384        let (
8385            ref_id,
8386            caller_file,
8387            kind,
8388            module_path,
8389            full_ref,
8390            wildcard,
8391            local_name,
8392            requested_name,
8393        ) = row?;
8394        if kind == "export_alias" {
8395            if let (Some(exported), Some(source_symbol), Some(file)) =
8396                (local_name, requested_name, files.get_mut(&caller_file))
8397            {
8398                file.export_aliases.insert(exported, source_symbol);
8399            }
8400            continue;
8401        }
8402        let Some(module_path) = module_path else {
8403            continue;
8404        };
8405        let file_deps = dependencies_by_file
8406            .get(&caller_file)
8407            .cloned()
8408            .unwrap_or_default();
8409        let deps = stored_dependencies_for_module(
8410            project_root,
8411            &caller_file,
8412            &module_path,
8413            &file_deps,
8414            &file_keys,
8415        );
8416        let target_file = deps
8417            .iter()
8418            .find(|dep| file_keys.contains(*dep))
8419            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8420        if let Some(file) = files.get_mut(&caller_file) {
8421            file.module_targets
8422                .entry(module_path.clone())
8423                .or_insert_with(|| target_file.clone());
8424            if kind == "reexport" {
8425                let raw = RawRef {
8426                    ref_id,
8427                    caller_node: None,
8428                    caller_symbol: None,
8429                    caller_file,
8430                    kind,
8431                    short_name: None,
8432                    full_ref,
8433                    module_path: Some(module_path),
8434                    import_kind: Some("reexport".to_string()),
8435                    local_name: None,
8436                    requested_name: None,
8437                    namespace_alias: None,
8438                    wildcard,
8439                    line: 0,
8440                    byte_start: 0,
8441                    byte_end: 0,
8442                    dependencies: deps,
8443                };
8444                file.reexports
8445                    .push(reexport_index_from_raw(&raw, target_file));
8446            }
8447        }
8448    }
8449
8450    Ok(files)
8451}
8452
8453fn stored_dependencies_for_module(
8454    project_root: &Path,
8455    caller_file: &str,
8456    module_path: &str,
8457    caller_dependencies: &BTreeSet<String>,
8458    indexed_files: &HashSet<String>,
8459) -> BTreeSet<String> {
8460    let caller_path = project_root.join(caller_file);
8461    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8462    if module_path.starts_with('.') {
8463        let caller_dir = caller_path.parent().unwrap_or(project_root);
8464        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8465            let normalized = if candidate.is_file() {
8466                canonicalize_path(&candidate)
8467            } else {
8468                candidate
8469            };
8470            candidates.insert(relative_path(project_root, &normalized));
8471        }
8472    }
8473    let exact = candidates
8474        .intersection(caller_dependencies)
8475        .filter(|dependency| indexed_files.contains(*dependency))
8476        .cloned()
8477        .collect::<BTreeSet<_>>();
8478    if !exact.is_empty() || module_path.starts_with('.') {
8479        return exact;
8480    }
8481
8482    let module_path = rust_module_path_without_alias_or_use_list(module_path)
8483        .trim_matches(|character| matches!(character, '\'' | '"'));
8484    let package_name = module_path
8485        .split('/')
8486        .next_back()
8487        .unwrap_or(module_path)
8488        .replace('_', "-");
8489    let matched = caller_dependencies
8490        .iter()
8491        .filter(|dependency| indexed_files.contains(*dependency))
8492        .filter(|dependency| {
8493            dependency.as_str() == module_path
8494                || dependency.ends_with(&format!("/{module_path}"))
8495                || Path::new(dependency).components().any(|component| {
8496                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8497                })
8498        })
8499        .cloned()
8500        .collect::<BTreeSet<_>>();
8501    if matched.len() == 1 {
8502        matched
8503    } else {
8504        BTreeSet::new()
8505    }
8506}
8507
8508fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8509    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8510    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8511    let rows = stmt.query_map([], |row| {
8512        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8513    })?;
8514    for row in rows {
8515        let (file_path, dependency) = row?;
8516        by_file.entry(file_path).or_default().insert(dependency);
8517    }
8518    Ok(by_file)
8519}
8520
8521struct ColdBuildInsertStatements<'stmt> {
8522    file: Statement<'stmt>,
8523    node: Statement<'stmt>,
8524    file_dependency: Statement<'stmt>,
8525    dispatch_hint: Statement<'stmt>,
8526    backend_state: Statement<'stmt>,
8527    reference: Statement<'stmt>,
8528    edge: Statement<'stmt>,
8529}
8530
8531impl<'stmt> ColdBuildInsertStatements<'stmt> {
8532    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8533        Ok(Self {
8534            file: tx.prepare(
8535                "INSERT OR REPLACE INTO files(
8536                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8537                    is_public_api, surface_fingerprint, indexed_at
8538                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8539            )?,
8540            node: tx.prepare(
8541                "INSERT OR REPLACE INTO nodes(
8542                    id, file_path, name, scoped_name, kind, start_line, start_col,
8543                    end_line, end_col, range_ordinal, signature, exported,
8544                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
8545                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8546            )?,
8547            file_dependency: tx.prepare(
8548                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8549            )?,
8550            dispatch_hint: tx.prepare(
8551                "INSERT OR REPLACE INTO dispatch_hints(
8552                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8553                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8554            )?,
8555            backend_state: tx.prepare(
8556                "INSERT OR REPLACE INTO backend_file_state(
8557                    backend, workspace_root, file_path, content_hash, status, updated_at
8558                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8559            )?,
8560            reference: tx.prepare(
8561                "INSERT OR REPLACE INTO refs(
8562                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8563                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8564                    byte_start, byte_end, status, target_node, target_file, target_symbol,
8565                    provenance
8566                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8567            )?,
8568            edge: tx.prepare(
8569                "INSERT OR REPLACE INTO edges(
8570                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8571                    kind, line, provenance
8572                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8573            )?,
8574        })
8575    }
8576}
8577
8578fn insert_file_extract_prepared(
8579    statements: &mut ColdBuildInsertStatements<'_>,
8580    workspace_root: &str,
8581    extract: &FileExtract,
8582) -> Result<()> {
8583    statements.file.execute(params![
8584        extract.rel_path,
8585        hash_to_hex(extract.freshness.content_hash),
8586        system_time_to_ns(extract.freshness.mtime),
8587        extract.freshness.size as i64,
8588        lang_label(extract.lang),
8589        extract.surface_fingerprint,
8590        unix_seconds_now(),
8591    ])?;
8592    for node in &extract.nodes {
8593        statements.node.execute(params![
8594            node.id,
8595            node.file_path,
8596            node.name,
8597            node.scoped_name,
8598            node.kind,
8599            node.range.start_line as i64,
8600            node.range.start_col as i64,
8601            node.range.end_line as i64,
8602            node.range.end_col as i64,
8603            node.range_ordinal as i64,
8604            node.signature,
8605            bool_int(node.exported),
8606            bool_int(node.is_default_export),
8607            bool_int(node.is_type_like),
8608            bool_int(node.is_callgraph_entry_point),
8609            PROVENANCE_TREESITTER,
8610        ])?;
8611    }
8612
8613    let mut dependencies = BTreeSet::new();
8614    for raw_ref in &extract.raw_refs {
8615        dependencies.extend(raw_ref.dependencies.iter().cloned());
8616    }
8617    for dep_file in &dependencies {
8618        statements
8619            .file_dependency
8620            .execute(params![extract.rel_path, dep_file])?;
8621    }
8622
8623    for hint in &extract.dispatch_hints {
8624        statements.dispatch_hint.execute(params![
8625            hint.id,
8626            hint.method_name,
8627            hint.caller_node,
8628            hint.file,
8629            hint.line as i64,
8630            hint.byte_start as i64,
8631            hint.byte_end as i64,
8632            PROVENANCE_TREESITTER,
8633        ])?;
8634    }
8635    insert_backend_state_prepared(
8636        &mut statements.backend_state,
8637        workspace_root,
8638        &extract.rel_path,
8639        Some(&extract.freshness.content_hash),
8640        "fresh",
8641    )?;
8642    Ok(())
8643}
8644
8645fn insert_backend_state_prepared(
8646    stmt: &mut Statement<'_>,
8647    workspace_root: &str,
8648    rel_path: &str,
8649    content_hash: Option<&blake3::Hash>,
8650    status: &str,
8651) -> Result<()> {
8652    let hash = content_hash
8653        .map(|hash| hash_to_hex(*hash))
8654        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8655    stmt.execute(params![
8656        BACKEND_TREESITTER,
8657        workspace_root,
8658        rel_path,
8659        hash,
8660        status,
8661        unix_seconds_now(),
8662    ])?;
8663    Ok(())
8664}
8665
8666fn insert_resolved_ref_prepared(
8667    statements: &mut ColdBuildInsertStatements<'_>,
8668    resolved: &ResolvedRef,
8669) -> Result<()> {
8670    let raw = &resolved.raw;
8671    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8672    statements.reference.execute(params![
8673        raw.ref_id,
8674        raw.caller_node,
8675        raw.caller_file,
8676        raw.kind,
8677        raw.short_name,
8678        raw.full_ref,
8679        raw.module_path,
8680        raw.import_kind,
8681        raw.local_name,
8682        raw.requested_name,
8683        raw.namespace_alias,
8684        bool_int(raw.wildcard),
8685        raw.line as i64,
8686        raw.byte_start as i64,
8687        raw.byte_end as i64,
8688        resolved.status,
8689        resolved.target_node,
8690        resolved.target_file,
8691        resolved.target_symbol,
8692        PROVENANCE_TREESITTER,
8693    ])?;
8694    if let Some(edge) = &resolved.edge {
8695        statements.edge.execute(params![
8696            edge.edge_id,
8697            raw.ref_id,
8698            edge.source_node,
8699            edge.target_node,
8700            edge.target_file,
8701            edge.target_symbol,
8702            edge.kind,
8703            edge.line as i64,
8704            PROVENANCE_TREESITTER,
8705        ])?;
8706    }
8707    Ok(())
8708}
8709
8710fn insert_file_extract(
8711    tx: &Transaction<'_>,
8712    project_root: &Path,
8713    extract: &FileExtract,
8714) -> Result<()> {
8715    tx.execute(
8716        "INSERT OR REPLACE INTO files(
8717            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8718            is_public_api, surface_fingerprint, indexed_at
8719        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8720        params![
8721            extract.rel_path,
8722            hash_to_hex(extract.freshness.content_hash),
8723            system_time_to_ns(extract.freshness.mtime),
8724            extract.freshness.size as i64,
8725            lang_label(extract.lang),
8726            extract.surface_fingerprint,
8727            unix_seconds_now(),
8728        ],
8729    )?;
8730    for node in &extract.nodes {
8731        tx.execute(
8732            "INSERT OR REPLACE INTO nodes(
8733                id, file_path, name, scoped_name, kind, start_line, start_col,
8734                end_line, end_col, range_ordinal, signature, exported,
8735                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8736            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8737            params![
8738                node.id,
8739                node.file_path,
8740                node.name,
8741                node.scoped_name,
8742                node.kind,
8743                node.range.start_line as i64,
8744                node.range.start_col as i64,
8745                node.range.end_line as i64,
8746                node.range.end_col as i64,
8747                node.range_ordinal as i64,
8748                node.signature,
8749                bool_int(node.exported),
8750                bool_int(node.is_default_export),
8751                bool_int(node.is_type_like),
8752                bool_int(node.is_callgraph_entry_point),
8753                PROVENANCE_TREESITTER,
8754            ],
8755        )?;
8756    }
8757    let mut dependencies = BTreeSet::new();
8758    for raw_ref in &extract.raw_refs {
8759        dependencies.extend(raw_ref.dependencies.iter().cloned());
8760    }
8761    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8762
8763    for hint in &extract.dispatch_hints {
8764        tx.execute(
8765            "INSERT OR REPLACE INTO dispatch_hints(
8766                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8767            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8768            params![
8769                hint.id,
8770                hint.method_name,
8771                hint.caller_node,
8772                hint.file,
8773                hint.line as i64,
8774                hint.byte_start as i64,
8775                hint.byte_end as i64,
8776                PROVENANCE_TREESITTER,
8777            ],
8778        )?;
8779    }
8780    mark_backend_state(
8781        tx,
8782        project_root,
8783        &extract.rel_path,
8784        Some(&extract.freshness.content_hash),
8785        "fresh",
8786    )?;
8787    Ok(())
8788}
8789
8790fn insert_file_dependencies(
8791    tx: &Transaction<'_>,
8792    file_path: &str,
8793    dependencies: &BTreeSet<String>,
8794) -> Result<()> {
8795    for dep_file in dependencies {
8796        tx.execute(
8797            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8798            params![file_path, dep_file],
8799        )?;
8800    }
8801    Ok(())
8802}
8803
8804fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8805    let raw = &resolved.raw;
8806    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8807    tx.execute(
8808        "INSERT OR REPLACE INTO refs(
8809            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8810            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8811            byte_start, byte_end, status, target_node, target_file, target_symbol,
8812            provenance
8813        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8814        params![
8815            raw.ref_id,
8816            raw.caller_node,
8817            raw.caller_file,
8818            raw.kind,
8819            raw.short_name,
8820            raw.full_ref,
8821            raw.module_path,
8822            raw.import_kind,
8823            raw.local_name,
8824            raw.requested_name,
8825            raw.namespace_alias,
8826            bool_int(raw.wildcard),
8827            raw.line as i64,
8828            raw.byte_start as i64,
8829            raw.byte_end as i64,
8830            resolved.status,
8831            resolved.target_node,
8832            resolved.target_file,
8833            resolved.target_symbol,
8834            PROVENANCE_TREESITTER,
8835        ],
8836    )?;
8837    if let Some(edge) = &resolved.edge {
8838        tx.execute(
8839            "INSERT OR REPLACE INTO edges(
8840                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8841                kind, line, provenance
8842            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8843            params![
8844                edge.edge_id,
8845                raw.ref_id,
8846                edge.source_node,
8847                edge.target_node,
8848                edge.target_file,
8849                edge.target_symbol,
8850                edge.kind,
8851                edge.line as i64,
8852                PROVENANCE_TREESITTER,
8853            ],
8854        )?;
8855    }
8856    Ok(())
8857}
8858
8859fn insert_method_dispatch_edges(
8860    tx: &Transaction<'_>,
8861    project_root: &Path,
8862    caller_files: Option<&BTreeSet<String>>,
8863) -> Result<usize> {
8864    let references = load_name_match_refs(tx, caller_files)?;
8865    if references.is_empty() {
8866        return Ok(0);
8867    }
8868
8869    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8870    let mut source_cache: DispatchSourceCache = HashMap::new();
8871    let mut inserted = 0usize;
8872    for reference in references {
8873        let key = (reference.method_name.clone(), reference.lang.clone());
8874        let candidates = match candidates_by_name.entry(key) {
8875            Entry::Occupied(entry) => entry.into_mut(),
8876            Entry::Vacant(entry) => {
8877                let candidates =
8878                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8879                entry.insert(candidates)
8880            }
8881        };
8882
8883        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
8884            ReceiverTypeInference::Known(receiver_type) => {
8885                let Some(candidate) =
8886                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8887                else {
8888                    continue;
8889                };
8890                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8891                inserted += 1;
8892                continue;
8893            }
8894            ReceiverTypeInference::RustDirectSelfField {
8895                receiver_type,
8896                declaration_file,
8897                module_scope,
8898            } => {
8899                let Some(candidate) = select_rust_direct_self_field_candidate(
8900                    project_root,
8901                    &reference,
8902                    candidates.as_slice(),
8903                    &receiver_type,
8904                    &declaration_file,
8905                    &module_scope,
8906                    &mut source_cache,
8907                ) else {
8908                    continue;
8909                };
8910                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8911                inserted += 1;
8912                continue;
8913            }
8914            ReceiverTypeInference::KnownButUnresolved => continue,
8915            ReceiverTypeInference::Unknown => {}
8916        }
8917
8918        if method_name_match_denylisted(&reference.method_name) {
8919            continue;
8920        }
8921
8922        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8923            continue;
8924        };
8925        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8926        inserted += 1;
8927    }
8928    Ok(inserted)
8929}
8930
8931fn insert_method_dispatch_edges_chunked(
8932    tx: &Transaction<'_>,
8933    project_root: &Path,
8934    caller_files: &BTreeSet<String>,
8935    chunk_size: usize,
8936) -> Result<usize> {
8937    if caller_files.is_empty() {
8938        return Ok(0);
8939    }
8940    if chunk_size == 0 || caller_files.len() <= chunk_size {
8941        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8942    }
8943
8944    let mut inserted = 0usize;
8945    let mut batch = BTreeSet::new();
8946    for caller_file in caller_files {
8947        batch.insert(caller_file.clone());
8948        if batch.len() == chunk_size {
8949            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8950            batch.clear();
8951        }
8952    }
8953    if !batch.is_empty() {
8954        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8955    }
8956    Ok(inserted)
8957}
8958
8959fn insert_method_dispatch_edge(
8960    tx: &Transaction<'_>,
8961    reference: &NameMatchRef,
8962    candidate: &NameMatchCandidate,
8963    provenance: &str,
8964) -> Result<()> {
8965    tx.execute(
8966        "INSERT OR REPLACE INTO edges(
8967            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8968            kind, line, provenance
8969        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8970        params![
8971            ref_id(&[&reference.ref_id, provenance, "edge"]),
8972            &reference.ref_id,
8973            &reference.caller_node,
8974            &candidate.node_id,
8975            &candidate.file_path,
8976            &candidate.scoped_name,
8977            reference.line as i64,
8978            provenance,
8979        ],
8980    )?;
8981    Ok(())
8982}
8983
8984fn delete_method_dispatch_edges_for_callers(
8985    tx: &Transaction<'_>,
8986    caller_files: &BTreeSet<String>,
8987) -> Result<()> {
8988    if caller_files.is_empty() {
8989        return Ok(());
8990    }
8991
8992    let mut stmt = tx.prepare(
8993        "DELETE FROM edges
8994         WHERE provenance IN (?1, ?2)
8995           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8996    )?;
8997    for caller_file in caller_files {
8998        stmt.execute(params![
8999            PROVENANCE_NAME_MATCH,
9000            PROVENANCE_TYPE_MATCH,
9001            caller_file
9002        ])?;
9003    }
9004    Ok(())
9005}
9006
9007fn load_name_match_refs(
9008    tx: &Transaction<'_>,
9009    caller_files: Option<&BTreeSet<String>>,
9010) -> Result<Vec<NameMatchRef>> {
9011    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9012                           n.signature, r.short_name, r.full_ref, r.line, f.lang
9013                    FROM refs r
9014                    JOIN files f ON f.path = r.caller_file
9015                    JOIN nodes n ON n.id = r.caller_node
9016                    WHERE r.kind = 'call'
9017                      AND r.status = 'unresolved'
9018                      AND r.caller_node IS NOT NULL
9019                      AND r.full_ref IS NOT NULL
9020                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9021                      AND NOT EXISTS (
9022                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9023                      )";
9024    let mut references = Vec::new();
9025
9026    if let Some(caller_files) = caller_files {
9027        if caller_files.is_empty() {
9028            return Ok(references);
9029        }
9030        let sql = format!(
9031            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9032        );
9033        let mut stmt = tx.prepare(&sql)?;
9034        for caller_file in caller_files {
9035            let rows = stmt.query_map(params![caller_file], |row| {
9036                Ok((
9037                    row.get::<_, String>(0)?,
9038                    row.get::<_, Option<String>>(1)?,
9039                    row.get::<_, String>(2)?,
9040                    row.get::<_, String>(3)?,
9041                    row.get::<_, Option<String>>(4)?,
9042                    row.get::<_, Option<String>>(5)?,
9043                    row.get::<_, Option<String>>(6)?,
9044                    row.get::<_, i64>(7)?,
9045                    row.get::<_, String>(8)?,
9046                ))
9047            })?;
9048            for row in rows {
9049                let (
9050                    ref_id,
9051                    caller_node,
9052                    caller_file,
9053                    caller_symbol,
9054                    caller_signature,
9055                    short_name,
9056                    full_ref,
9057                    line,
9058                    lang,
9059                ) = row?;
9060                if let Some(reference) = name_match_ref_from_parts(
9061                    ref_id,
9062                    caller_node,
9063                    caller_file,
9064                    caller_symbol,
9065                    caller_signature,
9066                    short_name,
9067                    full_ref,
9068                    line,
9069                    lang,
9070                ) {
9071                    references.push(reference);
9072                }
9073            }
9074        }
9075        return Ok(references);
9076    }
9077
9078    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9079    let mut stmt = tx.prepare(&sql)?;
9080    let rows = stmt.query_map([], |row| {
9081        Ok((
9082            row.get::<_, String>(0)?,
9083            row.get::<_, Option<String>>(1)?,
9084            row.get::<_, String>(2)?,
9085            row.get::<_, String>(3)?,
9086            row.get::<_, Option<String>>(4)?,
9087            row.get::<_, Option<String>>(5)?,
9088            row.get::<_, Option<String>>(6)?,
9089            row.get::<_, i64>(7)?,
9090            row.get::<_, String>(8)?,
9091        ))
9092    })?;
9093    for row in rows {
9094        let (
9095            ref_id,
9096            caller_node,
9097            caller_file,
9098            caller_symbol,
9099            caller_signature,
9100            short_name,
9101            full_ref,
9102            line,
9103            lang,
9104        ) = row?;
9105        if let Some(reference) = name_match_ref_from_parts(
9106            ref_id,
9107            caller_node,
9108            caller_file,
9109            caller_symbol,
9110            caller_signature,
9111            short_name,
9112            full_ref,
9113            line,
9114            lang,
9115        ) {
9116            references.push(reference);
9117        }
9118    }
9119    Ok(references)
9120}
9121
9122#[allow(clippy::too_many_arguments)]
9123fn name_match_ref_from_parts(
9124    ref_id: String,
9125    caller_node: Option<String>,
9126    caller_file: String,
9127    caller_symbol: String,
9128    caller_signature: Option<String>,
9129    short_name: Option<String>,
9130    full_ref: Option<String>,
9131    line: i64,
9132    lang: String,
9133) -> Option<NameMatchRef> {
9134    let caller_node = caller_node?;
9135    let full_ref = full_ref?;
9136    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9137    let method_name = if member.is_empty() {
9138        short_name.as_deref()?.to_string()
9139    } else {
9140        member
9141    };
9142    Some(NameMatchRef {
9143        ref_id,
9144        caller_node,
9145        caller_file,
9146        caller_symbol,
9147        caller_signature,
9148        receiver_expression,
9149        receiver,
9150        method_name,
9151        colon_dispatch,
9152        line: line.max(0) as u32,
9153        lang,
9154    })
9155}
9156
9157fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9158    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9159    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9160    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9161    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9162        .into_iter()
9163        .flatten()
9164        .max_by_key(|(index, _, _)| *index)?;
9165    if delimiter == 0 {
9166        return None;
9167    }
9168    let member_start = delimiter + delimiter_len;
9169    if member_start >= full_ref.len() {
9170        return None;
9171    }
9172    let receiver_expression = full_ref[..delimiter].trim();
9173    let receiver = last_name_segment(receiver_expression).trim();
9174    let member = &full_ref[member_start..];
9175    if receiver.is_empty() || member.is_empty() {
9176        return None;
9177    }
9178    Some((
9179        receiver_expression.to_string(),
9180        receiver.to_string(),
9181        member.to_string(),
9182        colon_dispatch,
9183    ))
9184}
9185
9186fn last_name_segment(value: &str) -> &str {
9187    value
9188        .rsplit(['.', ':', '/', '\\', '-', '>'])
9189        .find(|segment| !segment.is_empty())
9190        .unwrap_or(value)
9191}
9192
9193fn load_name_match_candidates(
9194    tx: &Transaction<'_>,
9195    method_name: &str,
9196    lang: &str,
9197) -> Result<Vec<NameMatchCandidate>> {
9198    let mut stmt = tx.prepare(
9199        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9200         FROM nodes n JOIN files f ON f.path = n.file_path
9201         WHERE n.name = ?1
9202           AND f.lang = ?2
9203           AND n.kind IN ('method', 'function')
9204         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9205    )?;
9206    let rows = stmt.query_map(params![method_name, lang], |row| {
9207        Ok(NameMatchCandidate {
9208            node_id: row.get(0)?,
9209            file_path: row.get(1)?,
9210            scoped_name: row.get(2)?,
9211            kind: row.get(3)?,
9212            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9213        })
9214    })?;
9215    rows.collect::<std::result::Result<Vec<_>, _>>()
9216        .map_err(Into::into)
9217}
9218
9219struct ParsedDispatchSource {
9220    source: String,
9221    tree: tree_sitter::Tree,
9222}
9223
9224type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9225
9226#[derive(Debug, Clone, PartialEq, Eq)]
9227enum ReceiverTypeInference {
9228    Unknown,
9229    Known(String),
9230    RustDirectSelfField {
9231        receiver_type: String,
9232        declaration_file: String,
9233        module_scope: Vec<(usize, usize)>,
9234    },
9235    KnownButUnresolved,
9236}
9237
9238#[cfg(test)]
9239fn infer_receiver_type(
9240    project_root: &Path,
9241    reference: &NameMatchRef,
9242    source_cache: &mut DispatchSourceCache,
9243) -> Option<String> {
9244    match infer_receiver_type_state(project_root, reference, source_cache) {
9245        ReceiverTypeInference::Known(receiver_type)
9246        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9247        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9248    }
9249}
9250
9251fn infer_receiver_type_state(
9252    project_root: &Path,
9253    reference: &NameMatchRef,
9254    source_cache: &mut DispatchSourceCache,
9255) -> ReceiverTypeInference {
9256    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9257    match reference.lang.as_str() {
9258        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9259        "java" => {
9260            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9261                .map(known)
9262                .unwrap_or(ReceiverTypeInference::Unknown)
9263        }
9264        "kotlin" => {
9265            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9266                .map(known)
9267                .unwrap_or(ReceiverTypeInference::Unknown)
9268        }
9269        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9270            .map(known)
9271            .unwrap_or(ReceiverTypeInference::Unknown),
9272        _ => ReceiverTypeInference::Unknown,
9273    }
9274}
9275
9276fn parse_dispatch_source(
9277    project_root: &Path,
9278    caller_file: &str,
9279    lang: LangId,
9280) -> Option<ParsedDispatchSource> {
9281    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9282    let grammar = crate::parser::grammar_for(lang);
9283    let mut parser = tree_sitter::Parser::new();
9284    parser.set_language(&grammar).ok()?;
9285    let tree = parser.parse(&source, None)?;
9286    Some(ParsedDispatchSource { source, tree })
9287}
9288
9289fn parsed_dispatch_source<'a>(
9290    project_root: &Path,
9291    reference: &NameMatchRef,
9292    lang: LangId,
9293    source_cache: &'a mut DispatchSourceCache,
9294) -> Option<&'a ParsedDispatchSource> {
9295    parsed_dispatch_source_for_file(
9296        project_root,
9297        &reference.caller_file,
9298        &reference.lang,
9299        lang,
9300        source_cache,
9301    )
9302}
9303
9304fn parsed_dispatch_source_for_file<'a>(
9305    project_root: &Path,
9306    file_path: &str,
9307    lang_label: &str,
9308    lang: LangId,
9309    source_cache: &'a mut DispatchSourceCache,
9310) -> Option<&'a ParsedDispatchSource> {
9311    let key = (file_path.to_string(), lang_label.to_string());
9312    source_cache
9313        .entry(key)
9314        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9315        .as_ref()
9316}
9317
9318fn infer_java_like_receiver_type(
9319    project_root: &Path,
9320    reference: &NameMatchRef,
9321    lang: LangId,
9322    source_cache: &mut DispatchSourceCache,
9323) -> Option<String> {
9324    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9325        return None;
9326    }
9327
9328    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9329    let root = parsed.tree.root_node();
9330    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9331
9332    let callable_scope = type_node
9333        .and_then(|node| {
9334            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9335        })
9336        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9337
9338    if let Some(callable_scope) = callable_scope {
9339        if let Some(receiver_type) = infer_java_like_local_receiver_type(
9340            callable_scope,
9341            &parsed.source,
9342            &reference.receiver,
9343            reference.line.max(1),
9344            lang,
9345        ) {
9346            return Some(receiver_type);
9347        }
9348    }
9349
9350    type_node.and_then(|node| {
9351        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9352    })
9353}
9354
9355fn infer_cpp_receiver_type(
9356    project_root: &Path,
9357    reference: &NameMatchRef,
9358    source_cache: &mut DispatchSourceCache,
9359) -> Option<String> {
9360    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9361        return None;
9362    }
9363
9364    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9365    let root = parsed.tree.root_node();
9366    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9367    infer_cpp_receiver_type_from_scope(
9368        scope,
9369        &parsed.source,
9370        &reference.receiver,
9371        reference.line.max(1),
9372    )
9373}
9374
9375fn find_enclosing_java_like_type_node<'tree>(
9376    root: tree_sitter::Node<'tree>,
9377    source: &str,
9378    reference: &NameMatchRef,
9379    lang: LangId,
9380) -> Option<tree_sitter::Node<'tree>> {
9381    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9382        .and_then(|name| simple_type_name(&name));
9383    let line = reference.line.max(1);
9384    let mut best = None;
9385    let mut stack = vec![root];
9386    while let Some(node) = stack.pop() {
9387        if !node_contains_line(node, line) {
9388            continue;
9389        }
9390        if is_java_like_type_kind(node.kind(), lang) {
9391            let name = declaration_name(node, source);
9392            if expected_type
9393                .as_deref()
9394                .is_none_or(|expected| name == Some(expected))
9395            {
9396                best = tighter_node(best, node);
9397            }
9398        }
9399        push_named_children(node, &mut stack);
9400    }
9401    best
9402}
9403
9404fn find_enclosing_java_like_callable_node<'tree>(
9405    root: tree_sitter::Node<'tree>,
9406    source: &str,
9407    reference: &NameMatchRef,
9408    lang: LangId,
9409) -> Option<tree_sitter::Node<'tree>> {
9410    let expected_name = reference.caller_symbol.rsplit("::").next();
9411    let line = reference.line.max(1);
9412    let mut best = None;
9413    let mut stack = vec![root];
9414    while let Some(node) = stack.pop() {
9415        if !node_contains_line(node, line) {
9416            continue;
9417        }
9418        if is_java_like_callable_kind(node.kind(), lang) {
9419            let name = declaration_name(node, source);
9420            if expected_name.is_none_or(|expected| name == Some(expected)) {
9421                best = tighter_node(best, node);
9422            }
9423        }
9424        push_named_children(node, &mut stack);
9425    }
9426    best
9427}
9428
9429fn find_enclosing_cpp_callable_node<'tree>(
9430    root: tree_sitter::Node<'tree>,
9431    _source: &str,
9432    reference: &NameMatchRef,
9433) -> Option<tree_sitter::Node<'tree>> {
9434    let line = reference.line.max(1);
9435    let mut best = None;
9436    let mut stack = vec![root];
9437    while let Some(node) = stack.pop() {
9438        if !node_contains_line(node, line) {
9439            continue;
9440        }
9441        if node.kind() == "function_definition" {
9442            best = tighter_node(best, node);
9443        }
9444        push_named_children(node, &mut stack);
9445    }
9446    best
9447}
9448
9449fn tighter_node<'tree>(
9450    current: Option<tree_sitter::Node<'tree>>,
9451    candidate: tree_sitter::Node<'tree>,
9452) -> Option<tree_sitter::Node<'tree>> {
9453    match current {
9454        Some(current)
9455            if current.start_byte() > candidate.start_byte()
9456                || (current.start_byte() == candidate.start_byte()
9457                    && current.end_byte() <= candidate.end_byte()) =>
9458        {
9459            Some(current)
9460        }
9461        _ => Some(candidate),
9462    }
9463}
9464
9465fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9466    let start = node.start_position().row as u32 + 1;
9467    let end = node.end_position().row as u32 + 1;
9468    start <= line && line <= end
9469}
9470
9471fn push_named_children<'tree>(
9472    node: tree_sitter::Node<'tree>,
9473    stack: &mut Vec<tree_sitter::Node<'tree>>,
9474) {
9475    for index in 0..node.named_child_count() {
9476        if let Some(child) = node.named_child(index as u32) {
9477            stack.push(child);
9478        }
9479    }
9480}
9481
9482fn declaration_name<'source>(
9483    node: tree_sitter::Node<'_>,
9484    source: &'source str,
9485) -> Option<&'source str> {
9486    node.child_by_field_name("name")
9487        .map(|name| node_text(name, source))
9488        .or_else(|| {
9489            first_named_child_text(
9490                node,
9491                source,
9492                &["identifier", "type_identifier", "simple_identifier"],
9493            )
9494        })
9495}
9496
9497fn first_named_child_text<'source>(
9498    node: tree_sitter::Node<'_>,
9499    source: &'source str,
9500    kinds: &[&str],
9501) -> Option<&'source str> {
9502    for index in 0..node.named_child_count() {
9503        let child = node.named_child(index as u32)?;
9504        if kinds.contains(&child.kind()) {
9505            return Some(node_text(child, source));
9506        }
9507    }
9508    None
9509}
9510
9511fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9512    &source[node.byte_range()]
9513}
9514
9515fn infer_java_like_field_receiver_type(
9516    type_node: tree_sitter::Node<'_>,
9517    source: &str,
9518    receiver: &str,
9519    lang: LangId,
9520) -> Option<String> {
9521    let mut stack = Vec::new();
9522    push_named_children(type_node, &mut stack);
9523    while let Some(node) = stack.pop() {
9524        if is_java_like_field_kind(node.kind(), lang) {
9525            if let Some(receiver_type) =
9526                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9527            {
9528                return Some(receiver_type);
9529            }
9530        }
9531        if is_java_like_type_kind(node.kind(), lang)
9532            || is_java_like_callable_kind(node.kind(), lang)
9533        {
9534            continue;
9535        }
9536        push_named_children(node, &mut stack);
9537    }
9538    None
9539}
9540
9541fn infer_java_like_local_receiver_type(
9542    callable_node: tree_sitter::Node<'_>,
9543    source: &str,
9544    receiver: &str,
9545    call_line: u32,
9546    lang: LangId,
9547) -> Option<String> {
9548    let mut best: Option<(u32, String)> = None;
9549    let mut stack = Vec::new();
9550    push_named_children(callable_node, &mut stack);
9551    while let Some(node) = stack.pop() {
9552        let start_line = node.start_position().row as u32 + 1;
9553        if start_line > call_line {
9554            continue;
9555        }
9556        if is_java_like_local_kind(node.kind(), lang) {
9557            if let Some(receiver_type) =
9558                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9559            {
9560                if best
9561                    .as_ref()
9562                    .is_none_or(|(best_line, _)| start_line >= *best_line)
9563                {
9564                    best = Some((start_line, receiver_type));
9565                }
9566            }
9567        }
9568        if is_java_like_type_kind(node.kind(), lang)
9569            || is_java_like_callable_kind(node.kind(), lang)
9570        {
9571            continue;
9572        }
9573        push_named_children(node, &mut stack);
9574    }
9575    best.map(|(_, receiver_type)| receiver_type)
9576}
9577
9578fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9579    match lang {
9580        LangId::Java => matches!(
9581            kind,
9582            "class_declaration"
9583                | "interface_declaration"
9584                | "enum_declaration"
9585                | "record_declaration"
9586                | "annotation_type_declaration"
9587        ),
9588        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9589        _ => false,
9590    }
9591}
9592
9593fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9594    match lang {
9595        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9596        LangId::Kotlin => kind == "function_declaration",
9597        _ => false,
9598    }
9599}
9600
9601fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9602    match lang {
9603        LangId::Java => kind == "field_declaration",
9604        LangId::Kotlin => kind == "property_declaration",
9605        _ => false,
9606    }
9607}
9608
9609fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9610    match lang {
9611        LangId::Java => kind == "local_variable_declaration",
9612        LangId::Kotlin => kind == "property_declaration",
9613        _ => false,
9614    }
9615}
9616
9617fn extract_java_like_declared_type(
9618    declaration: &str,
9619    receiver: &str,
9620    lang: LangId,
9621) -> Option<String> {
9622    match lang {
9623        LangId::Java => extract_java_declared_type(declaration, receiver),
9624        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9625        _ => None,
9626    }
9627}
9628
9629fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9630    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9631    let after = declaration[receiver_start + receiver.len()..].trim_start();
9632    if after
9633        .chars()
9634        .next()
9635        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9636    {
9637        return None;
9638    }
9639
9640    let before = declaration[..receiver_start].trim_end();
9641    if before.contains(',') {
9642        return None;
9643    }
9644    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9645}
9646
9647fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9648    loop {
9649        value = value.trim_start();
9650        if let Some(stripped) = strip_leading_java_annotation(value) {
9651            value = stripped;
9652            continue;
9653        }
9654        if let Some(stripped) = strip_leading_java_modifier(value) {
9655            value = stripped;
9656            continue;
9657        }
9658        return value.trim();
9659    }
9660}
9661
9662fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9663    let value = value.trim_start();
9664    let mut chars = value.char_indices();
9665    let (_, first) = chars.next()?;
9666    if first != '@' {
9667        return None;
9668    }
9669    let mut end = first.len_utf8();
9670    for (index, ch) in chars {
9671        if !(is_code_ident_char(ch) || ch == '.') {
9672            end = index;
9673            break;
9674        }
9675        end = index + ch.len_utf8();
9676    }
9677    let rest = value[end..].trim_start();
9678    if let Some(stripped) = rest.strip_prefix('(') {
9679        let mut depth = 1usize;
9680        for (index, ch) in stripped.char_indices() {
9681            match ch {
9682                '(' => depth += 1,
9683                ')' => {
9684                    depth = depth.saturating_sub(1);
9685                    if depth == 0 {
9686                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9687                    }
9688                }
9689                _ => {}
9690            }
9691        }
9692        return Some("");
9693    }
9694    Some(rest)
9695}
9696
9697fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9698    const MODIFIERS: &[&str] = &[
9699        "public",
9700        "protected",
9701        "private",
9702        "abstract",
9703        "static",
9704        "final",
9705        "transient",
9706        "volatile",
9707        "synchronized",
9708        "native",
9709        "strictfp",
9710    ];
9711    MODIFIERS
9712        .iter()
9713        .find_map(|modifier| strip_leading_word(value, modifier))
9714}
9715
9716fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9717    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9718    let before = &declaration[..receiver_start];
9719    if find_identifier_occurrence(before, "val").is_none()
9720        && find_identifier_occurrence(before, "var").is_none()
9721    {
9722        return None;
9723    }
9724
9725    let after = declaration[receiver_start + receiver.len()..].trim_start();
9726    if let Some(type_text) = after.strip_prefix(':') {
9727        return normalize_receiver_type_name(read_type_prefix(type_text));
9728    }
9729    after
9730        .strip_prefix('=')
9731        .and_then(infer_kotlin_constructor_type)
9732}
9733
9734fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9735    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9736    if rest.trim_start().starts_with('(') {
9737        normalize_receiver_type_name(head)
9738    } else {
9739        None
9740    }
9741}
9742
9743fn read_type_prefix(value: &str) -> &str {
9744    let mut angle_depth = 0usize;
9745    for (index, ch) in value.char_indices() {
9746        match ch {
9747            '<' => angle_depth += 1,
9748            '>' => angle_depth = angle_depth.saturating_sub(1),
9749            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9750                return value[..index].trim();
9751            }
9752            _ => {}
9753        }
9754    }
9755    value.trim()
9756}
9757
9758fn infer_cpp_receiver_type_from_scope(
9759    scope: tree_sitter::Node<'_>,
9760    source: &str,
9761    receiver: &str,
9762    call_line: u32,
9763) -> Option<String> {
9764    let lines = source.lines().collect::<Vec<_>>();
9765    if lines.is_empty() {
9766        return None;
9767    }
9768    let scope_start = scope.start_position().row as usize;
9769    let call_index = (call_line as usize)
9770        .saturating_sub(1)
9771        .min(lines.len().saturating_sub(1));
9772    for index in (scope_start..=call_index).rev() {
9773        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9774            return Some(receiver_type);
9775        }
9776    }
9777    None
9778}
9779
9780fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9781    for receiver_start in identifier_occurrences(line, receiver) {
9782        let after = line[receiver_start + receiver.len()..].trim_start();
9783        if after
9784            .chars()
9785            .next()
9786            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9787        {
9788            continue;
9789        }
9790        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9791        let normalized = normalize_cpp_type_name(type_text)?;
9792        if normalized == "auto" {
9793            if let Some(rhs) = after.strip_prefix('=') {
9794                return infer_cpp_auto_receiver_type(rhs);
9795            }
9796            continue;
9797        }
9798        return Some(normalized);
9799    }
9800    None
9801}
9802
9803fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9804    let candidate = prefix
9805        .rsplit([';', '{', '}', '('])
9806        .next()
9807        .unwrap_or(prefix)
9808        .trim();
9809    if candidate.is_empty() || candidate.ends_with(',') {
9810        None
9811    } else {
9812        Some(candidate)
9813    }
9814}
9815
9816fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9817    let without_templates = strip_angle_groups(type_text);
9818    let mut cleaned = String::with_capacity(without_templates.len());
9819    for token in without_templates.split_whitespace() {
9820        if matches!(
9821            token,
9822            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9823        ) {
9824            continue;
9825        }
9826        if !cleaned.is_empty() {
9827            cleaned.push(' ');
9828        }
9829        cleaned.push_str(token);
9830    }
9831    let token = cleaned
9832        .split_whitespace()
9833        .last()
9834        .unwrap_or(cleaned.trim())
9835        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9836        .trim_matches(['*', '&']);
9837    let simple = token.rsplit("::").next().unwrap_or(token).trim();
9838    if simple.is_empty() || cpp_non_type_token(simple) {
9839        None
9840    } else {
9841        Some(simple.to_string())
9842    }
9843}
9844
9845fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9846    let rhs = rhs.trim_start();
9847    if let Some(after_new) = rhs.strip_prefix("new ") {
9848        return infer_cpp_constructor_type(after_new);
9849    }
9850    infer_cpp_make_template_type(rhs)
9851        .or_else(|| infer_cpp_constructor_type(rhs))
9852        .or_else(|| infer_cpp_factory_type(rhs))
9853}
9854
9855fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9856    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9857    let normalized = normalize_cpp_type_name(head)?;
9858    if !normalized
9859        .chars()
9860        .next()
9861        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9862    {
9863        return None;
9864    }
9865    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9866        Some(normalized)
9867    } else {
9868        None
9869    }
9870}
9871
9872fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9873    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9874    if !rest.trim_start().starts_with('(') {
9875        return None;
9876    }
9877    let base = head.split('<').next().unwrap_or(head);
9878    let base_simple = base.rsplit("::").next().unwrap_or(base);
9879    if !matches!(base_simple, "make_unique" | "make_shared") {
9880        return None;
9881    }
9882    first_angle_arg(head).and_then(normalize_cpp_type_name)
9883}
9884
9885fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9886    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9887    if !rest.trim_start().starts_with('(') {
9888        return None;
9889    }
9890    let simple = head
9891        .split('<')
9892        .next()
9893        .unwrap_or(head)
9894        .rsplit("::")
9895        .next()
9896        .unwrap_or(head);
9897    for prefix in ["make", "create", "build"] {
9898        if let Some(suffix) = simple.strip_prefix(prefix) {
9899            if suffix
9900                .chars()
9901                .next()
9902                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9903            {
9904                return normalize_cpp_type_name(suffix);
9905            }
9906        }
9907    }
9908    None
9909}
9910
9911#[derive(Debug, Clone, Copy)]
9912enum JavaLikeInvocation {
9913    Kotlin,
9914    Cpp,
9915}
9916
9917fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9918    let value = value.trim_start();
9919    let mut end = 0usize;
9920    for (index, ch) in value.char_indices() {
9921        let allowed_separator = match flavor {
9922            JavaLikeInvocation::Kotlin => ch == '.',
9923            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9924        };
9925        if is_code_ident_char(ch) || allowed_separator {
9926            end = index + ch.len_utf8();
9927            continue;
9928        }
9929        break;
9930    }
9931    if end == 0 {
9932        return None;
9933    }
9934    let mut rest = &value[end..];
9935    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9936        let skipped = skip_balanced_angle(stripped)?;
9937        let rest_start = rest.len() - rest.trim_start().len();
9938        let angle_len = 1 + skipped;
9939        end += rest_start + angle_len;
9940        rest = &value[end..];
9941    }
9942    Some((value[..end].trim(), rest))
9943}
9944
9945fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9946    let mut depth = 1usize;
9947    for (index, ch) in value_after_open.char_indices() {
9948        match ch {
9949            '<' => depth += 1,
9950            '>' => {
9951                depth = depth.saturating_sub(1);
9952                if depth == 0 {
9953                    return Some(index + ch.len_utf8());
9954                }
9955            }
9956            _ => {}
9957        }
9958    }
9959    None
9960}
9961
9962fn first_angle_arg(value: &str) -> Option<&str> {
9963    let open = value.find('<')?;
9964    let inner_len = skip_balanced_angle(&value[open + 1..])?;
9965    let inner = &value[open + 1..open + inner_len];
9966    split_top_level_commas(inner).into_iter().next()
9967}
9968
9969fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9970    let without_generics = strip_angle_groups(type_text);
9971    let cleaned = without_generics
9972        .replace("[]", " ")
9973        .replace("...", " ")
9974        .replace(['?', '&', '*'], " ");
9975    let token = cleaned
9976        .split_whitespace()
9977        .last()
9978        .unwrap_or(cleaned.trim())
9979        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9980    let token = token.rsplit("::").next().unwrap_or(token);
9981    let simple = token.rsplit('.').next().unwrap_or(token).trim();
9982    if simple.is_empty()
9983        || java_like_primitive_type(simple)
9984        || !simple
9985            .chars()
9986            .next()
9987            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9988    {
9989        None
9990    } else {
9991        Some(simple.to_string())
9992    }
9993}
9994
9995fn simple_type_name(scoped_name: &str) -> Option<String> {
9996    scoped_name
9997        .rsplit("::")
9998        .find(|segment| !segment.is_empty())
9999        .and_then(normalize_receiver_type_name)
10000}
10001
10002fn strip_angle_groups(value: &str) -> String {
10003    let mut output = String::with_capacity(value.len());
10004    let mut depth = 0usize;
10005    for ch in value.chars() {
10006        match ch {
10007            '<' => {
10008                if depth == 0 {
10009                    output.push(' ');
10010                }
10011                depth += 1;
10012            }
10013            '>' => depth = depth.saturating_sub(1),
10014            _ if depth == 0 => output.push(ch),
10015            _ => {}
10016        }
10017    }
10018    output
10019}
10020
10021fn java_like_primitive_type(value: &str) -> bool {
10022    matches!(
10023        value,
10024        "boolean"
10025            | "byte"
10026            | "char"
10027            | "double"
10028            | "float"
10029            | "int"
10030            | "long"
10031            | "short"
10032            | "void"
10033            | "Boolean"
10034            | "Byte"
10035            | "Char"
10036            | "Double"
10037            | "Float"
10038            | "Int"
10039            | "Long"
10040            | "Short"
10041            | "Unit"
10042    )
10043}
10044
10045fn cpp_non_type_token(value: &str) -> bool {
10046    matches!(
10047        value,
10048        "return"
10049            | "if"
10050            | "else"
10051            | "for"
10052            | "while"
10053            | "do"
10054            | "switch"
10055            | "case"
10056            | "default"
10057            | "break"
10058            | "continue"
10059            | "goto"
10060            | "throw"
10061            | "new"
10062            | "delete"
10063            | "co_await"
10064            | "co_yield"
10065            | "co_return"
10066            | "static_cast"
10067            | "const_cast"
10068            | "dynamic_cast"
10069            | "reinterpret_cast"
10070            | "sizeof"
10071            | "alignof"
10072            | "typeid"
10073            | "and"
10074            | "or"
10075            | "not"
10076            | "xor"
10077    )
10078}
10079
10080fn receiver_is_bare_identifier(value: &str) -> bool {
10081    let mut chars = value.chars();
10082    let Some(first) = chars.next() else {
10083        return false;
10084    };
10085    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10086}
10087
10088fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10089    identifier_occurrences(value, needle).into_iter().next()
10090}
10091
10092fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10093    value
10094        .match_indices(needle)
10095        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10096        .collect()
10097}
10098
10099fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10100    let before = value[..start].chars().next_back();
10101    let after = value[start + len..].chars().next();
10102    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10103}
10104
10105fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10106    let stripped = value.strip_prefix(word)?;
10107    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10108        Some(stripped.trim_start())
10109    } else {
10110        None
10111    }
10112}
10113
10114fn is_code_ident_char(ch: char) -> bool {
10115    ch == '_' || ch.is_ascii_alphanumeric()
10116}
10117
10118fn infer_rust_receiver_type(
10119    project_root: &Path,
10120    reference: &NameMatchRef,
10121    source_cache: &mut DispatchSourceCache,
10122) -> ReceiverTypeInference {
10123    if matches!(reference.receiver.as_str(), "self" | "Self") {
10124        return enclosing_type_from_scoped_name(&reference.caller_symbol)
10125            .map(ReceiverTypeInference::Known)
10126            .unwrap_or(ReceiverTypeInference::Unknown);
10127    }
10128
10129    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10130        return ReceiverTypeInference::Known(reference.receiver.clone());
10131    }
10132
10133    if let Some(receiver_type) = reference
10134        .caller_signature
10135        .as_deref()
10136        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10137    {
10138        return ReceiverTypeInference::Known(receiver_type);
10139    }
10140
10141    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10142}
10143
10144fn infer_rust_direct_self_field_receiver_type(
10145    project_root: &Path,
10146    reference: &NameMatchRef,
10147    source_cache: &mut DispatchSourceCache,
10148) -> ReceiverTypeInference {
10149    if reference.colon_dispatch {
10150        return ReceiverTypeInference::Unknown;
10151    }
10152    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10153        return ReceiverTypeInference::Unknown;
10154    };
10155    if field_name != reference.receiver {
10156        return ReceiverTypeInference::Unknown;
10157    }
10158
10159    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10160        return ReceiverTypeInference::Unknown;
10161    };
10162    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10163        return ReceiverTypeInference::KnownButUnresolved;
10164    };
10165    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10166    else {
10167        return ReceiverTypeInference::Unknown;
10168    };
10169    let Some(impl_node) =
10170        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10171    else {
10172        return ReceiverTypeInference::Unknown;
10173    };
10174    if impl_node.child_by_field_name("trait").is_some()
10175        || impl_node.child_by_field_name("type_parameters").is_some()
10176    {
10177        return ReceiverTypeInference::KnownButUnresolved;
10178    }
10179    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10180        return ReceiverTypeInference::KnownButUnresolved;
10181    };
10182    if impl_target.kind() != "type_identifier"
10183        || node_text(impl_target, &parsed.source) != impl_type
10184    {
10185        return ReceiverTypeInference::KnownButUnresolved;
10186    }
10187
10188    let module_scope = rust_module_scope(impl_node);
10189    let Some(struct_node) = find_unique_rust_struct(
10190        parsed.tree.root_node(),
10191        &parsed.source,
10192        struct_name,
10193        &module_scope,
10194    ) else {
10195        return ReceiverTypeInference::KnownButUnresolved;
10196    };
10197    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10198    else {
10199        return ReceiverTypeInference::KnownButUnresolved;
10200    };
10201    if field_type.kind() != "type_identifier" {
10202        return ReceiverTypeInference::KnownButUnresolved;
10203    }
10204    let field_type_name = node_text(field_type, &parsed.source);
10205    if find_unique_rust_struct(
10206        parsed.tree.root_node(),
10207        &parsed.source,
10208        field_type_name,
10209        &module_scope,
10210    )
10211    .is_none()
10212    {
10213        return ReceiverTypeInference::KnownButUnresolved;
10214    }
10215
10216    ReceiverTypeInference::RustDirectSelfField {
10217        receiver_type: field_type_name.to_string(),
10218        declaration_file: reference.caller_file.clone(),
10219        module_scope,
10220    }
10221}
10222
10223fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10224    let (base, field) = receiver_expression.split_once('.')?;
10225    let base = base.trim();
10226    let field = field.trim();
10227    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10228}
10229
10230fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10231    let name = value.rsplit("::").next()?.trim();
10232    (!name.is_empty()
10233        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10234        && name.chars().all(is_rust_ident_char))
10235    .then_some(name)
10236}
10237
10238fn find_enclosing_rust_impl_node<'tree>(
10239    root: tree_sitter::Node<'tree>,
10240    line: u32,
10241) -> Option<tree_sitter::Node<'tree>> {
10242    let mut best = None;
10243    let mut stack = vec![root];
10244    while let Some(node) = stack.pop() {
10245        if !node_contains_line(node, line) {
10246            continue;
10247        }
10248        if node.kind() == "impl_item" {
10249            best = tighter_node(best, node);
10250        }
10251        push_named_children(node, &mut stack);
10252    }
10253    best
10254}
10255
10256fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10257    let mut scope = Vec::new();
10258    let mut current = node.parent();
10259    while let Some(parent) = current {
10260        if parent.kind() == "mod_item" {
10261            scope.push((parent.start_byte(), parent.end_byte()));
10262        }
10263        current = parent.parent();
10264    }
10265    scope.reverse();
10266    scope
10267}
10268
10269fn find_unique_rust_struct<'tree>(
10270    root: tree_sitter::Node<'tree>,
10271    source: &str,
10272    expected_name: &str,
10273    module_scope: &[(usize, usize)],
10274) -> Option<tree_sitter::Node<'tree>> {
10275    let mut found = None;
10276    let mut stack = vec![root];
10277    while let Some(node) = stack.pop() {
10278        if node.kind() == "struct_item"
10279            && rust_module_scope(node) == module_scope
10280            && node.child_by_field_name("type_parameters").is_none()
10281            && declaration_name(node, source) == Some(expected_name)
10282        {
10283            if found.is_some() {
10284                return None;
10285            }
10286            found = Some(node);
10287        }
10288        push_named_children(node, &mut stack);
10289    }
10290    found
10291}
10292
10293fn rust_struct_field_type_node<'tree>(
10294    struct_node: tree_sitter::Node<'tree>,
10295    source: &str,
10296    field_name: &str,
10297) -> Option<tree_sitter::Node<'tree>> {
10298    let fields = struct_node.child_by_field_name("body")?;
10299    if fields.kind() != "field_declaration_list" {
10300        return None;
10301    }
10302    for index in 0..fields.named_child_count() {
10303        let field = fields.named_child(index as u32)?;
10304        if field.kind() != "field_declaration"
10305            || declaration_name(field, source) != Some(field_name)
10306        {
10307            continue;
10308        }
10309        return field.child_by_field_name("type");
10310    }
10311    None
10312}
10313
10314fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10315    receiver
10316        .chars()
10317        .next()
10318        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10319}
10320
10321fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10322    scoped_name
10323        .rsplit_once("::")
10324        .map(|(enclosing, _)| enclosing)
10325        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10326        .map(ToString::to_string)
10327}
10328
10329fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10330    let params = signature_parameter_text(signature)?;
10331    for param in split_top_level_commas(params) {
10332        let Some((pattern, type_text)) = param.split_once(':') else {
10333            continue;
10334        };
10335        let Some(name) = rust_parameter_name(pattern) else {
10336            continue;
10337        };
10338        if name == receiver {
10339            return normalize_rust_receiver_type(type_text);
10340        }
10341    }
10342    None
10343}
10344
10345fn signature_parameter_text(signature: &str) -> Option<&str> {
10346    let open = signature.find('(')?;
10347    let mut depth = 0usize;
10348    for (offset, ch) in signature[open..].char_indices() {
10349        match ch {
10350            '(' => depth += 1,
10351            ')' => {
10352                depth = depth.saturating_sub(1);
10353                if depth == 0 {
10354                    return Some(&signature[open + 1..open + offset]);
10355                }
10356            }
10357            _ => {}
10358        }
10359    }
10360    None
10361}
10362
10363fn split_top_level_commas(value: &str) -> Vec<&str> {
10364    let mut parts = Vec::new();
10365    let mut start = 0usize;
10366    let mut angle_depth = 0usize;
10367    let mut paren_depth = 0usize;
10368    let mut bracket_depth = 0usize;
10369    for (index, ch) in value.char_indices() {
10370        match ch {
10371            '<' => angle_depth += 1,
10372            '>' => angle_depth = angle_depth.saturating_sub(1),
10373            '(' => paren_depth += 1,
10374            ')' => paren_depth = paren_depth.saturating_sub(1),
10375            '[' => bracket_depth += 1,
10376            ']' => bracket_depth = bracket_depth.saturating_sub(1),
10377            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10378                let part = value[start..index].trim();
10379                if !part.is_empty() {
10380                    parts.push(part);
10381                }
10382                start = index + ch.len_utf8();
10383            }
10384            _ => {}
10385        }
10386    }
10387    let part = value[start..].trim();
10388    if !part.is_empty() {
10389        parts.push(part);
10390    }
10391    parts
10392}
10393
10394fn rust_parameter_name(pattern: &str) -> Option<&str> {
10395    let mut pattern = pattern.trim();
10396    if let Some(stripped) = pattern.strip_prefix("mut ") {
10397        pattern = stripped.trim_start();
10398    }
10399    pattern
10400        .rsplit(|ch: char| !is_rust_ident_char(ch))
10401        .find(|part| !part.is_empty())
10402}
10403
10404fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10405    let mut ty = strip_leading_rust_type_modifiers(type_text);
10406    let owned_inner;
10407    if let Some(inner) = single_outer_generic_arg(ty) {
10408        owned_inner = inner.trim().to_string();
10409        ty = strip_leading_rust_type_modifiers(&owned_inner);
10410    }
10411    rust_base_type_ident(ty)
10412}
10413
10414fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10415    loop {
10416        ty = ty.trim_start();
10417        if let Some(stripped) = ty.strip_prefix('&') {
10418            ty = stripped.trim_start();
10419            if let Some(stripped) = strip_leading_lifetime(ty) {
10420                ty = stripped.trim_start();
10421            }
10422            if let Some(stripped) = ty.strip_prefix("mut ") {
10423                ty = stripped.trim_start();
10424            }
10425            continue;
10426        }
10427        if let Some(stripped) = ty.strip_prefix("mut ") {
10428            ty = stripped.trim_start();
10429            continue;
10430        }
10431        if let Some(stripped) = ty.strip_prefix("dyn ") {
10432            ty = stripped.trim_start();
10433            continue;
10434        }
10435        if let Some(stripped) = ty.strip_prefix("impl ") {
10436            ty = stripped.trim_start();
10437            continue;
10438        }
10439        break ty.trim();
10440    }
10441}
10442
10443fn strip_leading_lifetime(value: &str) -> Option<&str> {
10444    let mut chars = value.char_indices();
10445    let (_, first) = chars.next()?;
10446    if first != '\'' {
10447        return None;
10448    }
10449    for (index, ch) in chars {
10450        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10451            return Some(&value[index..]);
10452        }
10453    }
10454    Some("")
10455}
10456
10457fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10458    let ty = ty.trim();
10459    let open = ty.find('<')?;
10460    let mut depth = 0usize;
10461    let mut close = None;
10462    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10463        match ch {
10464            '<' => depth += 1,
10465            '>' => {
10466                depth = depth.saturating_sub(1);
10467                if depth == 0 {
10468                    close = Some(index);
10469                    break;
10470                }
10471            }
10472            _ => {}
10473        }
10474    }
10475    let close = close?;
10476    if !ty[close + 1..].trim().is_empty() {
10477        return None;
10478    }
10479    let inner = &ty[open + 1..close];
10480    let args = split_top_level_commas(inner);
10481    match args.as_slice() {
10482        [arg] => Some(*arg),
10483        _ => None,
10484    }
10485}
10486
10487fn rust_base_type_ident(ty: &str) -> Option<String> {
10488    let ty = ty.trim();
10489    let head = ty
10490        .split([' ', '+', '='])
10491        .find(|part| !part.is_empty())
10492        .unwrap_or(ty);
10493    let head = head.split('<').next().unwrap_or(head).trim();
10494    let ident = head
10495        .rsplit("::")
10496        .next()
10497        .unwrap_or(head)
10498        .trim_matches(|ch: char| !is_rust_ident_char(ch));
10499    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10500        None
10501    } else {
10502        Some(ident.to_string())
10503    }
10504}
10505
10506fn is_rust_ident_char(ch: char) -> bool {
10507    ch == '_' || ch.is_ascii_alphanumeric()
10508}
10509
10510fn select_rust_direct_self_field_candidate(
10511    project_root: &Path,
10512    reference: &NameMatchRef,
10513    candidates: &[NameMatchCandidate],
10514    receiver_type: &str,
10515    declaration_file: &str,
10516    declaration_scope: &[(usize, usize)],
10517    source_cache: &mut DispatchSourceCache,
10518) -> Option<NameMatchCandidate> {
10519    let eligible = candidates
10520        .iter()
10521        .filter(|candidate| candidate.node_id != reference.caller_node)
10522        .filter(|candidate| {
10523            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10524        })
10525        .filter(|candidate| {
10526            rust_direct_self_field_candidate_matches_scope(
10527                project_root,
10528                candidate,
10529                receiver_type,
10530                declaration_file,
10531                declaration_scope,
10532                source_cache,
10533            )
10534        })
10535        .collect::<Vec<_>>();
10536    match eligible.as_slice() {
10537        [candidate] => Some((**candidate).clone()),
10538        _ => None,
10539    }
10540}
10541
10542fn rust_direct_self_field_candidate_matches_scope(
10543    project_root: &Path,
10544    candidate: &NameMatchCandidate,
10545    receiver_type: &str,
10546    declaration_file: &str,
10547    declaration_scope: &[(usize, usize)],
10548    source_cache: &mut DispatchSourceCache,
10549) -> bool {
10550    if candidate.file_path != declaration_file {
10551        return false;
10552    }
10553    let Some(parsed) = parsed_dispatch_source_for_file(
10554        project_root,
10555        &candidate.file_path,
10556        "rust",
10557        LangId::Rust,
10558        source_cache,
10559    ) else {
10560        return false;
10561    };
10562    let Some(impl_node) =
10563        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10564    else {
10565        return false;
10566    };
10567    if impl_node.child_by_field_name("trait").is_some()
10568        || impl_node.child_by_field_name("type_parameters").is_some()
10569    {
10570        return false;
10571    }
10572    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10573        return false;
10574    };
10575    impl_target.kind() == "type_identifier"
10576        && node_text(impl_target, &parsed.source) == receiver_type
10577        && rust_module_scope(impl_node) == declaration_scope
10578}
10579
10580fn select_type_match_candidate(
10581    reference: &NameMatchRef,
10582    candidates: &[NameMatchCandidate],
10583    receiver_type: &str,
10584) -> Option<NameMatchCandidate> {
10585    let candidates = candidates
10586        .iter()
10587        .filter(|candidate| candidate.node_id != reference.caller_node)
10588        .filter(|candidate| {
10589            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10590        })
10591        .collect::<Vec<_>>();
10592    match candidates.as_slice() {
10593        [candidate] => Some((**candidate).clone()),
10594        _ => None,
10595    }
10596}
10597
10598fn type_candidate_matches(
10599    candidate: &NameMatchCandidate,
10600    receiver_type: &str,
10601    method_name: &str,
10602) -> bool {
10603    let normalized_type = receiver_type.replace('.', "::");
10604    let suffix = format!("{normalized_type}::{method_name}");
10605    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10606}
10607
10608fn select_name_match_candidate(
10609    reference: &NameMatchRef,
10610    candidates: &[NameMatchCandidate],
10611) -> Option<NameMatchCandidate> {
10612    let candidates = candidates
10613        .iter()
10614        .filter(|candidate| candidate.node_id != reference.caller_node)
10615        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10616        .collect::<Vec<_>>();
10617    match candidates.as_slice() {
10618        [] => None,
10619        [candidate] => Some((**candidate).clone()),
10620        _ => select_scored_name_match_candidate(reference, &candidates),
10621    }
10622}
10623
10624fn candidate_allowed_for_reference(
10625    reference: &NameMatchRef,
10626    candidate: &NameMatchCandidate,
10627) -> bool {
10628    if !reference.colon_dispatch {
10629        return true;
10630    }
10631
10632    candidate.kind == "method"
10633        && candidate
10634            .scoped_name
10635            .split("::")
10636            .any(|segment| segment == reference.receiver)
10637}
10638
10639fn select_scored_name_match_candidate(
10640    reference: &NameMatchRef,
10641    candidates: &[&NameMatchCandidate],
10642) -> Option<NameMatchCandidate> {
10643    let receiver_words = split_camel_case(&reference.receiver);
10644    if receiver_words.is_empty() {
10645        return None;
10646    }
10647
10648    let mut best: Option<(&NameMatchCandidate, f64)> = None;
10649    let mut tied_best = false;
10650    for candidate in candidates {
10651        let candidate_words = split_camel_case(&candidate.scoped_name);
10652        let overlap = receiver_words
10653            .iter()
10654            .filter(|receiver_word| {
10655                candidate_words
10656                    .iter()
10657                    .any(|candidate_word| candidate_word == *receiver_word)
10658            })
10659            .count() as f64;
10660        let score =
10661            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10662        match best {
10663            None => {
10664                best = Some((*candidate, score));
10665                tied_best = false;
10666            }
10667            Some((_, best_score)) if score > best_score => {
10668                best = Some((*candidate, score));
10669                tied_best = false;
10670            }
10671            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10672                tied_best = true;
10673            }
10674            _ => {}
10675        }
10676    }
10677
10678    let (candidate, score) = best?;
10679    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10680        Some(candidate.clone())
10681    } else {
10682        None
10683    }
10684}
10685
10686fn method_name_match_denylisted(method_name: &str) -> bool {
10687    matches!(
10688        method_name,
10689        "and_then"
10690            | "as_bytes"
10691            | "as_deref"
10692            | "as_mut"
10693            | "as_ref"
10694            | "as_str"
10695            | "borrow"
10696            | "borrow_mut"
10697            | "clear"
10698            | "clone"
10699            | "collect"
10700            | "contains"
10701            | "contains_key"
10702            | "count"
10703            | "dedup"
10704            | "default"
10705            | "drain"
10706            | "ends_with"
10707            | "entry"
10708            | "err"
10709            | "expect"
10710            | "extend"
10711            | "filter"
10712            | "filter_map"
10713            | "find"
10714            | "from"
10715            | "get"
10716            | "get_mut"
10717            | "insert"
10718            | "into"
10719            | "into_iter"
10720            | "is_empty"
10721            | "is_err"
10722            | "is_none"
10723            | "is_ok"
10724            | "is_some"
10725            | "iter"
10726            | "iter_mut"
10727            | "join"
10728            | "len"
10729            | "lock"
10730            | "map"
10731            | "map_err"
10732            | "max"
10733            | "min"
10734            | "new"
10735            | "next"
10736            | "ok"
10737            | "or_default"
10738            | "or_else"
10739            | "or_insert"
10740            | "or_insert_with"
10741            | "parse"
10742            | "pop"
10743            | "position"
10744            | "push"
10745            | "read"
10746            | "recv"
10747            | "remove"
10748            | "replace"
10749            | "retain"
10750            | "send"
10751            | "sort"
10752            | "sort_by"
10753            | "split"
10754            | "starts_with"
10755            | "sum"
10756            | "take"
10757            | "to_owned"
10758            | "to_string"
10759            | "trim"
10760            | "try_from"
10761            | "try_into"
10762            | "unwrap"
10763            | "unwrap_or"
10764            | "unwrap_or_default"
10765            | "unwrap_or_else"
10766            | "with_capacity"
10767            | "write"
10768    )
10769}
10770
10771fn split_camel_case(value: &str) -> Vec<String> {
10772    let chars = value.chars().collect::<Vec<_>>();
10773    let mut normalized = String::with_capacity(value.len() + 8);
10774    for (index, ch) in chars.iter().enumerate() {
10775        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10776        let next = chars.get(index + 1);
10777        let is_separator = ch.is_whitespace()
10778            || matches!(
10779                ch,
10780                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10781            );
10782        if is_separator {
10783            normalized.push(' ');
10784            continue;
10785        }
10786        let camel_boundary = previous.is_some_and(|prev| {
10787            (prev.is_lowercase() && ch.is_uppercase())
10788                || (prev.is_ascii_digit() && ch.is_alphabetic())
10789                || (prev.is_uppercase()
10790                    && ch.is_uppercase()
10791                    && next.is_some_and(|next| next.is_lowercase()))
10792        });
10793        if camel_boundary {
10794            normalized.push(' ');
10795        }
10796        normalized.push(*ch);
10797    }
10798
10799    normalized
10800        .split_whitespace()
10801        .filter(|word| word.len() > 1)
10802        .map(|word| word.to_ascii_lowercase())
10803        .collect()
10804}
10805
10806fn compute_path_proximity(left: &str, right: &str) -> f64 {
10807    let left_dirs = left
10808        .rsplit_once('/')
10809        .map(|(dir, _)| dir)
10810        .unwrap_or_default()
10811        .split('/')
10812        .filter(|part| !part.is_empty());
10813    let right_dirs = right
10814        .rsplit_once('/')
10815        .map(|(dir, _)| dir)
10816        .unwrap_or_default()
10817        .split('/')
10818        .filter(|part| !part.is_empty());
10819
10820    let shared = left_dirs
10821        .zip(right_dirs)
10822        .take_while(|(left, right)| left == right)
10823        .count();
10824    ((shared as f64) * 0.05).min(0.5)
10825}
10826
10827fn mark_backend_state(
10828    tx: &Transaction<'_>,
10829    project_root: &Path,
10830    rel_path: &str,
10831    content_hash: Option<&blake3::Hash>,
10832    status: &str,
10833) -> Result<()> {
10834    clear_backend_state_for_file(tx, project_root, rel_path)?;
10835    let hash = content_hash
10836        .map(|hash| hash_to_hex(*hash))
10837        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10838    tx.execute(
10839        "INSERT OR REPLACE INTO backend_file_state(
10840            backend, workspace_root, file_path, content_hash, status, updated_at
10841        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10842        params![
10843            BACKEND_TREESITTER,
10844            project_root.display().to_string(),
10845            rel_path,
10846            hash,
10847            status,
10848            unix_seconds_now(),
10849        ],
10850    )?;
10851    Ok(())
10852}
10853
10854fn clear_backend_state_for_file(
10855    tx: &Transaction<'_>,
10856    project_root: &Path,
10857    rel_path: &str,
10858) -> Result<()> {
10859    tx.execute(
10860        "DELETE FROM backend_file_state
10861         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10862        params![
10863            BACKEND_TREESITTER,
10864            project_root.display().to_string(),
10865            rel_path
10866        ],
10867    )?;
10868    Ok(())
10869}
10870
10871fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10872    tx.query_row(
10873        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10874        params![rel_path],
10875        |row| {
10876            let hash_text: String = row.get(1)?;
10877            Ok(FileRow {
10878                surface_fingerprint: row.get(0)?,
10879                freshness: FileFreshness {
10880                    content_hash: hash_from_hex(&hash_text)
10881                        .unwrap_or_else(cache_freshness::zero_hash),
10882                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
10883                    size: row.get::<_, i64>(3)? as u64,
10884                },
10885            })
10886        },
10887    )
10888    .optional()
10889    .map_err(CallGraphStoreError::from)
10890}
10891
10892fn stored_node_ids_match_extract(
10893    tx: &Transaction<'_>,
10894    rel_path: &str,
10895    extract: &FileExtract,
10896) -> Result<bool> {
10897    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
10898    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10899    let mut stored = BTreeSet::new();
10900    for row in rows {
10901        stored.insert(row?);
10902    }
10903    let extracted = extract
10904        .nodes
10905        .iter()
10906        .map(|node| node.id.clone())
10907        .collect::<BTreeSet<_>>();
10908    Ok(stored == extracted)
10909}
10910
10911/// Compare every persisted graph row that comes from this file before rewriting it.
10912/// Ranges and reference byte offsets are part of the key because queries expose
10913/// source locations; equal names and edges are not enough after a body shift.
10914fn stored_extract_matches(
10915    tx: &Transaction<'_>,
10916    rel_path: &str,
10917    extract: &FileExtract,
10918    index: &ProjectIndex<'_>,
10919) -> Result<bool> {
10920    let stored_file = tx
10921        .query_row(
10922            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
10923            params![rel_path],
10924            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
10925        )
10926        .optional()?;
10927    if stored_file
10928        != Some((
10929            lang_label(extract.lang).to_string(),
10930            extract.surface_fingerprint.clone(),
10931        ))
10932    {
10933        return Ok(false);
10934    }
10935
10936    let mut stored_nodes_stmt = tx.prepare(
10937        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
10938                end_line, end_col, range_ordinal, signature, exported,
10939                is_default_export, is_type_like, is_callgraph_entry_point, provenance
10940         FROM nodes WHERE file_path = ?1",
10941    )?;
10942    let stored_nodes = stored_nodes_stmt
10943        .query_map(params![rel_path], |row| {
10944            Ok(serde_json::json!([
10945                row.get::<_, String>(0)?,
10946                row.get::<_, String>(1)?,
10947                row.get::<_, String>(2)?,
10948                row.get::<_, String>(3)?,
10949                row.get::<_, String>(4)?,
10950                row.get::<_, i64>(5)?,
10951                row.get::<_, i64>(6)?,
10952                row.get::<_, i64>(7)?,
10953                row.get::<_, i64>(8)?,
10954                row.get::<_, i64>(9)?,
10955                row.get::<_, Option<String>>(10)?,
10956                row.get::<_, i64>(11)?,
10957                row.get::<_, i64>(12)?,
10958                row.get::<_, i64>(13)?,
10959                row.get::<_, i64>(14)?,
10960                row.get::<_, String>(15)?,
10961            ])
10962            .to_string())
10963        })?
10964        .collect::<rusqlite::Result<Vec<_>>>()?;
10965    let expected_nodes = extract
10966        .nodes
10967        .iter()
10968        .map(|node| {
10969            serde_json::json!([
10970                node.id,
10971                node.file_path,
10972                node.name,
10973                node.scoped_name,
10974                node.kind,
10975                node.range.start_line,
10976                node.range.start_col,
10977                node.range.end_line,
10978                node.range.end_col,
10979                node.range_ordinal,
10980                node.signature,
10981                bool_int(node.exported),
10982                bool_int(node.is_default_export),
10983                bool_int(node.is_type_like),
10984                bool_int(node.is_callgraph_entry_point),
10985                PROVENANCE_TREESITTER,
10986            ])
10987            .to_string()
10988        })
10989        .collect::<Vec<_>>();
10990    let mut stored_nodes = stored_nodes;
10991    let mut expected_nodes = expected_nodes;
10992    stored_nodes.sort();
10993    expected_nodes.sort();
10994    if stored_nodes != expected_nodes {
10995        return Ok(false);
10996    }
10997
10998    let resolved_refs = extract
10999        .raw_refs
11000        .iter()
11001        .cloned()
11002        .map(|raw| resolve_ref(raw, index))
11003        .collect::<Result<Vec<_>>>()?;
11004    let mut stored_refs_stmt = tx.prepare(
11005        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11006                module_path, import_kind, local_name, requested_name, namespace_alias,
11007                wildcard, line, byte_start, byte_end, status, target_node,
11008                target_file, target_symbol, provenance
11009         FROM refs WHERE caller_file = ?1",
11010    )?;
11011    let stored_refs = stored_refs_stmt
11012        .query_map(params![rel_path], |row| {
11013            Ok(serde_json::json!([
11014                row.get::<_, String>(0)?,
11015                row.get::<_, Option<String>>(1)?,
11016                row.get::<_, String>(2)?,
11017                row.get::<_, String>(3)?,
11018                row.get::<_, Option<String>>(4)?,
11019                row.get::<_, Option<String>>(5)?,
11020                row.get::<_, Option<String>>(6)?,
11021                row.get::<_, Option<String>>(7)?,
11022                row.get::<_, Option<String>>(8)?,
11023                row.get::<_, Option<String>>(9)?,
11024                row.get::<_, Option<String>>(10)?,
11025                row.get::<_, i64>(11)?,
11026                row.get::<_, i64>(12)?,
11027                row.get::<_, i64>(13)?,
11028                row.get::<_, i64>(14)?,
11029                row.get::<_, String>(15)?,
11030                row.get::<_, Option<String>>(16)?,
11031                row.get::<_, Option<String>>(17)?,
11032                row.get::<_, Option<String>>(18)?,
11033                row.get::<_, String>(19)?,
11034            ])
11035            .to_string())
11036        })?
11037        .collect::<rusqlite::Result<Vec<_>>>()?;
11038    let expected_refs = resolved_refs
11039        .iter()
11040        .map(|resolved| {
11041            let raw = &resolved.raw;
11042            serde_json::json!([
11043                raw.ref_id,
11044                raw.caller_node,
11045                raw.caller_file,
11046                raw.kind,
11047                raw.short_name,
11048                raw.full_ref,
11049                raw.module_path,
11050                raw.import_kind,
11051                raw.local_name,
11052                raw.requested_name,
11053                raw.namespace_alias,
11054                bool_int(raw.wildcard),
11055                raw.line,
11056                raw.byte_start,
11057                raw.byte_end,
11058                resolved.status,
11059                resolved.target_node,
11060                resolved.target_file,
11061                resolved.target_symbol,
11062                PROVENANCE_TREESITTER,
11063            ])
11064            .to_string()
11065        })
11066        .collect::<Vec<_>>();
11067    let mut stored_refs = stored_refs;
11068    let mut expected_refs = expected_refs;
11069    stored_refs.sort();
11070    expected_refs.sort();
11071    if stored_refs != expected_refs {
11072        return Ok(false);
11073    }
11074
11075    let mut stored_edges_stmt = tx.prepare(
11076        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11077                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11078         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11079         WHERE r.caller_file = ?1 AND e.provenance = ?2",
11080    )?;
11081    let stored_edges = stored_edges_stmt
11082        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11083            Ok(serde_json::json!([
11084                row.get::<_, String>(0)?,
11085                row.get::<_, String>(1)?,
11086                row.get::<_, String>(2)?,
11087                row.get::<_, Option<String>>(3)?,
11088                row.get::<_, String>(4)?,
11089                row.get::<_, String>(5)?,
11090                row.get::<_, String>(6)?,
11091                row.get::<_, i64>(7)?,
11092                row.get::<_, String>(8)?,
11093            ])
11094            .to_string())
11095        })?
11096        .collect::<rusqlite::Result<Vec<_>>>()?;
11097    let expected_edges = resolved_refs
11098        .iter()
11099        .filter_map(|resolved| {
11100            resolved.edge.as_ref().map(|edge| {
11101                serde_json::json!([
11102                    edge.edge_id,
11103                    resolved.raw.ref_id,
11104                    edge.source_node,
11105                    edge.target_node,
11106                    edge.target_file,
11107                    edge.target_symbol,
11108                    edge.kind,
11109                    edge.line,
11110                    PROVENANCE_TREESITTER,
11111                ])
11112                .to_string()
11113            })
11114        })
11115        .collect::<Vec<_>>();
11116    let mut stored_edges = stored_edges;
11117    let mut expected_edges = expected_edges;
11118    stored_edges.sort();
11119    expected_edges.sort();
11120    if stored_edges != expected_edges {
11121        return Ok(false);
11122    }
11123
11124    let mut stored_dependencies_stmt =
11125        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11126    let stored_dependencies = stored_dependencies_stmt
11127        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11128        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11129    let expected_dependencies = extract
11130        .raw_refs
11131        .iter()
11132        .flat_map(|raw| raw.dependencies.iter().cloned())
11133        .collect::<BTreeSet<_>>();
11134    if stored_dependencies != expected_dependencies {
11135        return Ok(false);
11136    }
11137
11138    let mut stored_hints_stmt = tx.prepare(
11139        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11140         FROM dispatch_hints WHERE file = ?1",
11141    )?;
11142    let stored_hints = stored_hints_stmt
11143        .query_map(params![rel_path], |row| {
11144            Ok(serde_json::json!([
11145                row.get::<_, String>(0)?,
11146                row.get::<_, String>(1)?,
11147                row.get::<_, String>(2)?,
11148                row.get::<_, String>(3)?,
11149                row.get::<_, i64>(4)?,
11150                row.get::<_, i64>(5)?,
11151                row.get::<_, i64>(6)?,
11152                row.get::<_, String>(7)?,
11153            ])
11154            .to_string())
11155        })?
11156        .collect::<rusqlite::Result<Vec<_>>>()?;
11157    let expected_hints = extract
11158        .dispatch_hints
11159        .iter()
11160        .map(|hint| {
11161            serde_json::json!([
11162                hint.id,
11163                hint.method_name,
11164                hint.caller_node,
11165                hint.file,
11166                hint.line,
11167                hint.byte_start,
11168                hint.byte_end,
11169                PROVENANCE_TREESITTER,
11170            ])
11171            .to_string()
11172        })
11173        .collect::<Vec<_>>();
11174    let mut stored_hints = stored_hints;
11175    let mut expected_hints = expected_hints;
11176    stored_hints.sort();
11177    expected_hints.sort();
11178    Ok(stored_hints == expected_hints)
11179}
11180
11181fn update_file_fresh_metadata(
11182    tx: &Transaction<'_>,
11183    project_root: &Path,
11184    rel_path: &str,
11185    hash: &blake3::Hash,
11186    mtime: SystemTime,
11187    size: u64,
11188) -> Result<()> {
11189    tx.execute(
11190        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11191         WHERE path = ?1",
11192        params![
11193            rel_path,
11194            hash_to_hex(*hash),
11195            system_time_to_ns(mtime),
11196            size as i64,
11197            unix_seconds_now()
11198        ],
11199    )?;
11200    tx.execute(
11201        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11202         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11203        params![
11204            BACKEND_TREESITTER,
11205            rel_path,
11206            hash_to_hex(*hash),
11207            project_root.display().to_string(),
11208            unix_seconds_now(),
11209        ],
11210    )?;
11211    Ok(())
11212}
11213
11214#[derive(Debug, Clone, PartialEq, Eq)]
11215struct DependentRefSelection {
11216    ref_id: String,
11217    caller_file: String,
11218}
11219
11220fn ref_ids_depending_on(
11221    tx: &Transaction<'_>,
11222    project_root: &Path,
11223    rel_path: &str,
11224) -> Result<Vec<DependentRefSelection>> {
11225    let mut stmt = tx.prepare(
11226        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11227         FROM refs r
11228         WHERE r.caller_file IN (
11229             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11230         )
11231            OR r.target_file = ?1
11232         ORDER BY r.ref_id",
11233    )?;
11234    let rows = stmt.query_map(params![rel_path], |row| {
11235        Ok(RefDependencyRow {
11236            ref_id: row.get(0)?,
11237            kind: row.get(1)?,
11238            caller_file: row.get(2)?,
11239            module_path: row.get(3)?,
11240            target_file: row.get(4)?,
11241        })
11242    })?;
11243    let mut ids = Vec::new();
11244    for row in rows {
11245        let row = row?;
11246        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11247            ids.push(DependentRefSelection {
11248                ref_id: row.ref_id,
11249                caller_file: row.caller_file,
11250            });
11251        }
11252    }
11253    Ok(ids)
11254}
11255
11256fn record_dependent_refs(
11257    selected_ref_ids: &mut BTreeSet<String>,
11258    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11259    dependent_refs: Vec<DependentRefSelection>,
11260) {
11261    for dependent_ref in dependent_refs {
11262        let DependentRefSelection {
11263            ref_id,
11264            caller_file,
11265        } = dependent_ref;
11266        selected_ref_ids.insert(ref_id.clone());
11267        selected_refs_by_caller
11268            .entry(caller_file)
11269            .or_default()
11270            .insert(ref_id);
11271    }
11272}
11273
11274#[cfg(test)]
11275fn refs_by_caller_for_ref_ids(
11276    tx: &Transaction<'_>,
11277    ref_ids: &BTreeSet<String>,
11278) -> Result<BTreeMap<String, BTreeSet<String>>> {
11279    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11280    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11281    for ref_id in ref_ids {
11282        if let Some(caller) = stmt
11283            .query_row(params![ref_id], |row| row.get::<_, String>(0))
11284            .optional()?
11285        {
11286            by_caller.entry(caller).or_default().insert(ref_id.clone());
11287        }
11288    }
11289    Ok(by_caller)
11290}
11291
11292fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11293    tx.execute(
11294        "DELETE FROM file_dependencies WHERE file_path = ?1",
11295        params![rel_path],
11296    )?;
11297    delete_refs_for_caller(tx, rel_path)?;
11298    tx.execute(
11299        "DELETE FROM dispatch_hints WHERE file = ?1",
11300        params![rel_path],
11301    )?;
11302    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11303    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11304    Ok(())
11305}
11306
11307fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11308    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11309    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11310    let mut ids = BTreeSet::new();
11311    for row in rows {
11312        ids.insert(row?);
11313    }
11314    delete_ref_ids(tx, &ids)
11315}
11316
11317fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11318    for ref_id in ref_ids {
11319        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11320        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11321    }
11322    Ok(())
11323}
11324
11325fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11326    let mut stmt = conn.prepare(
11327        "SELECT source.file_path, source.scoped_name, edges.target_file,
11328                edges.target_symbol, edges.kind, edges.line
11329         FROM edges
11330         JOIN nodes AS source ON source.id = edges.source_node
11331         ORDER BY source.file_path, source.scoped_name, edges.target_file,
11332                  edges.target_symbol, edges.kind, edges.line",
11333    )?;
11334    let rows = stmt.query_map([], |row| {
11335        Ok(StoredEdge {
11336            source_file: row.get(0)?,
11337            source_symbol: row.get(1)?,
11338            target_file: row.get(2)?,
11339            target_symbol: row.get(3)?,
11340            kind: row.get(4)?,
11341            line: row.get::<_, i64>(5)? as u32,
11342        })
11343    })?;
11344    let mut edges = BTreeSet::new();
11345    for row in rows {
11346        edges.insert(row?);
11347    }
11348    Ok(edges)
11349}
11350
11351fn module_target_from_dependencies(
11352    project_root: &Path,
11353    dependencies: &BTreeSet<String>,
11354) -> Option<String> {
11355    dependencies.iter().find_map(|dep| {
11356        let path = project_root.join(dep);
11357        if path.is_file() {
11358            Some(relative_path(project_root, &canonicalize_path(&path)))
11359        } else {
11360            None
11361        }
11362    })
11363}
11364
11365fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11366    let mut named = HashMap::new();
11367    if let Some(full_ref) = &raw_ref.full_ref {
11368        named = parse_reexport_names(full_ref);
11369    }
11370    ReexportIndex {
11371        target_file,
11372        named,
11373        wildcard: raw_ref.wildcard,
11374    }
11375}
11376
11377fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11378    let mut names = HashMap::new();
11379    let Some(open) = statement.find('{') else {
11380        return names;
11381    };
11382    let Some(close) = statement[open + 1..]
11383        .find('}')
11384        .map(|offset| open + 1 + offset)
11385    else {
11386        return names;
11387    };
11388    for spec in statement[open + 1..close].split(',') {
11389        let spec = spec.trim();
11390        if spec.is_empty() {
11391            continue;
11392        }
11393        if let Some((source, local)) = spec.split_once(" as ") {
11394            names.insert(local.trim().to_string(), source.trim().to_string());
11395        } else {
11396            names.insert(spec.to_string(), spec.to_string());
11397        }
11398    }
11399    names
11400}
11401
11402#[derive(Debug)]
11403struct RefDependencyRow {
11404    ref_id: String,
11405    kind: String,
11406    caller_file: String,
11407    module_path: Option<String>,
11408    target_file: Option<String>,
11409}
11410
11411fn ref_dependency_row_depends_on(
11412    project_root: &Path,
11413    row: &RefDependencyRow,
11414    rel_path: &str,
11415) -> bool {
11416    if row.target_file.as_deref() == Some(rel_path) {
11417        return true;
11418    }
11419
11420    match row.kind.as_str() {
11421        "call" => true,
11422        "import" | "reexport" => row
11423            .module_path
11424            .as_deref()
11425            .map(|module_path| {
11426                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11427                    .contains(rel_path)
11428            })
11429            .unwrap_or(false),
11430        "export_alias" => false,
11431        _ => false,
11432    }
11433}
11434
11435fn module_dependencies_for_ref(
11436    project_root: &Path,
11437    caller_file: &str,
11438    module_path: &str,
11439) -> BTreeSet<String> {
11440    module_dependencies(project_root, &project_root.join(caller_file), module_path)
11441}
11442
11443fn import_dependencies(
11444    project_root: &Path,
11445    abs_path: &Path,
11446    imports: &[ImportStatement],
11447) -> BTreeSet<String> {
11448    let mut deps = BTreeSet::new();
11449    for import in imports {
11450        deps.extend(module_dependencies(
11451            project_root,
11452            abs_path,
11453            &import.module_path,
11454        ));
11455    }
11456    deps
11457}
11458
11459fn module_dependencies(
11460    project_root: &Path,
11461    abs_path: &Path,
11462    module_path: &str,
11463) -> BTreeSet<String> {
11464    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11465    let caller_dir = abs_path.parent().unwrap_or(project_root);
11466    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11467        deps.insert(relative_path(project_root, &resolved));
11468    }
11469    if module_path.starts_with('.') {
11470        let base = caller_dir.join(module_path);
11471        for candidate in relative_module_candidates(&base) {
11472            deps.insert(relative_path(project_root, &candidate));
11473        }
11474    }
11475    deps
11476}
11477
11478fn rust_module_dependencies(
11479    project_root: &Path,
11480    abs_path: &Path,
11481    module_path: &str,
11482) -> BTreeSet<String> {
11483    let mut deps = BTreeSet::new();
11484    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11485    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11486        return deps;
11487    };
11488    let src_prefix = rust_src_prefix(&rel_path);
11489    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11490    if !path_segments.is_empty() {
11491        rust_push_module_dependency_candidate(
11492            project_root,
11493            &mut deps,
11494            &src_prefix,
11495            &path_segments[..path_segments.len() - 1],
11496        );
11497    }
11498    deps
11499}
11500
11501fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11502    let path = rust_module_path_without_alias_or_use_list(module_path);
11503    let segments = path
11504        .split("::")
11505        .map(str::trim)
11506        .filter(|segment| !segment.is_empty())
11507        .collect::<Vec<_>>();
11508    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11509        return None;
11510    }
11511    rust_resolve_segments(rel_path, &segments)
11512}
11513
11514fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11515    let path = module_path
11516        .trim()
11517        .trim_end_matches(';')
11518        .split_once(" as ")
11519        .map(|(left, _)| left.trim())
11520        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11521    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11522}
11523
11524fn rust_push_module_dependency_candidate(
11525    project_root: &Path,
11526    deps: &mut BTreeSet<String>,
11527    src_prefix: &str,
11528    segments: &[String],
11529) {
11530    let candidates = if segments.is_empty() {
11531        vec![
11532            format!("{src_prefix}/lib.rs"),
11533            format!("{src_prefix}/main.rs"),
11534        ]
11535    } else {
11536        vec![
11537            format!("{}/{}.rs", src_prefix, segments.join("/")),
11538            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11539        ]
11540    };
11541    for candidate in candidates {
11542        if project_root.join(&candidate).is_file() {
11543            deps.insert(candidate);
11544        }
11545    }
11546}
11547
11548fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11549    let mut candidates = Vec::new();
11550    if base.extension().is_some() {
11551        candidates.push(base.to_path_buf());
11552        return candidates;
11553    }
11554    for ext in JS_TS_EXTENSIONS {
11555        candidates.push(base.with_extension(ext));
11556    }
11557    for ext in JS_TS_EXTENSIONS {
11558        candidates.push(base.join(format!("index.{ext}")));
11559    }
11560    candidates
11561}
11562
11563fn import_local_names(import: &ImportStatement) -> Vec<String> {
11564    let mut names = Vec::new();
11565    if let Some(default) = &import.default_import {
11566        names.push(default.clone());
11567    }
11568    if let Some(namespace) = &import.namespace_import {
11569        names.push(namespace.clone());
11570    }
11571    for name in &import.names {
11572        names.push(crate::imports::specifier_local_name(name).to_string());
11573    }
11574    names
11575}
11576
11577fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11578    import
11579        .names
11580        .iter()
11581        .map(|name| crate::imports::specifier_imported_name(name).to_string())
11582        .collect()
11583}
11584
11585fn import_is_wildcard(import: &ImportStatement) -> bool {
11586    import.namespace_import.is_some() || import.raw_text.contains('*')
11587}
11588
11589fn namespace_alias(full_ref: &str) -> Option<String> {
11590    full_ref
11591        .split_once('.')
11592        .map(|(namespace, _)| namespace.to_string())
11593}
11594
11595fn import_kind_label(kind: ImportKind) -> &'static str {
11596    match kind {
11597        ImportKind::Value => "value",
11598        ImportKind::Type => "type",
11599        ImportKind::SideEffect => "side_effect",
11600    }
11601}
11602
11603fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11604    match kind {
11605        SymbolKind::Function => "function",
11606        SymbolKind::Class => "class",
11607        SymbolKind::Method => "method",
11608        SymbolKind::Struct => "struct",
11609        SymbolKind::Interface => "interface",
11610        SymbolKind::Enum => "enum",
11611        SymbolKind::TypeAlias => "type_alias",
11612        SymbolKind::Variable => "variable",
11613        SymbolKind::Heading => "heading",
11614        SymbolKind::FileSummary => "file_summary",
11615    }
11616}
11617
11618fn is_type_like(kind: &SymbolKind) -> bool {
11619    matches!(
11620        kind,
11621        SymbolKind::Class
11622            | SymbolKind::Struct
11623            | SymbolKind::Interface
11624            | SymbolKind::Enum
11625            | SymbolKind::TypeAlias
11626    )
11627}
11628
11629fn lang_label(lang: LangId) -> &'static str {
11630    match lang {
11631        LangId::TypeScript => "typescript",
11632        LangId::Tsx => "tsx",
11633        LangId::JavaScript => "javascript",
11634        LangId::Python => "python",
11635        LangId::Rust => "rust",
11636        LangId::Go => "go",
11637        LangId::C => "c",
11638        LangId::Cpp => "cpp",
11639        LangId::Zig => "zig",
11640        LangId::CSharp => "csharp",
11641        LangId::Bash => "bash",
11642        LangId::Html => "html",
11643        LangId::Markdown => "markdown",
11644        LangId::Solidity => "solidity",
11645        LangId::Scss => "scss",
11646        LangId::Vue => "vue",
11647        LangId::Json => "json",
11648        LangId::Scala => "scala",
11649        LangId::Java => "java",
11650        LangId::Ruby => "ruby",
11651        LangId::Kotlin => "kotlin",
11652        LangId::Swift => "swift",
11653        LangId::Php => "php",
11654        LangId::Lua => "lua",
11655        LangId::Perl => "perl",
11656        LangId::Yaml => "yaml",
11657        LangId::Pascal => "pascal",
11658        LangId::R => "r",
11659        LangId::Groovy => "groovy",
11660        LangId::ObjC => "objc",
11661    }
11662}
11663
11664fn lang_from_label(label: &str) -> Option<LangId> {
11665    match label {
11666        "typescript" => Some(LangId::TypeScript),
11667        "tsx" => Some(LangId::Tsx),
11668        "javascript" => Some(LangId::JavaScript),
11669        "python" => Some(LangId::Python),
11670        "rust" => Some(LangId::Rust),
11671        "go" => Some(LangId::Go),
11672        "c" => Some(LangId::C),
11673        "cpp" => Some(LangId::Cpp),
11674        "zig" => Some(LangId::Zig),
11675        "csharp" => Some(LangId::CSharp),
11676        "bash" => Some(LangId::Bash),
11677        "html" => Some(LangId::Html),
11678        "markdown" => Some(LangId::Markdown),
11679        "solidity" => Some(LangId::Solidity),
11680        "scss" => Some(LangId::Scss),
11681        "vue" => Some(LangId::Vue),
11682        "json" => Some(LangId::Json),
11683        "scala" => Some(LangId::Scala),
11684        "java" => Some(LangId::Java),
11685        "ruby" => Some(LangId::Ruby),
11686        "kotlin" => Some(LangId::Kotlin),
11687        "swift" => Some(LangId::Swift),
11688        "php" => Some(LangId::Php),
11689        "lua" => Some(LangId::Lua),
11690        "perl" => Some(LangId::Perl),
11691        "yaml" => Some(LangId::Yaml),
11692        "pascal" => Some(LangId::Pascal),
11693        "r" => Some(LangId::R),
11694        "groovy" => Some(LangId::Groovy),
11695        "objc" => Some(LangId::ObjC),
11696        _ => None,
11697    }
11698}
11699
11700fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11701    let mut normalized = if files.is_empty() {
11702        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11703    } else {
11704        files
11705            .iter()
11706            .map(|path| normalize_file_path(project_root, path))
11707            .collect::<Result<Vec<_>>>()?
11708    };
11709    normalized.sort();
11710    normalized.dedup();
11711    Ok(normalized)
11712}
11713
11714fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11715    let full_path = if path.is_relative() {
11716        project_root.join(path)
11717    } else {
11718        path.to_path_buf()
11719    };
11720    Ok(canonicalize_path(&full_path))
11721}
11722
11723fn canonicalize_path(path: &Path) -> PathBuf {
11724    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11725}
11726
11727fn relative_path(project_root: &Path, path: &Path) -> String {
11728    if let Ok(stripped) = path.strip_prefix(project_root) {
11729        return stripped.to_string_lossy().replace('\\', "/");
11730    }
11731    let canon_root = canonicalize_path(project_root);
11732    let canon_path = canonicalize_path(path);
11733    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11734        return stripped.to_string_lossy().replace('\\', "/");
11735    }
11736    canon_path.to_string_lossy().replace('\\', "/")
11737}
11738
11739fn unqualified_name(scoped: &str) -> &str {
11740    if scoped == TOP_LEVEL_SYMBOL {
11741        return scoped;
11742    }
11743    scoped
11744        .rsplit("::")
11745        .next()
11746        .unwrap_or(scoped)
11747        .rsplit('.')
11748        .next()
11749        .unwrap_or(scoped)
11750        .rsplit('#')
11751        .next()
11752        .unwrap_or(scoped)
11753}
11754
11755fn ref_id(parts: &[&str]) -> String {
11756    let joined = parts.join("\0");
11757    hash_to_hex(blake3::hash(joined.as_bytes()))
11758}
11759
11760fn hash_to_hex(hash: blake3::Hash) -> String {
11761    hash.to_hex().to_string()
11762}
11763
11764fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11765    let bytes = hex_to_bytes(value)?;
11766    Some(blake3::Hash::from_bytes(bytes))
11767}
11768
11769fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11770    if value.len() != 64 {
11771        return None;
11772    }
11773    let mut bytes = [0u8; 32];
11774    for (index, slot) in bytes.iter_mut().enumerate() {
11775        let start = index * 2;
11776        let end = start + 2;
11777        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11778    }
11779    Some(bytes)
11780}
11781
11782#[derive(Debug, Clone)]
11783struct LineIndex {
11784    newline_offsets: Vec<usize>,
11785    source_len: usize,
11786}
11787
11788impl LineIndex {
11789    fn new(source: &str) -> Self {
11790        Self {
11791            newline_offsets: source
11792                .bytes()
11793                .enumerate()
11794                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11795                .collect(),
11796            source_len: source.len(),
11797        }
11798    }
11799
11800    fn byte_to_line(&self, byte_offset: usize) -> u32 {
11801        let byte_offset = byte_offset.min(self.source_len);
11802        self.newline_offsets
11803            .partition_point(|offset| *offset < byte_offset) as u32
11804            + 1
11805    }
11806}
11807
11808fn empty_to_none(value: String) -> Option<String> {
11809    if value.is_empty() {
11810        None
11811    } else {
11812        Some(value)
11813    }
11814}
11815
11816fn bool_int(value: bool) -> i64 {
11817    if value {
11818        1
11819    } else {
11820        0
11821    }
11822}
11823
11824fn system_time_to_ns(time: SystemTime) -> i64 {
11825    time.duration_since(UNIX_EPOCH)
11826        .unwrap_or_default()
11827        .as_nanos()
11828        .min(i64::MAX as u128) as i64
11829}
11830
11831fn ns_to_system_time(value: i64) -> SystemTime {
11832    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11833}
11834
11835fn unix_millis_now() -> u64 {
11836    SystemTime::now()
11837        .duration_since(UNIX_EPOCH)
11838        .unwrap_or_default()
11839        .as_millis()
11840        .min(u128::from(u64::MAX)) as u64
11841}
11842
11843fn unix_seconds_now() -> i64 {
11844    SystemTime::now()
11845        .duration_since(UNIX_EPOCH)
11846        .unwrap_or_default()
11847        .as_secs() as i64
11848}
11849
11850/// Serializes every test that drives the process-wide refresh worker
11851/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
11852/// worker down between another test's enqueue and its flush, deferring the
11853/// batch and zeroing that test's seam counts).
11854#[cfg(test)]
11855pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11856
11857#[cfg(test)]
11858mod refresh_worker_tests {
11859    use super::*;
11860    use std::fs;
11861    use tempfile::tempdir;
11862
11863    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11864        let temp = tempdir().unwrap();
11865        let root = temp.path().join("root");
11866        fs::create_dir_all(&root).unwrap();
11867        let artifact_key = crate::search_index::artifact_cache_key(&root);
11868        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11869        let callgraph_dir = temp
11870            .path()
11871            .join("storage")
11872            .join("callgraph")
11873            .join(artifact_key);
11874        let source = root.join("main.rs");
11875        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11876        let (store, _) = CallGraphStore::cold_build_with_lease(
11877            callgraph_dir.clone(),
11878            root.clone(),
11879            std::slice::from_ref(&source),
11880        )
11881        .unwrap();
11882        drop(store);
11883        (temp, root, callgraph_dir, source)
11884    }
11885
11886    fn pending_paths() -> PendingCallGraphStorePaths {
11887        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
11888    }
11889
11890    fn wait_for_refresh_calls(root: &Path, expected: usize) {
11891        let deadline = Instant::now() + Duration::from_secs(12);
11892        while callgraph_refresh_worker_test_counts(root).0 < expected {
11893            assert!(
11894                Instant::now() < deadline,
11895                "timed out waiting for {expected} callgraph refresh worker call(s)"
11896            );
11897            std::thread::sleep(Duration::from_millis(5));
11898        }
11899    }
11900
11901    fn wait_for_refresh_worker_idle() {
11902        let deadline = Instant::now() + Duration::from_secs(12);
11903        loop {
11904            let worker = CALLGRAPH_REFRESH_WORKER
11905                .get_or_init(|| Mutex::new(None))
11906                .lock()
11907                .expect("callgraph refresh worker mutex poisoned")
11908                .clone();
11909            let idle = worker.is_none_or(|worker| {
11910                let queue = worker
11911                    .shared
11912                    .queue
11913                    .lock()
11914                    .expect("callgraph refresh queue mutex poisoned");
11915                queue.active.is_none() && queue.order.is_empty()
11916            });
11917            if idle {
11918                return;
11919            }
11920            assert!(
11921                Instant::now() < deadline,
11922                "timed out waiting for callgraph refresh worker to become idle"
11923            );
11924            std::thread::sleep(Duration::from_millis(5));
11925        }
11926    }
11927
11928    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11929        let temp = tempdir().unwrap();
11930        let root = temp.path().join("workspace");
11931        fs::create_dir_all(root.join("app/src")).unwrap();
11932        let artifact_key = crate::search_index::artifact_cache_key(&root);
11933        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11934        let callgraph_dir = temp
11935            .path()
11936            .join("storage")
11937            .join("callgraph")
11938            .join(artifact_key);
11939        fs::write(
11940            root.join("Cargo.toml"),
11941            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
11942        )
11943        .unwrap();
11944        fs::write(
11945            root.join("app/Cargo.toml"),
11946            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11947        )
11948        .unwrap();
11949        let caller = root.join("app/src/lib.rs");
11950        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
11951        let (store, _) = CallGraphStore::cold_build_with_lease(
11952            callgraph_dir.clone(),
11953            root.clone(),
11954            std::slice::from_ref(&caller),
11955        )
11956        .unwrap();
11957        drop(store);
11958        (temp, root, callgraph_dir, caller)
11959    }
11960
11961    #[test]
11962    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
11963        let _guard = REFRESH_WORKER_TEST_LOCK
11964            .lock()
11965            .unwrap_or_else(std::sync::PoisonError::into_inner);
11966        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11967        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11968        reset_workspace_crate_prefix_build_count(&root);
11969        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11970
11971        for revision in ["first", "second"] {
11972            fs::write(
11973                &caller,
11974                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
11975            )
11976            .unwrap();
11977            enqueue_callgraph_store_refresh(
11978                callgraph_dir.clone(),
11979                root.clone(),
11980                vec![caller.clone()],
11981                pending_paths(),
11982            );
11983            wait_for_refresh_worker_idle();
11984        }
11985
11986        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11987        assert!(flush_callgraph_store_refreshes_with_budget(
11988            Duration::from_secs(5)
11989        ));
11990        clear_callgraph_refresh_worker_test_seam(&root);
11991    }
11992
11993    #[test]
11994    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
11995        let _guard = REFRESH_WORKER_TEST_LOCK
11996            .lock()
11997            .unwrap_or_else(std::sync::PoisonError::into_inner);
11998        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11999        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12000        reset_workspace_crate_prefix_build_count(&root);
12001        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12002
12003        fs::write(
12004            &caller,
12005            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12006        )
12007        .unwrap();
12008        enqueue_callgraph_store_refresh(
12009            callgraph_dir.clone(),
12010            root.clone(),
12011            vec![caller.clone()],
12012            pending_paths(),
12013        );
12014        wait_for_refresh_worker_idle();
12015        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12016
12017        let added_manifest = root.join("added/Cargo.toml");
12018        let added_source = root.join("added/src/lib.rs");
12019        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12020        fs::write(
12021            root.join("Cargo.toml"),
12022            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12023        )
12024        .unwrap();
12025        fs::write(
12026            &added_manifest,
12027            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12028        )
12029        .unwrap();
12030        fs::write(&added_source, "pub fn target() {}\n").unwrap();
12031        fs::write(
12032            &caller,
12033            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12034        )
12035        .unwrap();
12036
12037        enqueue_callgraph_store_refresh(
12038            callgraph_dir.clone(),
12039            root.clone(),
12040            vec![
12041                root.join("Cargo.toml"),
12042                added_manifest,
12043                added_source,
12044                caller,
12045            ],
12046            pending_paths(),
12047        );
12048        assert!(flush_callgraph_store_refreshes_with_budget(
12049            Duration::from_secs(12)
12050        ));
12051
12052        // This is the negative control for a permanently-static cache: without
12053        // manifest invalidation the build count stays at one and the call remains
12054        // unresolved because `added_crate` was absent when the map was primed.
12055        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12056        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12057            .unwrap()
12058            .expect("refreshed workspace store");
12059        let tree = store
12060            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12061            .unwrap();
12062        assert_eq!(tree.children.len(), 1);
12063        assert_eq!(tree.children[0].file, "added/src/lib.rs");
12064        assert_eq!(tree.children[0].name, "target");
12065        assert!(tree.children[0].resolved);
12066        clear_callgraph_refresh_worker_test_seam(&root);
12067    }
12068
12069    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12070        let temp = tempdir().unwrap();
12071        let main = temp.path().join("main");
12072        let worktree = temp.path().join("worktree");
12073        fs::create_dir_all(&main).unwrap();
12074        let mut git = std::process::Command::new("git");
12075        assert!(
12076            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12077                .status()
12078                .unwrap()
12079                .success()
12080        );
12081        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12082        for args in [
12083            vec![
12084                "-C",
12085                main.to_str().unwrap(),
12086                "config",
12087                "user.email",
12088                "test@example.com",
12089            ],
12090            vec![
12091                "-C",
12092                main.to_str().unwrap(),
12093                "config",
12094                "user.name",
12095                "AFT Test",
12096            ],
12097            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12098            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12099        ] {
12100            let mut command = std::process::Command::new("git");
12101            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12102                .status()
12103                .unwrap()
12104                .success());
12105        }
12106        let mut add_worktree = std::process::Command::new("git");
12107        assert!(crate::test_env::apply_hermetic_git_env(
12108            add_worktree
12109                .arg("-C")
12110                .arg(&main)
12111                .args(["worktree", "add", "--detach"])
12112                .arg(&worktree),
12113        )
12114        .status()
12115        .unwrap()
12116        .success());
12117        let main = fs::canonicalize(main).unwrap();
12118        let worktree = fs::canonicalize(worktree).unwrap();
12119        let project_key = crate::search_index::artifact_cache_key(&main);
12120        assert_eq!(
12121            crate::search_index::artifact_cache_key(&worktree),
12122            project_key
12123        );
12124        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12125        (temp, main, worktree, project_key, callgraph_dir)
12126    }
12127
12128    #[test]
12129    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12130        let _git_env = crate::test_env::hermetic_git_env_guard();
12131        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12132        crate::root_cache::configure_artifact_access(&root, &project_key, true);
12133        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12134        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12135        let publications_for_observer = Arc::clone(&publications);
12136        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12137            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12138        })));
12139        let source = root.join("lib.rs");
12140
12141        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12142            .expect_err("borrow-only writable open must remain unavailable");
12143        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12144        assert!(
12145            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12146                .unwrap()
12147                .is_none()
12148        );
12149        assert!(
12150            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12151                .unwrap()
12152                .is_none()
12153        );
12154        assert!(matches!(
12155            CallGraphStore::cold_build_with_lease(
12156                callgraph_dir.clone(),
12157                root.clone(),
12158                std::slice::from_ref(&source),
12159            ),
12160            Err(CallGraphStoreError::Unavailable(_))
12161        ));
12162        assert!(matches!(
12163            CallGraphStore::ensure_built_with_lease(
12164                callgraph_dir.clone(),
12165                root.clone(),
12166                std::slice::from_ref(&source),
12167            ),
12168            Err(CallGraphStoreError::Unavailable(_))
12169        ));
12170        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12171            callgraph_dir.clone(),
12172            root.clone(),
12173            &[source],
12174            1,
12175        )
12176        .expect_err("borrow-only forced rebuild must remain unsatisfied");
12177        set_cold_build_swap_observer(None);
12178
12179        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12180        assert_eq!(
12181            crate::root_cache::writer_lease_acquisition_count_for_test(
12182                crate::root_cache::RootCacheDomain::Callgraph,
12183                &project_key,
12184                &root,
12185            ),
12186            0
12187        );
12188        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12189        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12190    }
12191
12192    #[test]
12193    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12194        let _git_env = crate::test_env::hermetic_git_env_guard();
12195        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12196        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12197        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12198        let source = owner.join("lib.rs");
12199        let (store, _) = CallGraphStore::cold_build_with_lease(
12200            callgraph_dir.clone(),
12201            owner.clone(),
12202            std::slice::from_ref(&source),
12203        )
12204        .unwrap();
12205        let sqlite_path = store.sqlite_path().to_path_buf();
12206        drop(store);
12207
12208        let conn = Connection::open(&sqlite_path).unwrap();
12209        conn.execute(
12210            "UPDATE backend_file_state SET workspace_root = ?1",
12211            [worktree.display().to_string()],
12212        )
12213        .unwrap();
12214        drop(conn);
12215
12216        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12217        let publications_for_observer = Arc::clone(&publications);
12218        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12219            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12220        })));
12221        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12222
12223        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12224            .unwrap()
12225            .expect("owner should purge the storm-era worktree root");
12226        drop(repaired);
12227        for _ in 0..3 {
12228            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12229                .unwrap()
12230                .expect("linked worktree should borrow the owner generation");
12231            drop(borrower);
12232            assert!(
12233                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12234                    .unwrap()
12235                    .is_none()
12236            );
12237            let owner_store =
12238                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12239                    .unwrap()
12240                    .expect("owner generation should remain ready");
12241            drop(owner_store);
12242        }
12243        set_cold_build_swap_observer(None);
12244
12245        assert_eq!(
12246            publications.load(AtomicOrdering::SeqCst),
12247            1,
12248            "the owner performs one expected post-storm purge and alternation stays read-only"
12249        );
12250        assert_eq!(
12251            crate::root_cache::writer_lease_acquisition_count_for_test(
12252                crate::root_cache::RootCacheDomain::Callgraph,
12253                &project_key,
12254                &worktree,
12255            ),
12256            0
12257        );
12258    }
12259
12260    #[test]
12261    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12262        let temp = tempdir().unwrap();
12263        let root = temp.path().join("owner");
12264        let other_root = temp.path().join("other");
12265        fs::create_dir_all(&root).unwrap();
12266        fs::create_dir_all(&other_root).unwrap();
12267        let source = root.join("lib.rs");
12268        fs::write(&source, "pub fn marker() {}\n").unwrap();
12269        let project_key = crate::search_index::artifact_cache_key(&root);
12270        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12271        crate::root_cache::configure_artifact_access(&root, &project_key, false);
12272        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12273        rebuild_cooldown_records()
12274            .lock()
12275            .unwrap_or_else(std::sync::PoisonError::into_inner)
12276            .remove(&cooldown_key);
12277        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12278        let stale_epoch = epoch.current();
12279        epoch.next();
12280
12281        let failed = with_publish_epoch(epoch, stale_epoch, || {
12282            CallGraphStore::cold_build_with_lease(
12283                callgraph_dir.clone(),
12284                root.clone(),
12285                std::slice::from_ref(&source),
12286            )
12287        });
12288        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12289        assert!(
12290            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12291                .is_none()
12292        );
12293
12294        let (store, _) = CallGraphStore::cold_build_with_lease(
12295            callgraph_dir.clone(),
12296            root.clone(),
12297            std::slice::from_ref(&source),
12298        )
12299        .unwrap();
12300        drop(store);
12301        assert!(
12302            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12303                .is_none()
12304        );
12305
12306        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12307        assert!(
12308            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12309        );
12310    }
12311
12312    #[test]
12313    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12314        let _guard = REFRESH_WORKER_TEST_LOCK
12315            .lock()
12316            .unwrap_or_else(std::sync::PoisonError::into_inner);
12317        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12318        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12319        let pending = pending_paths();
12320        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12321
12322        let lifecycle = SubcLifecycleAdmission::default();
12323        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12324        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12325        let ticket = CallgraphRefreshTicket::new(
12326            lifecycle,
12327            Arc::clone(&generation),
12328            7,
12329            publish_epoch.clone(),
12330            publish_epoch.current(),
12331        );
12332        // Supersede before the worker runs: the batch must defer, not commit.
12333        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12334        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12335            .unwrap()
12336            .expect("ready store snapshot");
12337        let refresh_state = CallgraphRefreshState::new(
12338            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12339            Arc::new(AtomicBool::new(true)),
12340        );
12341
12342        enqueue_callgraph_store_refresh_fenced_with_state(
12343            callgraph_dir,
12344            root.clone(),
12345            vec![source.clone()],
12346            Arc::clone(&pending),
12347            refresh_state,
12348            ticket,
12349        );
12350        assert!(flush_callgraph_store_refreshes_with_budget(
12351            Duration::from_secs(5)
12352        ));
12353        assert_eq!(
12354            callgraph_refresh_worker_test_counts(&root).0,
12355            0,
12356            "superseded batch must not reach refresh_files or self-replay"
12357        );
12358        assert!(
12359            pending.lock().contains(&source),
12360            "superseded batch must defer its paths to the pending sink"
12361        );
12362        clear_callgraph_refresh_worker_test_seam(&root);
12363    }
12364
12365    #[test]
12366    fn superseded_open_failure_defers_without_self_replay() {
12367        let _guard = REFRESH_WORKER_TEST_LOCK
12368            .lock()
12369            .unwrap_or_else(std::sync::PoisonError::into_inner);
12370        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12371        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12372        let pending = pending_paths();
12373        let installed = Arc::new(
12374            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12375                .unwrap()
12376                .expect("ready store snapshot"),
12377        );
12378        let refresh_state = CallgraphRefreshState::new(
12379            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12380            Arc::new(AtomicBool::new(true)),
12381        );
12382        assert!(!installed.is_legacy_fallback());
12383        assert!(installed.is_current());
12384        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12385        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12386        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12387        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12388
12389        let lifecycle = SubcLifecycleAdmission::default();
12390        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12391        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12392        let ticket = CallgraphRefreshTicket::new(
12393            lifecycle,
12394            Arc::clone(&generation),
12395            7,
12396            publish_epoch.clone(),
12397            publish_epoch.current(),
12398        );
12399        enqueue_callgraph_store_refresh_fenced_with_state(
12400            callgraph_dir,
12401            root.clone(),
12402            vec![source.clone()],
12403            Arc::clone(&pending),
12404            refresh_state,
12405            ticket,
12406        );
12407        held_rx
12408            .recv_timeout(Duration::from_secs(12))
12409            .expect("refresh worker must hold after injected open failure");
12410
12411        // Mark the refresh request obsolete after the injected open failure,
12412        // then unblock the worker before its deferred retry can run.
12413        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12414        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12415        release_tx
12416            .send(())
12417            .expect("release superseded refresh worker");
12418        wait_for_refresh_worker_idle();
12419
12420        assert_eq!(
12421            callgraph_refresh_worker_test_counts(&root).0,
12422            1,
12423            "superseded open-failure batch must not self-replay"
12424        );
12425        assert_eq!(
12426            callgraph_refresh_worker_test_worker_calls(&root),
12427            1,
12428            "superseded open-failure batch must not create another worker call"
12429        );
12430        assert!(
12431            pending.lock().contains(&source),
12432            "superseded open-failure paths must remain in the pending sink"
12433        );
12434        let tree = installed
12435            .call_tree(Path::new("main.rs"), "entry", 1)
12436            .unwrap();
12437        assert_eq!(
12438            tree.children[0].name, "old_leaf",
12439            "superseded open-failure batch must not converge the store"
12440        );
12441        clear_callgraph_refresh_worker_test_seam(&root);
12442    }
12443
12444    #[test]
12445    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12446        let _guard = REFRESH_WORKER_TEST_LOCK
12447            .lock()
12448            .unwrap_or_else(std::sync::PoisonError::into_inner);
12449        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12450        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12451        let pending = pending_paths();
12452        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12453
12454        let lifecycle = SubcLifecycleAdmission::default();
12455        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12456        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12457        let expected_epoch = publish_epoch.current();
12458        let ticket = CallgraphRefreshTicket::new(
12459            lifecycle,
12460            generation,
12461            3,
12462            publish_epoch.clone(),
12463            expected_epoch,
12464        );
12465        // A cold build published a replacement generation after enqueue.
12466        publish_epoch.next();
12467
12468        enqueue_callgraph_store_refresh_fenced(
12469            callgraph_dir,
12470            root.clone(),
12471            vec![source.clone()],
12472            Arc::clone(&pending),
12473            ticket,
12474        );
12475        assert!(flush_callgraph_store_refreshes_with_budget(
12476            Duration::from_secs(5)
12477        ));
12478        assert_eq!(
12479            callgraph_refresh_worker_test_counts(&root).0,
12480            0,
12481            "epoch-superseded batch must not reach refresh_files"
12482        );
12483        assert!(
12484            pending.lock().contains(&source),
12485            "epoch-superseded batch must defer its paths to the pending sink"
12486        );
12487        clear_callgraph_refresh_worker_test_seam(&root);
12488    }
12489
12490    #[test]
12491    fn fenced_refresh_with_current_ticket_commits_normally() {
12492        let _guard = REFRESH_WORKER_TEST_LOCK
12493            .lock()
12494            .unwrap_or_else(std::sync::PoisonError::into_inner);
12495        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12496        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12497        let pending = pending_paths();
12498        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12499
12500        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12501
12502        let lifecycle = SubcLifecycleAdmission::default();
12503        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12504        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12505        let ticket = CallgraphRefreshTicket::new(
12506            lifecycle,
12507            generation,
12508            5,
12509            publish_epoch.clone(),
12510            publish_epoch.current(),
12511        );
12512
12513        enqueue_callgraph_store_refresh_fenced(
12514            callgraph_dir.clone(),
12515            root.clone(),
12516            vec![source.clone()],
12517            Arc::clone(&pending),
12518            ticket,
12519        );
12520        assert!(flush_callgraph_store_refreshes_with_budget(
12521            Duration::from_secs(5)
12522        ));
12523        assert_eq!(
12524            callgraph_refresh_worker_test_counts(&root).0,
12525            1,
12526            "current ticket must run the refresh"
12527        );
12528        assert!(
12529            pending.lock().is_empty(),
12530            "committed batch must not defer paths"
12531        );
12532
12533        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12534            .unwrap()
12535            .expect("published generation must remain readable");
12536        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12537        assert_eq!(
12538            tree.children[0].name, "new_leaf",
12539            "fenced commit must actually persist the refreshed content"
12540        );
12541        clear_callgraph_refresh_worker_test_seam(&root);
12542    }
12543
12544    #[test]
12545    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12546        let _guard = REFRESH_WORKER_TEST_LOCK
12547            .lock()
12548            .unwrap_or_else(std::sync::PoisonError::into_inner);
12549        // Generous pre-drain: the refresh worker is process-wide, so a prior
12550        // test's still-running batch (slow Windows CI) must fully settle
12551        // before this test enqueues, or its wait deadline absorbs the
12552        // leftover work. Idle workers return immediately.
12553        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12554        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12555        let pending = pending_paths();
12556        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12557
12558        enqueue_callgraph_store_refresh(
12559            callgraph_dir.clone(),
12560            root.clone(),
12561            vec![source.clone()],
12562            Arc::clone(&pending),
12563        );
12564        wait_for_refresh_calls(&root, 1);
12565        for _ in 0..3 {
12566            enqueue_callgraph_store_refresh(
12567                callgraph_dir.clone(),
12568                root.clone(),
12569                vec![source.clone()],
12570                Arc::clone(&pending),
12571            );
12572        }
12573
12574        assert!(flush_callgraph_store_refreshes_with_budget(
12575            Duration::from_secs(2)
12576        ));
12577        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12578        assert!(pending.lock().is_empty());
12579        clear_callgraph_refresh_worker_test_seam(&root);
12580    }
12581
12582    #[test]
12583    fn queued_refresh_opens_generation_published_after_enqueue() {
12584        let _guard = REFRESH_WORKER_TEST_LOCK
12585            .lock()
12586            .unwrap_or_else(std::sync::PoisonError::into_inner);
12587        // Generous pre-drain: the refresh worker is process-wide, so a prior
12588        // test's still-running batch (slow Windows CI) must fully settle
12589        // before this test enqueues, or its wait deadline absorbs the
12590        // leftover work. Idle workers return immediately.
12591        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12592        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12593        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12594        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12595        let (active_held_rx, active_release_tx) =
12596            install_callgraph_refresh_worker_test_gate(active_root.clone());
12597        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12598        enqueue_callgraph_store_refresh(
12599            active_dir,
12600            active_root.clone(),
12601            vec![active_source],
12602            pending_paths(),
12603        );
12604        active_held_rx
12605            .recv_timeout(Duration::from_secs(12))
12606            .expect("active refresh worker holds the queue");
12607
12608        fs::write(
12609            &target_source,
12610            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12611        )
12612        .unwrap();
12613        enqueue_callgraph_store_refresh(
12614            target_dir.clone(),
12615            target_root.clone(),
12616            vec![target_source.clone()],
12617            pending_paths(),
12618        );
12619        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12620            target_dir.clone(),
12621            target_root.clone(),
12622            std::slice::from_ref(&target_source),
12623        )
12624        .unwrap();
12625        fs::write(
12626            &target_source,
12627            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12628        )
12629        .unwrap();
12630        drop(new_generation);
12631
12632        active_release_tx
12633            .send(())
12634            .expect("release active refresh worker");
12635        wait_for_refresh_calls(&target_root, 1);
12636        assert!(flush_callgraph_store_refreshes_with_budget(
12637            Duration::from_secs(12)
12638        ));
12639        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12640            .unwrap()
12641            .expect("current callgraph generation");
12642        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12643        assert_eq!(tree.children[0].name, "worker_leaf");
12644        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12645        clear_callgraph_refresh_worker_test_seam(&active_root);
12646        clear_callgraph_refresh_worker_test_seam(&target_root);
12647    }
12648
12649    #[test]
12650    fn refresh_failure_marks_files_stale() {
12651        let _guard = REFRESH_WORKER_TEST_LOCK
12652            .lock()
12653            .unwrap_or_else(std::sync::PoisonError::into_inner);
12654        // Generous pre-drain: the refresh worker is process-wide, so a prior
12655        // test's still-running batch (slow Windows CI) must fully settle
12656        // before this test enqueues, or its wait deadline absorbs the
12657        // leftover work. Idle workers return immediately.
12658        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12659        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12660        let pending = pending_paths();
12661        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12662
12663        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12664        assert!(flush_callgraph_store_refreshes_with_budget(
12665            Duration::from_secs(2)
12666        ));
12667
12668        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12669        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12670            .unwrap()
12671            .expect("ready callgraph store");
12672        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12673        clear_callgraph_refresh_worker_test_seam(&root);
12674    }
12675
12676    #[test]
12677    fn idle_refresh_truncates_wal() {
12678        let _guard = REFRESH_WORKER_TEST_LOCK
12679            .lock()
12680            .unwrap_or_else(std::sync::PoisonError::into_inner);
12681        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12682        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12683        let generation = read_pointer(
12684            &callgraph_dir,
12685            &crate::search_index::artifact_cache_key(&root),
12686        )
12687        .expect("fixture publishes a generation");
12688        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12689        let pending = pending_paths();
12690        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12691
12692        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12693        enqueue_callgraph_store_refresh(
12694            callgraph_dir.clone(),
12695            root.clone(),
12696            vec![source.clone()],
12697            Arc::clone(&pending),
12698        );
12699        wait_for_refresh_calls(&root, 1);
12700        wait_for_refresh_worker_idle();
12701        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12702        while fs::metadata(&wal_path)
12703            .map(|metadata| metadata.len())
12704            .unwrap_or(0)
12705            != 0
12706        {
12707            assert!(
12708                Instant::now() < checkpoint_deadline,
12709                "idle checkpoint did not truncate WAL"
12710            );
12711            std::thread::sleep(Duration::from_millis(5));
12712        }
12713        assert_eq!(
12714            fs::metadata(&wal_path)
12715                .map(|metadata| metadata.len())
12716                .unwrap_or(0),
12717            0,
12718            "idle transition truncates the refresh WAL"
12719        );
12720
12721        clear_callgraph_refresh_worker_test_seam(&root);
12722    }
12723
12724    #[test]
12725    fn bounded_shutdown_defers_unprocessed_batches() {
12726        let _guard = REFRESH_WORKER_TEST_LOCK
12727            .lock()
12728            .unwrap_or_else(std::sync::PoisonError::into_inner);
12729        // Generous pre-drain: the refresh worker is process-wide, so a prior
12730        // test's still-running batch (slow Windows CI) must fully settle
12731        // before this test enqueues, or its wait deadline absorbs the
12732        // leftover work. Idle workers return immediately.
12733        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12734        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12735        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12736        let active_pending = pending_paths();
12737        let queued_pending = pending_paths();
12738        set_callgraph_refresh_worker_test_seam(
12739            active_root.clone(),
12740            Duration::from_millis(300),
12741            false,
12742        );
12743
12744        enqueue_callgraph_store_refresh(
12745            active_dir,
12746            active_root.clone(),
12747            vec![active_source.clone()],
12748            Arc::clone(&active_pending),
12749        );
12750        wait_for_refresh_calls(&active_root, 1);
12751        enqueue_callgraph_store_refresh(
12752            queued_dir,
12753            queued_root.clone(),
12754            vec![queued_source.clone()],
12755            Arc::clone(&queued_pending),
12756        );
12757
12758        assert!(!flush_callgraph_store_refreshes_with_budget(
12759            Duration::from_millis(20)
12760        ));
12761        assert!(active_pending.lock().contains(&active_source));
12762        assert!(queued_pending.lock().contains(&queued_source));
12763        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12764        clear_callgraph_refresh_worker_test_seam(&active_root);
12765    }
12766}
12767
12768#[cfg(test)]
12769mod cold_build_insert_tests {
12770    use super::*;
12771    use crate::imports::ImportBlock;
12772    use std::cell::Cell;
12773    use std::fs;
12774    use std::path::{Path, PathBuf};
12775    use tempfile::tempdir;
12776
12777    thread_local! {
12778        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12779        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12780        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12781    }
12782
12783    fn count_caller_traversal_selects(sql: &str) {
12784        let sql = sql.trim_start();
12785        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12786            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12787        }
12788        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12789            && sql.contains("e.target_file =")
12790        {
12791            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12792        }
12793        if sql.starts_with("WITH requested") {
12794            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12795        }
12796    }
12797
12798    #[test]
12799    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12800        let dir = tempdir().unwrap();
12801        let previous_root = dir.path().join("previous-root");
12802        let current_root = dir.path().join("current-root");
12803        fs::create_dir_all(&previous_root).unwrap();
12804        fs::create_dir_all(&current_root).unwrap();
12805        fs::remove_dir(&previous_root).unwrap();
12806        let mut conn = Connection::open_in_memory().unwrap();
12807        initialize_schema(&conn).unwrap();
12808        conn.execute(
12809            "INSERT INTO backend_file_state(
12810                backend, workspace_root, file_path, content_hash, status, updated_at
12811             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12812            params![previous_root.display().to_string()],
12813        )
12814        .unwrap();
12815
12816        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
12817
12818        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12819        assert_eq!(
12820            stored_workspace_roots(&conn).unwrap(),
12821            vec![previous_root.display().to_string()]
12822        );
12823    }
12824
12825    #[test]
12826    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12827        assert_eq!(
12828            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12829            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12830        );
12831    }
12832
12833    #[test]
12834    fn legacy_migration_completion_log_has_operator_fields() {
12835        assert_eq!(
12836            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12837            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12838        );
12839    }
12840
12841    fn write_generation_with_age(
12842        dir: &Path,
12843        project_key: &str,
12844        ordinal: u64,
12845        age: Duration,
12846    ) -> String {
12847        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12848        let path = dir.join(&generation);
12849        fs::write(&path, b"sqlite placeholder").unwrap();
12850        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12851        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12852        generation
12853    }
12854
12855    #[test]
12856    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12857        let dir = tempfile::tempdir().unwrap();
12858        let project_key = "project";
12859        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12860        let previous =
12861            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12862        let pinned =
12863            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12864        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12865
12866        gc_old_generations(dir.path(), project_key, &current);
12867
12868        assert!(dir.path().join(&previous).is_file());
12869        assert!(dir.path().join(&pinned).is_file());
12870
12871        drop(marker);
12872        gc_old_generations(dir.path(), project_key, &current);
12873
12874        assert!(dir.path().join(&previous).is_file());
12875        assert!(!dir.path().join(&pinned).exists());
12876    }
12877
12878    #[test]
12879    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12880        let dir = tempfile::tempdir().unwrap();
12881        let project_key = "project";
12882        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12883        let _previous =
12884            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12885        let pinned =
12886            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12887        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12888        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
12889
12890        gc_old_generations(dir.path(), project_key, &current);
12891
12892        assert!(dir.path().join(&pinned).is_file());
12893    }
12894
12895    #[test]
12896    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
12897        let dir = tempfile::tempdir().unwrap();
12898        let project_key = "project";
12899        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
12900        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12901        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
12902        let old = write_generation_with_age(
12903            dir.path(),
12904            project_key,
12905            200,
12906            expired + Duration::from_secs(60),
12907        );
12908        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
12909
12910        gc_old_generations(dir.path(), project_key, &current);
12911
12912        assert!(dir.path().join(&current).is_file());
12913        assert!(dir.path().join(&previous).is_file());
12914        assert!(!dir.path().join(&old).exists());
12915    }
12916
12917    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
12918        let path = dir.join(name);
12919        fs::write(&path, b"temp placeholder").unwrap();
12920        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12921        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12922        path
12923    }
12924
12925    #[test]
12926    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
12927        let dir = tempdir().unwrap();
12928        // One directory holds both an aged orphan (with its journal sidecar) and a
12929        // fresh temporary, so this proves the sweep SELECTS by age rather than
12930        // deleting everything in the directory.
12931        let aged = "project.g100.1.sqlite.tmp.1.200";
12932        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
12933        let fresh = "project.g300.1.sqlite.tmp.1.400";
12934        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
12935        write_build_temp_with_age(dir.path(), aged, aged_age);
12936        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
12937        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12938
12939        sweep_orphaned_build_temps(dir.path());
12940
12941        assert!(
12942            !dir.path().join(aged).exists(),
12943            "aged orphan must be removed"
12944        );
12945        assert!(
12946            !dir.path().join(aged_journal).exists(),
12947            "aged journal sidecar must be removed"
12948        );
12949        assert!(
12950            dir.path().join(fresh).is_file(),
12951            "fresh temporary must survive"
12952        );
12953    }
12954
12955    #[test]
12956    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
12957        let storage = tempdir().unwrap();
12958        let storage_root = storage.path();
12959        // The production shape: a legacy per-harness store whose root no longer
12960        // builds there — no `.current` pointer, no running build — so the per-root
12961        // cleanup never fires for it. A sibling root still building in the
12962        // root-keyed store triggers the store-wide sweep, which must reach into the
12963        // legacy directory and reclaim the orphan.
12964        let legacy_dir = storage_root.join("opencode").join("callgraph");
12965        fs::create_dir_all(&legacy_dir).unwrap();
12966        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
12967        write_build_temp_with_age(
12968            &legacy_dir,
12969            orphan,
12970            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12971        );
12972        assert!(
12973            !legacy_dir.join("deadbeef.current").exists(),
12974            "the dead root has no current pointer"
12975        );
12976
12977        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
12978        fs::create_dir_all(&root_keyed_dir).unwrap();
12979
12980        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
12981
12982        assert!(
12983            !legacy_dir.join(orphan).exists(),
12984            "legacy orphan must be reclaimed by the store-wide sweep"
12985        );
12986    }
12987
12988    #[test]
12989    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
12990        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
12991        // everything (min_age = 0) removes the fresh temporary that the real 24h
12992        // threshold spares in the test above. If a mutation to the age check leaves
12993        // the fresh file in place here, the predicate is no longer doing the
12994        // selection work the fresh-survives assertion relies on.
12995        let dir = tempdir().unwrap();
12996        let fresh = "project.g300.1.sqlite.tmp.1.400";
12997        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12998
12999        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13000
13001        assert!(
13002            !dir.path().join(fresh).exists(),
13003            "with the age predicate forced open, the fresh temporary is removed"
13004        );
13005    }
13006
13007    #[test]
13008    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13009        let dir = tempdir().unwrap();
13010        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
13011        // to be swept, plus a live read marker, is generation GC's jurisdiction.
13012        // The orphan sweep must not intersect it.
13013        let generation = write_generation_with_age(
13014            dir.path(),
13015            "project",
13016            400,
13017            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13018        );
13019        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13020
13021        sweep_orphaned_build_temps(dir.path());
13022
13023        assert!(
13024            dir.path().join(&generation).is_file(),
13025            "completed generation must survive the orphan sweep"
13026        );
13027        assert!(
13028            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13029            "read marker must survive the orphan sweep"
13030        );
13031    }
13032
13033    #[test]
13034    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13035        let dir = tempfile::tempdir().unwrap();
13036        let project_key = "project".to_string();
13037        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13038        let sqlite_path = dir.path().join(&generation);
13039        fs::remove_file(&sqlite_path).unwrap();
13040        let conn = Connection::open(&sqlite_path).unwrap();
13041        let store = CallGraphStore::from_connection(
13042            dir.path().to_path_buf(),
13043            project_key,
13044            sqlite_path,
13045            dir.path().to_path_buf(),
13046            false,
13047            Some(generation.clone()),
13048            None,
13049            None,
13050            conn,
13051        );
13052
13053        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13054        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13055
13056        drop(marker);
13057        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13058    }
13059
13060    #[test]
13061    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13062        let dir = tempdir().expect("temp dir");
13063        let file = dir.path().join("main.ts");
13064        fs::write(&file, "export function main() {}\n").expect("write fixture");
13065        let store = CallGraphStore::open(
13066            dir.path().join(".store-readiness-cache"),
13067            dir.path().to_path_buf(),
13068        )
13069        .expect("open store");
13070        {
13071            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13072            conn.trace(Some(count_caller_traversal_selects));
13073        }
13074
13075        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13076        assert!(store.indexed_file_count().is_err());
13077        assert!(store.indexed_file_count().is_err());
13078        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13079
13080        store
13081            .cold_build(std::slice::from_ref(&file))
13082            .expect("cold build");
13083        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13084        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13085        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13086        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13087
13088        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13089        conn.trace(None);
13090    }
13091
13092    #[test]
13093    fn callers_depth_boundary_batches_sqlite_counts() {
13094        const CALLER_COUNT: usize = 1_000;
13095
13096        let dir = tempdir().expect("temp dir");
13097        let file = dir.path().join("main.ts");
13098        let mut source = String::from("export function sharedHotHelper() {}\n");
13099        for index in 0..CALLER_COUNT {
13100            source.push_str(&format!(
13101                "export function caller{index}() {{ sharedHotHelper(); }}\n"
13102            ));
13103        }
13104        fs::write(&file, source).expect("write fixture");
13105
13106        let store = CallGraphStore::open(
13107            dir.path().join(".store-callers-query-fanout"),
13108            dir.path().to_path_buf(),
13109        )
13110        .expect("open store");
13111        store
13112            .cold_build(std::slice::from_ref(&file))
13113            .expect("cold build");
13114
13115        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13116        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13117        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13118        {
13119            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13120            conn.trace(Some(count_caller_traversal_selects));
13121        }
13122
13123        let started = Instant::now();
13124        let result = crate::commands::callgraph_store_adapter::callers_result(
13125            &store,
13126            Path::new("main.ts"),
13127            "sharedHotHelper",
13128            1,
13129            true,
13130        )
13131        .expect("callers result");
13132        let elapsed = started.elapsed();
13133
13134        {
13135            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13136            conn.trace(None);
13137        }
13138        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13139        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13140        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13141        eprintln!(
13142            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13143            result.total_callers,
13144            caller_queries,
13145            boundary_queries,
13146            total_selects,
13147            elapsed.as_secs_f64() * 1_000.0
13148        );
13149
13150        assert_eq!(result.total_callers, CALLER_COUNT);
13151        assert_eq!(caller_queries, 1);
13152        assert_eq!(boundary_queries, 3);
13153        assert_eq!(total_selects, 9);
13154    }
13155
13156    #[test]
13157    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13158        let dir = tempdir().expect("temp dir");
13159        let file = dir.path().join("main.ts");
13160        fs::write(
13161            &file,
13162            r#"export function topA() {
13163  root();
13164}
13165
13166export function topB() {
13167  root();
13168}
13169
13170export function root() {
13171  leaf();
13172  missing();
13173}
13174
13175export function leaf() {}
13176"#,
13177        )
13178        .expect("write fixture");
13179
13180        let store = CallGraphStore::open(
13181            dir.path().join(".store-depth-boundary-counts"),
13182            dir.path().to_path_buf(),
13183        )
13184        .expect("open store");
13185        store
13186            .cold_build(std::slice::from_ref(&file))
13187            .expect("cold build");
13188
13189        let root = store
13190            .node_for(Path::new("main.ts"), "root")
13191            .expect("root node");
13192        let leaf = store
13193            .node_for(Path::new("main.ts"), "leaf")
13194            .expect("leaf node");
13195
13196        let (full_forward_len, full_direct_len) = {
13197            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13198            conn.execute(
13199                "INSERT INTO edges (
13200                    edge_id, ref_id, source_node, target_node, target_file,
13201                    target_symbol, kind, line, provenance
13202                 ) VALUES (
13203                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13204                    ?2, ?3, 'call', 98, ?4
13205                 )",
13206                rusqlite::params![
13207                    &root.node_id,
13208                    &leaf.file,
13209                    &leaf.symbol,
13210                    PROVENANCE_TREESITTER
13211                ],
13212            )
13213            .expect("insert dangling forward edge");
13214            conn.execute(
13215                "INSERT INTO edges (
13216                    edge_id, ref_id, source_node, target_node, target_file,
13217                    target_symbol, kind, line, provenance
13218                 ) VALUES (
13219                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13220                    ?1, ?2, ?3, 'call', 99, ?4
13221                 )",
13222                rusqlite::params![
13223                    &root.node_id,
13224                    &root.file,
13225                    &root.symbol,
13226                    PROVENANCE_TREESITTER
13227                ],
13228            )
13229            .expect("insert dangling direct-caller edge");
13230
13231            let full_forward_len = forward_calls_for_node(&conn, &root)
13232                .expect("full forward calls")
13233                .len();
13234            let counted_forward_len =
13235                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13236            assert_eq!(
13237                counted_forward_len, full_forward_len,
13238                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13239            );
13240
13241            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13242                .expect("full direct callers");
13243            let full_direct_len = full_direct.len();
13244            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13245                .expect("counted direct callers");
13246            assert_eq!(
13247                counted_direct_len, full_direct_len,
13248                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13249            );
13250
13251            let distinct_direct_len = full_direct
13252                .iter()
13253                .map(|site| {
13254                    (
13255                        site.caller.file.clone(),
13256                        site.line,
13257                        site.target_file.clone(),
13258                        site.target_symbol.clone(),
13259                    )
13260                })
13261                .collect::<BTreeSet<_>>()
13262                .len();
13263            let batch_counts = direct_caller_counts_for_tuples(
13264                &conn,
13265                &[
13266                    (root.file.clone(), root.symbol.clone()),
13267                    (root.file.clone(), root.symbol.clone()),
13268                    (leaf.file.clone(), leaf.symbol.clone()),
13269                ],
13270            )
13271            .expect("batched direct-caller counts");
13272            assert_eq!(batch_counts.len(), 2);
13273            assert_eq!(
13274                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13275                Some(&distinct_direct_len)
13276            );
13277
13278            (full_forward_len, full_direct_len)
13279        };
13280
13281        assert_eq!(
13282            full_forward_len, 2,
13283            "fixture root should have one resolved and one unresolved outgoing call"
13284        );
13285        assert_eq!(
13286            full_direct_len, 2,
13287            "fixture root should have two real direct callers"
13288        );
13289
13290        let tree = store
13291            .call_tree(Path::new("main.ts"), "root", 0)
13292            .expect("call tree");
13293        assert!(tree.depth_limited);
13294        assert_eq!(tree.children.len(), 0);
13295        assert_eq!(
13296            tree.truncated, full_forward_len,
13297            "call_tree depth boundary must report the full forward-call list length"
13298        );
13299
13300        let callers = store
13301            .callers_of(Path::new("main.ts"), "leaf", 0)
13302            .expect("callers");
13303        assert!(callers.depth_limited);
13304        assert_eq!(callers.callers.len(), 1);
13305        assert_eq!(callers.callers[0].caller.symbol, "root");
13306        assert_eq!(
13307            callers.truncated, full_direct_len,
13308            "callers depth boundary must report the full direct-caller list length"
13309        );
13310    }
13311
13312    #[test]
13313    fn source_freshness_matches_cache_collect_for_same_bytes() {
13314        let dir = tempdir().expect("temp dir");
13315        let path = dir.path().join("fixture.ts");
13316        let source = "export function main() { return helper(); }\n";
13317        fs::write(&path, source).expect("write fixture");
13318
13319        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13320        let actual =
13321            collect_source_freshness(&path, source).expect("collect freshness from source");
13322
13323        assert_eq!(actual, expected);
13324    }
13325
13326    #[test]
13327    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13328        let root = tempfile::tempdir().unwrap();
13329        let callgraph_dir = tempfile::tempdir().unwrap();
13330        let source_dir = root.path().join("src");
13331        std::fs::create_dir_all(&source_dir).unwrap();
13332        let source = source_dir.join("lib.rs");
13333        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13334        let files = vec![source.clone()];
13335        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13336        let old_epoch = epoch.next();
13337        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13338        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13339        let old_epoch_flag = epoch.clone();
13340        let old_dir = callgraph_dir.path().to_path_buf();
13341        let old_root = root.path().to_path_buf();
13342        let old_files = files.clone();
13343        let old = std::thread::spawn(move || {
13344            set_cold_build_before_publish_observer(Some(Arc::new(move || {
13345                reached_tx.send(()).unwrap();
13346                release_rx.recv().unwrap();
13347            })));
13348            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13349                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13350            });
13351            set_cold_build_before_publish_observer(None);
13352            result
13353        });
13354        // Positive wait: the older build runs a real cold build (git probe +
13355        // SQLite schema init) before the barrier, which can exceed 5s on a
13356        // contended Windows CI runner. Only negative waits stay short.
13357        reached_rx
13358            .recv_timeout(Duration::from_secs(30))
13359            .expect("older build did not reach its publication barrier");
13360
13361        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13362        let new_epoch = epoch.next();
13363        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13364            CallGraphStore::cold_build_with_lease(
13365                callgraph_dir.path().to_path_buf(),
13366                root.path().to_path_buf(),
13367                &files,
13368            )
13369        })
13370        .expect("newer build should publish");
13371        drop(new_store);
13372
13373        release_tx.send(()).unwrap();
13374        assert!(matches!(
13375            old.join().unwrap(),
13376            Err(CallGraphStoreError::Superseded)
13377        ));
13378
13379        let current = CallGraphStore::open_readonly(
13380            callgraph_dir.path().to_path_buf(),
13381            root.path().to_path_buf(),
13382        )
13383        .unwrap()
13384        .expect("current callgraph generation");
13385        assert_eq!(
13386            current
13387                .nodes_matching("new_generation_marker")
13388                .unwrap()
13389                .len(),
13390            1
13391        );
13392        assert!(current
13393            .nodes_matching("old_generation_marker")
13394            .unwrap()
13395            .is_empty());
13396    }
13397
13398    #[test]
13399    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13400        let dir = tempdir().expect("temp dir");
13401        let project_root = dir.path();
13402        let extract = fixture_extract(project_root);
13403        let resolved = fixture_resolved(&extract);
13404
13405        let reference = build_reference_connection(project_root, &extract, &resolved);
13406        let optimized = build_optimized_connection(project_root, &extract, &resolved);
13407
13408        for table in [
13409            "files",
13410            "nodes",
13411            "file_dependencies",
13412            "dispatch_hints",
13413            "refs",
13414            "edges",
13415        ] {
13416            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
13417            // the reference and optimized builds run sequentially and can straddle a
13418            // one-second tick under load, so it is legitimately allowed to differ.
13419            // This mirrors the existing exclusions of `backend_file_state.updated_at`
13420            // and the chunked-vs-unchunked sibling test. The check is for structural
13421            // row equivalence of the optimized bulk insert, not wall-clock equality.
13422            let excluded: &[&str] = if table == "files" {
13423                &["indexed_at"]
13424            } else {
13425                &[]
13426            };
13427            assert_eq!(
13428                table_rows_without(&reference, table, excluded),
13429                table_rows_without(&optimized, table, excluded),
13430                "table `{table}` rows must match apart from wall-clock columns"
13431            );
13432        }
13433        assert_eq!(
13434            backend_state_rows(&reference),
13435            backend_state_rows(&optimized),
13436            "backend freshness rows must match apart from updated_at"
13437        );
13438        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13439    }
13440
13441    #[test]
13442    fn cold_build_chunked_matches_unchunked_logical_rows() {
13443        let dir = tempdir().expect("temp dir");
13444        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13445        write_chunked_equivalence_fixture(&project_root);
13446        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13447        assert!(
13448            files.len() > 6,
13449            "fixture should be large enough to split into multiple chunks"
13450        );
13451
13452        let unchunked = CallGraphStore::open(
13453            project_root.join(".store-unchunked"),
13454            project_root.to_path_buf(),
13455        )
13456        .expect("open unchunked store");
13457        let unchunked_stats = unchunked
13458            .cold_build_chunked(&files, 0)
13459            .expect("unchunked cold build");
13460
13461        let chunked = CallGraphStore::open(
13462            project_root.join(".store-chunked"),
13463            project_root.to_path_buf(),
13464        )
13465        .expect("open chunked store");
13466        let chunked_stats = chunked
13467            .cold_build_chunked(&files, 3)
13468            .expect("chunked cold build");
13469
13470        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13471        assert_eq!(
13472            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13473            chunked.edge_snapshot().expect("chunked edge snapshot"),
13474            "public edge snapshots must match"
13475        );
13476
13477        let dispatch_edges = {
13478            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13479            conn.query_row(
13480                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13481                [],
13482                |row| row.get::<_, i64>(0),
13483            )
13484            .expect("count dispatch edges")
13485        };
13486        assert!(
13487            dispatch_edges > 0,
13488            "fixture must exercise method-dispatch edge insertion"
13489        );
13490
13491        for table in [
13492            "edges",
13493            "refs",
13494            "nodes",
13495            "file_dependencies",
13496            "dispatch_hints",
13497        ] {
13498            assert_eq!(
13499                graph_table_rows(&unchunked, table),
13500                graph_table_rows(&chunked, table),
13501                "chunked cold build must match unchunked rows for {table}"
13502            );
13503        }
13504        assert_eq!(
13505            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13506            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13507            "files rows must match apart from indexed_at"
13508        );
13509        assert_eq!(
13510            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13511            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13512            "backend freshness rows must match apart from updated_at"
13513        );
13514
13515        let published_dir = project_root.join(".store-published");
13516        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13517            published_dir.clone(),
13518            project_root.to_path_buf(),
13519            &files,
13520            0,
13521        )
13522        .expect("published unchunked cold build");
13523        assert!(
13524            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13525                .expect("needs_cold_build after publish"),
13526            "published store should be ready"
13527        );
13528        drop(_published);
13529        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13530            published_dir,
13531            project_root.to_path_buf(),
13532            &files,
13533            3,
13534        )
13535        .expect("ensure with a different chunk size");
13536        assert!(
13537            rebuild_stats.is_none(),
13538            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13539        );
13540    }
13541
13542    // Perf A/B bench (not a gate): measures cold_build wall time at a given
13543    // chunk size against a real repo. Driven by env so the same binary can A/B
13544    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
13545    // memory work. Run:
13546    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
13547    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
13548    #[test]
13549    #[ignore]
13550    fn bench_cold_build_chunk() {
13551        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13552        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13553            .expect("AFT_PERF_CHUNK")
13554            .parse()
13555            .expect("AFT_PERF_CHUNK must be a non-negative integer");
13556        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13557        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13558        let dir = tempdir().expect("temp dir");
13559        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13560            .expect("open store");
13561        let started = Instant::now();
13562        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13563        let ms = started.elapsed().as_millis();
13564        println!(
13565            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13566            stats.files, stats.nodes, stats.refs, stats.edges
13567        );
13568    }
13569
13570    #[test]
13571    fn persisted_workspace_reexport_selects_its_package_dependency() {
13572        let root = tempdir().expect("temp dir");
13573        let dependencies = BTreeSet::from([
13574            "packages/aft-bridge/src/index.ts".to_string(),
13575            "packages/opencode-plugin/src/types.ts".to_string(),
13576        ]);
13577        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13578
13579        assert_eq!(
13580            stored_dependencies_for_module(
13581                root.path(),
13582                "packages/opencode-plugin/src/shared/bash-hints.ts",
13583                "@cortexkit/aft-bridge",
13584                &dependencies,
13585                &indexed_files,
13586            ),
13587            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13588        );
13589    }
13590
13591    #[test]
13592    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13593        let dir = tempdir().expect("temp dir");
13594        let project_root = dir.path();
13595        let files =
13596            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13597        let index_path = project_root.join("src/index.ts");
13598
13599        let store = CallGraphStore::open(
13600            project_root.join(".store-incremental-barrel"),
13601            project_root.to_path_buf(),
13602        )
13603        .expect("open incremental store");
13604        store.cold_build(&files).expect("initial cold build");
13605
13606        {
13607            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13608            let tx = conn.transaction().expect("dependency transaction");
13609            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13610                .expect("dependent refs for barrel");
13611            let selected_ref_ids = dependent_refs
13612                .iter()
13613                .map(|dependent_ref| dependent_ref.ref_id.clone())
13614                .collect::<BTreeSet<_>>();
13615            let mut threaded_ref_ids = BTreeSet::new();
13616            let mut threaded_by_caller = BTreeMap::new();
13617            record_dependent_refs(
13618                &mut threaded_ref_ids,
13619                &mut threaded_by_caller,
13620                dependent_refs,
13621            );
13622            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13623                .expect("old per-ref caller lookup");
13624
13625            assert_eq!(threaded_ref_ids, selected_ref_ids);
13626            assert_eq!(threaded_by_caller, old_by_caller);
13627            for consumer in [
13628                "src/consumer_a.ts",
13629                "src/consumer_b.ts",
13630                "src/consumer_c.ts",
13631            ] {
13632                assert!(
13633                    threaded_by_caller.contains_key(consumer),
13634                    "barrel edit should select dependent refs from {consumer}"
13635                );
13636            }
13637        }
13638
13639        fs::write(
13640            &index_path,
13641            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13642        )
13643        .expect("edit barrel");
13644        let stats = store
13645            .refresh_files(std::slice::from_ref(&index_path))
13646            .expect("incremental refresh");
13647        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13648        assert!(
13649            stats.dependency_selected_refs > 0,
13650            "barrel surface edit should select dependent refs"
13651        );
13652
13653        let cold_store = CallGraphStore::open(
13654            project_root.join(".store-cold-barrel"),
13655            project_root.to_path_buf(),
13656        )
13657        .expect("open cold rebuild store");
13658        cold_store
13659            .cold_build(&files)
13660            .expect("comparison cold build");
13661
13662        for table in [
13663            "nodes",
13664            "refs",
13665            "file_dependencies",
13666            "edges",
13667            "dispatch_hints",
13668        ] {
13669            assert_eq!(
13670                graph_table_rows(&store, table),
13671                graph_table_rows(&cold_store, table),
13672                "incremental refresh {table} rows must match cold rebuild"
13673            );
13674        }
13675
13676        let consumer_path = project_root.join("src/consumer_a.ts");
13677        fs::write(
13678            &consumer_path,
13679            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13680        )
13681        .expect("edit barrel consumer");
13682        store
13683            .refresh_files(std::slice::from_ref(&consumer_path))
13684            .expect("refresh consumer through unchanged barrel");
13685        cold_store
13686            .cold_build(&files)
13687            .expect("comparison cold rebuild after consumer refresh");
13688        for table in [
13689            "nodes",
13690            "refs",
13691            "file_dependencies",
13692            "edges",
13693            "dispatch_hints",
13694        ] {
13695            assert_eq!(
13696                graph_table_rows(&store, table),
13697                graph_table_rows(&cold_store, table),
13698                "refresh through a persisted barrel must preserve cold-build {table} rows"
13699            );
13700        }
13701    }
13702
13703    fn build_reference_connection(
13704        project_root: &Path,
13705        extract: &FileExtract,
13706        resolved: &ResolvedRef,
13707    ) -> Connection {
13708        let mut conn = Connection::open_in_memory().expect("open reference db");
13709        configure_build_connection(&conn).expect("configure reference db");
13710        initialize_schema(&conn).expect("initialize reference schema");
13711        {
13712            let tx = conn.transaction().expect("reference transaction");
13713            clear_tables(&tx).expect("reference clear");
13714            insert_meta(&tx).expect("reference meta");
13715            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13716            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13717            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13718                .expect("reference dispatch edges");
13719            assert_eq!(supplemental, 0);
13720            tx.commit().expect("reference commit");
13721        }
13722        conn
13723    }
13724
13725    fn build_optimized_connection(
13726        project_root: &Path,
13727        extract: &FileExtract,
13728        resolved: &ResolvedRef,
13729    ) -> Connection {
13730        let mut conn = Connection::open_in_memory().expect("open optimized db");
13731        configure_build_connection(&conn).expect("configure optimized db");
13732        initialize_schema(&conn).expect("initialize optimized schema");
13733        {
13734            let tx = conn.transaction().expect("optimized transaction");
13735            clear_tables(&tx).expect("optimized clear");
13736            insert_meta(&tx).expect("optimized meta");
13737            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13738            {
13739                let workspace_root = project_root.display().to_string();
13740                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13741                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13742                    .expect("optimized file extract");
13743                insert_resolved_ref_prepared(&mut inserts, resolved)
13744                    .expect("optimized resolved ref");
13745            }
13746            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13747            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13748                .expect("optimized dispatch edges");
13749            assert_eq!(supplemental, 0);
13750            tx.commit().expect("optimized commit");
13751        }
13752        conn
13753    }
13754
13755    fn fixture_extract(_project_root: &Path) -> FileExtract {
13756        let rel_path = "src/main.ts".to_string();
13757        let target_path = "src/helper.ts".to_string();
13758        let node = NodeRecord {
13759            id: "node-main".to_string(),
13760            file_path: rel_path.clone(),
13761            name: "main".to_string(),
13762            scoped_name: "main".to_string(),
13763            kind: "function".to_string(),
13764            range: Range {
13765                start_line: 0,
13766                start_col: 0,
13767                end_line: 0,
13768                end_col: 32,
13769            },
13770            range_ordinal: 0,
13771            signature: Some("export function main()".to_string()),
13772            exported: true,
13773            is_default_export: false,
13774            is_type_like: false,
13775            is_callgraph_entry_point: true,
13776        };
13777        let mut dependencies = BTreeSet::new();
13778        dependencies.insert(target_path.clone());
13779        let raw_ref = RawRef {
13780            ref_id: "ref-main-helper".to_string(),
13781            caller_node: Some(node.id.clone()),
13782            caller_symbol: Some(node.scoped_name.clone()),
13783            caller_file: rel_path.clone(),
13784            kind: "call".to_string(),
13785            short_name: Some("helper".to_string()),
13786            full_ref: Some("helper".to_string()),
13787            module_path: None,
13788            import_kind: None,
13789            local_name: Some("helper".to_string()),
13790            requested_name: Some("helper".to_string()),
13791            namespace_alias: None,
13792            wildcard: false,
13793            line: 1,
13794            byte_start: 24,
13795            byte_end: 32,
13796            dependencies,
13797        };
13798        FileExtract {
13799            rel_path,
13800            freshness: FileFreshness {
13801                mtime: UNIX_EPOCH + Duration::from_secs(123),
13802                size: 40,
13803                content_hash: cache_freshness::hash_bytes(b"fixture source"),
13804            },
13805            lang: LangId::TypeScript,
13806            data: FileCallData {
13807                calls_by_symbol: HashMap::new(),
13808                exported_symbols: Vec::new(),
13809                symbol_metadata: HashMap::new(),
13810                default_export_symbol: None,
13811                import_block: ImportBlock::empty(),
13812                lang: LangId::TypeScript,
13813            },
13814            nodes: vec![node.clone()],
13815            raw_refs: vec![raw_ref],
13816            dispatch_hints: vec![DispatchHint {
13817                id: "dispatch-main-helper".to_string(),
13818                method_name: "helper".to_string(),
13819                caller_node: node.id,
13820                file: "src/main.ts".to_string(),
13821                line: 1,
13822                byte_start: 24,
13823                byte_end: 32,
13824            }],
13825            surface_fingerprint: "surface".to_string(),
13826        }
13827    }
13828
13829    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13830        let raw = extract.raw_refs[0].clone();
13831        let mut dependencies = raw.dependencies.clone();
13832        dependencies.insert("src/helper.ts".to_string());
13833        ResolvedRef {
13834            edge: Some(EdgeRecord {
13835                edge_id: "edge-main-helper".to_string(),
13836                source_node: raw.caller_node.clone().expect("caller node"),
13837                target_node: Some("node-helper".to_string()),
13838                target_file: "src/helper.ts".to_string(),
13839                target_symbol: "helper".to_string(),
13840                kind: "call".to_string(),
13841                line: raw.line,
13842            }),
13843            raw,
13844            status: "resolved".to_string(),
13845            target_node: Some("node-helper".to_string()),
13846            target_file: Some("src/helper.ts".to_string()),
13847            target_symbol: Some("helper".to_string()),
13848            dependencies,
13849        }
13850    }
13851
13852    fn write_chunked_equivalence_fixture(project_root: &Path) {
13853        let ts_dir = project_root.join("ts");
13854        fs::create_dir_all(&ts_dir).expect("create ts dir");
13855        fs::write(
13856            ts_dir.join("leaf.ts"),
13857            "export function leaf(value: number) {\n  return value + 1;\n}\n",
13858        )
13859        .expect("write ts leaf");
13860        fs::write(
13861            ts_dir.join("mid.ts"),
13862            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
13863        )
13864        .expect("write ts mid");
13865        fs::write(
13866            ts_dir.join("entry.ts"),
13867            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
13868        )
13869        .expect("write ts entry");
13870        fs::write(
13871            ts_dir.join("worker.ts"),
13872            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
13873        )
13874        .expect("write ts worker");
13875        for idx in 0..4 {
13876            fs::write(
13877                ts_dir.join(format!("extra_{idx}.ts")),
13878                format!(
13879                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
13880                ),
13881            )
13882            .expect("write ts extra");
13883        }
13884
13885        let rust_dir = project_root.join("src");
13886        let commands_dir = rust_dir.join("commands");
13887        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
13888        fs::write(
13889            rust_dir.join("context.rs"),
13890            r#"pub struct AppContext;
13891
13892impl AppContext {
13893    pub fn callgraph_store_for_ops(&self) -> usize {
13894        1
13895    }
13896}
13897"#,
13898        )
13899        .expect("write rust context");
13900        fs::write(
13901            rust_dir.join("lib.rs"),
13902            "pub mod context;\npub mod commands;\n",
13903        )
13904        .expect("write rust lib");
13905        fs::write(
13906            commands_dir.join("mod.rs"),
13907            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
13908        )
13909        .expect("write rust commands mod");
13910        for name in ["callers", "impact", "trace_to"] {
13911            fs::write(
13912                commands_dir.join(format!("{name}.rs")),
13913                format!(
13914                    r#"use crate::context::AppContext;
13915
13916pub fn handle_{name}(ctx: &AppContext) -> usize {{
13917    ctx.callgraph_store_for_ops()
13918}}
13919"#
13920                ),
13921            )
13922            .expect("write rust command");
13923        }
13924    }
13925
13926    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
13927        let src_dir = project_root.join("src");
13928        fs::create_dir_all(&src_dir).expect("create src dir");
13929
13930        let target_path = src_dir.join("target.ts");
13931        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
13932            .expect("write target");
13933
13934        let index_path = src_dir.join("index.ts");
13935        fs::write(&index_path, barrel_source).expect("write barrel");
13936
13937        let mut files = vec![target_path, index_path];
13938        for (file_name, function_name) in [
13939            ("consumer_a.ts", "consumerA"),
13940            ("consumer_b.ts", "consumerB"),
13941            ("consumer_c.ts", "consumerC"),
13942        ] {
13943            let path = src_dir.join(file_name);
13944            fs::write(
13945                &path,
13946                format!(
13947                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
13948                ),
13949            )
13950            .expect("write consumer");
13951            files.push(path);
13952        }
13953        files
13954    }
13955
13956    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
13957        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13958        table_rows(&conn, table)
13959    }
13960
13961    fn graph_table_rows_without(
13962        store: &CallGraphStore,
13963        table: &str,
13964        excluded_columns: &[&str],
13965    ) -> Vec<String> {
13966        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13967        table_rows_without(&conn, table, excluded_columns)
13968    }
13969
13970    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
13971        table_rows_without(conn, table, &[])
13972    }
13973
13974    fn table_rows_without(
13975        conn: &Connection,
13976        table: &str,
13977        excluded_columns: &[&str],
13978    ) -> Vec<String> {
13979        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
13980        let columns: Vec<String> = conn
13981            .prepare(&format!("PRAGMA table_info({table})"))
13982            .expect("prepare table_info")
13983            .query_map([], |row| row.get::<_, String>(1))
13984            .expect("query table_info")
13985            .collect::<std::result::Result<Vec<String>, _>>()
13986            .expect("collect columns")
13987            .into_iter()
13988            .filter(|column| !excluded_columns.contains(column.as_str()))
13989            .collect();
13990        let sql = format!(
13991            "SELECT {} FROM {table} ORDER BY {}",
13992            columns.join(", "),
13993            columns.join(", ")
13994        );
13995        conn.prepare(&sql)
13996            .expect("prepare table rows")
13997            .query_map([], |row| row_to_strings(row, columns.len()))
13998            .expect("query table rows")
13999            .collect::<std::result::Result<_, _>>()
14000            .expect("collect table rows")
14001    }
14002
14003    fn assert_cold_build_stats_match_except_elapsed(
14004        expected: &ColdBuildStats,
14005        actual: &ColdBuildStats,
14006    ) {
14007        assert_eq!(actual.files, expected.files, "file counts must match");
14008        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14009        assert_eq!(actual.refs, expected.refs, "ref counts must match");
14010        assert_eq!(actual.edges, expected.edges, "edge counts must match");
14011        assert_eq!(
14012            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14013            expected
14014                .failed_files
14015                .iter()
14016                .cloned()
14017                .collect::<BTreeSet<_>>(),
14018            "failed file sets must match"
14019        );
14020    }
14021
14022    fn backend_state_rows(conn: &Connection) -> Vec<String> {
14023        conn.prepare(
14024            "SELECT backend, workspace_root, file_path, content_hash, status
14025             FROM backend_file_state
14026             ORDER BY backend, workspace_root, file_path, content_hash, status",
14027        )
14028        .expect("prepare backend rows")
14029        .query_map([], |row| row_to_strings(row, 5))
14030        .expect("query backend rows")
14031        .collect::<std::result::Result<_, _>>()
14032        .expect("collect backend rows")
14033    }
14034
14035    fn secondary_indexes(conn: &Connection) -> Vec<String> {
14036        let mut indexes = Vec::new();
14037        for table in [
14038            "files",
14039            "nodes",
14040            "refs",
14041            "file_dependencies",
14042            "edges",
14043            "dispatch_hints",
14044            "type_ref_names",
14045            "backend_file_state",
14046            "meta",
14047        ] {
14048            let sql = format!("PRAGMA index_list({table})");
14049            let mut stmt = conn.prepare(&sql).expect("prepare index list");
14050            let rows = stmt
14051                .query_map([], |row| row.get::<_, String>(1))
14052                .expect("query index list");
14053            for name in rows {
14054                let name = name.expect("index name");
14055                if name.starts_with("idx_") {
14056                    indexes.push(format!("{table}:{name}"));
14057                }
14058            }
14059        }
14060        indexes.sort();
14061        indexes
14062    }
14063
14064    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14065        let mut values = Vec::with_capacity(len);
14066        for index in 0..len {
14067            let value = row.get_ref(index)?;
14068            values.push(match value {
14069                rusqlite::types::ValueRef::Null => "NULL".to_string(),
14070                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14071                rusqlite::types::ValueRef::Real(value) => value.to_string(),
14072                rusqlite::types::ValueRef::Text(value) => {
14073                    String::from_utf8_lossy(value).into_owned()
14074                }
14075                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14076            });
14077        }
14078        Ok(values.join("\u{1f}"))
14079    }
14080}
14081
14082#[cfg(test)]
14083mod rust_resolution_tests {
14084    use super::*;
14085    use crate::inspect::job::CallgraphSnapshot;
14086    use std::fs;
14087    use tempfile::tempdir;
14088
14089    #[test]
14090    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14091        let dir = tempdir().expect("tempdir");
14092        let root = dir.path();
14093        write_rust_manifest(root, "scoped-alias-fixture");
14094        write_file(
14095            root,
14096            "src/lib.rs",
14097            r#"pub mod finalization_contract;
14098
14099pub fn run_alias() {
14100    use crate::finalization_contract as fc;
14101    fc::check_mason_contract();
14102}
14103"#,
14104        );
14105        write_file(
14106            root,
14107            "src/finalization_contract.rs",
14108            r#"pub fn check_mason_contract() {}
14109fn planted_dead() {}
14110"#,
14111        );
14112
14113        let (store, snapshot) = cold_build_twice(root);
14114        assert_direct_caller(
14115            &store,
14116            "src/finalization_contract.rs",
14117            "check_mason_contract",
14118            "src/lib.rs",
14119            "run_alias",
14120        );
14121        assert_projected_call(
14122            root,
14123            &snapshot,
14124            "src/finalization_contract.rs",
14125            "check_mason_contract",
14126        );
14127        assert_no_projected_call(
14128            root,
14129            &snapshot,
14130            "src/finalization_contract.rs",
14131            "planted_dead",
14132        );
14133        assert!(
14134            store
14135                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14136                .expect("planted dead callers")
14137                .is_empty(),
14138            "planted-dead guard should stay without callers"
14139        );
14140    }
14141
14142    #[test]
14143    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14144        let dir = tempdir().expect("tempdir");
14145        let root = dir.path();
14146        write_rust_manifest(root, "inline-module-fixture");
14147        write_file(
14148            root,
14149            "src/lib.rs",
14150            r#"mod work_graph { fn operations() {} }
14151mod manifest { fn operations() {} }
14152mod audit { fn operations() {} }
14153mod dispatch { fn operations() {} }
14154mod finalization { fn operations() {} }
14155
14156pub fn run_inline_operations() {
14157    work_graph::operations();
14158    manifest::operations();
14159    audit::operations();
14160    dispatch::operations();
14161    finalization::operations();
14162}
14163
14164fn planted_dead() {}
14165"#,
14166        );
14167
14168        let (store, snapshot) = cold_build_twice(root);
14169        for module in [
14170            "work_graph",
14171            "manifest",
14172            "audit",
14173            "dispatch",
14174            "finalization",
14175        ] {
14176            assert_direct_caller(
14177                &store,
14178                "src/lib.rs",
14179                &format!("{module}::operations"),
14180                "src/lib.rs",
14181                "run_inline_operations",
14182            );
14183        }
14184        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14185        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14186    }
14187
14188    #[test]
14189    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14190        let dir = tempdir().expect("tempdir");
14191        let root = dir.path();
14192        fs::write(
14193            root.join("Cargo.toml"),
14194            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14195        )
14196        .expect("write workspace manifest");
14197        write_file(
14198            root,
14199            "crates/but-action/Cargo.toml",
14200            r#"[package]
14201name = "but-action"
14202version = "0.1.0"
14203edition = "2021"
14204"#,
14205        );
14206        write_file(
14207            root,
14208            "crates/but-action/src/lib.rs",
14209            "mod action;\npub use action::{list_actions};\n",
14210        );
14211        write_file(
14212            root,
14213            "crates/but-action/src/action.rs",
14214            "pub fn list_actions() {}\nfn planted_dead() {}\n",
14215        );
14216        write_file(
14217            root,
14218            "crates/app/Cargo.toml",
14219            r#"[package]
14220name = "app"
14221version = "0.1.0"
14222edition = "2021"
14223"#,
14224        );
14225        write_file(
14226            root,
14227            "crates/app/src/lib.rs",
14228            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
14229        );
14230
14231        let (store, snapshot) = cold_build_twice(root);
14232        assert_direct_caller(
14233            &store,
14234            "crates/but-action/src/action.rs",
14235            "list_actions",
14236            "crates/app/src/lib.rs",
14237            "run_actions",
14238        );
14239        assert!(
14240            store
14241                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14242                .expect("lib reexport callers")
14243                .is_empty(),
14244            "call should target the reexported source function, not lib.rs"
14245        );
14246        assert_projected_call(
14247            root,
14248            &snapshot,
14249            "crates/but-action/src/action.rs",
14250            "list_actions",
14251        );
14252        assert_no_projected_call(
14253            root,
14254            &snapshot,
14255            "crates/but-action/src/action.rs",
14256            "planted_dead",
14257        );
14258    }
14259
14260    #[test]
14261    fn rust_generic_self_turbofish_method_dispatch_resolves() {
14262        let dir = tempdir().expect("tempdir");
14263        let root = dir.path();
14264        write_rust_manifest(root, "generic-self-fixture");
14265        write_file(
14266            root,
14267            "src/lib.rs",
14268            r#"pub struct Matcher;
14269
14270impl Matcher {
14271    pub fn run(&self) -> bool {
14272        self.fuzzy_match_optimal::<usize>("needle")
14273    }
14274
14275    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14276        let _ = std::marker::PhantomData::<T>;
14277        true
14278    }
14279
14280    fn planted_dead(&self) {}
14281}
14282
14283pub fn entry() -> bool {
14284    let matcher = Matcher;
14285    matcher.run()
14286}
14287"#,
14288        );
14289
14290        let (store, snapshot) = cold_build_twice(root);
14291        assert_direct_caller(
14292            &store,
14293            "src/lib.rs",
14294            "Matcher::fuzzy_match_optimal",
14295            "src/lib.rs",
14296            "Matcher::run",
14297        );
14298        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14299        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14300    }
14301
14302    #[test]
14303    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14304        let dir = tempdir().expect("tempdir");
14305        let root = dir.path();
14306        write_rust_manifest(root, "manifest-operations-fixture");
14307        write_file(
14308            root,
14309            "src/main.rs",
14310            r#"mod dispatch;
14311use dispatch::{manifest_operations};
14312
14313fn main() {
14314    manifest_operations();
14315}
14316"#,
14317        );
14318        write_file(
14319            root,
14320            "src/dispatch.rs",
14321            r#"mod work_graph { fn operations() {} }
14322mod manifest { fn operations() {} }
14323mod audit { fn operations() {} }
14324mod descriptor { fn operations() {} }
14325mod writer { fn operations() {} }
14326
14327pub fn manifest_operations() {
14328    manifest::operations();
14329}
14330
14331pub fn work_graph_operations() {
14332    work_graph::operations();
14333}
14334
14335pub fn audit_operations() {
14336    audit::operations();
14337}
14338
14339pub fn descriptor_operations() {
14340    descriptor::operations();
14341}
14342
14343pub fn writer_operations() {
14344    writer::operations();
14345}
14346
14347fn planted_dead() {}
14348"#,
14349        );
14350
14351        let (store, snapshot) = cold_build_twice(root);
14352        assert_direct_caller(
14353            &store,
14354            "src/dispatch.rs",
14355            "manifest_operations",
14356            "src/main.rs",
14357            "main",
14358        );
14359        assert_direct_caller(
14360            &store,
14361            "src/dispatch.rs",
14362            "manifest::operations",
14363            "src/dispatch.rs",
14364            "manifest_operations",
14365        );
14366        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14367        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14368        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14369    }
14370
14371    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14372        let files = rust_files(root);
14373        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14374            .expect("open first store");
14375        first.cold_build(&files).expect("first cold build");
14376        let first_snapshot =
14377            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14378
14379        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14380            .expect("open second store");
14381        second.cold_build(&files).expect("second cold build");
14382        let second_snapshot =
14383            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14384
14385        assert_eq!(
14386            projection_rows(&first_snapshot),
14387            projection_rows(&second_snapshot),
14388            "cold-build projection should be deterministic"
14389        );
14390        (first, first_snapshot)
14391    }
14392
14393    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14394        let mut rows = Vec::new();
14395        for export in &snapshot.exported_symbols {
14396            rows.push(format!(
14397                "export\t{}\t{}\t{}\t{}",
14398                export.file.display(),
14399                export.symbol,
14400                export.kind,
14401                export.line
14402            ));
14403        }
14404        for call in &snapshot.outbound_calls {
14405            rows.push(format!(
14406                "call\t{}\t{}\t{}\t{}\t{}",
14407                call.caller_file.display(),
14408                call.caller_symbol,
14409                call.target,
14410                call.line,
14411                call.provenance
14412            ));
14413        }
14414        for file in &snapshot.entry_points {
14415            rows.push(format!("entry_file\t{}", file.display()));
14416        }
14417        for (file, symbols) in &snapshot.entry_point_symbols {
14418            for symbol in symbols {
14419                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14420            }
14421        }
14422        rows.sort();
14423        rows
14424    }
14425
14426    fn assert_direct_caller(
14427        store: &CallGraphStore,
14428        target_rel: &str,
14429        target_symbol: &str,
14430        caller_rel: &str,
14431        caller_symbol: &str,
14432    ) {
14433        let callers = store
14434            .direct_callers_of(Path::new(target_rel), target_symbol)
14435            .unwrap_or_else(|error| {
14436                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14437            });
14438        assert!(
14439            callers.iter().any(|site| {
14440                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14441            }),
14442            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14443        );
14444    }
14445
14446    fn assert_projected_call(
14447        root: &Path,
14448        snapshot: &CallgraphSnapshot,
14449        target_rel: &str,
14450        symbol: &str,
14451    ) {
14452        let target = projected_target(root, target_rel, symbol);
14453        assert!(
14454            snapshot.outbound_calls.iter().any(|call| {
14455                call.target == target
14456                    || call.target.starts_with(&format!(
14457                        "{target}{}",
14458                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14459                    ))
14460            }),
14461            "expected projected call to {target}; calls: {:#?}",
14462            snapshot.outbound_calls
14463        );
14464    }
14465
14466    fn assert_no_projected_call(
14467        root: &Path,
14468        snapshot: &CallgraphSnapshot,
14469        target_rel: &str,
14470        symbol: &str,
14471    ) {
14472        let target = projected_target(root, target_rel, symbol);
14473        assert!(
14474            snapshot.outbound_calls.iter().all(|call| {
14475                call.target != target
14476                    && !call.target.starts_with(&format!(
14477                        "{target}{}",
14478                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14479                    ))
14480            }),
14481            "did not expect projected call to {target}; calls: {:#?}",
14482            snapshot.outbound_calls
14483        );
14484    }
14485
14486    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14487        // Projection targets carry the normalized (verbatim-stripped)
14488        // canonical form; bare fs::canonicalize diverges on Windows.
14489        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14490        format!("{}::{symbol}", path.display())
14491    }
14492
14493    fn write_rust_manifest(root: &Path, name: &str) {
14494        write_file(
14495            root,
14496            "Cargo.toml",
14497            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14498        );
14499    }
14500
14501    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14502        let path = root.join(rel_path);
14503        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14504        fs::write(&path, source).expect("write fixture file");
14505        path
14506    }
14507
14508    fn rust_files(root: &Path) -> Vec<PathBuf> {
14509        let mut files = Vec::new();
14510        collect_rust_files(root, &mut files);
14511        files.sort();
14512        files
14513    }
14514
14515    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14516        for entry in fs::read_dir(dir).expect("read fixture dir") {
14517            let entry = entry.expect("read fixture entry");
14518            let path = entry.path();
14519            if path.is_dir() {
14520                let name = path
14521                    .file_name()
14522                    .and_then(|name| name.to_str())
14523                    .unwrap_or("");
14524                if !name.starts_with(".store") {
14525                    collect_rust_files(&path, files);
14526                }
14527            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14528                files.push(path);
14529            }
14530        }
14531    }
14532}
14533
14534#[cfg(test)]
14535mod build_pool_tests {
14536    use super::build_pool_size;
14537
14538    #[test]
14539    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14540        let size = build_pool_size();
14541        // Never zero, never the full core count, never above the 8 cap — this is
14542        // the starvation guard for the cold-build's all-cores tree-sitter pass.
14543        assert!(size >= 1, "pool size must be at least 1");
14544        assert!(size <= 8, "pool size must be capped at 8, got {size}");
14545
14546        let cores = std::thread::available_parallelism()
14547            .map(|p| p.get())
14548            .unwrap_or(1);
14549        let expected = cores.div_ceil(2).clamp(1, 8);
14550        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14551    }
14552}
14553
14554#[cfg(test)]
14555mod reexport_resolution_tests {
14556    use super::*;
14557
14558    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14559        ProjectIndex {
14560            project_root: PathBuf::from("/fixture"),
14561            files: files.into_iter().collect(),
14562            caller_data: HashMap::new(),
14563            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14564        }
14565    }
14566
14567    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14568        DbFileIndex {
14569            lang: None,
14570            exports: HashSet::new(),
14571            default_export: None,
14572            export_aliases: HashMap::new(),
14573            node_by_scoped: HashMap::new(),
14574            node_by_bare: HashMap::new(),
14575            module_targets: HashMap::new(),
14576            reexports: reexport_targets
14577                .iter()
14578                .map(|target| ReexportIndex {
14579                    target_file: Some((*target).to_string()),
14580                    named: HashMap::new(),
14581                    wildcard: true,
14582                })
14583                .collect(),
14584        }
14585    }
14586
14587    /// A dense wildcard re-export cycle (barrel files re-exporting each
14588    /// other) must resolve in O(files), not O(branching^depth). Without the
14589    /// resolver's memoization, resolving a MISSING symbol through this
14590    /// 12-file complete digraph explores ~11^16 paths and this test never
14591    /// finishes: the depth cap bounds path length, not path count, and one
14592    /// such resolution can pin a worker thread at 100% CPU indefinitely.
14593    #[test]
14594    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14595        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14596        let files = names
14597            .iter()
14598            .map(|name| {
14599                let targets: Vec<&str> = names
14600                    .iter()
14601                    .filter(|other| *other != name)
14602                    .map(String::as_str)
14603                    .collect();
14604                (name.clone(), barrel_file(&targets))
14605            })
14606            .collect();
14607        let index = barrel_index(files);
14608
14609        assert_eq!(
14610            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14611            None
14612        );
14613    }
14614
14615    /// Depth-dominance counterexample: the walk first reaches `shared` down a
14616    /// 16-hop chain (no budget left for its leaf), then reaches it again
14617    /// directly at depth 1. Plain visited-set pruning would skip the second
14618    /// visit and lose a resolution the capped resolver finds; the
14619    /// depth-dominance memo revisits because the second arrival is shallower.
14620    #[test]
14621    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14622        let mut leaf = barrel_file(&[]);
14623        leaf.exports.insert("deep_symbol".to_string());
14624        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14625        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
14626        // entry's SECOND reexport goes straight to shared.
14627        files.push((
14628            "src/entry.ts".to_string(),
14629            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14630        ));
14631        for i in 0..15 {
14632            let next = if i == 14 {
14633                "src/shared.ts".to_string()
14634            } else {
14635                format!("src/chain{}.ts", i + 1)
14636            };
14637            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14638        }
14639        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14640        files.push(("src/leaf.ts".to_string(), leaf));
14641        let index = barrel_index(files);
14642
14643        assert_eq!(
14644            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14645            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14646            "a shallower re-visit must not be pruned by a deeper capped visit"
14647        );
14648    }
14649
14650    #[test]
14651    fn symbol_reachable_through_reexport_cycle_still_resolves() {
14652        let mut leaf = barrel_file(&[]);
14653        leaf.exports.insert("real_symbol".to_string());
14654        let index = barrel_index(vec![
14655            (
14656                "src/a.ts".to_string(),
14657                barrel_file(&["src/b.ts", "src/a.ts"]),
14658            ),
14659            (
14660                "src/b.ts".to_string(),
14661                barrel_file(&["src/a.ts", "src/leaf.ts"]),
14662            ),
14663            ("src/leaf.ts".to_string(), leaf),
14664        ]);
14665
14666        assert_eq!(
14667            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14668            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14669        );
14670    }
14671}
14672
14673#[cfg(test)]
14674mod method_dispatch_inference_tests {
14675    use super::*;
14676    use std::fs;
14677    use tempfile::tempdir;
14678
14679    #[test]
14680    fn java_field_receiver_type_selects_declared_class_method() {
14681        let source = r#"class EntryPoint {
14682    private UserService userService;
14683
14684    void handle() {
14685        userService.find();
14686    }
14687}
14688
14689class UserService {
14690    void find() {}
14691}
14692
14693class AuditService {
14694    void find() {}
14695}
14696"#;
14697        let dir = tempdir().expect("temp dir");
14698        let root = dir.path();
14699        write_fixture(root, "src/EntryPoint.java", source);
14700        let reference = reference(
14701            "java",
14702            "src/EntryPoint.java",
14703            "EntryPoint::handle",
14704            "userService",
14705            "find",
14706            line_of(source, "userService.find()"),
14707        );
14708        let mut cache = DispatchSourceCache::new();
14709
14710        let receiver_type =
14711            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14712        assert_eq!(receiver_type, "UserService");
14713
14714        let candidates = vec![
14715            method_candidate("audit", "AuditService::find"),
14716            method_candidate("user", "UserService::find"),
14717        ];
14718        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14719            .expect("type candidate");
14720        assert_eq!(selected.scoped_name, "UserService::find");
14721
14722        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14723        assert!(
14724            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14725        );
14726    }
14727
14728    #[test]
14729    fn kotlin_property_and_local_value_types_are_inferred() {
14730        let source = r#"class Handler {
14731    private val auditService: AuditService = AuditService()
14732
14733    fun handle() {
14734        auditService.find()
14735        val userService: UserService = UserService()
14736        userService.find()
14737        val billingService = BillingService()
14738        billingService.find()
14739    }
14740}
14741
14742class UserService { fun find() {} }
14743class AuditService { fun find() {} }
14744class BillingService { fun find() {} }
14745"#;
14746        let dir = tempdir().expect("temp dir");
14747        let root = dir.path();
14748        write_fixture(root, "src/Handler.kt", source);
14749        let mut cache = DispatchSourceCache::new();
14750
14751        let audit_ref = reference(
14752            "kotlin",
14753            "src/Handler.kt",
14754            "Handler::handle",
14755            "auditService",
14756            "find",
14757            line_of(source, "auditService.find()"),
14758        );
14759        assert_eq!(
14760            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14761            Some("AuditService")
14762        );
14763
14764        let user_ref = reference(
14765            "kotlin",
14766            "src/Handler.kt",
14767            "Handler::handle",
14768            "userService",
14769            "find",
14770            line_of(source, "userService.find()"),
14771        );
14772        assert_eq!(
14773            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14774            Some("UserService")
14775        );
14776
14777        let billing_ref = reference(
14778            "kotlin",
14779            "src/Handler.kt",
14780            "Handler::handle",
14781            "billingService",
14782            "find",
14783            line_of(source, "billingService.find()"),
14784        );
14785        assert_eq!(
14786            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14787            Some("BillingService")
14788        );
14789    }
14790
14791    #[test]
14792    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14793        let source = r#"struct Foo { void run(); };
14794struct PointerFoo { void run(); };
14795struct FactoryFoo { void run(); };
14796FactoryFoo makeFactoryFoo();
14797
14798void handle() {
14799    Foo foo;
14800    foo.run();
14801    PointerFoo* pointerFoo = nullptr;
14802    pointerFoo->run();
14803    auto factoryFoo = makeFactoryFoo();
14804    factoryFoo.run();
14805}
14806"#;
14807        let dir = tempdir().expect("temp dir");
14808        let root = dir.path();
14809        write_fixture(root, "src/fixture.cpp", source);
14810        let mut cache = DispatchSourceCache::new();
14811
14812        let foo_ref = reference(
14813            "cpp",
14814            "src/fixture.cpp",
14815            "handle",
14816            "foo",
14817            "run",
14818            line_of(source, "foo.run()"),
14819        );
14820        assert_eq!(
14821            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14822            Some("Foo")
14823        );
14824
14825        let pointer_ref = reference(
14826            "cpp",
14827            "src/fixture.cpp",
14828            "handle",
14829            "pointerFoo",
14830            "run",
14831            line_of(source, "pointerFoo->run()"),
14832        );
14833        assert_eq!(
14834            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14835            Some("PointerFoo")
14836        );
14837
14838        let factory_ref = reference(
14839            "cpp",
14840            "src/fixture.cpp",
14841            "handle",
14842            "factoryFoo",
14843            "run",
14844            line_of(source, "factoryFoo.run()"),
14845        );
14846        assert_eq!(
14847            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
14848            Some("FactoryFoo")
14849        );
14850    }
14851
14852    #[test]
14853    fn rust_direct_self_field_name_trims_separator_whitespace() {
14854        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
14855            assert_eq!(
14856                rust_direct_self_field_name(receiver_expression),
14857                Some("engine")
14858            );
14859        }
14860    }
14861
14862    #[test]
14863    fn rust_direct_self_field_receiver_type_is_conservative() {
14864        let source = r#"struct Engine;
14865
14866struct Car {
14867    engine: Engine,
14868}
14869
14870impl Car {
14871    fn run(&self) {
14872        self.engine.start();
14873    }
14874}
14875
14876struct NestedCar {
14877    engine: Engine,
14878}
14879
14880impl NestedCar {
14881    fn run(&self) {
14882        self.inner.engine.start();
14883    }
14884}
14885
14886struct WrappedCar {
14887    engine: Option<Engine>,
14888}
14889
14890impl WrappedCar {
14891    fn run(&self) {
14892        self.engine.start(); // wrapped
14893    }
14894}
14895
14896struct GenericCar<T> {
14897    engine: T,
14898}
14899
14900impl<T> GenericCar<T> {
14901    fn run(&self) {
14902        self.engine.start(); // generic
14903    }
14904}
14905
14906type EngineAlias = Engine;
14907
14908struct AliasCar {
14909    engine: EngineAlias,
14910}
14911
14912impl AliasCar {
14913    fn run(&self) {
14914        self.engine.start(); // alias
14915    }
14916}
14917"#;
14918        let dir = tempdir().expect("temp dir");
14919        let root = dir.path();
14920        write_fixture(root, "src/lib.rs", source);
14921        let mut cache = DispatchSourceCache::new();
14922
14923        let mut direct = reference(
14924            "rust",
14925            "src/lib.rs",
14926            "Car::run",
14927            "engine",
14928            "start",
14929            line_of(source, "self.engine.start()"),
14930        );
14931        direct.receiver_expression = "self.engine".to_string();
14932        assert_eq!(
14933            infer_receiver_type(root, &direct, &mut cache).as_deref(),
14934            Some("Engine")
14935        );
14936
14937        let mut mismatched_impl_target = direct.clone();
14938        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
14939        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
14940
14941        let mut nested = reference(
14942            "rust",
14943            "src/lib.rs",
14944            "NestedCar::run",
14945            "engine",
14946            "start",
14947            line_of(source, "self.inner.engine.start()"),
14948        );
14949        nested.receiver_expression = "self.inner.engine".to_string();
14950        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
14951
14952        let mut wrapped = reference(
14953            "rust",
14954            "src/lib.rs",
14955            "WrappedCar::run",
14956            "engine",
14957            "start",
14958            line_of(source, "self.engine.start(); // wrapped"),
14959        );
14960        wrapped.receiver_expression = "self.engine".to_string();
14961        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
14962
14963        let mut generic = reference(
14964            "rust",
14965            "src/lib.rs",
14966            "GenericCar::run",
14967            "engine",
14968            "start",
14969            line_of(source, "self.engine.start(); // generic"),
14970        );
14971        generic.receiver_expression = "self.engine".to_string();
14972        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
14973
14974        let mut alias = reference(
14975            "rust",
14976            "src/lib.rs",
14977            "AliasCar::run",
14978            "engine",
14979            "start",
14980            line_of(source, "self.engine.start(); // alias"),
14981        );
14982        alias.receiver_expression = "self.engine".to_string();
14983        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
14984    }
14985
14986    #[test]
14987    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
14988        let source = r#"struct Engine;
14989
14990struct Car {
14991    engine: &'static Engine,
14992}
14993
14994impl Car {
14995    fn run(&self) {
14996        self.engine.start();
14997    }
14998}
14999"#;
15000        let dir = tempdir().expect("temp dir");
15001        let root = dir.path();
15002        write_fixture(root, "src/lib.rs", source);
15003        let mut cache = DispatchSourceCache::new();
15004        let mut reference = reference(
15005            "rust",
15006            "src/lib.rs",
15007            "Car::run",
15008            "engine",
15009            "start",
15010            line_of(source, "self.engine.start()"),
15011        );
15012        reference.receiver_expression = "self.engine".to_string();
15013
15014        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15015    }
15016
15017    #[test]
15018    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15019        let source = r#"trait Drive {
15020    fn run(&self);
15021}
15022
15023struct Engine;
15024
15025struct Car {
15026    engine: Engine,
15027}
15028
15029impl Drive for Car {
15030    fn run(&self) {
15031        self.engine.start();
15032    }
15033}
15034"#;
15035        let dir = tempdir().expect("temp dir");
15036        let root = dir.path();
15037        write_fixture(root, "src/lib.rs", source);
15038        let mut cache = DispatchSourceCache::new();
15039        let mut reference = reference(
15040            "rust",
15041            "src/lib.rs",
15042            "Car::run",
15043            "engine",
15044            "start",
15045            line_of(source, "self.engine.start()"),
15046        );
15047        reference.receiver_expression = "self.engine".to_string();
15048
15049        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15050    }
15051
15052    #[test]
15053    fn rust_self_field_does_not_bind_struct_from_another_module() {
15054        let source = r#"struct Engine;
15055
15056mod unrelated {
15057    struct Car {
15058        engine: Engine,
15059    }
15060}
15061
15062impl Car {
15063    fn run(&self) {
15064        self.engine.start();
15065    }
15066}
15067"#;
15068        let dir = tempdir().expect("temp dir");
15069        let root = dir.path();
15070        write_fixture(root, "src/lib.rs", source);
15071        let mut cache = DispatchSourceCache::new();
15072        let mut reference = reference(
15073            "rust",
15074            "src/lib.rs",
15075            "Car::run",
15076            "engine",
15077            "start",
15078            line_of(source, "self.engine.start()"),
15079        );
15080        reference.receiver_expression = "self.engine".to_string();
15081
15082        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15083    }
15084
15085    #[test]
15086    fn unknown_java_receiver_still_uses_name_match_fallback() {
15087        let source = r#"class EntryPoint {
15088    void handle() {
15089        service.runSpecial();
15090    }
15091}
15092
15093class OnlyService {
15094    void runSpecial() {}
15095}
15096"#;
15097        let dir = tempdir().expect("temp dir");
15098        let root = dir.path();
15099        write_fixture(root, "src/EntryPoint.java", source);
15100        let reference = reference(
15101            "java",
15102            "src/EntryPoint.java",
15103            "EntryPoint::handle",
15104            "service",
15105            "runSpecial",
15106            line_of(source, "service.runSpecial()"),
15107        );
15108        let mut cache = DispatchSourceCache::new();
15109
15110        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15111        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15112        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15113        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15114    }
15115
15116    fn reference(
15117        lang: &str,
15118        caller_file: &str,
15119        caller_symbol: &str,
15120        receiver: &str,
15121        method_name: &str,
15122        line: u32,
15123    ) -> NameMatchRef {
15124        NameMatchRef {
15125            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15126            caller_node: format!("{caller_symbol}:node"),
15127            caller_file: caller_file.to_string(),
15128            caller_symbol: caller_symbol.to_string(),
15129            caller_signature: None,
15130            receiver_expression: receiver.to_string(),
15131            receiver: receiver.to_string(),
15132            method_name: method_name.to_string(),
15133            colon_dispatch: false,
15134            line,
15135            lang: lang.to_string(),
15136        }
15137    }
15138
15139    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15140        NameMatchCandidate {
15141            node_id: node_id.to_string(),
15142            file_path: "src/targets.fixture".to_string(),
15143            scoped_name: scoped_name.to_string(),
15144            kind: "method".to_string(),
15145            start_line: 1,
15146        }
15147    }
15148
15149    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15150        let path = root.join(rel_path);
15151        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15152        fs::write(path, source).expect("write fixture");
15153    }
15154
15155    fn line_of(source: &str, needle: &str) -> u32 {
15156        source
15157            .lines()
15158            .position(|line| line.contains(needle))
15159            .map(|index| index as u32 + 1)
15160            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15161    }
15162}