Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44/// Marker-protected generations older than this absolute age are reclaimed even
45/// if a stale reader marker remains. Current and newest-previous generations are
46/// always retained, bounding the root-keyed callgraph store to roughly two or
47/// three large generations without adding user-visible configuration.
48const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
57
58fn write_amplification_baseline_enabled() -> bool {
59    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
60}
61
62type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
63
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65struct RebuildCooldownKey {
66    callgraph_dir: PathBuf,
67    project_key: String,
68}
69
70#[derive(Clone, Debug)]
71struct RebuildCooldownRecord {
72    project_root: PathBuf,
73    published_at: Instant,
74    cross_root_cooldown_armed: bool,
75}
76
77// Prevent repeated rebuilds when requests rapidly switch between project
78// roots. Allow the first successful rebuild for a different root; after that
79// transition, report the artifact as unavailable instead of publishing another
80// complete generation. Record only successful publications in this map.
81static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
82    OnceLock::new();
83
84#[derive(Clone, Debug, Eq, Hash, PartialEq)]
85struct RootRepairWarningKey {
86    project_key: String,
87}
88
89#[derive(Clone, Debug)]
90struct RootRepairWarningRecord {
91    window_start: Instant,
92    last_emitted: Instant,
93    entry_count: u64,
94    suppressed: u64,
95}
96
97static ROOT_REPAIR_WARNINGS: OnceLock<
98    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
99> = OnceLock::new();
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub(crate) struct CallgraphWriteMetricsSnapshot {
103    pub commits_60s: u64,
104    pub pages_or_bytes_written_60s: u64,
105}
106
107#[derive(Debug, Default)]
108struct CallgraphWriteMetrics {
109    window_start_ms: AtomicU64,
110    commits_60s: AtomicU64,
111    pages_or_bytes_written_60s: AtomicU64,
112}
113
114static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
115    OnceLock::new();
116
117fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
118    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
119    let mut metrics = metrics
120        .lock()
121        .expect("callgraph write metrics mutex poisoned");
122    Arc::clone(
123        metrics
124            .entry(project_key.to_string())
125            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
126    )
127}
128
129fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
130    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
131    if current_start == 0 {
132        let _ = metrics.window_start_ms.compare_exchange(
133            0,
134            now_ms,
135            AtomicOrdering::AcqRel,
136            AtomicOrdering::Acquire,
137        );
138        return;
139    }
140    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
141        return;
142    }
143    if metrics
144        .window_start_ms
145        .compare_exchange(
146            current_start,
147            now_ms,
148            AtomicOrdering::AcqRel,
149            AtomicOrdering::Acquire,
150        )
151        .is_ok()
152    {
153        metrics.commits_60s.store(0, AtomicOrdering::Release);
154        metrics
155            .pages_or_bytes_written_60s
156            .store(0, AtomicOrdering::Release);
157    }
158}
159
160impl CallgraphWriteMetrics {
161    fn record_commit(&self, pages_or_bytes_written: u64) {
162        let now_ms = unix_millis_now();
163        roll_callgraph_write_metric_window(self, now_ms);
164        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
165        self.pages_or_bytes_written_60s
166            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
167    }
168
169    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
170        roll_callgraph_write_metric_window(self, unix_millis_now());
171        CallgraphWriteMetricsSnapshot {
172            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
173            pages_or_bytes_written_60s: self
174                .pages_or_bytes_written_60s
175                .load(AtomicOrdering::Acquire),
176        }
177    }
178}
179
180pub(crate) fn callgraph_write_metrics_for_project(
181    project_key: &str,
182) -> CallgraphWriteMetricsSnapshot {
183    callgraph_write_metrics_for_key(project_key).snapshot()
184}
185
186pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
187    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
188        return CallgraphWriteMetricsSnapshot::default();
189    };
190    let metrics = metrics
191        .lock()
192        .expect("callgraph write metrics mutex poisoned");
193    metrics.values().map(|metrics| metrics.snapshot()).fold(
194        CallgraphWriteMetricsSnapshot::default(),
195        |total, current| CallgraphWriteMetricsSnapshot {
196            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
197            pages_or_bytes_written_60s: total
198                .pages_or_bytes_written_60s
199                .saturating_add(current.pages_or_bytes_written_60s),
200        },
201    )
202}
203
204const ROOT_REPAIR_WARNING_TEXT: &str =
205    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
206
207fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
208    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
209    let mut warnings = warnings.lock().ok()?;
210    let entry = warnings.entry(key);
211    let record = match entry {
212        Entry::Vacant(entry) => {
213            entry.insert(RootRepairWarningRecord {
214                window_start: now,
215                last_emitted: now,
216                entry_count: 1,
217                suppressed: 0,
218            });
219            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
220        }
221        Entry::Occupied(entry) => entry.into_mut(),
222    };
223
224    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
225        let suppressed = record.suppressed;
226        record.window_start = now;
227        record.last_emitted = now;
228        record.entry_count = 1;
229        record.suppressed = 0;
230        return Some(if suppressed == 0 {
231            ROOT_REPAIR_WARNING_TEXT.to_string()
232        } else {
233            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
234        });
235    }
236
237    record.entry_count = record.entry_count.saturating_add(1);
238    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
239        record.suppressed = record.suppressed.saturating_add(1);
240        None
241    } else {
242        record.last_emitted = now;
243        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
244    }
245}
246
247pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
248    next_root_repair_warning(
249        RootRepairWarningKey {
250            project_key: project_key.to_string(),
251        },
252        Instant::now(),
253    )
254}
255
256/// Return the number of repair entries in the active 60-second window.
257///
258/// The window start is returned for callers that need to show freshness without
259/// adding another status verdict or turning this into a user-facing setting.
260pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
261    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
262    let warnings = warnings.lock().ok()?;
263    let record = warnings.get(&RootRepairWarningKey {
264        project_key: project_key.to_string(),
265    })?;
266    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
267        .then_some((record.entry_count, record.window_start))
268}
269
270pub(crate) fn repair_entry_rate_total() -> u64 {
271    let Ok(warnings) = ROOT_REPAIR_WARNINGS
272        .get_or_init(|| Mutex::new(HashMap::new()))
273        .lock()
274    else {
275        return 0;
276    };
277    let now = Instant::now();
278    warnings
279        .values()
280        .filter(|record| {
281            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
282        })
283        .map(|record| record.entry_count)
284        .sum()
285}
286
287#[cfg(test)]
288pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
289    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
290    let mut warnings = warnings.lock().unwrap();
291    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
292        project_key: project_key.to_string(),
293    }) {
294        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
295    }
296}
297
298#[cfg(test)]
299mod root_repair_warning_tests {
300    use super::*;
301
302    #[test]
303    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
304        let key = RootRepairWarningKey {
305            project_key: "test-project".to_string(),
306        };
307        let first_at = Instant::now();
308        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
309        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
310        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
311        assert_eq!(
312            repair_entry_rate("test-project").map(|rate| rate.0),
313            Some(2)
314        );
315
316        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
317        assert!(repeated.ends_with("(repeated 1x in 60s)"));
318        expire_repair_entry_window_for_test("test-project");
319        assert!(repair_entry_rate("test-project").is_none());
320    }
321}
322
323#[cfg(test)]
324mod write_amplification_tests {
325    use super::*;
326    use std::fs;
327    use tempfile::tempdir;
328
329    #[test]
330    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
331        let temp = tempdir().unwrap();
332        let root = temp.path().join("root");
333        fs::create_dir_all(&root).unwrap();
334        let source = root.join("main.ts");
335        fs::write(&source, "export function main() {}\n").unwrap();
336        let store_dir = temp.path().join("store");
337        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
338
339        let conn = store.conn.lock().unwrap();
340        let synchronous: i64 = conn
341            .pragma_query_value(None, "synchronous", |row| row.get(0))
342            .unwrap();
343        let autocheckpoint: i64 = conn
344            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
345            .unwrap();
346        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
347        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
348        drop(conn);
349        store.cold_build(std::slice::from_ref(&source)).unwrap();
350        drop(store);
351
352        let readonly = CallGraphStore::open_readonly(store_dir, root)
353            .unwrap()
354            .expect("writer-created empty schema should be readable");
355        let conn = readonly.inner.conn.lock().unwrap();
356        let synchronous: i64 = conn
357            .pragma_query_value(None, "synchronous", |row| row.get(0))
358            .unwrap();
359        assert_eq!(synchronous, 1);
360    }
361
362    #[test]
363    fn own_refresh_skips_identical_extract_but_not_position_shift() {
364        let temp = tempdir().unwrap();
365        let root = temp.path().join("root");
366        fs::create_dir_all(&root).unwrap();
367        let source = root.join("main.ts");
368        fs::write(&source, "export function main() { return 1; }\n").unwrap();
369        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
370        store.cold_build(std::slice::from_ref(&source)).unwrap();
371        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
372        assert!(write_metrics.commits_60s > 0);
373        assert!(write_metrics.pages_or_bytes_written_60s > 0);
374
375        let before = store.conn.lock().unwrap().total_changes();
376        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
377        let (stats, _) = store
378            .refresh_files_profiled(std::slice::from_ref(&source))
379            .unwrap();
380        let after = store.conn.lock().unwrap().total_changes();
381        assert_eq!(stats.unchanged_extract_files, 1);
382        assert_eq!(stats.refreshed_own_files, 0);
383        assert_eq!(
384            after - before,
385            3,
386            "files, backend freshness, and the durable projection revision update"
387        );
388
389        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
390        let (shifted_stats, _) = store
391            .refresh_files_profiled(std::slice::from_ref(&source))
392            .unwrap();
393        assert_eq!(shifted_stats.unchanged_extract_files, 0);
394        assert_eq!(shifted_stats.refreshed_own_files, 1);
395    }
396
397    #[test]
398    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
399        let now = Instant::now();
400        assert!(idle_checkpoint_due(None, now));
401        assert!(!idle_checkpoint_due(
402            Some(now),
403            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
404        ));
405        assert!(idle_checkpoint_due(
406            Some(now),
407            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
408        ));
409    }
410
411    #[test]
412    fn write_metrics_decay_after_the_sixty_second_window() {
413        let key = format!("metrics-test-{}", now_nanos());
414        let metrics = callgraph_write_metrics_for_key(&key);
415        metrics.record_commit(17);
416        assert_eq!(metrics.snapshot().commits_60s, 1);
417        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
418        metrics.window_start_ms.store(
419            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
420            AtomicOrdering::Release,
421        );
422        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
423    }
424}
425
426#[cfg(test)]
427type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
428// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
429// thread running the cold build, and the only caller (a test) installs and
430// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
431// across parallel tests — one test's installed observer fired during ANOTHER
432// test's `cold_build_with_lease`, asserting against the wrong build's edges
433// (flaked on Windows CI under parallel scheduling). Production never sets it.
434thread_local! {
435    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
436        const { std::cell::RefCell::new(None) };
437    #[cfg(test)]
438    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
439        const { std::cell::RefCell::new(None) };
440    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
441        const { std::cell::RefCell::new(None) };
442    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
443    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
444        const { std::cell::Cell::new(false) };
445    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
446        const { std::cell::RefCell::new(None) };
447    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
448        const { std::cell::RefCell::new(None) };
449}
450
451mod dead_code_projection;
452pub use dead_code_projection::project_dead_code_snapshot;
453pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
454#[cfg(test)]
455pub(crate) use dead_code_projection::set_projection_before_open_observer;
456
457#[doc(hidden)]
458pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
459    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
460}
461
462#[cfg(test)]
463fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
464    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
465}
466
467#[cfg(test)]
468fn notify_cold_build_before_publish_observer() {
469    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
470    if let Some(observer) = observer {
471        observer();
472    }
473}
474
475#[cfg(not(test))]
476fn notify_cold_build_before_publish_observer() {}
477
478#[doc(hidden)]
479pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
480    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
481}
482
483#[doc(hidden)]
484pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
485    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
486}
487
488#[doc(hidden)]
489pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
490    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
491}
492
493struct PublishAdmissionGuard {
494    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
495}
496
497impl Drop for PublishAdmissionGuard {
498    fn drop(&mut self) {
499        PUBLISH_ADMISSION.with(|slot| {
500            *slot.borrow_mut() = self.previous.take();
501        });
502    }
503}
504
505pub(crate) fn with_publish_epoch<R>(
506    epoch: crate::root_cache::ArtifactPublishEpoch,
507    expected: u64,
508    run: impl FnOnce() -> R,
509) -> R {
510    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
511    let _guard = PublishAdmissionGuard { previous };
512    run()
513}
514
515fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
516    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
517    match admission {
518        Some((epoch, expected)) => epoch
519            .run_if_current(expected, publish)
520            .unwrap_or(Err(CallGraphStoreError::Superseded)),
521        None => publish(),
522    }
523}
524
525struct RefreshCommitAdmissionGuard {
526    previous: Option<(
527        SubcLifecycleAdmission,
528        Arc<std::sync::atomic::AtomicU64>,
529        u64,
530    )>,
531}
532
533impl Drop for RefreshCommitAdmissionGuard {
534    fn drop(&mut self) {
535        REFRESH_COMMIT_ADMISSION.with(|slot| {
536            *slot.borrow_mut() = self.previous.take();
537        });
538    }
539}
540
541fn with_refresh_commit_admission<R>(
542    lifecycle: SubcLifecycleAdmission,
543    generation_flag: Arc<std::sync::atomic::AtomicU64>,
544    expected_generation: u64,
545    run: impl FnOnce() -> R,
546) -> R {
547    let previous = REFRESH_COMMIT_ADMISSION
548        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
549    let _guard = RefreshCommitAdmissionGuard { previous };
550    run()
551}
552
553fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
554    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
555    let commit = || {
556        publish_if_current(|| {
557            tx.commit()?;
558            Ok(())
559        })
560    };
561    match admission {
562        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
563            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
564            .unwrap_or(Err(CallGraphStoreError::Superseded)),
565        None => commit(),
566    }
567}
568
569fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
570    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
571    if let Some(observer) = observer {
572        observer(temp_path, target_path);
573    }
574}
575
576#[derive(Debug)]
577pub enum CallGraphStoreError {
578    Io(std::io::Error),
579    Sqlite(rusqlite::Error),
580    Json(serde_json::Error),
581    Aft(AftError),
582    Lock(crate::fs_lock::AcquireError),
583    MissingCallerData { file: String },
584    Unavailable(String),
585    Superseded,
586    StaleFiles(Vec<String>),
587}
588
589impl fmt::Display for CallGraphStoreError {
590    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
591        match self {
592            Self::Io(error) => write!(formatter, "I/O error: {error}"),
593            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
594            Self::Json(error) => write!(formatter, "json error: {error}"),
595            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
596            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
597            Self::MissingCallerData { file } => {
598                write!(formatter, "missing extracted caller data for {file}")
599            }
600            Self::Unavailable(message) => {
601                write!(formatter, "callgraph store unavailable: {message}")
602            }
603            Self::Superseded => {
604                write!(formatter, "callgraph store build superseded before publish")
605            }
606            Self::StaleFiles(files) => {
607                write!(
608                    formatter,
609                    "callgraph store has stale files: {}",
610                    files.join(", ")
611                )
612            }
613        }
614    }
615}
616
617impl std::error::Error for CallGraphStoreError {}
618
619impl From<std::io::Error> for CallGraphStoreError {
620    fn from(error: std::io::Error) -> Self {
621        Self::Io(error)
622    }
623}
624
625impl From<rusqlite::Error> for CallGraphStoreError {
626    fn from(error: rusqlite::Error) -> Self {
627        Self::Sqlite(error)
628    }
629}
630
631impl From<serde_json::Error> for CallGraphStoreError {
632    fn from(error: serde_json::Error) -> Self {
633        Self::Json(error)
634    }
635}
636
637impl From<AftError> for CallGraphStoreError {
638    fn from(error: AftError) -> Self {
639        Self::Aft(error)
640    }
641}
642
643impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
644    fn from(error: crate::fs_lock::AcquireError) -> Self {
645        Self::Lock(error)
646    }
647}
648
649pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
650
651/// Config flag name gating whether the store is opened (default on). Production
652/// commands open it through `open_if_enabled` so the substrate can be disabled
653/// without code changes.
654pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
655
656#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
657pub struct CallGraphStoreOptions {
658    pub enabled: bool,
659}
660
661pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
662
663/// Shared context state that lets the refresh worker observe a store installed
664/// after its batch was opened. The worker clones the installed store Arc before
665/// checking it, so no context lock guard crosses the check or enqueue call.
666#[derive(Clone)]
667pub(crate) struct CallgraphRefreshState {
668    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
669    heavy_root_work_allowed: Arc<AtomicBool>,
670}
671
672impl CallgraphRefreshState {
673    pub(crate) fn new(
674        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
675        heavy_root_work_allowed: Arc<AtomicBool>,
676    ) -> Self {
677        Self {
678            store,
679            heavy_root_work_allowed,
680        }
681    }
682
683    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
684        self.store
685            .read()
686            .unwrap_or_else(std::sync::PoisonError::into_inner)
687            .as_ref()
688            .map(Arc::clone)
689    }
690}
691
692type WorkspaceCratePrefixes = HashMap<String, String>;
693
694#[derive(Clone, Debug, Default)]
695struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
696
697const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
698
699pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
700    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
701}
702
703#[derive(Clone, Debug, Hash, PartialEq, Eq)]
704struct RefreshRoot {
705    callgraph_dir: PathBuf,
706    project_root: PathBuf,
707}
708
709#[derive(Clone)]
710pub(crate) struct CallgraphRefreshTicket {
711    lifecycle: SubcLifecycleAdmission,
712    generation_flag: Arc<std::sync::atomic::AtomicU64>,
713    expected_generation: u64,
714    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
715    expected_publish_epoch: u64,
716}
717
718impl CallgraphRefreshTicket {
719    pub(crate) fn new(
720        lifecycle: SubcLifecycleAdmission,
721        generation_flag: Arc<std::sync::atomic::AtomicU64>,
722        expected_generation: u64,
723        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
724        expected_publish_epoch: u64,
725    ) -> Self {
726        Self {
727            lifecycle,
728            generation_flag,
729            expected_generation,
730            publish_epoch,
731            expected_publish_epoch,
732        }
733    }
734
735    fn is_current(&self) -> bool {
736        self.lifecycle
737            .is_current(self.generation_flag.as_ref(), self.expected_generation)
738            && self.publish_epoch.current() == self.expected_publish_epoch
739    }
740}
741
742#[derive(Clone)]
743struct RefreshBatch {
744    root: RefreshRoot,
745    paths: BTreeSet<PathBuf>,
746    pending_sinks: Vec<PendingCallGraphStorePaths>,
747    refresh_states: Vec<CallgraphRefreshState>,
748    ticket: Option<CallgraphRefreshTicket>,
749}
750
751impl RefreshBatch {
752    fn defer(&self) {
753        for sink in &self.pending_sinks {
754            sink.lock().extend(self.paths.iter().cloned());
755        }
756    }
757
758    fn defer_after_open_failure(&self) {
759        self.defer();
760        if self
761            .ticket
762            .as_ref()
763            .is_some_and(|ticket| !ticket.is_current())
764            || !self
765                .refresh_states
766                .iter()
767                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
768        {
769            return;
770        }
771
772        let ready_store_installed = self.refresh_states.iter().any(|state| {
773            let store = state.installed_store_snapshot();
774            store.is_some_and(|store| {
775                store.project_root() == self.root.project_root
776                    && !store.is_legacy_fallback()
777                    && store.is_current()
778            })
779        });
780        if !ready_store_installed {
781            return;
782        }
783
784        // This re-check and the ready-store install's pending-sink take form a
785        // check-then-act handoff: after this defer, exactly one site observes
786        // the parked paths with a ready current store, so no polling is needed.
787        for sink in &self.pending_sinks {
788            let paths = {
789                let mut pending = sink.lock();
790                self.paths
791                    .iter()
792                    .filter(|path| pending.remove(*path))
793                    .cloned()
794                    .collect::<Vec<_>>()
795            };
796            if paths.is_empty() {
797                continue;
798            }
799            let _ = enqueue_callgraph_store_refresh_inner(
800                self.root.callgraph_dir.clone(),
801                self.root.project_root.clone(),
802                paths,
803                Arc::clone(sink),
804                self.refresh_states.clone(),
805                self.ticket.clone(),
806            );
807        }
808    }
809
810    fn merge(
811        &mut self,
812        paths: impl IntoIterator<Item = PathBuf>,
813        sink: PendingCallGraphStorePaths,
814        refresh_states: Vec<CallgraphRefreshState>,
815        ticket: Option<CallgraphRefreshTicket>,
816    ) {
817        self.paths.extend(paths);
818        if ticket.is_some() {
819            self.ticket = ticket;
820        }
821        if !self
822            .pending_sinks
823            .iter()
824            .any(|existing| Arc::ptr_eq(existing, &sink))
825        {
826            self.pending_sinks.push(sink);
827        }
828        for refresh_state in refresh_states {
829            if !self.refresh_states.iter().any(|existing| {
830                Arc::ptr_eq(&existing.store, &refresh_state.store)
831                    && Arc::ptr_eq(
832                        &existing.heavy_root_work_allowed,
833                        &refresh_state.heavy_root_work_allowed,
834                    )
835            }) {
836                self.refresh_states.push(refresh_state);
837            }
838        }
839    }
840}
841
842#[derive(Default)]
843struct RefreshQueue {
844    order: VecDeque<RefreshRoot>,
845    queued: HashMap<RefreshRoot, RefreshBatch>,
846    active: Option<RefreshBatch>,
847    shutdown_requested: bool,
848}
849
850struct RefreshWorkerShared {
851    queue: Mutex<RefreshQueue>,
852    wake: Condvar,
853}
854
855struct RefreshWorker {
856    shared: Arc<RefreshWorkerShared>,
857    thread: Mutex<Option<JoinHandle<()>>>,
858}
859
860struct RefreshWorkerWatchdog {
861    first_path: PathBuf,
862    batch_len: usize,
863    started: Instant,
864}
865
866impl RefreshWorkerWatchdog {
867    fn start(paths: &[PathBuf]) -> Self {
868        Self {
869            first_path: paths
870                .first()
871                .expect("non-empty callgraph refresh batch has a first path")
872                .clone(),
873            batch_len: paths.len(),
874            started: Instant::now(),
875        }
876    }
877}
878
879impl Drop for RefreshWorkerWatchdog {
880    fn drop(&mut self) {
881        let elapsed = self.started.elapsed();
882        if elapsed < REFRESH_WORKER_WARN_AFTER {
883            return;
884        }
885        let path = if self.batch_len == 1 {
886            self.first_path.display().to_string()
887        } else {
888            format!(
889                "{} (+{} paths)",
890                self.first_path.display(),
891                self.batch_len - 1
892            )
893        };
894        log::warn!(
895            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
896            path,
897            elapsed.as_millis()
898        );
899        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
900            log::warn!(
901                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
902                path,
903                elapsed.as_millis()
904            );
905        }
906    }
907}
908
909impl RefreshWorker {
910    fn spawn() -> Arc<Self> {
911        let shared = Arc::new(RefreshWorkerShared {
912            queue: Mutex::new(RefreshQueue::default()),
913            wake: Condvar::new(),
914        });
915        let thread_shared = Arc::clone(&shared);
916        let thread = std::thread::Builder::new()
917            .name("aft-callgraph-refresh".to_string())
918            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
919            .expect("failed to spawn callgraph refresh worker");
920        Arc::new(Self {
921            shared,
922            thread: Mutex::new(Some(thread)),
923        })
924    }
925
926    fn enqueue(
927        &self,
928        root: RefreshRoot,
929        paths: Vec<PathBuf>,
930        pending_sink: PendingCallGraphStorePaths,
931        refresh_states: Vec<CallgraphRefreshState>,
932        ticket: Option<CallgraphRefreshTicket>,
933    ) -> bool {
934        let mut queue = self
935            .shared
936            .queue
937            .lock()
938            .expect("callgraph refresh queue mutex poisoned");
939        if queue.shutdown_requested {
940            pending_sink.lock().extend(paths);
941            return false;
942        }
943        if let Some(batch) = queue.queued.get_mut(&root) {
944            batch.merge(paths, pending_sink, refresh_states, ticket);
945        } else {
946            queue.order.push_back(root.clone());
947            queue.queued.insert(
948                root.clone(),
949                RefreshBatch {
950                    root,
951                    paths: paths.into_iter().collect(),
952                    pending_sinks: vec![pending_sink],
953                    refresh_states,
954                    ticket,
955                },
956            );
957        }
958        self.shared.wake.notify_one();
959        true
960    }
961
962    fn shutdown_with_budget(&self, budget: Duration) -> bool {
963        let deadline = Instant::now() + budget;
964        let mut queue = self
965            .shared
966            .queue
967            .lock()
968            .expect("callgraph refresh queue mutex poisoned");
969        queue.shutdown_requested = true;
970        self.shared.wake.notify_one();
971        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
972            let remaining = deadline.saturating_duration_since(Instant::now());
973            let (next, _) = self
974                .shared
975                .wake
976                .wait_timeout(queue, remaining)
977                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
978            queue = next;
979        }
980        let drained = queue.active.is_none() && queue.order.is_empty();
981        if !drained {
982            if let Some(active) = queue.active.as_ref() {
983                active.defer();
984            }
985            for batch in queue.queued.values() {
986                batch.defer();
987            }
988            queue.order.clear();
989            queue.queued.clear();
990        }
991        drop(queue);
992
993        if drained {
994            if let Some(thread) = self
995                .thread
996                .lock()
997                .expect("callgraph refresh worker thread mutex poisoned")
998                .take()
999            {
1000                let _ = thread.join();
1001            }
1002        }
1003        drained
1004    }
1005}
1006
1007static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1008
1009pub fn enqueue_callgraph_store_refresh(
1010    callgraph_dir: PathBuf,
1011    project_root: PathBuf,
1012    paths: Vec<PathBuf>,
1013    pending_sink: PendingCallGraphStorePaths,
1014) -> bool {
1015    enqueue_callgraph_store_refresh_inner(
1016        callgraph_dir,
1017        project_root,
1018        paths,
1019        pending_sink,
1020        Vec::new(),
1021        None,
1022    )
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1027    callgraph_dir: PathBuf,
1028    project_root: PathBuf,
1029    paths: Vec<PathBuf>,
1030    pending_sink: PendingCallGraphStorePaths,
1031    ticket: CallgraphRefreshTicket,
1032) -> bool {
1033    enqueue_callgraph_store_refresh_inner(
1034        callgraph_dir,
1035        project_root,
1036        paths,
1037        pending_sink,
1038        Vec::new(),
1039        Some(ticket),
1040    )
1041}
1042
1043pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1044    callgraph_dir: PathBuf,
1045    project_root: PathBuf,
1046    paths: Vec<PathBuf>,
1047    pending_sink: PendingCallGraphStorePaths,
1048    refresh_state: CallgraphRefreshState,
1049    ticket: CallgraphRefreshTicket,
1050) -> bool {
1051    enqueue_callgraph_store_refresh_inner(
1052        callgraph_dir,
1053        project_root,
1054        paths,
1055        pending_sink,
1056        vec![refresh_state],
1057        Some(ticket),
1058    )
1059}
1060
1061fn enqueue_callgraph_store_refresh_inner(
1062    callgraph_dir: PathBuf,
1063    project_root: PathBuf,
1064    paths: Vec<PathBuf>,
1065    pending_sink: PendingCallGraphStorePaths,
1066    refresh_states: Vec<CallgraphRefreshState>,
1067    ticket: Option<CallgraphRefreshTicket>,
1068) -> bool {
1069    if paths.is_empty() {
1070        return true;
1071    }
1072    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1073    let worker = {
1074        let mut worker = slot
1075            .lock()
1076            .expect("callgraph refresh worker mutex poisoned");
1077        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1078    };
1079    worker.enqueue(
1080        RefreshRoot {
1081            callgraph_dir,
1082            project_root,
1083        },
1084        paths,
1085        pending_sink,
1086        refresh_states,
1087        ticket,
1088    )
1089}
1090
1091pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1092    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1093}
1094
1095#[doc(hidden)]
1096pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1097    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1098    let worker = slot
1099        .lock()
1100        .expect("callgraph refresh worker mutex poisoned")
1101        .clone();
1102    let Some(worker) = worker else {
1103        return true;
1104    };
1105    let drained = worker.shutdown_with_budget(budget);
1106    if drained {
1107        let mut current = slot
1108            .lock()
1109            .expect("callgraph refresh worker mutex poisoned");
1110        if current
1111            .as_ref()
1112            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1113        {
1114            *current = None;
1115        }
1116    }
1117    drained
1118}
1119
1120fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1121    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1122}
1123
1124fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1125    // The worker owns these caches so maps are shared only by refreshes for the
1126    // same canonical root and disappear when the worker shuts down.
1127    let mut workspace_crate_prefixes = HashMap::new();
1128    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1129    loop {
1130        let batch = {
1131            let mut queue = shared
1132                .queue
1133                .lock()
1134                .expect("callgraph refresh queue mutex poisoned");
1135            loop {
1136                if let Some(root) = queue.order.pop_front() {
1137                    let batch = queue
1138                        .queued
1139                        .remove(&root)
1140                        .expect("queued callgraph refresh root has a batch");
1141                    queue.active = Some(batch.clone());
1142                    break batch;
1143                }
1144                if queue.shutdown_requested {
1145                    return;
1146                }
1147                queue = shared
1148                    .wake
1149                    .wait(queue)
1150                    .expect("callgraph refresh queue mutex poisoned while waiting");
1151            }
1152        };
1153
1154        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1155
1156        let mut queue = shared
1157            .queue
1158            .lock()
1159            .expect("callgraph refresh queue mutex poisoned");
1160        queue.active = None;
1161        let became_idle = queue.order.is_empty();
1162        shared.wake.notify_all();
1163        drop(queue);
1164
1165        if became_idle {
1166            let checkpoint_due = idle_checkpoint_due(
1167                last_idle_checkpoints.get(&batch.root).copied(),
1168                Instant::now(),
1169            );
1170            if checkpoint_due {
1171                if let Some(store) = store {
1172                    if store.checkpoint_wal_truncate() {
1173                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1174                    }
1175                }
1176            }
1177        }
1178    }
1179}
1180
1181fn process_callgraph_refresh_batch(
1182    batch: &RefreshBatch,
1183    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1184) -> Option<CallGraphStore> {
1185    // A manifest event is an invalidation signal, not a source file to parse.
1186    // Drop the root's map even for a superseded batch: the filesystem changed,
1187    // and a later configure must never inherit crate membership from before it.
1188    if batch
1189        .paths
1190        .iter()
1191        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1192    {
1193        workspace_crate_prefixes.remove(&batch.root);
1194    }
1195
1196    let paths = batch
1197        .paths
1198        .iter()
1199        .filter(|path| crate::parser::detect_language(path).is_some())
1200        .cloned()
1201        .collect::<Vec<_>>();
1202    if paths.is_empty() {
1203        return None;
1204    }
1205    note_refresh_worker_batch_for_test(&batch.root.project_root);
1206    if batch
1207        .ticket
1208        .as_ref()
1209        .is_some_and(|ticket| !ticket.is_current())
1210    {
1211        // Superseded before starting: park the paths so the next configure's
1212        // pending replay (or unbind cleanup) decides their fate.
1213        batch.defer();
1214        return None;
1215    }
1216    let workspace_crate_prefix_cache =
1217        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1218    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1219    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1220    note_refresh_worker_call_for_test(&batch.root.project_root);
1221    let opened = if test_seam.fail_open {
1222        Ok(None)
1223    } else {
1224        CallGraphStore::open_ready(
1225            batch.root.callgraph_dir.clone(),
1226            batch.root.project_root.clone(),
1227        )
1228    };
1229    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1230        // The gate is deliberately after open_ready so tests can hold a failed
1231        // open between its result and the defer that parks the batch.
1232        let _ = gate.held_tx.send(());
1233        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1234    }
1235    let store = match opened {
1236        Ok(Some(store)) => store,
1237        Ok(None) => {
1238            batch.defer_after_open_failure();
1239            return None;
1240        }
1241        Err(error) => {
1242            batch.defer_after_open_failure();
1243            crate::slog_warn!(
1244                "callgraph store writer open failed during refresh; deferred paths: {}",
1245                error
1246            );
1247            return None;
1248        }
1249    };
1250    if !test_seam.delay.is_zero() {
1251        std::thread::sleep(test_seam.delay);
1252    }
1253    if batch
1254        .ticket
1255        .as_ref()
1256        .is_some_and(|ticket| !ticket.is_current())
1257    {
1258        // This is a superseded-ticket defer, not an open-failure defer: leave
1259        // the paths for the replacement configure instead of self-replaying.
1260        batch.defer();
1261        return Some(store);
1262    }
1263    let refresh_result = if test_seam.fail_refresh {
1264        Err(CallGraphStoreError::Unavailable(
1265            "injected refresh worker failure".to_string(),
1266        ))
1267    } else if let Some(ticket) = &batch.ticket {
1268        with_publish_epoch(
1269            ticket.publish_epoch.clone(),
1270            ticket.expected_publish_epoch,
1271            || {
1272                with_refresh_commit_admission(
1273                    ticket.lifecycle.clone(),
1274                    Arc::clone(&ticket.generation_flag),
1275                    ticket.expected_generation,
1276                    || {
1277                        store
1278                            .refresh_files_with_workspace_crate_prefix_cache(
1279                                &paths,
1280                                workspace_crate_prefix_cache.clone(),
1281                            )
1282                            .map(|_| ())
1283                    },
1284                )
1285            },
1286        )
1287    } else {
1288        store
1289            .refresh_files_with_workspace_crate_prefix_cache(
1290                &paths,
1291                workspace_crate_prefix_cache.clone(),
1292            )
1293            .map(|_| ())
1294    };
1295    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1296        // The commit lost the fence race: a newer configure or publication
1297        // owns the store now. Defer instead of stale-marking — the paths were
1298        // never committed, and the replacement generation re-indexes them.
1299        batch.defer();
1300        return Some(store);
1301    }
1302    if let Err(error) = refresh_result {
1303        crate::slog_warn!("callgraph store refresh failed: {}", error);
1304        match store.mark_files_stale(&paths) {
1305            Ok(marked) => {
1306                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1307                crate::slog_warn!(
1308                    "marked {} callgraph store file(s) stale after refresh failure",
1309                    marked.len()
1310                );
1311            }
1312            Err(mark_error) => crate::slog_warn!(
1313                "failed to mark callgraph store files stale after refresh failure: {}",
1314                mark_error
1315            ),
1316        }
1317    } else {
1318        crate::logging::note_callgraph_invalidations(paths.len());
1319    }
1320    Some(store)
1321}
1322
1323fn workspace_crate_prefix_cache_for_root(
1324    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1325    root: &RefreshRoot,
1326) -> WorkspaceCratePrefixCache {
1327    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1328        // Eviction only costs a future rebuild; it cannot make resolution stale.
1329        if let Some(evicted) = caches.keys().next().cloned() {
1330            caches.remove(&evicted);
1331        }
1332    }
1333    caches.entry(root.clone()).or_default().clone()
1334}
1335
1336#[derive(Clone, Copy, Default)]
1337struct RefreshWorkerTestSeam {
1338    delay: Duration,
1339    fail_refresh: bool,
1340    fail_open: bool,
1341    refresh_calls: usize,
1342    worker_calls: usize,
1343    stale_marks: usize,
1344}
1345
1346static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1347    OnceLock::new();
1348
1349struct RefreshWorkerTestGate {
1350    held_tx: crossbeam_channel::Sender<()>,
1351    release_rx: crossbeam_channel::Receiver<()>,
1352}
1353
1354static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1355    OnceLock::new();
1356
1357#[doc(hidden)]
1358pub fn install_callgraph_refresh_worker_test_gate(
1359    project_root: PathBuf,
1360) -> (
1361    crossbeam_channel::Receiver<()>,
1362    crossbeam_channel::Sender<()>,
1363) {
1364    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1365    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1366    REFRESH_WORKER_TEST_GATES
1367        .get_or_init(|| Mutex::new(HashMap::new()))
1368        .lock()
1369        .expect("callgraph refresh test gate mutex poisoned")
1370        .insert(
1371            project_root,
1372            RefreshWorkerTestGate {
1373                held_tx,
1374                release_rx,
1375            },
1376        );
1377    (held_rx, release_tx)
1378}
1379
1380fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1381    REFRESH_WORKER_TEST_GATES
1382        .get_or_init(|| Mutex::new(HashMap::new()))
1383        .lock()
1384        .expect("callgraph refresh test gate mutex poisoned")
1385        .remove(project_root)
1386}
1387
1388fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1389    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1390        return RefreshWorkerTestSeam::default();
1391    };
1392    seams
1393        .lock()
1394        .expect("callgraph refresh test seam mutex poisoned")
1395        .get(project_root)
1396        .copied()
1397        .unwrap_or_default()
1398}
1399
1400fn note_refresh_worker_batch_for_test(project_root: &Path) {
1401    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1402        if let Some(seam) = seams
1403            .lock()
1404            .expect("callgraph refresh test seam mutex poisoned")
1405            .get_mut(project_root)
1406        {
1407            seam.worker_calls += 1;
1408        }
1409    }
1410}
1411
1412fn note_refresh_worker_call_for_test(project_root: &Path) {
1413    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1414        if let Some(seam) = seams
1415            .lock()
1416            .expect("callgraph refresh test seam mutex poisoned")
1417            .get_mut(project_root)
1418        {
1419            seam.refresh_calls += 1;
1420        }
1421    }
1422}
1423
1424fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1425    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1426        if let Some(seam) = seams
1427            .lock()
1428            .expect("callgraph refresh test seam mutex poisoned")
1429            .get_mut(project_root)
1430        {
1431            seam.stale_marks += 1;
1432        }
1433    }
1434}
1435
1436#[doc(hidden)]
1437pub fn set_callgraph_refresh_worker_test_seam(
1438    project_root: PathBuf,
1439    delay: Duration,
1440    fail_refresh: bool,
1441) {
1442    REFRESH_WORKER_TEST_SEAMS
1443        .get_or_init(|| Mutex::new(HashMap::new()))
1444        .lock()
1445        .expect("callgraph refresh test seam mutex poisoned")
1446        .insert(
1447            project_root,
1448            RefreshWorkerTestSeam {
1449                delay,
1450                fail_refresh,
1451                ..RefreshWorkerTestSeam::default()
1452            },
1453        );
1454}
1455
1456#[doc(hidden)]
1457pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1458    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1459        if let Some(seam) = seams
1460            .lock()
1461            .expect("callgraph refresh test seam mutex poisoned")
1462            .get_mut(&project_root)
1463        {
1464            seam.fail_open = enabled;
1465        }
1466    }
1467}
1468
1469#[doc(hidden)]
1470pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1471    let seam = refresh_worker_test_seam(project_root);
1472    (seam.refresh_calls, seam.stale_marks)
1473}
1474
1475#[doc(hidden)]
1476pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1477    refresh_worker_test_seam(project_root).worker_calls
1478}
1479
1480#[doc(hidden)]
1481pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1482    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1483        seams
1484            .lock()
1485            .expect("callgraph refresh test seam mutex poisoned")
1486            .remove(project_root);
1487    }
1488}
1489
1490#[derive(Debug)]
1491pub struct CallGraphStore {
1492    project_root: PathBuf,
1493    project_key: String,
1494    /// The concrete on-disk DB file this store opened. With the generation
1495    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1496    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1497    sqlite_path: PathBuf,
1498    /// Root-keyed directory whose pointer controls this store. For a legacy
1499    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1500    /// newly published root-keyed generation invalidates the fallback reader.
1501    publication_dir: PathBuf,
1502    /// True only when the root-keyed read path opened data from a legacy
1503    /// harness partition. Writer-capable callers use this to schedule migration
1504    /// without making read-only/worktree callers acquire a writer lease.
1505    legacy_fallback: bool,
1506    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1507    /// or `None` when it opened the legacy single-file DB. Used to detect when
1508    /// another process has published a newer generation so this process can
1509    /// drop its connection and reopen (see `current_generation`).
1510    generation: Option<String>,
1511    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1512    read_marker: Option<crate::root_cache::ReadMarker>,
1513    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1514    // Failed validations are not cached, so a later successful build remains visible.
1515    database_ready: AtomicBool,
1516    write_metrics: Arc<CallgraphWriteMetrics>,
1517    conn: Mutex<Connection>,
1518}
1519
1520#[derive(Debug)]
1521pub struct ReadonlyCallGraphStore {
1522    inner: CallGraphStore,
1523}
1524
1525pub trait CallGraphRead {
1526    fn project_root(&self) -> &Path;
1527    fn project_key(&self) -> &str;
1528    fn sqlite_path(&self) -> &Path;
1529    fn is_current(&self) -> bool;
1530    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1531    fn indexed_file_count(&self) -> Result<usize>;
1532    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1533    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1534    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1535    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1536    fn direct_callers_for_symbols(
1537        &self,
1538        targets: &[(String, String)],
1539    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1540        targets
1541            .iter()
1542            .cloned()
1543            .map(|target| {
1544                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1545                Ok((target, callers))
1546            })
1547            .collect()
1548    }
1549    fn direct_caller_counts_of(
1550        &self,
1551        targets: &[(String, String)],
1552    ) -> Result<HashMap<(String, String), usize>>;
1553    fn outgoing_calls_for_symbols(
1554        &self,
1555        sources: &[(String, String)],
1556    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1557    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1558        -> Result<StoreCallersResult>;
1559    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1560    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1561    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1562    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1563    fn call_tree(
1564        &self,
1565        file_rel: &Path,
1566        symbol: &str,
1567        depth: usize,
1568    ) -> Result<callgraph::CallTreeNode>;
1569    fn trace_to(
1570        &self,
1571        file_rel: &Path,
1572        symbol: &str,
1573        max_depth: usize,
1574    ) -> Result<callgraph::TraceToResult>;
1575    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1576    fn trace_to_symbol(
1577        &self,
1578        file_rel: &Path,
1579        symbol: &str,
1580        to_symbol: &str,
1581        to_file: Option<&Path>,
1582        max_depth: usize,
1583    ) -> Result<callgraph::TraceToSymbolResult>;
1584}
1585
1586#[derive(Debug, Clone, PartialEq, Eq)]
1587enum OpenRootRepair {
1588    None,
1589    ReRooted,
1590    NeedsRebuild {
1591        previous_roots: Vec<String>,
1592        current_root: String,
1593        reason: String,
1594    },
1595}
1596
1597struct OpenedStore {
1598    store: CallGraphStore,
1599    root_repair: OpenRootRepair,
1600}
1601
1602#[derive(Clone, Debug)]
1603struct LegacyCallgraphPartition {
1604    harness: String,
1605    dir: PathBuf,
1606    key: String,
1607    bytes: u64,
1608    freshness: Option<SystemTime>,
1609}
1610
1611#[derive(Clone, Debug)]
1612struct LegacyCallgraphTarget {
1613    partition: LegacyCallgraphPartition,
1614    sqlite_path: PathBuf,
1615    generation: Option<String>,
1616    source_bytes: u64,
1617    source_blake3: String,
1618}
1619
1620#[derive(Clone, Debug)]
1621struct SourceFingerprint {
1622    bytes: u64,
1623    blake3: String,
1624}
1625
1626#[derive(Clone, Debug)]
1627struct PublishedLegacyMigration {
1628    generation: String,
1629    migrated_bytes: u64,
1630}
1631
1632#[derive(Debug, Clone)]
1633pub struct ColdBuildStats {
1634    pub files: usize,
1635    pub nodes: usize,
1636    pub refs: usize,
1637    pub edges: usize,
1638    pub failed_files: Vec<String>,
1639    pub elapsed_ms: u128,
1640}
1641
1642#[derive(Debug, Clone)]
1643pub struct IncrementalStats {
1644    pub changed_files: Vec<String>,
1645    pub surface_changed: Vec<String>,
1646    pub deleted_files: Vec<String>,
1647    pub dependency_selected_refs: usize,
1648    pub refreshed_own_files: usize,
1649    pub unchanged_extract_files: usize,
1650}
1651
1652/// Phase timings for the copy-based incremental refresh benchmark.
1653#[doc(hidden)]
1654#[derive(Debug, Clone, Default, PartialEq, Eq)]
1655pub struct RefreshFilesProfile {
1656    pub parse: Duration,
1657    pub dependency_selection: Duration,
1658    pub row_deletes: Duration,
1659    pub row_inserts: Duration,
1660    pub dependent_parse: Duration,
1661    pub index_load: Duration,
1662    pub ref_resolution: Duration,
1663    pub method_dispatch: Duration,
1664    pub commit: Duration,
1665    pub total: Duration,
1666}
1667
1668impl RefreshFilesProfile {
1669    pub fn report(&self) -> String {
1670        format!(
1671            "parse={}ms dependency_selection={}ms row_deletes={}ms row_inserts={}ms dependent_parse={}ms index_load={}ms ref_resolution={}ms method_dispatch={}ms commit={}ms total={}ms",
1672            self.parse.as_millis(),
1673            self.dependency_selection.as_millis(),
1674            self.row_deletes.as_millis(),
1675            self.row_inserts.as_millis(),
1676            self.dependent_parse.as_millis(),
1677            self.index_load.as_millis(),
1678            self.ref_resolution.as_millis(),
1679            self.method_dispatch.as_millis(),
1680            self.commit.as_millis(),
1681            self.total.as_millis(),
1682        )
1683    }
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1687pub struct StoredEdge {
1688    pub source_file: String,
1689    pub source_symbol: String,
1690    pub target_file: String,
1691    pub target_symbol: String,
1692    pub kind: String,
1693    pub line: u32,
1694}
1695
1696#[derive(Debug, Clone, PartialEq, Eq)]
1697pub struct StoreNode {
1698    node_id: String,
1699    pub file: String,
1700    pub symbol: String,
1701    pub name: String,
1702    pub kind: String,
1703    pub line: u32,
1704    pub end_line: u32,
1705    pub signature: Option<String>,
1706    pub exported: bool,
1707    pub is_entry_point: bool,
1708    pub lang: LangId,
1709}
1710
1711#[cfg(test)]
1712impl StoreNode {
1713    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1714        Self {
1715            node_id: format!("{file}:{symbol}"),
1716            file: file.to_string(),
1717            symbol: symbol.to_string(),
1718            name: symbol.to_string(),
1719            kind: "function".to_string(),
1720            line: 1,
1721            end_line: 1,
1722            signature: None,
1723            exported: is_entry_point,
1724            is_entry_point,
1725            lang: LangId::TypeScript,
1726        }
1727    }
1728}
1729
1730#[derive(Debug, Clone, PartialEq, Eq)]
1731pub struct StoreCallSite {
1732    pub caller: StoreNode,
1733    pub target_file: String,
1734    pub target_symbol: String,
1735    pub target: Option<StoreNode>,
1736    pub line: u32,
1737    pub byte_start: usize,
1738    pub byte_end: usize,
1739    pub resolved: bool,
1740    pub provenance: String,
1741}
1742
1743impl StoreCallSite {
1744    pub fn approximate(&self) -> bool {
1745        self.provenance == PROVENANCE_NAME_MATCH
1746    }
1747
1748    pub fn resolved_by(&self) -> &str {
1749        &self.provenance
1750    }
1751
1752    pub fn supplemental_resolution(&self) -> Option<&str> {
1753        match self.provenance.as_str() {
1754            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1755            _ => None,
1756        }
1757    }
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq)]
1761pub struct StoreUnresolvedCall {
1762    pub caller: StoreNode,
1763    pub symbol: String,
1764    pub full_ref: Option<String>,
1765    pub line: u32,
1766    pub byte_start: usize,
1767    pub byte_end: usize,
1768}
1769
1770#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct StoreCallersResult {
1772    pub target: StoreNode,
1773    pub callers: Vec<StoreCallSite>,
1774    pub scanned_files: usize,
1775    pub depth_limited: bool,
1776    pub truncated: usize,
1777}
1778
1779#[derive(Debug, Clone, PartialEq, Eq)]
1780pub struct StoreImpactCaller {
1781    pub site: StoreCallSite,
1782    pub signature: Option<String>,
1783    pub is_entry_point: bool,
1784    pub call_expression: Option<String>,
1785    pub parameters: Vec<String>,
1786}
1787
1788#[derive(Debug, Clone, PartialEq, Eq)]
1789pub struct StoreImpactResult {
1790    pub target: StoreNode,
1791    pub parameters: Vec<String>,
1792    pub callers: Vec<StoreImpactCaller>,
1793    pub depth_limited: bool,
1794    pub truncated: usize,
1795}
1796
1797#[derive(Debug, Clone)]
1798struct ExtractFailure {
1799    rel_path: String,
1800    freshness: Option<FileFreshness>,
1801}
1802
1803#[derive(Debug, Clone)]
1804struct BuildExtractsResult {
1805    extracts: Vec<FileExtract>,
1806    failures: Vec<ExtractFailure>,
1807}
1808
1809#[derive(Debug, Clone)]
1810enum StoreForwardCall {
1811    Resolved(StoreCallSite),
1812    Unresolved(StoreUnresolvedCall),
1813}
1814
1815impl StoreForwardCall {
1816    fn byte_start(&self) -> usize {
1817        match self {
1818            Self::Resolved(site) => site.byte_start,
1819            Self::Unresolved(call) => call.byte_start,
1820        }
1821    }
1822
1823    fn line(&self) -> u32 {
1824        match self {
1825            Self::Resolved(site) => site.line,
1826            Self::Unresolved(call) => call.line,
1827        }
1828    }
1829}
1830
1831#[derive(Debug, Clone)]
1832struct FileExtract {
1833    rel_path: String,
1834    freshness: FileFreshness,
1835    lang: LangId,
1836    data: FileCallData,
1837    nodes: Vec<NodeRecord>,
1838    raw_refs: Vec<RawRef>,
1839    dispatch_hints: Vec<DispatchHint>,
1840    surface_fingerprint: String,
1841}
1842
1843#[derive(Debug, Clone)]
1844struct NodeRecord {
1845    id: String,
1846    file_path: String,
1847    name: String,
1848    scoped_name: String,
1849    kind: String,
1850    range: Range,
1851    range_ordinal: u32,
1852    signature: Option<String>,
1853    exported: bool,
1854    is_default_export: bool,
1855    is_type_like: bool,
1856    is_callgraph_entry_point: bool,
1857}
1858
1859#[derive(Debug, Clone)]
1860struct RawRef {
1861    ref_id: String,
1862    caller_node: Option<String>,
1863    caller_symbol: Option<String>,
1864    caller_file: String,
1865    kind: String,
1866    short_name: Option<String>,
1867    full_ref: Option<String>,
1868    module_path: Option<String>,
1869    import_kind: Option<String>,
1870    local_name: Option<String>,
1871    requested_name: Option<String>,
1872    namespace_alias: Option<String>,
1873    wildcard: bool,
1874    line: u32,
1875    byte_start: usize,
1876    byte_end: usize,
1877    dependencies: BTreeSet<String>,
1878}
1879
1880#[derive(Debug, Clone)]
1881struct ResolvedRef {
1882    raw: RawRef,
1883    status: String,
1884    target_node: Option<String>,
1885    target_file: Option<String>,
1886    target_symbol: Option<String>,
1887    dependencies: BTreeSet<String>,
1888    edge: Option<EdgeRecord>,
1889}
1890
1891#[derive(Debug, Clone)]
1892struct EdgeRecord {
1893    edge_id: String,
1894    source_node: String,
1895    target_node: Option<String>,
1896    target_file: String,
1897    target_symbol: String,
1898    kind: String,
1899    line: u32,
1900}
1901
1902#[derive(Debug, Clone)]
1903struct DispatchHint {
1904    id: String,
1905    method_name: String,
1906    caller_node: String,
1907    file: String,
1908    line: u32,
1909    byte_start: usize,
1910    byte_end: usize,
1911}
1912
1913#[derive(Debug, Clone)]
1914struct NameMatchRef {
1915    ref_id: String,
1916    caller_node: String,
1917    caller_file: String,
1918    caller_symbol: String,
1919    caller_signature: Option<String>,
1920    receiver_expression: String,
1921    receiver: String,
1922    method_name: String,
1923    colon_dispatch: bool,
1924    line: u32,
1925    lang: String,
1926}
1927
1928#[derive(Debug, Clone)]
1929struct NameMatchCandidate {
1930    node_id: String,
1931    file_path: String,
1932    scoped_name: String,
1933    kind: String,
1934    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
1935    start_line: u32,
1936}
1937
1938#[derive(Debug, Clone)]
1939struct FileRow {
1940    surface_fingerprint: String,
1941    freshness: FileFreshness,
1942}
1943
1944#[derive(Debug, Clone)]
1945struct DbFileIndex {
1946    lang: Option<LangId>,
1947    exports: HashSet<String>,
1948    default_export: Option<String>,
1949    export_aliases: HashMap<String, String>,
1950    node_by_scoped: HashMap<String, String>,
1951    node_by_bare: HashMap<String, String>,
1952    node_kind_by_id: HashMap<String, String>,
1953    module_targets: HashMap<String, Option<String>>,
1954    reexports: Vec<ReexportIndex>,
1955}
1956
1957#[derive(Debug, Clone)]
1958struct ReexportIndex {
1959    target_file: Option<String>,
1960    named: HashMap<String, String>,
1961    wildcard: bool,
1962}
1963
1964#[derive(Debug, Clone)]
1965struct ProjectIndex<'a> {
1966    project_root: PathBuf,
1967    files: HashMap<String, DbFileIndex>,
1968    caller_data: HashMap<String, &'a FileCallData>,
1969    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1970    /// watcher events replace the cache before another batch can resolve refs.
1971    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1972    /// its own workspace mapping.
1973    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1974}
1975
1976impl ProjectIndex<'_> {
1977    /// Resolve a crate name to its `src` prefix, building the workspace map on
1978    /// first use. Refresh-worker indexes for the same root share this cache.
1979    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1980        self.workspace_crate_prefixes
1981            .0
1982            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1983            .get(crate_name)
1984            .cloned()
1985    }
1986}
1987
1988impl CallGraphStore {
1989    pub fn open_if_enabled(
1990        options: CallGraphStoreOptions,
1991        callgraph_dir: PathBuf,
1992        project_root: PathBuf,
1993    ) -> Result<Option<Self>> {
1994        if !options.enabled {
1995            return Ok(None);
1996        }
1997        Self::open(callgraph_dir, project_root).map(Some)
1998    }
1999
2000    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2001        let project_key = crate::search_index::artifact_cache_key(&project_root);
2002        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2003        else {
2004            return Err(CallGraphStoreError::Unavailable(
2005                "writer capability denied; use the read-only callgraph opener".to_string(),
2006            ));
2007        };
2008        std::fs::create_dir_all(&callgraph_dir)?;
2009        // Resolve the current generation via the pointer (falling back to the
2010        // legacy single-file DB). If nothing is published yet, open the legacy
2011        // path so a brand-new store still gets a writable DB + schema.
2012        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2013            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2014        let OpenedStore { store, root_repair } = Self::open_at_path(
2015            project_root.clone(),
2016            project_key,
2017            sqlite_path,
2018            generation,
2019            true,
2020            Some(Arc::clone(&writer_lease)),
2021            None,
2022        )?;
2023        match root_repair {
2024            OpenRootRepair::NeedsRebuild { .. } => {
2025                log_root_repair_rebuild(&root_repair);
2026                drop(store);
2027                drop(writer_lease);
2028                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2029                let (store, _stats) =
2030                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2031                Ok(store)
2032            }
2033            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2034        }
2035    }
2036
2037    pub fn open_readonly(
2038        callgraph_dir: PathBuf,
2039        project_root: PathBuf,
2040    ) -> Result<Option<ReadonlyCallGraphStore>> {
2041        let project_key = crate::search_index::artifact_cache_key(&project_root);
2042        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2043        {
2044            let conn = open_readonly_connection(&sqlite_path)?;
2045            if !database_ready(&conn).unwrap_or(false) {
2046                return Ok(None);
2047            }
2048            let marker_label = generation.as_deref().unwrap_or("legacy");
2049            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2050            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2051                Self::from_connection(
2052                    project_root,
2053                    project_key,
2054                    sqlite_path,
2055                    callgraph_dir,
2056                    false,
2057                    generation,
2058                    None,
2059                    Some(read_marker),
2060                    conn,
2061                ),
2062            )));
2063        }
2064
2065        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2066            return Ok(None);
2067        };
2068        crate::slog_warn!(
2069            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2070            target.partition.harness,
2071            target.sqlite_path.display()
2072        );
2073        let conn = open_readonly_connection(&target.sqlite_path)?;
2074        if !database_ready(&conn).unwrap_or(false) {
2075            return Ok(None);
2076        }
2077        let marker_label =
2078            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2079        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2080        Ok(Some(ReadonlyCallGraphStore::from_inner(
2081            Self::from_connection(
2082                project_root,
2083                project_key,
2084                target.sqlite_path,
2085                callgraph_dir,
2086                true,
2087                target.generation,
2088                None,
2089                Some(read_marker),
2090                conn,
2091            ),
2092        )))
2093    }
2094
2095    /// Open the currently-published ready store with write access so moved-root
2096    /// metadata can be repaired before projection readers consume it. Unlike
2097    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2098    /// ready generation exists, it returns `Ok(None)` instead of creating an
2099    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2100    pub fn open_ready_repairing(
2101        callgraph_dir: PathBuf,
2102        project_root: PathBuf,
2103    ) -> Result<Option<Self>> {
2104        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2105    }
2106
2107    /// Open a ready store for bounded maintenance work without repairing root
2108    /// metadata or starting a cold rebuild. A store that needs either action is
2109    /// reported as unavailable so a background build can own that work.
2110    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2111        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2112    }
2113
2114    pub fn open_ready_no_rebuild(
2115        callgraph_dir: PathBuf,
2116        project_root: PathBuf,
2117    ) -> Result<Option<Self>> {
2118        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2119    }
2120
2121    fn open_ready_with_rebuild_policy(
2122        callgraph_dir: PathBuf,
2123        project_root: PathBuf,
2124        allow_cold_build: bool,
2125        allow_root_repair: bool,
2126    ) -> Result<Option<Self>> {
2127        let project_key = crate::search_index::artifact_cache_key(&project_root);
2128        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2129        else {
2130            return Ok(None);
2131        };
2132        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2133        else {
2134            return Ok(None);
2135        };
2136        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2137            project_root.clone(),
2138            project_key.clone(),
2139            sqlite_path,
2140            generation,
2141            true,
2142            Some(Arc::clone(&writer_lease)),
2143            None,
2144            allow_root_repair,
2145        )?;
2146        match root_repair {
2147            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2148                log_root_repair_rebuild(&root_repair);
2149                drop(store);
2150                drop(writer_lease);
2151                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2152                let (store, _stats) =
2153                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2154                Ok(Some(store))
2155            }
2156            OpenRootRepair::NeedsRebuild { .. } => {
2157                if let Some(message) = note_repair_entry(&project_key) {
2158                    crate::slog_warn!("{message}");
2159                }
2160                Ok(None)
2161            }
2162            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2163        }
2164    }
2165
2166    pub fn cold_build_with_lease(
2167        callgraph_dir: PathBuf,
2168        project_root: PathBuf,
2169        files: &[PathBuf],
2170    ) -> Result<(Self, ColdBuildStats)> {
2171        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2172    }
2173
2174    pub fn cold_build_with_lease_chunked(
2175        callgraph_dir: PathBuf,
2176        project_root: PathBuf,
2177        files: &[PathBuf],
2178        chunk_size: usize,
2179    ) -> Result<(Self, ColdBuildStats)> {
2180        Self::cold_build_with_lease_chunked_inner(
2181            callgraph_dir,
2182            project_root,
2183            files,
2184            chunk_size,
2185            false,
2186        )
2187    }
2188
2189    pub(crate) fn force_cold_build_with_lease_chunked(
2190        callgraph_dir: PathBuf,
2191        project_root: PathBuf,
2192        files: &[PathBuf],
2193        chunk_size: usize,
2194    ) -> Result<(Self, ColdBuildStats)> {
2195        Self::cold_build_with_lease_chunked_inner(
2196            callgraph_dir,
2197            project_root,
2198            files,
2199            chunk_size,
2200            true,
2201        )
2202    }
2203
2204    fn cold_build_with_lease_chunked_inner(
2205        callgraph_dir: PathBuf,
2206        project_root: PathBuf,
2207        files: &[PathBuf],
2208        chunk_size: usize,
2209        require_new_publication: bool,
2210    ) -> Result<(Self, ColdBuildStats)> {
2211        let project_key = crate::search_index::artifact_cache_key(&project_root);
2212        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2213        else {
2214            let operation = if require_new_publication {
2215                "forced rebuild"
2216            } else {
2217                "cold build"
2218            };
2219            return Err(CallGraphStoreError::Unavailable(format!(
2220                "{operation} could not acquire writer capability"
2221            )));
2222        };
2223        std::fs::create_dir_all(&callgraph_dir)?;
2224        let (stats, generation) = Self::cold_build_publish_locked(
2225            &callgraph_dir,
2226            &project_root,
2227            &project_key,
2228            files,
2229            chunk_size,
2230            Arc::clone(&writer_lease),
2231        )?;
2232        let store = Self::open_generation(
2233            &callgraph_dir,
2234            project_root,
2235            project_key,
2236            generation,
2237            writer_lease,
2238        )?;
2239        Ok((store, stats))
2240    }
2241
2242    pub fn ensure_built_with_lease(
2243        callgraph_dir: PathBuf,
2244        project_root: PathBuf,
2245        files: &[PathBuf],
2246    ) -> Result<(Self, Option<ColdBuildStats>)> {
2247        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2248    }
2249
2250    pub fn ensure_built_with_lease_chunked(
2251        callgraph_dir: PathBuf,
2252        project_root: PathBuf,
2253        files: &[PathBuf],
2254        chunk_size: usize,
2255    ) -> Result<(Self, Option<ColdBuildStats>)> {
2256        let project_key = crate::search_index::artifact_cache_key(&project_root);
2257        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2258        else {
2259            return Err(CallGraphStoreError::Unavailable(
2260                "callgraph ensure could not acquire writer capability".to_string(),
2261            ));
2262        };
2263        std::fs::create_dir_all(&callgraph_dir)?;
2264        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2265        // Another process may have published a ready generation while we waited
2266        // for the lock — open it instead of rebuilding. If that generation is
2267        // from this same project at an older filesystem root, repair the root
2268        // metadata in-place while still holding the build lease. If data rows
2269        // contain absolute paths, publish a fresh generation under this lease
2270        // rather than recursively reacquiring the same lock.
2271        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2272        {
2273            let OpenedStore { store, root_repair } = Self::open_at_path(
2274                project_root.clone(),
2275                project_key.clone(),
2276                sqlite_path,
2277                generation,
2278                true,
2279                Some(Arc::clone(&writer_lease)),
2280                None,
2281            )?;
2282            match root_repair {
2283                OpenRootRepair::NeedsRebuild { .. } => {
2284                    log_root_repair_rebuild(&root_repair);
2285                    drop(store);
2286                    let (stats, generation) = Self::cold_build_publish_locked(
2287                        &callgraph_dir,
2288                        &project_root,
2289                        &project_key,
2290                        files,
2291                        chunk_size,
2292                        Arc::clone(&writer_lease),
2293                    )?;
2294                    let store = Self::open_generation(
2295                        &callgraph_dir,
2296                        project_root,
2297                        project_key,
2298                        generation,
2299                        writer_lease,
2300                    )?;
2301                    return Ok((store, Some(stats)));
2302                }
2303                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2304                    return Ok((store, None));
2305                }
2306            }
2307        }
2308        if let Some(store) = try_legacy_migration_or_fallback(
2309            &callgraph_dir,
2310            &project_root,
2311            &project_key,
2312            Arc::clone(&writer_lease),
2313        )? {
2314            return Ok((store, None));
2315        }
2316        let (stats, generation) = Self::cold_build_publish_locked(
2317            &callgraph_dir,
2318            &project_root,
2319            &project_key,
2320            files,
2321            chunk_size,
2322            Arc::clone(&writer_lease),
2323        )?;
2324        let store = Self::open_generation(
2325            &callgraph_dir,
2326            project_root,
2327            project_key,
2328            generation,
2329            writer_lease,
2330        )?;
2331        Ok((store, Some(stats)))
2332    }
2333
2334    /// Migrate a legacy harness-partition store without falling through to a
2335    /// cold build. This is used after a query has already opened a read-only
2336    /// fallback: the caller runs it on the same limited background lane as cold
2337    /// builds while queries continue using that fallback. Public so crash/retry
2338    /// tests can drive the migration synchronously on a thread where the
2339    /// thread-local failure seams apply.
2340    pub fn migrate_legacy_with_lease(
2341        callgraph_dir: PathBuf,
2342        project_root: PathBuf,
2343    ) -> Result<Option<Self>> {
2344        let project_key = crate::search_index::artifact_cache_key(&project_root);
2345        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2346        else {
2347            return Ok(None);
2348        };
2349        std::fs::create_dir_all(&callgraph_dir)?;
2350        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2351
2352        // Another writer may have completed the migration while this worker was
2353        // waiting for the lease. Adopt its root-keyed generation rather than
2354        // copying the legacy source a second time.
2355        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2356        {
2357            let OpenedStore { store, root_repair } = Self::open_at_path(
2358                project_root,
2359                project_key,
2360                sqlite_path,
2361                generation,
2362                true,
2363                Some(writer_lease),
2364                None,
2365            )?;
2366            return match root_repair {
2367                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2368                OpenRootRepair::NeedsRebuild { reason, .. } => {
2369                    Err(CallGraphStoreError::Unavailable(format!(
2370                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2371                    )))
2372                }
2373            };
2374        }
2375
2376        let store = try_legacy_migration_or_fallback(
2377            &callgraph_dir,
2378            &project_root,
2379            &project_key,
2380            writer_lease,
2381        )?;
2382        // A disk-floor or backup-budget failure returns a readable legacy store.
2383        // Keep the already-resident fallback instead of sending this duplicate
2384        // reader through the background-install channel.
2385        Ok(store.filter(|store| !store.is_legacy_fallback()))
2386    }
2387
2388    /// Build a fresh DB and publish it as a new generation, then atomically flip
2389    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2390    /// succeeds even when other processes hold an older generation open (the
2391    /// multi-TUI Windows case). The builder owns the temp + generation files
2392    /// exclusively (unique pid+nanos names), so it can rename/replace them
2393    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2394    ///
2395    /// Returns the published generation file name so callers open exactly the
2396    /// generation they built (avoiding a race where a concurrent build's flip
2397    /// would otherwise reopen a different generation).
2398    fn cold_build_publish_locked(
2399        callgraph_dir: &Path,
2400        project_root: &Path,
2401        project_key: &str,
2402        files: &[PathBuf],
2403        chunk_size: usize,
2404        writer_lease: Arc<crate::root_cache::WriterLease>,
2405    ) -> Result<(ColdBuildStats, String)> {
2406        if let Some((previous_root, remaining)) =
2407            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2408        {
2409            return Err(CallGraphStoreError::Unavailable(format!(
2410                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2411                previous_root.display(),
2412                remaining.as_millis()
2413            )));
2414        }
2415        let generation = generation_file_name(project_key);
2416        let gen_path = callgraph_dir.join(&generation);
2417        let temp_path = callgraph_dir.join(format!(
2418            "{generation}.tmp.{}.{}",
2419            std::process::id(),
2420            now_nanos()
2421        ));
2422        remove_sqlite_file_set(&temp_path);
2423
2424        let stats = {
2425            let temp_store = Self::open_at_path(
2426                project_root.to_path_buf(),
2427                project_key.to_string(),
2428                temp_path.clone(),
2429                None,
2430                false,
2431                Some(Arc::clone(&writer_lease)),
2432                None,
2433            )?
2434            .store;
2435            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2436            let _ = temp_store.checkpoint_wal_truncate();
2437            temp_store.prepare_for_atomic_swap()?;
2438            stats
2439        };
2440
2441        notify_cold_build_before_publish_observer();
2442        let publication = publish_if_current(|| {
2443            verify_writer_lease(&writer_lease)?;
2444            // Move the finished build to its final generation path. This target is
2445            // brand-new and owned by us, so the rename never hits an open file.
2446            remove_sqlite_file_set(&gen_path);
2447            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2448            crate::fs_lock::sync_parent(&gen_path);
2449            remove_sqlite_sidecars(&gen_path);
2450
2451            notify_cold_build_swap_observer(&temp_path, &gen_path);
2452
2453            // Atomically publish the new generation, then best-effort GC old ones.
2454            verify_writer_lease(&writer_lease)?;
2455            publish_pointer(callgraph_dir, project_key, &generation)?;
2456            gc_old_generations(callgraph_dir, project_key, &generation);
2457            // Store-wide orphan sweep on the same cadence: reclaims aged build
2458            // temps for roots that no longer build here, which the per-root GC
2459            // above never reaches.
2460            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2461            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2462                let inspect_root =
2463                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2464                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2465                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2466            }
2467            Ok(())
2468        });
2469        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2470            remove_sqlite_file_set(&temp_path);
2471        }
2472        publication?;
2473        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2474        Ok((stats, generation))
2475    }
2476
2477    /// Open a specific just-published generation (read-write, WAL) so a builder
2478    /// returns a store pinned to exactly what it built.
2479    fn open_generation(
2480        callgraph_dir: &Path,
2481        project_root: PathBuf,
2482        project_key: String,
2483        generation: String,
2484        writer_lease: Arc<crate::root_cache::WriterLease>,
2485    ) -> Result<Self> {
2486        let gen_path = callgraph_dir.join(&generation);
2487        Ok(Self::open_at_path(
2488            project_root,
2489            project_key,
2490            gen_path,
2491            Some(generation),
2492            true,
2493            Some(writer_lease),
2494            None,
2495        )?
2496        .store)
2497    }
2498
2499    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2500        let project_key = crate::search_index::artifact_cache_key(project_root);
2501        // A cold build is needed unless a ready generation (or ready legacy DB)
2502        // is currently published.
2503        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2504    }
2505
2506    fn open_at_path(
2507        project_root: PathBuf,
2508        project_key: String,
2509        sqlite_path: PathBuf,
2510        generation: Option<String>,
2511        use_wal: bool,
2512        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2513        read_marker: Option<crate::root_cache::ReadMarker>,
2514    ) -> Result<OpenedStore> {
2515        Self::open_at_path_with_root_repair(
2516            project_root,
2517            project_key,
2518            sqlite_path,
2519            generation,
2520            use_wal,
2521            writer_lease,
2522            read_marker,
2523            true,
2524        )
2525    }
2526
2527    fn open_at_path_with_root_repair(
2528        project_root: PathBuf,
2529        project_key: String,
2530        sqlite_path: PathBuf,
2531        generation: Option<String>,
2532        use_wal: bool,
2533        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2534        read_marker: Option<crate::root_cache::ReadMarker>,
2535        allow_root_repair: bool,
2536    ) -> Result<OpenedStore> {
2537        if let Some(lease) = writer_lease.as_ref() {
2538            verify_writer_lease(lease)?;
2539        }
2540        if let Some(parent) = sqlite_path.parent() {
2541            std::fs::create_dir_all(parent)?;
2542        }
2543        let mut conn = Connection::open(&sqlite_path)?;
2544        if use_wal {
2545            configure_connection(&conn)?;
2546        } else {
2547            configure_build_connection(&conn)?;
2548        }
2549        if let Some(lease) = writer_lease.as_ref() {
2550            verify_writer_lease(lease)?;
2551        }
2552        initialize_schema(&conn)?;
2553        if let Some(lease) = writer_lease.as_ref() {
2554            verify_writer_lease(lease)?;
2555        }
2556        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2557        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2558            (Some(marker), _, _) => Some(marker),
2559            (None, Some(label), Some(cache_dir)) => {
2560                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2561            }
2562            (None, _, _) => None,
2563        };
2564        let publication_dir = sqlite_path
2565            .parent()
2566            .map(Path::to_path_buf)
2567            .unwrap_or_default();
2568        let store = Self::from_connection(
2569            project_root,
2570            project_key,
2571            sqlite_path,
2572            publication_dir,
2573            false,
2574            generation,
2575            writer_lease,
2576            read_marker,
2577            conn,
2578        );
2579        Ok(OpenedStore { store, root_repair })
2580    }
2581
2582    fn prepare_for_atomic_swap(&self) -> Result<()> {
2583        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2584        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2585        Ok(())
2586    }
2587
2588    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2589        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2590            self.sqlite_path
2591                .parent()
2592                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2593        });
2594        if protected_reader {
2595            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2596        } else {
2597            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2598        }
2599    }
2600
2601    fn from_connection(
2602        project_root: PathBuf,
2603        project_key: String,
2604        sqlite_path: PathBuf,
2605        publication_dir: PathBuf,
2606        legacy_fallback: bool,
2607        generation: Option<String>,
2608        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2609        read_marker: Option<crate::root_cache::ReadMarker>,
2610        conn: Connection,
2611    ) -> Self {
2612        let write_metrics = callgraph_write_metrics_for_key(&project_key);
2613        Self {
2614            project_root,
2615            project_key,
2616            sqlite_path,
2617            publication_dir,
2618            legacy_fallback,
2619            generation,
2620            writer_lease,
2621            read_marker,
2622            database_ready: AtomicBool::new(false),
2623            write_metrics,
2624            conn: Mutex::new(conn),
2625        }
2626    }
2627
2628    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2629        if self.database_ready.load(AtomicOrdering::Acquire) {
2630            return Ok(());
2631        }
2632        ensure_database_ready(conn)?;
2633        self.database_ready.store(true, AtomicOrdering::Release);
2634        Ok(())
2635    }
2636
2637    pub fn project_root(&self) -> &Path {
2638        &self.project_root
2639    }
2640
2641    pub fn project_key(&self) -> &str {
2642        &self.project_key
2643    }
2644
2645    pub fn sqlite_path(&self) -> &Path {
2646        &self.sqlite_path
2647    }
2648
2649    /// The generation file named by the publication pointer when this store opened.
2650    pub(crate) fn projection_generation(&self) -> Option<&str> {
2651        self.generation.as_deref()
2652    }
2653
2654    /// Read the durable revision that changes in the same transaction as graph writes.
2655    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
2656        self.refresh_read_marker()?;
2657        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2658        self.ensure_ready(&conn)?;
2659        projection_write_revision(&conn)
2660    }
2661
2662    /// Whether this store is reading from a legacy harness partition because
2663    /// the root-keyed store has not published a generation yet.
2664    pub fn is_legacy_fallback(&self) -> bool {
2665        self.legacy_fallback
2666    }
2667
2668    pub(crate) fn is_legacy_migration(&self) -> bool {
2669        self.generation.as_deref().is_some_and(|generation| {
2670            migration_generation_requires_manifest(generation)
2671                && migration_manifest_valid(&self.publication_dir, generation)
2672        })
2673    }
2674
2675    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2676        self.writer_lease.as_ref().map(|lease| lease.epoch())
2677    }
2678
2679    fn verify_writer_lease(&self) -> Result<()> {
2680        let Some(lease) = self.writer_lease.as_ref() else {
2681            return Err(CallGraphStoreError::Unavailable(
2682                "callgraph store opened read-only; write API is unavailable".to_string(),
2683            ));
2684        };
2685        verify_writer_lease(lease)
2686    }
2687
2688    fn refresh_read_marker(&self) -> Result<()> {
2689        if let Some(marker) = self.read_marker.as_ref() {
2690            marker.touch_if_due()?;
2691        }
2692        Ok(())
2693    }
2694
2695    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2696        self.write_metrics
2697            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2698    }
2699
2700    fn checkpoint_wal_truncate(&self) -> bool {
2701        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2702        checkpoint_wal_truncate(&conn)
2703    }
2704
2705    /// True if this store still reflects the currently-published generation.
2706    /// Cheap (one small pointer-file read). When false, another process (or a
2707    /// local cold rebuild) has published a newer generation and the holder
2708    /// should drop this store and reopen via the pointer to converge. A missing
2709    /// pointer keeps the current store (legacy DB still valid, or transient).
2710    pub fn is_current(&self) -> bool {
2711        let _ = self.refresh_read_marker();
2712        match (
2713            read_pointer(&self.publication_dir, &self.project_key),
2714            &self.generation,
2715        ) {
2716            // Even when both generations happen to have the same filename, the
2717            // root-keyed pointer names a different directory from the fallback.
2718            (Some(_), _) if self.legacy_fallback => false,
2719            (Some(published), Some(opened)) => &published == opened,
2720            // A generation now supersedes the legacy single-file DB we opened.
2721            (Some(_), None) => false,
2722            // No pointer: keep serving (legacy DB, or an anomalous pointer
2723            // removal where our open generation file is still valid).
2724            (None, _) => true,
2725        }
2726    }
2727
2728    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2729        self.cold_build_chunked(files, 0)
2730    }
2731
2732    pub fn cold_build_chunked(
2733        &self,
2734        files: &[PathBuf],
2735        chunk_size: usize,
2736    ) -> Result<ColdBuildStats> {
2737        let started = Instant::now();
2738        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2739        macro_rules! phase {
2740            ($label:expr, $t:expr) => {
2741                if bench {
2742                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2743                    let _ = std::io::Write::flush(&mut std::io::stderr());
2744                }
2745            };
2746        }
2747        let files = normalize_file_list(&self.project_root, files)?;
2748
2749        if chunk_size == 0 {
2750            let t = Instant::now();
2751            let build = build_extracts_parallel(&self.project_root, &files);
2752            phase!("extract_parallel", t);
2753            let extracts = build.extracts;
2754            let failures = build.failures;
2755            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2756
2757            let t = Instant::now();
2758            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2759            phase!("build_index", t);
2760            let t = Instant::now();
2761            let mut resolved_refs = Vec::new();
2762            for extract in &extracts {
2763                for raw_ref in &extract.raw_refs {
2764                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2765                }
2766            }
2767            phase!("resolve_refs", t);
2768            let ref_count = resolved_refs.len();
2769            let edge_count = resolved_refs
2770                .iter()
2771                .filter(|item| item.edge.is_some())
2772                .count();
2773
2774            let t = Instant::now();
2775            self.verify_writer_lease()?;
2776            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2777            let total_changes_before = conn.total_changes();
2778            let tx = conn.transaction()?;
2779            clear_tables(&tx)?;
2780            insert_meta(&tx)?;
2781            drop_cold_build_secondary_indexes(&tx)?;
2782            {
2783                let workspace_root = self.project_root.display().to_string();
2784                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2785                for extract in &extracts {
2786                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2787                }
2788                for failure in &failures {
2789                    insert_backend_state_prepared(
2790                        &mut inserts.backend_state,
2791                        &workspace_root,
2792                        &failure.rel_path,
2793                        failure
2794                            .freshness
2795                            .as_ref()
2796                            .map(|freshness| &freshness.content_hash),
2797                        "stale",
2798                    )?;
2799                }
2800                for resolved in &resolved_refs {
2801                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2802                }
2803            }
2804            create_cold_build_secondary_indexes(&tx)?;
2805            let supplemental_edge_count =
2806                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2807            set_meta_ready(&tx, true)?;
2808            tx.commit()?;
2809            self.record_commit(total_changes_before, &conn);
2810            phase!("sqlite_insert", t);
2811
2812            let elapsed_ms = started.elapsed().as_millis();
2813            crate::slog_info!(
2814                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2815                extracts.len(),
2816                node_count,
2817                ref_count,
2818                edge_count + supplemental_edge_count,
2819                elapsed_ms
2820            );
2821            return Ok(ColdBuildStats {
2822                files: extracts.len(),
2823                nodes: node_count,
2824                refs: ref_count,
2825                edges: edge_count + supplemental_edge_count,
2826                failed_files: failures
2827                    .into_iter()
2828                    .map(|failure| failure.rel_path)
2829                    .collect(),
2830                elapsed_ms,
2831            });
2832        }
2833
2834        // Chunked implementation: parse and resolve in batches to reduce peak
2835        // memory during cold build without changing the persisted graph.
2836        let t = Instant::now();
2837        self.verify_writer_lease()?;
2838        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2839        let total_changes_before = conn.total_changes();
2840        let tx = conn.transaction()?;
2841        clear_tables(&tx)?;
2842        insert_meta(&tx)?;
2843        drop_cold_build_secondary_indexes(&tx)?;
2844
2845        let mut all_raw_refs = Vec::new();
2846        let mut failures = Vec::new();
2847        let mut node_count = 0;
2848        let mut files_parsed = 0;
2849
2850        let mut persistent_call_data = Vec::new();
2851        let mut file_to_call_data_index = HashMap::new();
2852        let mut files_index = HashMap::new();
2853
2854        let workspace_root = self.project_root.display().to_string();
2855
2856        {
2857            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2858            for chunk in files.chunks(chunk_size) {
2859                let build = build_extracts_parallel(&self.project_root, chunk);
2860                failures.extend(build.failures.clone());
2861
2862                for extract in build.extracts {
2863                    files_parsed += 1;
2864                    node_count += extract.nodes.len();
2865                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2866
2867                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2868                    files_index.insert(extract.rel_path.clone(), db_file_index);
2869
2870                    persistent_call_data.push(extract.data);
2871                    let idx = persistent_call_data.len() - 1;
2872                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2873
2874                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2875                }
2876                for failure in &build.failures {
2877                    insert_backend_state_prepared(
2878                        &mut inserts.backend_state,
2879                        &workspace_root,
2880                        &failure.rel_path,
2881                        failure
2882                            .freshness
2883                            .as_ref()
2884                            .map(|freshness| &freshness.content_hash),
2885                        "stale",
2886                    )?;
2887                }
2888            }
2889        }
2890
2891        let mut caller_data = HashMap::new();
2892        for (rel_path, idx) in &file_to_call_data_index {
2893            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2894        }
2895        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2896        let index = ProjectIndex::from_parts(
2897            &self.project_root,
2898            files_index,
2899            caller_data,
2900            WorkspaceCratePrefixCache::default(),
2901        );
2902
2903        let mut resolved_refs = Vec::new();
2904        for (_, raw_refs) in all_raw_refs {
2905            for raw_ref in raw_refs {
2906                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2907            }
2908        }
2909
2910        let ref_count = resolved_refs.len();
2911        let edge_count = resolved_refs
2912            .iter()
2913            .filter(|item| item.edge.is_some())
2914            .count();
2915
2916        {
2917            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2918            for resolved in &resolved_refs {
2919                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2920            }
2921        }
2922        create_cold_build_secondary_indexes(&tx)?;
2923        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2924            &tx,
2925            &self.project_root,
2926            &indexed_caller_files,
2927            chunk_size,
2928        )?;
2929        set_meta_ready(&tx, true)?;
2930        bump_projection_write_revision(&tx)?;
2931        tx.commit()?;
2932        self.record_commit(total_changes_before, &conn);
2933        phase!("sqlite_insert", t);
2934
2935        let elapsed_ms = started.elapsed().as_millis();
2936        crate::slog_info!(
2937            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2938            files_parsed,
2939            node_count,
2940            ref_count,
2941            edge_count + supplemental_edge_count,
2942            elapsed_ms
2943        );
2944        Ok(ColdBuildStats {
2945            files: files_parsed,
2946            nodes: node_count,
2947            refs: ref_count,
2948            edges: edge_count + supplemental_edge_count,
2949            failed_files: failures
2950                .into_iter()
2951                .map(|failure| failure.rel_path)
2952                .collect(),
2953            elapsed_ms,
2954        })
2955    }
2956
2957    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2958        self.refresh_files_with_workspace_crate_prefix_cache(
2959            changed_files,
2960            WorkspaceCratePrefixCache::default(),
2961        )
2962    }
2963
2964    fn refresh_files_with_workspace_crate_prefix_cache(
2965        &self,
2966        changed_files: &[PathBuf],
2967        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2968    ) -> Result<IncrementalStats> {
2969        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2970            changed_files,
2971            workspace_crate_prefixes,
2972        )?;
2973        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2974            eprintln!("refresh_files phases: {}", profile.report());
2975        }
2976        Ok(stats)
2977    }
2978
2979    /// Run an incremental refresh and return phase timings for an offline store copy.
2980    #[doc(hidden)]
2981    pub fn refresh_files_profiled(
2982        &self,
2983        changed_files: &[PathBuf],
2984    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2985        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2986            changed_files,
2987            WorkspaceCratePrefixCache::default(),
2988        )
2989    }
2990
2991    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2992        &self,
2993        changed_files: &[PathBuf],
2994        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2995    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2996        let total_started = Instant::now();
2997        let mut profile = RefreshFilesProfile::default();
2998        self.verify_writer_lease()?;
2999        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3000        ensure_database_ready(&conn)?;
3001        let total_changes_before = conn.total_changes();
3002        let mut changed = Vec::new();
3003        let mut surface_changed = BTreeSet::new();
3004        let mut deleted = BTreeSet::new();
3005        let mut own_refresh = BTreeSet::new();
3006        let mut candidate_own_refresh = BTreeSet::new();
3007        let mut unchanged_extracts = 0usize;
3008        let mut selected_ref_ids = BTreeSet::new();
3009        let mut selected_refs_by_caller = BTreeMap::new();
3010        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3011        let mut fresh_metadata = BTreeMap::new();
3012
3013        for input in changed_files {
3014            let abs_path = normalize_file_path(&self.project_root, input)?;
3015            let rel_path = relative_path(&self.project_root, &abs_path);
3016            changed.push(rel_path.clone());
3017            let old_row = load_file_row(&conn, &rel_path)?;
3018            if !abs_path.exists() {
3019                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3020                    surface_changed.insert(rel_path.clone());
3021                    let started = Instant::now();
3022                    let dependent_refs =
3023                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3024                    profile.dependency_selection += started.elapsed();
3025                    record_dependent_refs(
3026                        &mut selected_ref_ids,
3027                        &mut selected_refs_by_caller,
3028                        dependent_refs,
3029                    );
3030                }
3031                continue;
3032            }
3033
3034            if let Some(row) = &old_row {
3035                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3036                    FreshnessVerdict::HotFresh => continue,
3037                    FreshnessVerdict::ContentFresh {
3038                        new_mtime,
3039                        new_size,
3040                    } => {
3041                        fresh_metadata.insert(
3042                            rel_path.clone(),
3043                            FileFreshness {
3044                                content_hash: row.freshness.content_hash,
3045                                mtime: new_mtime,
3046                                size: new_size,
3047                            },
3048                        );
3049                        continue;
3050                    }
3051                    FreshnessVerdict::Deleted => {
3052                        if deleted.insert(rel_path.clone()) {
3053                            surface_changed.insert(rel_path.clone());
3054                            let started = Instant::now();
3055                            let dependent_refs =
3056                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3057                            profile.dependency_selection += started.elapsed();
3058                            record_dependent_refs(
3059                                &mut selected_ref_ids,
3060                                &mut selected_refs_by_caller,
3061                                dependent_refs,
3062                            );
3063                        }
3064                        continue;
3065                    }
3066                    FreshnessVerdict::Stale => {}
3067                }
3068            }
3069
3070            let started = Instant::now();
3071            let extract = build_file_extract(&self.project_root, &abs_path)?;
3072            profile.parse += started.elapsed();
3073            let surface_is_changed = old_row
3074                .as_ref()
3075                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3076                .unwrap_or(true);
3077            if surface_is_changed {
3078                surface_changed.insert(rel_path.clone());
3079                let started = Instant::now();
3080                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3081                profile.dependency_selection += started.elapsed();
3082                record_dependent_refs(
3083                    &mut selected_ref_ids,
3084                    &mut selected_refs_by_caller,
3085                    dependent_refs,
3086                );
3087            }
3088            candidate_own_refresh.insert(rel_path.clone());
3089            changed_extracts.insert(rel_path, extract);
3090        }
3091
3092        let dependency_selected_refs = selected_ref_ids.len();
3093        let mut touched_callers: BTreeSet<String> =
3094            selected_refs_by_caller.keys().cloned().collect();
3095        touched_callers.extend(candidate_own_refresh.iter().cloned());
3096
3097        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3098        for rel_path in &touched_callers {
3099            if deleted.contains(rel_path) {
3100                continue;
3101            }
3102            if let Some(extract) = changed_extracts.get(rel_path) {
3103                caller_extracts.insert(rel_path.clone(), extract.clone());
3104                continue;
3105            }
3106            let abs_path = self.project_root.join(rel_path);
3107            if abs_path.exists() {
3108                let started = Instant::now();
3109                let extract = build_file_extract(&self.project_root, &abs_path)?;
3110                profile.dependent_parse += started.elapsed();
3111                caller_extracts.insert(rel_path.clone(), extract);
3112            }
3113        }
3114
3115        let tx = conn.transaction()?;
3116        for (rel_path, freshness) in fresh_metadata {
3117            update_file_fresh_metadata(
3118                &tx,
3119                &self.project_root,
3120                &rel_path,
3121                &freshness.content_hash,
3122                freshness.mtime,
3123                freshness.size,
3124            )?;
3125        }
3126        for rel_path in &deleted {
3127            let started = Instant::now();
3128            delete_file_rows(&tx, rel_path)?;
3129            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3130            profile.row_deletes += started.elapsed();
3131        }
3132
3133        let started = Instant::now();
3134        let index = ProjectIndex::from_db_and_callers(
3135            &tx,
3136            &self.project_root,
3137            &caller_extracts,
3138            workspace_crate_prefixes,
3139        )?;
3140        profile.index_load += started.elapsed();
3141
3142        let workspace_root = self.project_root.display().to_string();
3143        {
3144            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3145            for rel_path in &candidate_own_refresh {
3146                let Some(extract) = changed_extracts.get(rel_path) else {
3147                    continue;
3148                };
3149                if !write_amplification_baseline_enabled()
3150                    && stored_extract_matches(&tx, rel_path, extract, &index)?
3151                {
3152                    unchanged_extracts += 1;
3153                    update_file_fresh_metadata(
3154                        &tx,
3155                        &self.project_root,
3156                        rel_path,
3157                        &extract.freshness.content_hash,
3158                        extract.freshness.mtime,
3159                        extract.freshness.size,
3160                    )?;
3161                    continue;
3162                }
3163
3164                own_refresh.insert(rel_path.clone());
3165                let started = Instant::now();
3166                delete_file_rows(&tx, rel_path)?;
3167                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3168                profile.row_deletes += started.elapsed();
3169                let started = Instant::now();
3170                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3171                profile.row_inserts += started.elapsed();
3172            }
3173
3174            let dependency_callers = touched_callers
3175                .iter()
3176                .filter(|rel_path| {
3177                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3178                })
3179                .cloned()
3180                .collect::<Vec<_>>();
3181            for rel_path in dependency_callers {
3182                let Some(extract) = caller_extracts.get(&rel_path) else {
3183                    continue;
3184                };
3185                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3186                    continue;
3187                }
3188
3189                own_refresh.insert(rel_path.clone());
3190                let started = Instant::now();
3191                delete_file_rows(&tx, &rel_path)?;
3192                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3193                profile.row_deletes += started.elapsed();
3194                let started = Instant::now();
3195                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3196                profile.row_inserts += started.elapsed();
3197            }
3198            let started = Instant::now();
3199            for rel_path in &touched_callers {
3200                if deleted.contains(rel_path) {
3201                    continue;
3202                }
3203                let Some(extract) = caller_extracts.get(rel_path) else {
3204                    continue;
3205                };
3206                if own_refresh.contains(rel_path) {
3207                    delete_refs_for_caller(&tx, rel_path)?;
3208                    for raw_ref in &extract.raw_refs {
3209                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3210                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3211                    }
3212                    continue;
3213                }
3214
3215                let selected_for_caller = selected_refs_by_caller
3216                    .get(rel_path)
3217                    .cloned()
3218                    .unwrap_or_default();
3219                delete_ref_ids(&tx, &selected_for_caller)?;
3220                for raw_ref in &extract.raw_refs {
3221                    if selected_for_caller.contains(&raw_ref.ref_id) {
3222                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3223                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3224                    }
3225                }
3226            }
3227            profile.ref_resolution += started.elapsed();
3228        }
3229
3230        let started = Instant::now();
3231        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3232        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3233        profile.method_dispatch += started.elapsed();
3234
3235        bump_projection_write_revision(&tx)?;
3236        let started = Instant::now();
3237        commit_incremental_if_current(tx)?;
3238        self.record_commit(total_changes_before, &conn);
3239        profile.commit += started.elapsed();
3240        profile.total = total_started.elapsed();
3241        Ok((
3242            IncrementalStats {
3243                changed_files: changed,
3244                surface_changed: surface_changed.into_iter().collect(),
3245                deleted_files: deleted.into_iter().collect(),
3246                dependency_selected_refs,
3247                refreshed_own_files: own_refresh.len(),
3248                unchanged_extract_files: unchanged_extracts,
3249            },
3250            profile,
3251        ))
3252    }
3253
3254    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3255        self.cold_build(current_files)
3256    }
3257
3258    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3259        self.verify_writer_lease()?;
3260        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3261        let total_changes_before = conn.total_changes();
3262        let tx = conn.transaction()?;
3263        let mut marked = Vec::new();
3264        for path in files {
3265            let abs_path = normalize_file_path(&self.project_root, path)?;
3266            let rel_path = relative_path(&self.project_root, &abs_path);
3267            let freshness = cache_freshness::collect(&abs_path).ok();
3268            mark_backend_state(
3269                &tx,
3270                &self.project_root,
3271                &rel_path,
3272                freshness.as_ref().map(|freshness| &freshness.content_hash),
3273                "stale",
3274            )?;
3275            marked.push(rel_path);
3276        }
3277        bump_projection_write_revision(&tx)?;
3278        tx.commit()?;
3279        self.record_commit(total_changes_before, &conn);
3280        marked.sort();
3281        marked.dedup();
3282        Ok(marked)
3283    }
3284
3285    pub fn stale_files(&self) -> Result<Vec<String>> {
3286        self.refresh_read_marker()?;
3287        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3288        let mut stmt = conn.prepare(
3289            "SELECT DISTINCT file_path FROM backend_file_state
3290             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3291             ORDER BY file_path",
3292        )?;
3293        let rows = stmt.query_map(
3294            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3295            |row| row.get::<_, String>(0),
3296        )?;
3297        rows.collect::<std::result::Result<Vec<_>, _>>()
3298            .map_err(Into::into)
3299    }
3300
3301    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3302        self.refresh_read_marker()?;
3303        let rel_path = relative_path(
3304            &self.project_root,
3305            &normalize_file_path(&self.project_root, file)?,
3306        );
3307        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3308        conn.query_row(
3309            "SELECT status FROM backend_file_state
3310             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3311             ORDER BY updated_at DESC LIMIT 1",
3312            params![
3313                BACKEND_TREESITTER,
3314                self.project_root.display().to_string(),
3315                rel_path
3316            ],
3317            |row| row.get(0),
3318        )
3319        .optional()
3320        .map_err(Into::into)
3321    }
3322
3323    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3324        self.refresh_read_marker()?;
3325        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3326        self.ensure_ready(&conn)?;
3327        edge_snapshot_with_conn(&conn)
3328    }
3329
3330    pub fn indexed_file_count(&self) -> Result<usize> {
3331        self.refresh_read_marker()?;
3332        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3333        self.ensure_ready(&conn)?;
3334        indexed_file_count(&conn)
3335    }
3336
3337    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3338        self.refresh_read_marker()?;
3339        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3340        let rel_path = relative_path(&self.project_root, &abs_path);
3341        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3342        self.ensure_ready(&conn)?;
3343        resolve_node_for_rel(&conn, &rel_path, symbol)
3344    }
3345
3346    /// Return all positional nodes matching a legacy symbol query in a file.
3347    ///
3348    /// Consumers that need legacy compatibility can collapse these by
3349    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3350    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3351        self.refresh_read_marker()?;
3352        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3353        let rel_path = relative_path(&self.project_root, &abs_path);
3354        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3355        self.ensure_ready(&conn)?;
3356        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3357    }
3358
3359    /// Return all positional nodes matching a symbol query anywhere in the store.
3360    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3361        self.refresh_read_marker()?;
3362        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3363        self.ensure_ready(&conn)?;
3364        nodes_matching_symbol(&conn, symbol)
3365    }
3366
3367    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3368    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3369        self.refresh_read_marker()?;
3370        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3371        let rel_path = relative_path(&self.project_root, &abs_path);
3372        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3373        self.ensure_ready(&conn)?;
3374        direct_callers_for_tuple(&conn, &rel_path, symbol)
3375    }
3376
3377    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
3378    pub fn direct_callers_for_symbols(
3379        &self,
3380        targets: &[(String, String)],
3381    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3382        if targets.is_empty() {
3383            return Ok(HashMap::new());
3384        }
3385        self.refresh_read_marker()?;
3386        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3387        self.ensure_ready(&conn)?;
3388        direct_callers_for_tuples(&conn, targets)
3389    }
3390
3391    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3392    pub fn direct_caller_counts_of(
3393        &self,
3394        targets: &[(String, String)],
3395    ) -> Result<HashMap<(String, String), usize>> {
3396        if targets.is_empty() {
3397            return Ok(HashMap::new());
3398        }
3399        self.refresh_read_marker()?;
3400        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3401        self.ensure_ready(&conn)?;
3402        direct_caller_counts_for_tuples(&conn, targets)
3403    }
3404
3405    pub fn callers_of(
3406        &self,
3407        file_rel: &Path,
3408        symbol: &str,
3409        depth: usize,
3410    ) -> Result<StoreCallersResult> {
3411        let target = self.node_for(file_rel, symbol)?;
3412        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3413        self.ensure_ready(&conn)?;
3414        let effective_depth = depth.max(1);
3415        let mut visited = HashSet::new();
3416        let mut callers = Vec::new();
3417        let mut depth_limited = false;
3418        let mut truncated = 0usize;
3419        collect_callers_recursive(
3420            &conn,
3421            &target.file,
3422            &target.symbol,
3423            effective_depth,
3424            0,
3425            &mut visited,
3426            &mut callers,
3427            &mut depth_limited,
3428            &mut truncated,
3429        )?;
3430        Ok(StoreCallersResult {
3431            target,
3432            callers,
3433            scanned_files: indexed_file_count(&conn)?,
3434            depth_limited,
3435            truncated,
3436        })
3437    }
3438
3439    pub fn impact_of(
3440        &self,
3441        file_rel: &Path,
3442        symbol: &str,
3443        depth: usize,
3444    ) -> Result<StoreImpactResult> {
3445        let callers = self.callers_of(file_rel, symbol, depth)?;
3446        let target_parameters = callers
3447            .target
3448            .signature
3449            .as_deref()
3450            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3451            .unwrap_or_default();
3452        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3453        for site in &callers.callers {
3454            source_lines_by_file
3455                .entry(site.caller.file.clone())
3456                .or_insert_with(|| {
3457                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3458                });
3459        }
3460        let enriched = callers
3461            .callers
3462            .iter()
3463            .map(|site| StoreImpactCaller {
3464                site: site.clone(),
3465                signature: site.caller.signature.clone(),
3466                is_entry_point: site.caller.is_entry_point,
3467                call_expression: source_lines_by_file
3468                    .get(&site.caller.file)
3469                    .and_then(|lines| lines.as_ref())
3470                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3471                    .cloned(),
3472                parameters: site
3473                    .caller
3474                    .signature
3475                    .as_deref()
3476                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3477                    .unwrap_or_default(),
3478            })
3479            .collect();
3480        Ok(StoreImpactResult {
3481            target: callers.target,
3482            parameters: target_parameters,
3483            callers: enriched,
3484            depth_limited: callers.depth_limited,
3485            truncated: callers.truncated,
3486        })
3487    }
3488
3489    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3490        self.refresh_read_marker()?;
3491        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3492        self.ensure_ready(&conn)?;
3493        outgoing_calls_for_node(&conn, node)
3494    }
3495
3496    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3497    pub fn outgoing_calls_for_symbols(
3498        &self,
3499        sources: &[(String, String)],
3500    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3501        if sources.is_empty() {
3502            return Ok(HashMap::new());
3503        }
3504        self.refresh_read_marker()?;
3505        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3506        self.ensure_ready(&conn)?;
3507        outgoing_calls_for_symbol_tuples(&conn, sources)
3508    }
3509
3510    /// Return resolved direct self-call refs suppressed from the general edge table.
3511    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3512        self.refresh_read_marker()?;
3513        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3514        self.ensure_ready(&conn)?;
3515        resolved_self_calls_for_node(&conn, node)
3516    }
3517
3518    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3519        self.refresh_read_marker()?;
3520        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3521        self.ensure_ready(&conn)?;
3522        unresolved_calls_for_node(&conn, node)
3523    }
3524
3525    pub fn call_tree(
3526        &self,
3527        file_rel: &Path,
3528        symbol: &str,
3529        max_depth: usize,
3530    ) -> Result<callgraph::CallTreeNode> {
3531        let node = self.node_for(file_rel, symbol)?;
3532        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3533        self.ensure_ready(&conn)?;
3534        let mut visited = HashSet::new();
3535        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3536    }
3537
3538    pub fn trace_to(
3539        &self,
3540        file_rel: &Path,
3541        symbol: &str,
3542        max_depth: usize,
3543    ) -> Result<callgraph::TraceToResult> {
3544        let target = self.node_for(file_rel, symbol)?;
3545        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3546        self.ensure_ready(&conn)?;
3547        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3548
3549        #[derive(Clone)]
3550        struct PathElem {
3551            node: StoreNode,
3552        }
3553
3554        let initial = vec![PathElem {
3555            node: target.clone(),
3556        }];
3557        let mut complete_paths = Vec::new();
3558        if target.is_entry_point {
3559            complete_paths.push(initial.clone());
3560        }
3561
3562        let mut queue = vec![(initial, 0usize)];
3563        let mut max_depth_reached = false;
3564        let mut truncated_paths = 0usize;
3565
3566        while let Some((path, depth)) = queue.pop() {
3567            if depth >= effective_max {
3568                max_depth_reached = true;
3569                continue;
3570            }
3571            let Some(current) = path.last() else {
3572                continue;
3573            };
3574            let callers =
3575                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3576            if callers.is_empty() {
3577                if path.len() > 1 {
3578                    truncated_paths += 1;
3579                }
3580                continue;
3581            }
3582
3583            let mut has_new_path = false;
3584            for site in callers {
3585                if path.iter().any(|elem| {
3586                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3587                }) {
3588                    continue;
3589                }
3590                has_new_path = true;
3591                let mut new_path = path.clone();
3592                new_path.push(PathElem {
3593                    node: site.caller.clone(),
3594                });
3595                if site.caller.is_entry_point {
3596                    complete_paths.push(new_path.clone());
3597                }
3598                queue.push((new_path, depth + 1));
3599            }
3600            if !has_new_path && path.len() > 1 {
3601                truncated_paths += 1;
3602            }
3603        }
3604
3605        let mut paths: Vec<callgraph::TracePath> = complete_paths
3606            .into_iter()
3607            .map(|mut elems| {
3608                elems.reverse();
3609                let hops = elems
3610                    .iter()
3611                    .enumerate()
3612                    .map(|(index, elem)| callgraph::TraceHop {
3613                        symbol: elem.node.symbol.clone(),
3614                        file: elem.node.file.clone(),
3615                        line: elem.node.line,
3616                        signature: elem.node.signature.clone(),
3617                        is_entry_point: index == 0 && elem.node.is_entry_point,
3618                    })
3619                    .collect();
3620                callgraph::TracePath { hops }
3621            })
3622            .collect();
3623        paths.sort_by(|left, right| {
3624            let left_entry = left
3625                .hops
3626                .first()
3627                .map(|hop| hop.symbol.as_str())
3628                .unwrap_or("");
3629            let right_entry = right
3630                .hops
3631                .first()
3632                .map(|hop| hop.symbol.as_str())
3633                .unwrap_or("");
3634            left_entry
3635                .cmp(right_entry)
3636                .then(left.hops.len().cmp(&right.hops.len()))
3637        });
3638        let entry_points_found = paths
3639            .iter()
3640            .filter_map(|path| path.hops.first())
3641            .filter(|hop| hop.is_entry_point)
3642            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3643            .collect::<HashSet<_>>()
3644            .len();
3645
3646        Ok(callgraph::TraceToResult {
3647            target_symbol: target.symbol,
3648            target_file: target.file,
3649            total_paths: paths.len(),
3650            paths,
3651            entry_points_found,
3652            max_depth_reached,
3653            truncated_paths,
3654        })
3655    }
3656
3657    pub fn trace_to_symbol_candidates(
3658        &self,
3659        to_symbol: &str,
3660    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3661        self.refresh_read_marker()?;
3662        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3663        self.ensure_ready(&conn)?;
3664        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3665        for node in nodes_matching_symbol(&conn, to_symbol)? {
3666            candidates_by_file
3667                .entry(node.file)
3668                .and_modify(|line| *line = (*line).min(node.line))
3669                .or_insert(node.line);
3670        }
3671        let mut candidates: Vec<_> = candidates_by_file
3672            .into_iter()
3673            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3674            .collect();
3675        candidates
3676            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3677        Ok(candidates)
3678    }
3679
3680    pub fn trace_to_symbol(
3681        &self,
3682        file_rel: &Path,
3683        symbol: &str,
3684        to_symbol: &str,
3685        to_file: Option<&Path>,
3686        max_depth: usize,
3687    ) -> Result<callgraph::TraceToSymbolResult> {
3688        let origin = self.node_for(file_rel, symbol)?;
3689        let target_file = to_file
3690            .map(|path| normalize_file_path(&self.project_root, path))
3691            .transpose()?
3692            .map(|path| relative_path(&self.project_root, &path));
3693        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3694        self.ensure_ready(&conn)?;
3695        let effective_max = if max_depth == 0 {
3696            10
3697        } else {
3698            max_depth.min(16)
3699        };
3700
3701        let start_hop = trace_to_symbol_hop(&origin);
3702        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3703            return Ok(callgraph::TraceToSymbolResult {
3704                path: Some(vec![start_hop]),
3705                complete: true,
3706                reason: None,
3707            });
3708        }
3709
3710        let mut queue = VecDeque::new();
3711        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3712        let mut visited = HashSet::new();
3713        visited.insert((origin.file.clone(), origin.symbol.clone()));
3714        let mut max_depth_exhausted = false;
3715
3716        while let Some((current, path, depth)) = queue.pop_front() {
3717            let callees = outgoing_calls_for_node(&conn, &current)?
3718                .into_iter()
3719                .filter_map(|site| site.target)
3720                .collect::<Vec<_>>();
3721
3722            if depth >= effective_max {
3723                if callees
3724                    .iter()
3725                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3726                {
3727                    max_depth_exhausted = true;
3728                }
3729                continue;
3730            }
3731
3732            for callee in callees {
3733                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3734                    continue;
3735                }
3736                let mut next_path = path.clone();
3737                next_path.push(trace_to_symbol_hop(&callee));
3738                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3739                    return Ok(callgraph::TraceToSymbolResult {
3740                        path: Some(next_path),
3741                        complete: true,
3742                        reason: None,
3743                    });
3744                }
3745                queue.push_back((callee, next_path, depth + 1));
3746            }
3747        }
3748
3749        if max_depth_exhausted {
3750            Ok(callgraph::TraceToSymbolResult {
3751                path: None,
3752                complete: false,
3753                reason: Some("max_depth_exhausted".to_string()),
3754            })
3755        } else {
3756            Ok(callgraph::TraceToSymbolResult {
3757                path: None,
3758                complete: true,
3759                reason: Some("no_path_found".to_string()),
3760            })
3761        }
3762    }
3763}
3764
3765impl ReadonlyCallGraphStore {
3766    fn from_inner(inner: CallGraphStore) -> Self {
3767        Self { inner }
3768    }
3769
3770    pub fn project_root(&self) -> &Path {
3771        self.inner.project_root()
3772    }
3773
3774    pub fn project_key(&self) -> &str {
3775        self.inner.project_key()
3776    }
3777
3778    pub fn sqlite_path(&self) -> &Path {
3779        self.inner.sqlite_path()
3780    }
3781
3782    pub(crate) fn projection_generation(&self) -> Option<&str> {
3783        self.inner.projection_generation()
3784    }
3785
3786    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3787        self.inner.projection_write_revision()
3788    }
3789
3790    /// Report the open generation handle. SQLite-owned allocations are measured
3791    /// once by the process-wide SQLite allocator counters.
3792    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3793        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3794    }
3795
3796    /// Whether this reader is temporarily serving a legacy harness partition.
3797    pub fn is_legacy_fallback(&self) -> bool {
3798        self.inner.is_legacy_fallback()
3799    }
3800
3801    pub fn is_current(&self) -> bool {
3802        self.inner.is_current()
3803    }
3804
3805    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3806        self.inner.edge_snapshot()
3807    }
3808
3809    pub fn indexed_file_count(&self) -> Result<usize> {
3810        self.inner.indexed_file_count()
3811    }
3812
3813    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3814        self.inner.node_for(file_rel, symbol)
3815    }
3816
3817    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3818        self.inner.nodes_for(file_rel, symbol)
3819    }
3820
3821    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3822        self.inner.nodes_matching(symbol)
3823    }
3824
3825    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3826        self.inner.direct_callers_of(file_rel, symbol)
3827    }
3828
3829    pub fn direct_callers_for_symbols(
3830        &self,
3831        targets: &[(String, String)],
3832    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3833        self.inner.direct_callers_for_symbols(targets)
3834    }
3835
3836    pub fn direct_caller_counts_of(
3837        &self,
3838        targets: &[(String, String)],
3839    ) -> Result<HashMap<(String, String), usize>> {
3840        self.inner.direct_caller_counts_of(targets)
3841    }
3842
3843    pub fn callers_of(
3844        &self,
3845        file_rel: &Path,
3846        symbol: &str,
3847        depth: usize,
3848    ) -> Result<StoreCallersResult> {
3849        self.inner.callers_of(file_rel, symbol, depth)
3850    }
3851
3852    pub fn impact_of(
3853        &self,
3854        file_rel: &Path,
3855        symbol: &str,
3856        depth: usize,
3857    ) -> Result<StoreImpactResult> {
3858        self.inner.impact_of(file_rel, symbol, depth)
3859    }
3860
3861    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3862        self.inner.outgoing_calls_of(node)
3863    }
3864
3865    pub fn outgoing_calls_for_symbols(
3866        &self,
3867        sources: &[(String, String)],
3868    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3869        self.inner.outgoing_calls_for_symbols(sources)
3870    }
3871
3872    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3873        self.inner.resolved_self_calls_of(node)
3874    }
3875
3876    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3877        self.inner.unresolved_calls_of(node)
3878    }
3879
3880    pub fn call_tree(
3881        &self,
3882        file_rel: &Path,
3883        symbol: &str,
3884        depth: usize,
3885    ) -> Result<callgraph::CallTreeNode> {
3886        self.inner.call_tree(file_rel, symbol, depth)
3887    }
3888
3889    pub fn trace_to(
3890        &self,
3891        file_rel: &Path,
3892        symbol: &str,
3893        max_depth: usize,
3894    ) -> Result<callgraph::TraceToResult> {
3895        self.inner.trace_to(file_rel, symbol, max_depth)
3896    }
3897
3898    pub fn trace_to_symbol_candidates(
3899        &self,
3900        to_symbol: &str,
3901    ) -> Result<Vec<TraceToSymbolCandidate>> {
3902        self.inner.trace_to_symbol_candidates(to_symbol)
3903    }
3904
3905    pub fn trace_to_symbol(
3906        &self,
3907        file_rel: &Path,
3908        symbol: &str,
3909        to_symbol: &str,
3910        to_file: Option<&Path>,
3911        max_depth: usize,
3912    ) -> Result<callgraph::TraceToSymbolResult> {
3913        self.inner
3914            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3915    }
3916}
3917
3918impl CallGraphRead for CallGraphStore {
3919    fn project_root(&self) -> &Path {
3920        CallGraphStore::project_root(self)
3921    }
3922    fn project_key(&self) -> &str {
3923        CallGraphStore::project_key(self)
3924    }
3925    fn sqlite_path(&self) -> &Path {
3926        CallGraphStore::sqlite_path(self)
3927    }
3928    fn is_current(&self) -> bool {
3929        CallGraphStore::is_current(self)
3930    }
3931    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3932        CallGraphStore::edge_snapshot(self)
3933    }
3934    fn indexed_file_count(&self) -> Result<usize> {
3935        CallGraphStore::indexed_file_count(self)
3936    }
3937    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3938        CallGraphStore::node_for(self, file_rel, symbol)
3939    }
3940    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3941        CallGraphStore::nodes_for(self, file_rel, symbol)
3942    }
3943    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3944        CallGraphStore::nodes_matching(self, symbol)
3945    }
3946    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3947        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3948    }
3949    fn direct_callers_for_symbols(
3950        &self,
3951        targets: &[(String, String)],
3952    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3953        CallGraphStore::direct_callers_for_symbols(self, targets)
3954    }
3955    fn direct_caller_counts_of(
3956        &self,
3957        targets: &[(String, String)],
3958    ) -> Result<HashMap<(String, String), usize>> {
3959        CallGraphStore::direct_caller_counts_of(self, targets)
3960    }
3961    fn callers_of(
3962        &self,
3963        file_rel: &Path,
3964        symbol: &str,
3965        depth: usize,
3966    ) -> Result<StoreCallersResult> {
3967        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3968    }
3969    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3970        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3971    }
3972    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3973        CallGraphStore::outgoing_calls_of(self, node)
3974    }
3975    fn outgoing_calls_for_symbols(
3976        &self,
3977        sources: &[(String, String)],
3978    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3979        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3980    }
3981    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3982        CallGraphStore::resolved_self_calls_of(self, node)
3983    }
3984    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3985        CallGraphStore::unresolved_calls_of(self, node)
3986    }
3987    fn call_tree(
3988        &self,
3989        file_rel: &Path,
3990        symbol: &str,
3991        depth: usize,
3992    ) -> Result<callgraph::CallTreeNode> {
3993        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3994    }
3995    fn trace_to(
3996        &self,
3997        file_rel: &Path,
3998        symbol: &str,
3999        max_depth: usize,
4000    ) -> Result<callgraph::TraceToResult> {
4001        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4002    }
4003    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4004        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4005    }
4006    fn trace_to_symbol(
4007        &self,
4008        file_rel: &Path,
4009        symbol: &str,
4010        to_symbol: &str,
4011        to_file: Option<&Path>,
4012        max_depth: usize,
4013    ) -> Result<callgraph::TraceToSymbolResult> {
4014        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4015    }
4016}
4017
4018impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4019    fn project_root(&self) -> &Path {
4020        (**self).project_root()
4021    }
4022    fn project_key(&self) -> &str {
4023        (**self).project_key()
4024    }
4025    fn sqlite_path(&self) -> &Path {
4026        (**self).sqlite_path()
4027    }
4028    fn is_current(&self) -> bool {
4029        (**self).is_current()
4030    }
4031    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4032        (**self).edge_snapshot()
4033    }
4034    fn indexed_file_count(&self) -> Result<usize> {
4035        (**self).indexed_file_count()
4036    }
4037    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4038        (**self).node_for(file_rel, symbol)
4039    }
4040    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4041        (**self).nodes_for(file_rel, symbol)
4042    }
4043    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4044        (**self).nodes_matching(symbol)
4045    }
4046    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4047        (**self).direct_callers_of(file_rel, symbol)
4048    }
4049    fn direct_callers_for_symbols(
4050        &self,
4051        targets: &[(String, String)],
4052    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4053        (**self).direct_callers_for_symbols(targets)
4054    }
4055    fn direct_caller_counts_of(
4056        &self,
4057        targets: &[(String, String)],
4058    ) -> Result<HashMap<(String, String), usize>> {
4059        (**self).direct_caller_counts_of(targets)
4060    }
4061    fn callers_of(
4062        &self,
4063        file_rel: &Path,
4064        symbol: &str,
4065        depth: usize,
4066    ) -> Result<StoreCallersResult> {
4067        (**self).callers_of(file_rel, symbol, depth)
4068    }
4069    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4070        (**self).impact_of(file_rel, symbol, depth)
4071    }
4072    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4073        (**self).outgoing_calls_of(node)
4074    }
4075    fn outgoing_calls_for_symbols(
4076        &self,
4077        sources: &[(String, String)],
4078    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4079        (**self).outgoing_calls_for_symbols(sources)
4080    }
4081    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4082        (**self).resolved_self_calls_of(node)
4083    }
4084    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4085        (**self).unresolved_calls_of(node)
4086    }
4087    fn call_tree(
4088        &self,
4089        file_rel: &Path,
4090        symbol: &str,
4091        depth: usize,
4092    ) -> Result<callgraph::CallTreeNode> {
4093        (**self).call_tree(file_rel, symbol, depth)
4094    }
4095    fn trace_to(
4096        &self,
4097        file_rel: &Path,
4098        symbol: &str,
4099        max_depth: usize,
4100    ) -> Result<callgraph::TraceToResult> {
4101        (**self).trace_to(file_rel, symbol, max_depth)
4102    }
4103    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4104        (**self).trace_to_symbol_candidates(to_symbol)
4105    }
4106    fn trace_to_symbol(
4107        &self,
4108        file_rel: &Path,
4109        symbol: &str,
4110        to_symbol: &str,
4111        to_file: Option<&Path>,
4112        max_depth: usize,
4113    ) -> Result<callgraph::TraceToSymbolResult> {
4114        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4115    }
4116}
4117
4118impl CallGraphRead for ReadonlyCallGraphStore {
4119    fn project_root(&self) -> &Path {
4120        self.project_root()
4121    }
4122    fn project_key(&self) -> &str {
4123        self.project_key()
4124    }
4125    fn sqlite_path(&self) -> &Path {
4126        self.sqlite_path()
4127    }
4128    fn is_current(&self) -> bool {
4129        self.is_current()
4130    }
4131    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4132        self.edge_snapshot()
4133    }
4134    fn indexed_file_count(&self) -> Result<usize> {
4135        self.indexed_file_count()
4136    }
4137    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4138        self.node_for(file_rel, symbol)
4139    }
4140    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4141        self.nodes_for(file_rel, symbol)
4142    }
4143    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4144        self.nodes_matching(symbol)
4145    }
4146    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4147        self.direct_callers_of(file_rel, symbol)
4148    }
4149    fn direct_callers_for_symbols(
4150        &self,
4151        targets: &[(String, String)],
4152    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4153        self.direct_callers_for_symbols(targets)
4154    }
4155    fn direct_caller_counts_of(
4156        &self,
4157        targets: &[(String, String)],
4158    ) -> Result<HashMap<(String, String), usize>> {
4159        self.direct_caller_counts_of(targets)
4160    }
4161    fn callers_of(
4162        &self,
4163        file_rel: &Path,
4164        symbol: &str,
4165        depth: usize,
4166    ) -> Result<StoreCallersResult> {
4167        self.callers_of(file_rel, symbol, depth)
4168    }
4169    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4170        self.impact_of(file_rel, symbol, depth)
4171    }
4172    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4173        self.outgoing_calls_of(node)
4174    }
4175    fn outgoing_calls_for_symbols(
4176        &self,
4177        sources: &[(String, String)],
4178    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4179        self.outgoing_calls_for_symbols(sources)
4180    }
4181    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4182        self.resolved_self_calls_of(node)
4183    }
4184    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4185        self.unresolved_calls_of(node)
4186    }
4187    fn call_tree(
4188        &self,
4189        file_rel: &Path,
4190        symbol: &str,
4191        depth: usize,
4192    ) -> Result<callgraph::CallTreeNode> {
4193        self.call_tree(file_rel, symbol, depth)
4194    }
4195    fn trace_to(
4196        &self,
4197        file_rel: &Path,
4198        symbol: &str,
4199        max_depth: usize,
4200    ) -> Result<callgraph::TraceToResult> {
4201        self.trace_to(file_rel, symbol, max_depth)
4202    }
4203    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4204        self.trace_to_symbol_candidates(to_symbol)
4205    }
4206    fn trace_to_symbol(
4207        &self,
4208        file_rel: &Path,
4209        symbol: &str,
4210        to_symbol: &str,
4211        to_file: Option<&Path>,
4212        max_depth: usize,
4213    ) -> Result<callgraph::TraceToSymbolResult> {
4214        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4215    }
4216}
4217
4218fn indexed_file_count(conn: &Connection) -> Result<usize> {
4219    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4220    Ok(count.max(0) as usize)
4221}
4222
4223fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4224    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4225    match candidates.as_slice() {
4226        [candidate] => Ok(candidate.clone()),
4227        [] => Err(AftError::SymbolNotFound {
4228            name: symbol.to_string(),
4229            file: rel_path.to_string(),
4230        }
4231        .into()),
4232        _ => Err(AftError::AmbiguousSymbol {
4233            name: symbol.to_string(),
4234            candidates: candidates
4235                .iter()
4236                .map(|candidate| candidate.symbol.clone())
4237                .collect(),
4238        }
4239        .into()),
4240    }
4241}
4242
4243fn nodes_for_file_matching_symbol(
4244    conn: &Connection,
4245    rel_path: &str,
4246    symbol: &str,
4247) -> Result<Vec<StoreNode>> {
4248    let qualified_query = symbol.contains("::");
4249    let sql = if qualified_query {
4250        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4251                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4252         FROM nodes n JOIN files f ON f.path = n.file_path
4253         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4254         ORDER BY n.scoped_name, n.start_line, n.start_col"
4255    } else {
4256        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4257                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4258         FROM nodes n JOIN files f ON f.path = n.file_path
4259         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4260         ORDER BY n.scoped_name, n.start_line, n.start_col"
4261    };
4262    let mut stmt = conn.prepare(sql)?;
4263    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4264    rows.collect::<std::result::Result<Vec<_>, _>>()
4265        .map_err(Into::into)
4266}
4267
4268fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4269    let qualified_query = symbol.contains("::");
4270    let sql = if qualified_query {
4271        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4272                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4273         FROM nodes n JOIN files f ON f.path = n.file_path
4274         WHERE n.scoped_name = ?1
4275         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4276    } else {
4277        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4278                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4279         FROM nodes n JOIN files f ON f.path = n.file_path
4280         WHERE n.scoped_name = ?1 OR n.name = ?1
4281         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4282    };
4283    let mut stmt = conn.prepare(sql)?;
4284    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4285    rows.collect::<std::result::Result<Vec<_>, _>>()
4286        .map_err(Into::into)
4287}
4288
4289fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4290    store_node_from_row_at(row, 0)
4291}
4292
4293fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4294    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4295    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4296    let lang_label_value: String = row.get(offset + 10)?;
4297    Ok(StoreNode {
4298        node_id: row.get(offset)?,
4299        file: row.get(offset + 1)?,
4300        symbol: row.get(offset + 2)?,
4301        name: row.get(offset + 3)?,
4302        kind: row.get(offset + 4)?,
4303        line: start_line.saturating_add(1),
4304        end_line: end_line.saturating_add(1),
4305        signature: row.get(offset + 7)?,
4306        exported: row.get::<_, i64>(offset + 8)? != 0,
4307        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4308        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4309    })
4310}
4311
4312fn optional_store_node_from_row_at(
4313    row: &rusqlite::Row<'_>,
4314    offset: usize,
4315) -> rusqlite::Result<Option<StoreNode>> {
4316    if row.get::<_, Option<String>>(offset)?.is_some() {
4317        store_node_from_row_at(row, offset).map(Some)
4318    } else {
4319        Ok(None)
4320    }
4321}
4322
4323#[allow(clippy::too_many_arguments)]
4324fn collect_callers_recursive(
4325    conn: &Connection,
4326    file: &str,
4327    symbol: &str,
4328    max_depth: usize,
4329    current_depth: usize,
4330    visited: &mut HashSet<(String, String)>,
4331    result: &mut Vec<StoreCallSite>,
4332    depth_limited: &mut bool,
4333    truncated: &mut usize,
4334) -> Result<()> {
4335    if current_depth >= max_depth {
4336        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4337        if omitted > 0 {
4338            *depth_limited = true;
4339            *truncated += omitted;
4340        }
4341        return Ok(());
4342    }
4343
4344    if !visited.insert((file.to_string(), symbol.to_string())) {
4345        return Ok(());
4346    }
4347
4348    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4349    for site in sites {
4350        result.push(site.clone());
4351        if current_depth + 1 < max_depth {
4352            collect_callers_recursive(
4353                conn,
4354                &site.caller.file,
4355                &site.caller.symbol,
4356                max_depth,
4357                current_depth + 1,
4358                visited,
4359                result,
4360                depth_limited,
4361                truncated,
4362            )?;
4363        } else {
4364            let omitted =
4365                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4366            if omitted > 0 {
4367                *depth_limited = true;
4368                *truncated += omitted;
4369            }
4370        }
4371    }
4372    Ok(())
4373}
4374
4375// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4376const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4377
4378fn direct_caller_counts_for_tuples(
4379    conn: &Connection,
4380    targets: &[(String, String)],
4381) -> Result<HashMap<(String, String), usize>> {
4382    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4383    let mut counts = unique_targets
4384        .iter()
4385        .cloned()
4386        .map(|target| (target, 0usize))
4387        .collect::<HashMap<_, _>>();
4388
4389    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4390    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4391        let requested_values = (0..chunk.len())
4392            .map(|_| "(?, ?)")
4393            .collect::<Vec<_>>()
4394            .join(", ");
4395        let sql = format!(
4396            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4397             deduped AS (
4398                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4399                 FROM requested requested
4400                 JOIN edges e
4401                   ON e.target_file = requested.target_file
4402                  AND e.target_symbol = requested.target_symbol
4403                  AND e.kind = 'call'
4404                 JOIN refs r ON r.ref_id = e.ref_id
4405                 JOIN nodes src ON src.id = e.source_node
4406                 JOIN files src_file ON src_file.path = src.file_path
4407                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4408             )
4409             SELECT target_file, target_symbol, COUNT(*)
4410             FROM deduped
4411             GROUP BY target_file, target_symbol"
4412        );
4413        let bindings = chunk
4414            .iter()
4415            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4416        let mut stmt = conn.prepare(&sql)?;
4417        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4418            Ok((
4419                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4420                row.get::<_, i64>(2)?,
4421            ))
4422        })?;
4423        for row in rows {
4424            let (target, count) = row?;
4425            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4426        }
4427    }
4428
4429    Ok(counts)
4430}
4431
4432fn direct_caller_count_for_tuple(
4433    conn: &Connection,
4434    target_file: &str,
4435    target_symbol: &str,
4436) -> Result<usize> {
4437    let count: i64 = conn.query_row(
4438        "SELECT COUNT(*)
4439         FROM edges e
4440         JOIN refs r ON r.ref_id = e.ref_id
4441         JOIN nodes src ON src.id = e.source_node
4442         JOIN files src_file ON src_file.path = src.file_path
4443         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4444        params![target_file, target_symbol],
4445        |row| row.get(0),
4446    )?;
4447    Ok(usize::try_from(count).unwrap_or(usize::MAX))
4448}
4449
4450fn direct_callers_for_tuple(
4451    conn: &Connection,
4452    target_file: &str,
4453    target_symbol: &str,
4454) -> Result<Vec<StoreCallSite>> {
4455    let mut stmt = conn.prepare(
4456        "SELECT e.target_file, e.target_symbol, e.line,
4457                r.byte_start, r.byte_end, r.status, e.provenance,
4458                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4459                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4460                src_file.lang,
4461                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4462                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4463                tgt_file.lang
4464         FROM edges e
4465         JOIN refs r ON r.ref_id = e.ref_id
4466         JOIN nodes src ON src.id = e.source_node
4467         JOIN files src_file ON src_file.path = src.file_path
4468         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4469             ON tgt.id = e.target_node
4470         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4471         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4472    )?;
4473    let rows = stmt.query_map(
4474        params![target_file, target_symbol],
4475        direct_call_site_from_row,
4476    )?;
4477    rows.collect::<std::result::Result<Vec<_>, _>>()
4478        .map_err(Into::into)
4479}
4480
4481fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
4482    let caller = store_node_from_row_at(row, 7)?;
4483    let target = optional_store_node_from_row_at(row, 18)?;
4484    Ok(StoreCallSite {
4485        caller,
4486        target_file: row.get(0)?,
4487        target_symbol: row.get(1)?,
4488        target,
4489        line: row.get::<_, i64>(2)?.max(0) as u32,
4490        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4491        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4492        resolved: row.get::<_, String>(5)? == "resolved",
4493        provenance: row.get(6)?,
4494    })
4495}
4496
4497fn direct_callers_for_tuples(
4498    conn: &Connection,
4499    targets: &[(String, String)],
4500) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4501    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4502    let mut callers_by_target = unique_targets
4503        .iter()
4504        .cloned()
4505        .map(|target| (target, Vec::new()))
4506        .collect::<HashMap<_, _>>();
4507    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4508
4509    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4510        let requested_values = (0..chunk.len())
4511            .map(|_| "(?, ?)")
4512            .collect::<Vec<_>>()
4513            .join(", ");
4514        let sql = format!(
4515            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
4516             SELECT e.target_file, e.target_symbol, e.line,
4517                    r.byte_start, r.byte_end, r.status, e.provenance,
4518                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4519                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4520                    src_file.lang,
4521                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4522                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4523                    tgt_file.lang
4524             FROM requested requested
4525             JOIN edges e
4526               ON e.target_file = requested.target_file
4527              AND e.target_symbol = requested.target_symbol
4528              AND e.kind = 'call'
4529             JOIN refs r ON r.ref_id = e.ref_id
4530             JOIN nodes src ON src.id = e.source_node
4531             JOIN files src_file ON src_file.path = src.file_path
4532             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4533                 ON tgt.id = e.target_node
4534             ORDER BY e.target_file, e.target_symbol, e.source_node,
4535                      r.byte_start, r.line, r.ref_id"
4536        );
4537        let bindings = chunk
4538            .iter()
4539            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4540        let mut stmt = conn.prepare(&sql)?;
4541        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4542            let call = direct_call_site_from_row(row)?;
4543            let target_key = (call.target_file.clone(), call.target_symbol.clone());
4544            Ok((target_key, call))
4545        })?;
4546        for row in rows {
4547            let (target, call) = row?;
4548            callers_by_target
4549                .get_mut(&target)
4550                .expect("batched caller row belongs to a requested target")
4551                .push(call);
4552        }
4553    }
4554
4555    Ok(callers_by_target)
4556}
4557
4558// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4559const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4560// Outgoing-edge batches bind one source node per parameter.
4561const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4562
4563fn outgoing_calls_for_symbol_tuples(
4564    conn: &Connection,
4565    sources: &[(String, String)],
4566) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4567    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4568    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4569    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4570    let source_nodes = unique_sources
4571        .iter()
4572        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4573        .cloned()
4574        .collect::<Vec<_>>();
4575    let source_nodes_by_id = source_nodes
4576        .iter()
4577        .cloned()
4578        .map(|node| (node.node_id.clone(), node))
4579        .collect::<HashMap<_, _>>();
4580    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4581
4582    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4583        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4584        let sql = format!(
4585            "SELECT e.source_node,
4586                    e.target_file, e.target_symbol, e.line,
4587                    r.byte_start, r.byte_end, r.status, e.provenance,
4588                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4589                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4590                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4591                    tgt_file.lang
4592             FROM edges e
4593             JOIN refs r ON r.ref_id = e.ref_id
4594             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4595             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4596             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4597             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4598        );
4599        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4600        let mut stmt = conn.prepare(&sql)?;
4601        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4602            let source_node_id = row.get::<_, String>(0)?;
4603            let caller = source_nodes_by_id
4604                .get(&source_node_id)
4605                .expect("batched outgoing row belongs to a requested source node")
4606                .clone();
4607            let target = optional_store_node_from_row_at(row, 8)?;
4608            Ok((
4609                source_node_id,
4610                StoreCallSite {
4611                    caller,
4612                    target_file: row.get(1)?,
4613                    target_symbol: row.get(2)?,
4614                    target,
4615                    line: row.get::<_, i64>(3)?.max(0) as u32,
4616                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4617                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4618                    resolved: row.get::<_, String>(6)? == "resolved",
4619                    provenance: row.get(7)?,
4620                },
4621            ))
4622        })?;
4623        for row in rows {
4624            let (source_node_id, call) = row?;
4625            calls_by_node.entry(source_node_id).or_default().push(call);
4626        }
4627    }
4628
4629    let mut calls_by_source = HashMap::new();
4630    for source in &unique_sources {
4631        let mut calls = Vec::new();
4632        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4633            for node in nodes {
4634                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4635                    calls.extend(node_calls);
4636                }
4637            }
4638        }
4639        calls_by_source.insert(source.clone(), calls);
4640    }
4641
4642    // Resolve each logical target once for the whole frontier. Keeping this separate
4643    // preserves positional-symbol representatives without a correlated lookup per edge.
4644    let target_tuples = calls_by_source
4645        .values()
4646        .flatten()
4647        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4648        .collect::<Vec<_>>();
4649    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4650    for calls in calls_by_source.values_mut() {
4651        for call in calls {
4652            if let Some(target) = target_nodes
4653                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4654                .and_then(|nodes| nodes.first())
4655            {
4656                call.target = Some(target.clone());
4657            }
4658        }
4659    }
4660
4661    Ok(calls_by_source)
4662}
4663
4664fn nodes_for_symbol_tuples(
4665    conn: &Connection,
4666    symbols: &[(String, String)],
4667) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4668    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4669    let mut nodes_by_symbol = unique_symbols
4670        .iter()
4671        .cloned()
4672        .map(|symbol| (symbol, Vec::new()))
4673        .collect::<HashMap<_, _>>();
4674    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4675
4676    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4677        let requested_values = (0..chunk.len())
4678            .map(|_| "(?, ?)")
4679            .collect::<Vec<_>>()
4680            .join(", ");
4681        let sql = format!(
4682            "WITH requested(file, symbol) AS (VALUES {requested_values})
4683             SELECT requested.file, requested.symbol,
4684                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4685                    node.start_line, node.end_line, node.signature, node.exported,
4686                    node.is_callgraph_entry_point, node_file.lang
4687             FROM requested
4688             JOIN nodes node INDEXED BY idx_nodes_file
4689               ON node.file_path = requested.file
4690              AND node.scoped_name = requested.symbol
4691             JOIN files node_file ON node_file.path = node.file_path
4692             ORDER BY requested.file, requested.symbol,
4693                      node.scoped_name, node.start_line, node.end_line,
4694                      node.start_col, node.range_ordinal"
4695        );
4696        let bindings = chunk
4697            .iter()
4698            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4699        let mut stmt = conn.prepare(&sql)?;
4700        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4701            Ok((
4702                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4703                store_node_from_row_at(row, 2)?,
4704            ))
4705        })?;
4706        for row in rows {
4707            let (symbol, node) = row?;
4708            nodes_by_symbol.entry(symbol).or_default().push(node);
4709        }
4710    }
4711
4712    Ok(nodes_by_symbol)
4713}
4714
4715fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4716    let mut stmt = conn.prepare(
4717        "SELECT e.target_file, e.target_symbol, e.line,
4718                r.byte_start, r.byte_end, r.status, e.provenance,
4719                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4720                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4721                tgt_file.lang
4722         FROM edges e
4723         JOIN refs r ON r.ref_id = e.ref_id
4724         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4725             ON tgt.id = e.target_node
4726         WHERE e.kind = 'call' AND e.source_node = ?1
4727         ORDER BY r.byte_start, r.line, r.ref_id",
4728    )?;
4729    let rows = stmt.query_map(params![node.node_id], |row| {
4730        let target = optional_store_node_from_row_at(row, 7)?;
4731        Ok(StoreCallSite {
4732            caller: node.clone(),
4733            target_file: row.get(0)?,
4734            target_symbol: row.get(1)?,
4735            target,
4736            line: row.get::<_, i64>(2)?.max(0) as u32,
4737            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4738            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4739            resolved: row.get::<_, String>(5)? == "resolved",
4740            provenance: row.get(6)?,
4741        })
4742    })?;
4743    rows.collect::<std::result::Result<Vec<_>, _>>()
4744        .map_err(Into::into)
4745}
4746
4747fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4748    let mut stmt = conn.prepare(
4749        "SELECT r.target_file, r.target_symbol, r.line,
4750                r.byte_start, r.byte_end, r.status, r.provenance,
4751                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4752                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4753                tgt_file.lang
4754         FROM refs r
4755         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4756             ON tgt.id = r.target_node
4757         WHERE r.caller_node = ?1
4758           AND r.kind = 'call'
4759           AND r.status <> 'unresolved'
4760           AND r.target_file = ?2
4761           AND r.target_symbol = ?3
4762           AND r.provenance = ?4
4763           AND NOT EXISTS (
4764               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4765           )
4766         ORDER BY r.byte_start, r.line, r.ref_id",
4767    )?;
4768    let rows = stmt.query_map(
4769        params![
4770            &node.node_id,
4771            &node.file,
4772            &node.symbol,
4773            PROVENANCE_TREESITTER
4774        ],
4775        |row| {
4776            let target = optional_store_node_from_row_at(row, 7)?;
4777            Ok(StoreCallSite {
4778                caller: node.clone(),
4779                target_file: row.get(0)?,
4780                target_symbol: row.get(1)?,
4781                target,
4782                line: row.get::<_, i64>(2)?.max(0) as u32,
4783                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4784                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4785                resolved: row.get::<_, String>(5)? == "resolved",
4786                provenance: row.get(6)?,
4787            })
4788        },
4789    )?;
4790    rows.collect::<std::result::Result<Vec<_>, _>>()
4791        .map_err(Into::into)
4792}
4793
4794fn unresolved_calls_for_node(
4795    conn: &Connection,
4796    node: &StoreNode,
4797) -> Result<Vec<StoreUnresolvedCall>> {
4798    let mut stmt = conn.prepare(
4799        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4800         FROM refs
4801         WHERE caller_node = ?1
4802           AND kind = 'call'
4803           AND status = 'unresolved'
4804           AND NOT EXISTS (
4805               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4806           )
4807         ORDER BY byte_start, line, ref_id",
4808    )?;
4809    let rows = stmt.query_map(params![node.node_id], |row| {
4810        Ok(StoreUnresolvedCall {
4811            caller: node.clone(),
4812            symbol: row.get(0)?,
4813            full_ref: row.get(1)?,
4814            line: row.get::<_, i64>(2)?.max(0) as u32,
4815            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4816            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4817        })
4818    })?;
4819    rows.collect::<std::result::Result<Vec<_>, _>>()
4820        .map_err(Into::into)
4821}
4822
4823fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4824    let mut calls = Vec::new();
4825    calls.extend(
4826        outgoing_calls_for_node(conn, node)?
4827            .into_iter()
4828            .map(StoreForwardCall::Resolved),
4829    );
4830    calls.extend(
4831        unresolved_calls_for_node(conn, node)?
4832            .into_iter()
4833            .map(StoreForwardCall::Unresolved),
4834    );
4835    calls.sort_by(|left, right| {
4836        left.byte_start()
4837            .cmp(&right.byte_start())
4838            .then(left.line().cmp(&right.line()))
4839    });
4840    Ok(calls)
4841}
4842
4843fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4844    let resolved_count: i64 = conn.query_row(
4845        "SELECT COUNT(*)
4846         FROM edges e
4847         JOIN refs r ON r.ref_id = e.ref_id
4848         WHERE e.kind = 'call' AND e.source_node = ?1",
4849        params![&node.node_id],
4850        |row| row.get(0),
4851    )?;
4852    let unresolved_count: i64 = conn.query_row(
4853        "SELECT COUNT(*)
4854         FROM refs
4855         WHERE caller_node = ?1
4856           AND kind = 'call'
4857           AND status = 'unresolved'
4858           AND NOT EXISTS (
4859               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4860           )",
4861        params![&node.node_id],
4862        |row| row.get(0),
4863    )?;
4864    let total = resolved_count.saturating_add(unresolved_count);
4865    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4866}
4867
4868fn call_tree_inner(
4869    conn: &Connection,
4870    node: &StoreNode,
4871    max_depth: usize,
4872    current_depth: usize,
4873    visited: &mut HashSet<(String, String)>,
4874) -> Result<callgraph::CallTreeNode> {
4875    let visit_key = (node.file.clone(), node.symbol.clone());
4876    if visited.contains(&visit_key) {
4877        return Ok(callgraph::CallTreeNode {
4878            name: node.symbol.clone(),
4879            file: node.file.clone(),
4880            line: node.line,
4881            signature: node.signature.clone(),
4882            resolved: true,
4883            children: Vec::new(),
4884            depth_limited: false,
4885            truncated: 0,
4886        });
4887    }
4888    visited.insert(visit_key.clone());
4889
4890    let mut children = Vec::new();
4891    let mut depth_limited = false;
4892    let mut truncated = 0usize;
4893
4894    if current_depth < max_depth {
4895        let calls = forward_calls_for_node(conn, node)?;
4896        for call in calls {
4897            match call {
4898                StoreForwardCall::Resolved(site) => {
4899                    if let Some(target) = site.target {
4900                        let child =
4901                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4902                        depth_limited |= child.depth_limited;
4903                        truncated += child.truncated;
4904                        children.push(child);
4905                    } else {
4906                        children.push(callgraph::CallTreeNode {
4907                            name: site.target_symbol,
4908                            file: site.target_file,
4909                            line: site.line,
4910                            signature: None,
4911                            resolved: false,
4912                            children: Vec::new(),
4913                            depth_limited: false,
4914                            truncated: 0,
4915                        });
4916                    }
4917                }
4918                StoreForwardCall::Unresolved(call) => {
4919                    children.push(callgraph::CallTreeNode {
4920                        name: call.symbol,
4921                        file: call.caller.file,
4922                        line: call.line,
4923                        signature: None,
4924                        resolved: false,
4925                        children: Vec::new(),
4926                        depth_limited: false,
4927                        truncated: 0,
4928                    });
4929                }
4930            }
4931        }
4932    } else {
4933        truncated = forward_call_count_for_node(conn, node)?;
4934        depth_limited = truncated > 0;
4935    }
4936
4937    visited.remove(&visit_key);
4938    Ok(callgraph::CallTreeNode {
4939        name: node.symbol.clone(),
4940        file: node.file.clone(),
4941        line: node.line,
4942        signature: node.signature.clone(),
4943        resolved: true,
4944        children,
4945        depth_limited,
4946        truncated,
4947    })
4948}
4949
4950fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4951    callgraph::TraceToSymbolHop {
4952        symbol: node.symbol.clone(),
4953        file: node.file.clone(),
4954        line: node.line,
4955    }
4956}
4957
4958fn trace_to_symbol_matches_target(
4959    node: &StoreNode,
4960    to_symbol: &str,
4961    to_file: Option<&str>,
4962) -> bool {
4963    if !symbol_query_matches(&node.symbol, to_symbol) {
4964        return false;
4965    }
4966    match to_file {
4967        Some(file) => node.file == file,
4968        None => true,
4969    }
4970}
4971
4972fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4973    symbol == query || unqualified_name(symbol) == query
4974}
4975
4976fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4977    let source = std::fs::read_to_string(path).ok()?;
4978    Some(source.lines().map(|line| line.trim().to_string()).collect())
4979}
4980
4981#[doc(hidden)]
4982pub fn live_callgraph_edge_snapshot(
4983    project_root: &Path,
4984    files: &[PathBuf],
4985) -> Result<BTreeSet<StoredEdge>> {
4986    let files = normalize_file_list(project_root, files)?;
4987    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4988    let mut file_data = Vec::new();
4989    for file in &files {
4990        let canon = canonicalize_path(file);
4991        let data = graph.build_file(&canon)?.clone();
4992        file_data.push((canon, data));
4993    }
4994
4995    let mut edges = BTreeSet::new();
4996    for (caller_file, data) in &file_data {
4997        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4998            for call_site in call_sites {
4999                let resolution = graph.resolve_cross_file_edge(
5000                    &call_site.full_callee,
5001                    &call_site.callee_name,
5002                    caller_file,
5003                    &data.import_block,
5004                );
5005                let (target_file, target_symbol) = match resolution {
5006                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5007                    EdgeResolution::Unresolved { callee_name } => {
5008                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5009                            continue;
5010                        }
5011                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5012                            data,
5013                            caller_file,
5014                            &callee_name,
5015                        ) else {
5016                            continue;
5017                        };
5018                        (caller_file.clone(), target_symbol)
5019                    }
5020                };
5021                if target_file == *caller_file && target_symbol == *caller_symbol {
5022                    continue;
5023                }
5024                edges.insert(StoredEdge {
5025                    source_file: relative_path(project_root, caller_file),
5026                    source_symbol: caller_symbol.clone(),
5027                    target_file: relative_path(project_root, &target_file),
5028                    target_symbol,
5029                    kind: "call".to_string(),
5030                    line: call_site.line,
5031                });
5032            }
5033        }
5034    }
5035    Ok(edges)
5036}
5037
5038fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5039{
5040    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5041}
5042
5043fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5044    RebuildCooldownKey {
5045        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5046            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5047        project_key: project_key.to_string(),
5048    }
5049}
5050
5051fn rebuild_cooldown_denial(
5052    callgraph_dir: &Path,
5053    project_key: &str,
5054    project_root: &Path,
5055    now: Instant,
5056) -> Option<(PathBuf, Duration)> {
5057    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5058    let records = rebuild_cooldown_records()
5059        .lock()
5060        .unwrap_or_else(std::sync::PoisonError::into_inner);
5061    let record = records.get(&key)?;
5062    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5063        return None;
5064    }
5065    let elapsed = now.saturating_duration_since(record.published_at);
5066    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5067}
5068
5069fn record_successful_rebuild(
5070    callgraph_dir: &Path,
5071    project_key: &str,
5072    project_root: &Path,
5073    published_at: Instant,
5074) {
5075    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5076    let mut records = rebuild_cooldown_records()
5077        .lock()
5078        .unwrap_or_else(std::sync::PoisonError::into_inner);
5079    if records.len() >= 4_096 && !records.contains_key(&key) {
5080        if let Some(evict) = records.keys().next().cloned() {
5081            records.remove(&evict);
5082        }
5083    }
5084    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5085        previous.cross_root_cooldown_armed || previous.project_root != project_root
5086    });
5087    records.insert(
5088        key,
5089        RebuildCooldownRecord {
5090            project_root: project_root.to_path_buf(),
5091            published_at,
5092            cross_root_cooldown_armed,
5093        },
5094    );
5095}
5096
5097fn acquire_writer_lease(
5098    callgraph_dir: &Path,
5099    project_key: &str,
5100    project_root: &Path,
5101) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5102    crate::root_cache::WriterLease::acquire_shared(
5103        crate::root_cache::RootCacheDomain::Callgraph,
5104        callgraph_dir,
5105        project_key,
5106        project_root,
5107    )
5108    .map_err(CallGraphStoreError::from)
5109}
5110
5111fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5112    if lease.verify()? {
5113        Ok(())
5114    } else {
5115        Err(CallGraphStoreError::Unavailable(format!(
5116            "callgraph writer lease for key {} lost epoch {}; aborting write",
5117            lease.key(),
5118            lease.epoch()
5119        )))
5120    }
5121}
5122
5123fn legacy_migration_completion_line(
5124    project_key: &str,
5125    method: &str,
5126    legacy_bytes: u64,
5127    migrated_bytes: u64,
5128) -> String {
5129    format!(
5130        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5131    )
5132}
5133
5134fn log_legacy_migration_completion(
5135    project_key: &str,
5136    method: &str,
5137    legacy_bytes: u64,
5138    migrated_bytes: u64,
5139) {
5140    crate::slog_info!(
5141        "{}",
5142        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5143    );
5144}
5145
5146fn try_legacy_migration_or_fallback(
5147    callgraph_dir: &Path,
5148    project_root: &Path,
5149    project_key: &str,
5150    writer_lease: Arc<crate::root_cache::WriterLease>,
5151) -> Result<Option<CallGraphStore>> {
5152    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5153    if partitions.is_empty() {
5154        return Ok(None);
5155    }
5156
5157    for partition in &partitions {
5158        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5159            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5160                return open_legacy_fallback_store(
5161                    callgraph_dir,
5162                    project_root,
5163                    project_key,
5164                    &partitions,
5165                );
5166            }
5167            match publish_generation_copy_migration(
5168                callgraph_dir,
5169                project_key,
5170                &source,
5171                Arc::clone(&writer_lease),
5172            ) {
5173                Ok(published) => {
5174                    log_legacy_migration_completion(
5175                        project_key,
5176                        "generation_copy",
5177                        source.source_bytes,
5178                        published.migrated_bytes,
5179                    );
5180                    return CallGraphStore::open_generation(
5181                        callgraph_dir,
5182                        project_root.to_path_buf(),
5183                        project_key.to_string(),
5184                        published.generation,
5185                        writer_lease,
5186                    )
5187                    .map(Some);
5188                }
5189                Err(error) => {
5190                    crate::slog_warn!(
5191                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5192                        source.sqlite_path.display(),
5193                        error
5194                    );
5195                    return open_legacy_fallback_store(
5196                        callgraph_dir,
5197                        project_root,
5198                        project_key,
5199                        &partitions,
5200                    );
5201                }
5202            }
5203        }
5204
5205        if let Some(source) = current_legacy_generation(partition)? {
5206            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5207                return open_legacy_fallback_store(
5208                    callgraph_dir,
5209                    project_root,
5210                    project_key,
5211                    &partitions,
5212                );
5213            }
5214            match publish_backup_migration(
5215                callgraph_dir,
5216                project_key,
5217                &source,
5218                Arc::clone(&writer_lease),
5219            ) {
5220                Ok(published) => {
5221                    log_legacy_migration_completion(
5222                        project_key,
5223                        "sqlite_backup",
5224                        source.source_bytes,
5225                        published.migrated_bytes,
5226                    );
5227                    return CallGraphStore::open_generation(
5228                        callgraph_dir,
5229                        project_root.to_path_buf(),
5230                        project_key.to_string(),
5231                        published.generation,
5232                        writer_lease,
5233                    )
5234                    .map(Some);
5235                }
5236                Err(error) => {
5237                    crate::slog_warn!(
5238                        "root-keyed callgraph backup migration failed from {}: {}",
5239                        source.sqlite_path.display(),
5240                        error
5241                    );
5242                    return open_legacy_fallback_store(
5243                        callgraph_dir,
5244                        project_root,
5245                        project_key,
5246                        &partitions,
5247                    );
5248                }
5249            }
5250        }
5251    }
5252
5253    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5254}
5255
5256fn open_legacy_fallback_store(
5257    callgraph_dir: &Path,
5258    project_root: &Path,
5259    project_key: &str,
5260    partitions: &[LegacyCallgraphPartition],
5261) -> Result<Option<CallGraphStore>> {
5262    let Some(target) = first_ready_legacy_target(partitions)? else {
5263        return Ok(None);
5264    };
5265    crate::slog_warn!(
5266        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5267        target.partition.harness,
5268        target.sqlite_path.display()
5269    );
5270    let conn = open_readonly_connection(&target.sqlite_path)?;
5271    if !database_ready(&conn).unwrap_or(false) {
5272        return Ok(None);
5273    }
5274    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5275    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5276    Ok(Some(CallGraphStore::from_connection(
5277        project_root.to_path_buf(),
5278        project_key.to_string(),
5279        target.sqlite_path,
5280        callgraph_dir.to_path_buf(),
5281        true,
5282        target.generation,
5283        None,
5284        Some(read_marker),
5285        conn,
5286    )))
5287}
5288
5289fn migration_disk_floor_allows(
5290    source: &LegacyCallgraphTarget,
5291    callgraph_dir: &Path,
5292) -> Result<bool> {
5293    let available = migration_available_disk(callgraph_dir)?;
5294    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5295        source.source_bytes,
5296        available,
5297    );
5298    if decision.should_skip_copy() {
5299        crate::slog_warn!(
5300            "{}",
5301            decision.warning_message(&source.sqlite_path, callgraph_dir)
5302        );
5303        return Ok(false);
5304    }
5305    Ok(true)
5306}
5307
5308fn migration_available_disk(path: &Path) -> Result<u64> {
5309    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5310        return Ok(bytes);
5311    }
5312    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5313}
5314
5315fn legacy_callgraph_partitions(
5316    callgraph_dir: &Path,
5317    project_key: &str,
5318) -> Result<Vec<LegacyCallgraphPartition>> {
5319    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5320        return Ok(Vec::new());
5321    };
5322    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5323    let mut partitions = inventory
5324        .into_iter()
5325        .filter(|entry| {
5326            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5327                && entry.key == project_key
5328        })
5329        .map(|entry| {
5330            let dir = if entry.path.is_dir() {
5331                entry.path.clone()
5332            } else {
5333                entry
5334                    .path
5335                    .parent()
5336                    .map(Path::to_path_buf)
5337                    .unwrap_or_else(|| entry.path.clone())
5338            };
5339            LegacyCallgraphPartition {
5340                harness: entry.harness,
5341                dir,
5342                key: entry.key,
5343                bytes: entry.bytes,
5344                freshness: entry.callgraph_pointer_mtime,
5345            }
5346        })
5347        .collect::<Vec<_>>();
5348    partitions.sort_by(|left, right| {
5349        right
5350            .freshness
5351            .cmp(&left.freshness)
5352            .then_with(|| right.bytes.cmp(&left.bytes))
5353            .then_with(|| left.harness.cmp(&right.harness))
5354    });
5355    Ok(partitions)
5356}
5357
5358fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5359    let domain_dir = callgraph_dir.parent()?;
5360    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5361        return None;
5362    }
5363    domain_dir.parent().map(Path::to_path_buf)
5364}
5365
5366pub(crate) fn all_legacy_partitions_migrated_for_keys(
5367    callgraph_dir: &Path,
5368    configured_keys: &BTreeSet<String>,
5369) -> Result<bool> {
5370    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5371        return Ok(false);
5372    };
5373    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5374        .into_iter()
5375        .filter(|entry| {
5376            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5377                && configured_keys.contains(&entry.key)
5378        })
5379        .map(|entry| entry.key)
5380        .collect::<BTreeSet<_>>();
5381    if legacy_keys.is_empty() {
5382        return Ok(false);
5383    }
5384
5385    for key in legacy_keys {
5386        let migrated_dir = storage_root.join("callgraph").join(&key);
5387        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5388            return Ok(false);
5389        };
5390        if !migration_generation_requires_manifest(&generation)
5391            || !migration_manifest_valid(&migrated_dir, &generation)
5392        {
5393            return Ok(false);
5394        }
5395    }
5396    Ok(true)
5397}
5398
5399fn newest_superseded_legacy_generation(
5400    partition: &LegacyCallgraphPartition,
5401) -> Result<Option<LegacyCallgraphTarget>> {
5402    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5403        return Ok(None);
5404    };
5405    let prefix = format!("{}.g", partition.key);
5406    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5407        return Ok(None);
5408    };
5409    let mut candidates = Vec::new();
5410    for entry in entries.flatten() {
5411        let name = entry.file_name().to_string_lossy().to_string();
5412        if name == current
5413            || name.contains(".tmp.")
5414            || !name.starts_with(&prefix)
5415            || !name.ends_with(".sqlite")
5416        {
5417            continue;
5418        }
5419        let path = entry.path();
5420        if !db_path_ready(&path) {
5421            continue;
5422        }
5423        let modified = entry
5424            .metadata()
5425            .and_then(|metadata| metadata.modified())
5426            .unwrap_or(SystemTime::UNIX_EPOCH);
5427        candidates.push((modified, path, name));
5428    }
5429    candidates.sort_by(|left, right| right.0.cmp(&left.0));
5430    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5431        return Ok(None);
5432    };
5433    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5434    Ok(Some(LegacyCallgraphTarget {
5435        partition: partition.clone(),
5436        sqlite_path,
5437        generation: Some(generation),
5438        source_bytes,
5439        source_blake3: String::new(),
5440    }))
5441}
5442
5443fn current_legacy_generation(
5444    partition: &LegacyCallgraphPartition,
5445) -> Result<Option<LegacyCallgraphTarget>> {
5446    let Some(target) = ready_legacy_target(partition)? else {
5447        return Ok(None);
5448    };
5449    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5450    if has_superseded {
5451        return Ok(None);
5452    }
5453    Ok(Some(target))
5454}
5455
5456fn freshest_legacy_fallback_target(
5457    callgraph_dir: &Path,
5458    project_key: &str,
5459) -> Result<Option<LegacyCallgraphTarget>> {
5460    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5461    first_ready_legacy_target(&partitions)
5462}
5463
5464fn first_ready_legacy_target(
5465    partitions: &[LegacyCallgraphPartition],
5466) -> Result<Option<LegacyCallgraphTarget>> {
5467    for partition in partitions {
5468        if let Some(target) = ready_legacy_target(partition)? {
5469            return Ok(Some(target));
5470        }
5471    }
5472    Ok(None)
5473}
5474
5475fn ready_legacy_target(
5476    partition: &LegacyCallgraphPartition,
5477) -> Result<Option<LegacyCallgraphTarget>> {
5478    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5479        let sqlite_path = partition.dir.join(&generation);
5480        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5481            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5482            return Ok(Some(LegacyCallgraphTarget {
5483                partition: partition.clone(),
5484                sqlite_path,
5485                generation: Some(generation),
5486                source_bytes,
5487                source_blake3: String::new(),
5488            }));
5489        }
5490    }
5491
5492    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5493    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5494        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5495        return Ok(Some(LegacyCallgraphTarget {
5496            partition: partition.clone(),
5497            sqlite_path,
5498            generation: None,
5499            source_bytes,
5500            source_blake3: String::new(),
5501        }));
5502    }
5503    Ok(None)
5504}
5505
5506fn publish_generation_copy_migration(
5507    callgraph_dir: &Path,
5508    project_key: &str,
5509    source: &LegacyCallgraphTarget,
5510    writer_lease: Arc<crate::root_cache::WriterLease>,
5511) -> Result<PublishedLegacyMigration> {
5512    let generation = migration_generation_file_name(project_key, "copy");
5513    let temp_path = migration_temp_path(callgraph_dir, &generation);
5514    remove_sqlite_file_set(&temp_path);
5515    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5516    fail_after_temp_copy_for_test()?;
5517
5518    let mut source = source.clone();
5519    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5520    source.source_blake3 = fingerprint.blake3;
5521    let generation = publish_migrated_generation(
5522        callgraph_dir,
5523        project_key,
5524        &generation,
5525        &temp_path,
5526        &source,
5527        fingerprint.bytes,
5528        writer_lease,
5529        "generation_copy",
5530    )?;
5531    Ok(PublishedLegacyMigration {
5532        generation,
5533        migrated_bytes: fingerprint.bytes,
5534    })
5535}
5536
5537fn publish_backup_migration(
5538    callgraph_dir: &Path,
5539    project_key: &str,
5540    source: &LegacyCallgraphTarget,
5541    writer_lease: Arc<crate::root_cache::WriterLease>,
5542) -> Result<PublishedLegacyMigration> {
5543    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5544        return Err(CallGraphStoreError::Unavailable(
5545            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5546        ));
5547    }
5548
5549    let generation = migration_generation_file_name(project_key, "backup");
5550    let temp_path = migration_temp_path(callgraph_dir, &generation);
5551    remove_sqlite_file_set(&temp_path);
5552
5553    let source_conn = open_readonly_connection(&source.sqlite_path)?;
5554    let mut destination = Connection::open(&temp_path)?;
5555    destination.busy_timeout(Duration::from_secs(5))?;
5556    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5557    let started = Instant::now();
5558    let mut retries = 0;
5559    loop {
5560        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5561            rusqlite::backup::StepResult::Done => break,
5562            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5563            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5564                retries += 1;
5565                if retries > MIGRATION_BACKUP_RETRY_BUDGET
5566                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5567                {
5568                    return Err(CallGraphStoreError::Unavailable(format!(
5569                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5570                    )));
5571                }
5572                std::thread::sleep(Duration::from_millis(20));
5573            }
5574            _ => {
5575                return Err(CallGraphStoreError::Unavailable(
5576                    "legacy callgraph backup returned an unknown step result".to_string(),
5577                ));
5578            }
5579        }
5580    }
5581    drop(backup);
5582
5583    let integrity: String =
5584        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5585    if integrity != "ok" {
5586        return Err(CallGraphStoreError::Unavailable(format!(
5587            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5588        )));
5589    }
5590    if !database_ready(&destination)? {
5591        return Err(CallGraphStoreError::Unavailable(
5592            "legacy callgraph backup produced a database without ready metadata".to_string(),
5593        ));
5594    }
5595    destination.execute_batch("PRAGMA optimize;")?;
5596    drop(destination);
5597    sync_file(&temp_path)?;
5598    fail_after_temp_copy_for_test()?;
5599
5600    let mut source = source.clone();
5601    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5602    source.source_blake3 = fingerprint.blake3;
5603    let generation = publish_migrated_generation(
5604        callgraph_dir,
5605        project_key,
5606        &generation,
5607        &temp_path,
5608        &source,
5609        fingerprint.bytes,
5610        writer_lease,
5611        "sqlite_backup",
5612    )?;
5613    Ok(PublishedLegacyMigration {
5614        generation,
5615        migrated_bytes: fingerprint.bytes,
5616    })
5617}
5618
5619fn publish_migrated_generation(
5620    callgraph_dir: &Path,
5621    project_key: &str,
5622    generation: &str,
5623    temp_path: &Path,
5624    source: &LegacyCallgraphTarget,
5625    migrated_bytes: u64,
5626    writer_lease: Arc<crate::root_cache::WriterLease>,
5627    method: &str,
5628) -> Result<String> {
5629    let gen_path = callgraph_dir.join(generation);
5630    checkpoint_sqlite_before_publication(temp_path);
5631    let publication = publish_if_current(|| {
5632        verify_writer_lease(&writer_lease)?;
5633        remove_sqlite_file_set(&gen_path);
5634        rename_sqlite_file_set(temp_path, &gen_path)?;
5635        crate::fs_lock::sync_parent(&gen_path);
5636
5637        verify_writer_lease(&writer_lease)?;
5638        publish_pointer(callgraph_dir, project_key, generation)?;
5639        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5640        Ok(generation.to_string())
5641    });
5642    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5643        remove_sqlite_file_set(temp_path);
5644    }
5645    publication
5646}
5647
5648fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5649    if let Some(parent) = destination.parent() {
5650        std::fs::create_dir_all(parent)?;
5651    }
5652    for suffix in SQLITE_FILE_SET_SUFFIXES {
5653        let source_path = sqlite_file_set_path(source, suffix);
5654        if !source_path.is_file() {
5655            continue;
5656        }
5657        let destination_path = sqlite_file_set_path(destination, suffix);
5658        std::fs::copy(&source_path, &destination_path)?;
5659        sync_file(&destination_path)?;
5660    }
5661    Ok(())
5662}
5663
5664fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5665    for suffix in SQLITE_FILE_SET_SUFFIXES {
5666        let source_path = sqlite_file_set_path(source, suffix);
5667        if !source_path.exists() {
5668            continue;
5669        }
5670        let destination_path = sqlite_file_set_path(destination, suffix);
5671        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5672            let _ = std::fs::remove_file(&source_path);
5673            return Err(error.into());
5674        }
5675    }
5676    Ok(())
5677}
5678
5679fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5680    let mut bytes = 0_u64;
5681    for suffix in SQLITE_FILE_SET_SUFFIXES {
5682        let member = sqlite_file_set_path(path, suffix);
5683        if !member.is_file() {
5684            continue;
5685        }
5686        bytes = bytes.saturating_add(member.metadata()?.len());
5687    }
5688    Ok(bytes)
5689}
5690
5691fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5692    let mut hasher = blake3::Hasher::new();
5693    let mut bytes = 0_u64;
5694    let mut buffer = [0_u8; 64 * 1024];
5695    for suffix in SQLITE_FILE_SET_SUFFIXES {
5696        let member = sqlite_file_set_path(path, suffix);
5697        if !member.is_file() {
5698            continue;
5699        }
5700        hasher.update(suffix.as_bytes());
5701        let mut file = std::fs::File::open(&member)?;
5702        loop {
5703            let read = file.read(&mut buffer)?;
5704            if read == 0 {
5705                break;
5706            }
5707            bytes = bytes.saturating_add(read as u64);
5708            hasher.update(&buffer[..read]);
5709        }
5710    }
5711    Ok(SourceFingerprint {
5712        bytes,
5713        blake3: hash_to_hex(hasher.finalize()),
5714    })
5715}
5716
5717fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5718    if suffix.is_empty() {
5719        path.to_path_buf()
5720    } else {
5721        PathBuf::from(format!("{}{suffix}", path.display()))
5722    }
5723}
5724
5725fn sync_file(path: &Path) -> Result<()> {
5726    let file = std::fs::OpenOptions::new()
5727        .read(true)
5728        .write(true)
5729        .open(path)?;
5730    file.sync_all()?;
5731    Ok(())
5732}
5733
5734fn fail_after_temp_copy_for_test() -> Result<()> {
5735    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5736        return Err(CallGraphStoreError::Unavailable(
5737            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5738        ));
5739    }
5740    Ok(())
5741}
5742
5743fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5744    format!(
5745        "{project_key}.g{}.{}{}{}.sqlite",
5746        now_nanos(),
5747        std::process::id(),
5748        MIGRATION_GENERATION_TAG,
5749        method
5750    )
5751}
5752
5753fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5754    callgraph_dir.join(format!(
5755        "{generation}.tmp.{}.{}",
5756        std::process::id(),
5757        now_nanos()
5758    ))
5759}
5760
5761fn write_migration_manifest(
5762    callgraph_dir: &Path,
5763    generation: &str,
5764    source: &LegacyCallgraphTarget,
5765    migrated_bytes: u64,
5766    method: &str,
5767) -> Result<()> {
5768    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5769    let temp_path = manifest_path.with_extension(format!(
5770        "migration.json.tmp.{}.{}",
5771        std::process::id(),
5772        now_nanos()
5773    ));
5774    let manifest = serde_json::json!({
5775        "version": MIGRATION_MANIFEST_VERSION,
5776        "method": method,
5777        "target_generation": generation,
5778        "source_harness": source.partition.harness,
5779        "source_path": source.sqlite_path.display().to_string(),
5780        "source_generation": source.generation,
5781        "source_bytes": source.source_bytes,
5782        "source_blake3": source.source_blake3,
5783        "migrated_bytes": migrated_bytes,
5784    });
5785    {
5786        use std::io::Write as _;
5787        let mut file = std::fs::File::create(&temp_path)?;
5788        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5789        file.write_all(b"\n")?;
5790        file.sync_all()?;
5791    }
5792    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5793        let _ = std::fs::remove_file(&temp_path);
5794        return Err(error.into());
5795    }
5796    crate::fs_lock::sync_parent(&manifest_path);
5797    Ok(())
5798}
5799
5800fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5801    callgraph_dir.join(format!("{generation}.migration.json"))
5802}
5803
5804fn migration_generation_requires_manifest(generation: &str) -> bool {
5805    generation.contains(MIGRATION_GENERATION_TAG)
5806}
5807
5808fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5809    if !migration_generation_requires_manifest(generation) {
5810        return true;
5811    }
5812    let path = migration_manifest_path(callgraph_dir, generation);
5813    let Ok(bytes) = std::fs::read(path) else {
5814        return false;
5815    };
5816    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5817        return false;
5818    };
5819    value.get("version").and_then(serde_json::Value::as_u64)
5820        == Some(MIGRATION_MANIFEST_VERSION as u64)
5821        && value
5822            .get("target_generation")
5823            .and_then(serde_json::Value::as_str)
5824            == Some(generation)
5825        && value
5826            .get("source_bytes")
5827            .and_then(serde_json::Value::as_u64)
5828            .is_some_and(|bytes| bytes > 0)
5829        && value
5830            .get("source_blake3")
5831            .and_then(serde_json::Value::as_str)
5832            .is_some_and(|hash| hash.len() == 64)
5833}
5834
5835fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5836    let pointer_generation = read_pointer(callgraph_dir, project_key);
5837    if let Some(generation) = pointer_generation.as_deref() {
5838        if migration_generation_requires_manifest(generation)
5839            && !migration_manifest_valid(callgraph_dir, generation)
5840        {
5841            let path = callgraph_dir.join(generation);
5842            remove_sqlite_file_set(&path);
5843            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5844            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5845        }
5846    }
5847
5848    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5849        return;
5850    };
5851    for entry in entries.flatten() {
5852        let name = entry.file_name().to_string_lossy().to_string();
5853        let path = entry.path();
5854        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5855            let _ = std::fs::remove_file(path);
5856            continue;
5857        }
5858        if name.starts_with(&format!("{project_key}.g"))
5859            && name.ends_with(".sqlite")
5860            && name.contains(MIGRATION_GENERATION_TAG)
5861            && pointer_generation.as_deref() != Some(&name)
5862            && !migration_manifest_valid(callgraph_dir, &name)
5863        {
5864            remove_sqlite_file_set(&path);
5865            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5866        }
5867    }
5868    crate::fs_lock::sync_parent(callgraph_dir);
5869}
5870
5871fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5872    let mut hasher = blake3::Hasher::new();
5873    hasher.update(path.to_string_lossy().as_bytes());
5874    if let Some(generation) = generation {
5875        hasher.update(generation.as_bytes());
5876    }
5877    let digest = hash_to_hex(hasher.finalize());
5878    format!("legacy-{}", &digest[..16])
5879}
5880
5881fn open_readonly_connection(path: &Path) -> Result<Connection> {
5882    let uri = sqlite_readonly_uri(path);
5883    let conn = Connection::open_with_flags(
5884        &uri,
5885        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5886    )?;
5887    conn.pragma_update(
5888        None,
5889        "synchronous",
5890        if write_amplification_baseline_enabled() {
5891            "FULL"
5892        } else {
5893            "NORMAL"
5894        },
5895    )?;
5896    conn.busy_timeout(reader_busy_timeout())?;
5897    conn.execute_batch("PRAGMA query_only=ON;")?;
5898    Ok(conn)
5899}
5900
5901fn reader_busy_timeout() -> Duration {
5902    let jitter = (now_nanos() % 500) as u64;
5903    Duration::from_millis(250 + jitter)
5904}
5905
5906fn sqlite_readonly_uri(path: &Path) -> String {
5907    let raw = path.to_string_lossy().replace('\\', "/");
5908    let encoded = percent_encode_sqlite_uri_path(&raw);
5909    if raw.starts_with('/') {
5910        format!("file://{encoded}?mode=ro")
5911    } else if raw.as_bytes().get(1) == Some(&b':') {
5912        format!("file:///{encoded}?mode=ro")
5913    } else {
5914        format!("file:{encoded}?mode=ro")
5915    }
5916}
5917
5918fn percent_encode_sqlite_uri_path(path: &str) -> String {
5919    let mut encoded = String::with_capacity(path.len());
5920    for byte in path.bytes() {
5921        match byte {
5922            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5923                encoded.push(byte as char)
5924            }
5925            _ => encoded.push_str(&format!("%{byte:02X}")),
5926        }
5927    }
5928    encoded
5929}
5930
5931fn configure_connection(conn: &Connection) -> Result<()> {
5932    conn.pragma_update(None, "journal_mode", "WAL")?;
5933    let baseline = write_amplification_baseline_enabled();
5934    conn.pragma_update(
5935        None,
5936        "synchronous",
5937        if baseline { "FULL" } else { "NORMAL" },
5938    )?;
5939    conn.pragma_update(
5940        None,
5941        "wal_autocheckpoint",
5942        if baseline {
5943            1_000
5944        } else {
5945            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5946        },
5947    )?;
5948    conn.pragma_update(None, "busy_timeout", 5_000)?;
5949    Ok(())
5950}
5951
5952fn configure_build_connection(conn: &Connection) -> Result<()> {
5953    conn.pragma_update(None, "journal_mode", "DELETE")?;
5954    conn.pragma_update(
5955        None,
5956        "synchronous",
5957        if write_amplification_baseline_enabled() {
5958            "FULL"
5959        } else {
5960            "NORMAL"
5961        },
5962    )?;
5963    conn.pragma_update(None, "busy_timeout", 5_000)?;
5964    Ok(())
5965}
5966
5967/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
5968/// private temporary copy before publishing it; a busy reader is harmless because
5969/// the next publication or cleanup pass can retry without affecting the source.
5970fn checkpoint_sqlite_before_publication(path: &Path) {
5971    let Ok(conn) = Connection::open(path) else {
5972        return;
5973    };
5974    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5975    let _ = conn.busy_timeout(Duration::from_secs(5));
5976    let _ = checkpoint_wal_truncate(&conn);
5977}
5978
5979fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5980    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5981        row.get::<_, i64>(0)
5982    }) {
5983        Ok(0) => true,
5984        Ok(_) => false,
5985        Err(rusqlite::Error::SqliteFailure(error, _))
5986            if matches!(
5987                error.code,
5988                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5989            ) =>
5990        {
5991            false
5992        }
5993        Err(error) => {
5994            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5995            false
5996        }
5997    }
5998}
5999
6000fn initialize_schema(conn: &Connection) -> Result<()> {
6001    conn.execute_batch(
6002        "CREATE TABLE IF NOT EXISTS files (
6003            path                TEXT PRIMARY KEY,
6004            content_hash        TEXT NOT NULL,
6005            mtime_ns            INTEGER NOT NULL,
6006            size                INTEGER NOT NULL,
6007            lang                TEXT NOT NULL,
6008            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
6009            is_public_api       INTEGER NOT NULL DEFAULT 0,
6010            surface_fingerprint TEXT NOT NULL,
6011            indexed_at          INTEGER NOT NULL
6012        );
6013
6014        CREATE TABLE IF NOT EXISTS nodes (
6015            id                         TEXT PRIMARY KEY,
6016            file_path                  TEXT NOT NULL,
6017            name                       TEXT NOT NULL,
6018            scoped_name                TEXT NOT NULL,
6019            kind                       TEXT NOT NULL,
6020            start_line                 INTEGER NOT NULL,
6021            start_col                  INTEGER NOT NULL,
6022            end_line                   INTEGER NOT NULL,
6023            end_col                    INTEGER NOT NULL,
6024            range_ordinal              INTEGER NOT NULL,
6025            signature                  TEXT,
6026            exported                   INTEGER NOT NULL,
6027            is_default_export          INTEGER NOT NULL,
6028            is_type_like               INTEGER NOT NULL,
6029            is_callgraph_entry_point   INTEGER NOT NULL,
6030            provenance                 TEXT NOT NULL,
6031            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6032        );
6033        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6034        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6035        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6036
6037        CREATE TABLE IF NOT EXISTS refs (
6038            ref_id          TEXT PRIMARY KEY,
6039            caller_node     TEXT,
6040            caller_file     TEXT NOT NULL,
6041            kind            TEXT NOT NULL,
6042            short_name      TEXT,
6043            full_ref        TEXT,
6044            module_path     TEXT,
6045            import_kind     TEXT,
6046            local_name      TEXT,
6047            requested_name  TEXT,
6048            namespace_alias TEXT,
6049            wildcard        INTEGER NOT NULL DEFAULT 0,
6050            line            INTEGER NOT NULL,
6051            byte_start      INTEGER NOT NULL,
6052            byte_end        INTEGER NOT NULL,
6053            status          TEXT NOT NULL,
6054            target_node     TEXT,
6055            target_file     TEXT,
6056            target_symbol   TEXT,
6057            provenance      TEXT NOT NULL
6058        );
6059        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6060        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6061        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6062        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6063        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6064
6065        CREATE TABLE IF NOT EXISTS file_dependencies (
6066            file_path   TEXT NOT NULL,
6067            dep_file    TEXT NOT NULL,
6068            PRIMARY KEY(file_path, dep_file)
6069        );
6070        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6071
6072        CREATE TABLE IF NOT EXISTS edges (
6073            edge_id       TEXT PRIMARY KEY,
6074            ref_id        TEXT NOT NULL,
6075            source_node   TEXT NOT NULL,
6076            target_node   TEXT,
6077            target_file   TEXT NOT NULL,
6078            target_symbol TEXT NOT NULL,
6079            kind          TEXT NOT NULL,
6080            line          INTEGER NOT NULL,
6081            provenance    TEXT NOT NULL
6082        );
6083        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6084        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6085        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6086        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6087
6088        CREATE TABLE IF NOT EXISTS dispatch_hints (
6089            id           TEXT PRIMARY KEY,
6090            method_name  TEXT NOT NULL,
6091            caller_node  TEXT NOT NULL,
6092            file         TEXT NOT NULL,
6093            line         INTEGER NOT NULL,
6094            byte_start   INTEGER NOT NULL,
6095            byte_end     INTEGER NOT NULL,
6096            provenance   TEXT NOT NULL
6097        );
6098        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6099        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6100
6101        CREATE TABLE IF NOT EXISTS type_ref_names (
6102            name TEXT PRIMARY KEY
6103        );
6104
6105        CREATE TABLE IF NOT EXISTS backend_file_state (
6106            backend        TEXT NOT NULL,
6107            workspace_root TEXT NOT NULL,
6108            file_path      TEXT NOT NULL,
6109            content_hash   TEXT NOT NULL,
6110            status         TEXT NOT NULL,
6111            updated_at     INTEGER NOT NULL,
6112            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6113        );
6114        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6115
6116        CREATE TABLE IF NOT EXISTS meta (
6117            k TEXT PRIMARY KEY,
6118            v TEXT NOT NULL
6119        );",
6120    )?;
6121    insert_meta(conn)?;
6122    Ok(())
6123}
6124
6125fn insert_meta(conn: &Connection) -> Result<()> {
6126    conn.execute(
6127        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6128        params![SCHEMA_VERSION.to_string()],
6129    )?;
6130    conn.execute(
6131        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6132        params![schema_fingerprint()],
6133    )?;
6134    conn.execute(
6135        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6136        [],
6137    )?;
6138    Ok(())
6139}
6140
6141/// Return the durable revision paired atomically with graph mutations. Stores
6142/// created by older binaries lack the revision row, so callers cannot detect
6143/// in-place graph changes and must not cache their snapshots.
6144fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6145    let revision: Option<String> = conn
6146        .query_row(
6147            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6148            [],
6149            |row| row.get(0),
6150        )
6151        .optional()?;
6152    revision
6153        .map(|revision| {
6154            revision.parse::<u64>().map_err(|error| {
6155                CallGraphStoreError::Unavailable(format!(
6156                    "callgraph projection write revision is invalid: {error}"
6157                ))
6158            })
6159        })
6160        .transpose()
6161}
6162
6163/// Advance the projection revision inside the graph mutation transaction so a
6164/// cached snapshot never survives an in-place refresh.
6165fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6166    tx.execute(
6167        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6168         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6169        [],
6170    )?;
6171    Ok(())
6172}
6173
6174fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6175    conn.execute(
6176        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6177        params![if ready { "1" } else { "0" }],
6178    )?;
6179    Ok(())
6180}
6181
6182fn database_ready(conn: &Connection) -> Result<bool> {
6183    let schema_version: Option<String> = conn
6184        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6185            row.get(0)
6186        })
6187        .optional()?;
6188    let fingerprint: Option<String> = conn
6189        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6190            row.get(0)
6191        })
6192        .optional()?;
6193    let ready: Option<String> = conn
6194        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6195        .optional()?;
6196
6197    let expected_schema = SCHEMA_VERSION.to_string();
6198    let expected_fingerprint = schema_fingerprint();
6199    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6200        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6201        && ready.as_deref() == Some("1"))
6202}
6203
6204fn ensure_database_ready(conn: &Connection) -> Result<()> {
6205    if database_ready(conn)? {
6206        Ok(())
6207    } else {
6208        Err(CallGraphStoreError::Unavailable(
6209            "database is missing, stale, or mid-build".to_string(),
6210        ))
6211    }
6212}
6213
6214fn schema_fingerprint() -> String {
6215    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6216    // edge sources, broader call extraction) even if the table SHAPE is
6217    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6218    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6219    let input =
6220        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6221    hash_to_hex(blake3::hash(input.as_bytes()))
6222}
6223
6224fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6225    tx.execute_batch(
6226        "DELETE FROM edges;
6227         DELETE FROM file_dependencies;
6228         DELETE FROM refs;
6229         DELETE FROM dispatch_hints;
6230         DELETE FROM type_ref_names;
6231         DELETE FROM backend_file_state;
6232         DELETE FROM nodes;
6233         DELETE FROM files;",
6234    )?;
6235    Ok(())
6236}
6237
6238fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6239    tx.execute_batch(
6240        "DROP INDEX IF EXISTS idx_nodes_file;
6241         DROP INDEX IF EXISTS idx_nodes_name;
6242         DROP INDEX IF EXISTS idx_nodes_scoped;
6243         DROP INDEX IF EXISTS idx_refs_short_name;
6244         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6245         DROP INDEX IF EXISTS idx_refs_caller_file;
6246         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6247         DROP INDEX IF EXISTS idx_refs_target_file;
6248         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6249         DROP INDEX IF EXISTS idx_edges_source_kind;
6250         DROP INDEX IF EXISTS idx_edges_target_kind;
6251         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6252         DROP INDEX IF EXISTS idx_edges_ref_id;
6253         DROP INDEX IF EXISTS idx_dispatch_hints_method;
6254         DROP INDEX IF EXISTS idx_dispatch_hints_file;
6255         DROP INDEX IF EXISTS idx_backend_file_state_file;",
6256    )?;
6257    Ok(())
6258}
6259
6260fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6261    tx.execute_batch(
6262        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6263         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6264         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6265         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6266         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6267         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6268         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6269         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6270         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6271         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6272         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6273         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6274         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6275         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6276         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6277         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6278    )?;
6279    Ok(())
6280}
6281
6282const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6283    ("files", "path"),
6284    ("nodes", "file_path"),
6285    ("refs", "caller_file"),
6286    ("refs", "target_file"),
6287    ("file_dependencies", "file_path"),
6288    ("file_dependencies", "dep_file"),
6289    ("edges", "target_file"),
6290    ("dispatch_hints", "file"),
6291    ("backend_file_state", "file_path"),
6292];
6293
6294/// Reconcile `backend_file_state.workspace_root` when the opener's project root
6295/// differs from what is stored. The store key is the git-root commit hash, so
6296/// multiple live checkouts/clones share one on-disk generation.
6297///
6298/// Cheap in-place re-root is only safe when every previously stored root path is
6299/// gone from disk (true move/rename). If any stale root still exists, another
6300/// clone is still alive and rewriting metadata would ping-pong relative rows
6301/// between trees (possibly on different branches). We then return
6302/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
6303/// opener. That can make each clone rebuild on open when they alternate — bounded
6304/// by open frequency — but each rebuild is correct for its opener, unlike silent
6305/// cross-clone corruption.
6306fn reconcile_workspace_roots(
6307    conn: &mut Connection,
6308    project_root: &Path,
6309    allow_repair: bool,
6310) -> Result<OpenRootRepair> {
6311    let roots = stored_workspace_roots(conn)?;
6312    let current_root = project_root.display().to_string();
6313    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6314        return Ok(OpenRootRepair::None);
6315    }
6316
6317    if let Some(sample) = sample_absolute_data_path(conn)? {
6318        return Ok(OpenRootRepair::NeedsRebuild {
6319            previous_roots: roots,
6320            current_root,
6321            reason: format!("absolute store data path row {sample}"),
6322        });
6323    }
6324
6325    for stored_root in roots.iter() {
6326        if stored_root == &current_root {
6327            continue;
6328        }
6329        if Path::new(stored_root).exists() {
6330            let reason = format!(
6331                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6332            );
6333            return Ok(OpenRootRepair::NeedsRebuild {
6334                previous_roots: roots,
6335                current_root,
6336                reason,
6337            });
6338        }
6339    }
6340
6341    if !allow_repair {
6342        return Ok(OpenRootRepair::NeedsRebuild {
6343            previous_roots: roots,
6344            current_root,
6345            reason: "workspace root metadata requires deferred repair".to_string(),
6346        });
6347    }
6348
6349    publish_if_current(|| {
6350        let tx = conn.transaction()?;
6351        tx.execute(
6352            "UPDATE OR IGNORE backend_file_state
6353             SET workspace_root = ?1
6354             WHERE workspace_root <> ?1",
6355            params![&current_root],
6356        )?;
6357        tx.execute(
6358            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6359            params![&current_root],
6360        )?;
6361        tx.commit()?;
6362        Ok(())
6363    })?;
6364
6365    crate::slog_info!(
6366        "callgraph store re-rooted from {} to {}",
6367        roots.join(", "),
6368        current_root
6369    );
6370    Ok(OpenRootRepair::ReRooted)
6371}
6372
6373fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6374    let mut stmt = conn.prepare(
6375        "SELECT DISTINCT workspace_root
6376         FROM backend_file_state
6377         ORDER BY workspace_root",
6378    )?;
6379    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6380    rows.collect::<std::result::Result<Vec<_>, _>>()
6381        .map_err(Into::into)
6382}
6383
6384fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6385    for (table, column) in STORE_DATA_PATH_COLUMNS {
6386        let sql = format!(
6387            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6388        );
6389        let mut stmt = conn.prepare(&sql)?;
6390        let mut rows = stmt.query([])?;
6391        while let Some(row) = rows.next()? {
6392            let value: String = row.get(0)?;
6393            if stored_path_is_absolute(&value) {
6394                return Ok(Some(format!("{table}.{column}={value}")));
6395            }
6396        }
6397    }
6398    Ok(None)
6399}
6400
6401fn stored_path_is_absolute(value: &str) -> bool {
6402    if value.is_empty() {
6403        return false;
6404    }
6405    if Path::new(value).is_absolute() || value.starts_with('/') {
6406        return true;
6407    }
6408    let bytes = value.as_bytes();
6409    if bytes.len() >= 3
6410        && bytes[1] == b':'
6411        && (bytes[2] == b'/' || bytes[2] == b'\\')
6412        && bytes[0].is_ascii_alphabetic()
6413    {
6414        return true;
6415    }
6416    value.starts_with("\\\\") || value.starts_with("//")
6417}
6418
6419fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6420    if let OpenRootRepair::NeedsRebuild {
6421        previous_roots,
6422        current_root,
6423        reason,
6424    } = repair
6425    {
6426        crate::slog_info!(
6427            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6428            previous_roots.join(", "),
6429            current_root,
6430            reason
6431        );
6432    }
6433}
6434
6435/// Nanosecond clock used to make temp/generation file names unique.
6436fn now_nanos() -> u128 {
6437    SystemTime::now()
6438        .duration_since(UNIX_EPOCH)
6439        .unwrap_or(Duration::ZERO)
6440        .as_nanos()
6441}
6442
6443/// The pointer file `<dir>/<key>.current`. Its single line names the current
6444/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
6445/// can always be atomically replaced via rename even on Windows — Rust opens
6446/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
6447fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6448    callgraph_dir.join(format!("{project_key}.current"))
6449}
6450
6451/// The legacy single-file DB path used before the generation scheme. Still read
6452/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
6453/// build publishes a generation.
6454fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6455    callgraph_dir.join(format!("{project_key}.sqlite"))
6456}
6457
6458/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
6459/// cold build writes a brand-new generation file, so publishing NEVER replaces
6460/// a file another process holds open (the root Windows fix).
6461fn generation_file_name(project_key: &str) -> String {
6462    format!(
6463        "{project_key}.g{}.{}.sqlite",
6464        now_nanos(),
6465        std::process::id()
6466    )
6467}
6468
6469/// Read the pointer; returns the generation file name if present and non-empty.
6470fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6471    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6472    let name = text.trim();
6473    if name.is_empty() {
6474        None
6475    } else {
6476        Some(name.to_string())
6477    }
6478}
6479
6480/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
6481/// `ready` flag). Uses a throwaway read-only connection.
6482fn db_path_ready(path: &Path) -> bool {
6483    (|| -> Result<bool> {
6484        let conn = open_readonly_connection(path)?;
6485        database_ready(&conn)
6486    })()
6487    .unwrap_or(false)
6488}
6489
6490/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
6491/// where `generation` is `Some(name)` for a pointer-published generation or
6492/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
6493/// published (caller treats that as "needs cold build").
6494///
6495/// Handles the GC race (the pointer names a generation that was just deleted) by
6496/// re-reading the pointer and retrying a few times.
6497fn resolve_ready_target(
6498    callgraph_dir: &Path,
6499    project_key: &str,
6500) -> Option<(PathBuf, Option<String>)> {
6501    for _ in 0..5 {
6502        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6503            let gen_path = callgraph_dir.join(&generation);
6504            if gen_path.is_file() {
6505                return (migration_manifest_valid(callgraph_dir, &generation)
6506                    && db_path_ready(&gen_path))
6507                .then_some((gen_path, Some(generation)));
6508            }
6509            // Pointer names a missing generation (a GC/publish race): re-read the
6510            // pointer and retry rather than failing the reader.
6511            std::thread::sleep(Duration::from_millis(5));
6512            continue;
6513        }
6514        // No pointer: fall back to the legacy single-file DB if it is ready.
6515        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6516        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6517    }
6518    None
6519}
6520
6521/// Atomically publish `generation` as the current store by flipping the pointer
6522/// file. Writes a temp file, fsyncs, then renames over the pointer — never
6523/// replacing an open DB file, so it succeeds cross-platform.
6524fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6525    let pointer = pointer_path(callgraph_dir, project_key);
6526    let tmp = callgraph_dir.join(format!(
6527        "{project_key}.current.tmp.{}.{}",
6528        std::process::id(),
6529        now_nanos()
6530    ));
6531    {
6532        use std::io::Write as _;
6533        let mut file = std::fs::File::create(&tmp)?;
6534        file.write_all(generation.as_bytes())?;
6535        file.write_all(b"\n")?;
6536        file.sync_all()?;
6537    }
6538    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6539        let _ = std::fs::remove_file(&tmp);
6540        return Err(error.into());
6541    }
6542    crate::fs_lock::sync_parent(&pointer);
6543    Ok(())
6544}
6545
6546#[derive(Clone, Debug)]
6547struct GenerationGcCandidate {
6548    name: String,
6549    path: PathBuf,
6550    modified: SystemTime,
6551}
6552
6553/// Best-effort GC of superseded generation files. The current pointer target and
6554/// newest previous generation are always retained. Older generations are removed
6555/// when they have no protected read marker, or after the absolute retention TTL
6556/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
6557/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
6558fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6559    let temp_grace = Duration::from_secs(60);
6560    let now = SystemTime::now();
6561    let pointer_current =
6562        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6563    let gen_prefix = format!("{project_key}.g");
6564    let tmp_prefixes = [
6565        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
6566        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
6567        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
6568    ];
6569    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6570        return;
6571    };
6572    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6573    for entry in entries.flatten() {
6574        let name = entry.file_name();
6575        let name = name.to_string_lossy().to_string();
6576        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6577        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6578
6579        // Orphaned temp files from a crashed build/publish: remove once aged out.
6580        if name.contains(".tmp.") {
6581            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6582                let _ = std::fs::remove_file(entry.path());
6583            }
6584            continue;
6585        }
6586
6587        // Superseded legacy single-file DB: best-effort delete once a generation
6588        // is published (ignored if another process still holds it open).
6589        if name == format!("{project_key}.sqlite") {
6590            remove_sqlite_file_set(&entry.path());
6591            continue;
6592        }
6593
6594        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6595            gens.push(GenerationGcCandidate {
6596                name,
6597                path: entry.path(),
6598                modified: mtime,
6599            });
6600        }
6601    }
6602
6603    let mut superseded = gens
6604        .iter()
6605        .filter(|generation| generation.name != pointer_current)
6606        .collect::<Vec<_>>();
6607    superseded.sort_by(|left, right| {
6608        right
6609            .modified
6610            .cmp(&left.modified)
6611            .then_with(|| right.name.cmp(&left.name))
6612    });
6613    let previous = superseded.first().map(|generation| generation.name.clone());
6614
6615    for generation in gens {
6616        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6617        if generation.name == pointer_current
6618            || Some(generation.name.as_str()) == previous.as_deref()
6619        {
6620            continue;
6621        }
6622
6623        let age = now
6624            .duration_since(generation.modified)
6625            .unwrap_or(Duration::ZERO);
6626        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6627            continue;
6628        }
6629
6630        remove_sqlite_file_set(&generation.path);
6631        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6632        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6633            callgraph_dir,
6634            &generation.name,
6635        ));
6636    }
6637}
6638
6639fn remove_sqlite_file_set(path: &Path) {
6640    let _ = std::fs::remove_file(path);
6641    remove_sqlite_sidecars(path);
6642}
6643
6644fn remove_sqlite_sidecars(path: &Path) {
6645    let path_text = path.to_string_lossy();
6646    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6647    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6648    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6649}
6650
6651/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
6652///
6653/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
6654/// place on success; a build that dies (process kill, crash, host restart) leaves
6655/// the temporary behind. The largest observed cold build finishes well under a
6656/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
6657/// never rename. A live build's temporary is minutes old at most.
6658///
6659/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
6660/// liveness check read false-positive on exactly the oldest files — the ones most
6661/// worth deleting: in production an orphan's embedded pid had been recycled to an
6662/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
6663/// cannot lie that way, so it is the honest orphan predicate.
6664const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6665
6666/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
6667/// same cadence as [`gc_old_generations`] (after a generation is published) but,
6668/// unlike it, is not scoped to the building root: it covers every directory in the
6669/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
6670///
6671/// That last case is the production hole this fixes. The per-root cleanup in
6672/// [`gc_old_generations`] only fires when a root actually builds, so when activity
6673/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
6674/// store's orphans become permanent — gigabytes accumulated in a legacy store
6675/// whose roots no longer built there, while the active store stayed clean. A
6676/// sibling root that still builds triggers this pass and cleans both layouts.
6677fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6678    sweep_orphaned_build_temps(callgraph_dir);
6679    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6680        return;
6681    };
6682    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6683
6684    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
6685    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6686        for entry in entries.flatten() {
6687            if entry.path().is_dir() {
6688                sweep_orphaned_build_temps(&entry.path());
6689            }
6690        }
6691    }
6692
6693    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
6694    if let Ok(entries) = std::fs::read_dir(&storage_root) {
6695        for entry in entries.flatten() {
6696            let legacy_dir = entry.path().join(domain);
6697            if legacy_dir.is_dir() {
6698                sweep_orphaned_build_temps(&legacy_dir);
6699            }
6700        }
6701    }
6702}
6703
6704/// Sweep one callgraph directory, removing build temporaries older than
6705/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
6706fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6707    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6708}
6709
6710/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
6711/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
6712fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6713    let now = SystemTime::now();
6714    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6715        return;
6716    };
6717    let mut removed_any = false;
6718    for entry in entries.flatten() {
6719        let name = entry.file_name().to_string_lossy().to_string();
6720        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
6721        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
6722        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
6723        // without that substring — a completed `.sqlite` generation, a pointer, a
6724        // read-marker dir — is left alone: those belong to generation GC.
6725        if !name.contains(".sqlite.tmp.") {
6726            continue;
6727        }
6728        let mtime = entry
6729            .metadata()
6730            .and_then(|meta| meta.modified())
6731            .unwrap_or(now);
6732        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6733            continue;
6734        }
6735        // Deletion races a concurrent build finishing: that build renames the temp
6736        // into place, so the file is gone by the time we unlink. The 24h age makes
6737        // this overlap practically impossible, but treat a missing file as success
6738        // (the rename won) rather than an error, and never touch a path that does
6739        // not match the temporary shape above.
6740        match std::fs::remove_file(entry.path()) {
6741            Ok(()) => removed_any = true,
6742            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6743            Err(_) => {}
6744        }
6745    }
6746    if removed_any {
6747        crate::fs_lock::sync_parent(callgraph_dir);
6748    }
6749}
6750
6751/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6752/// the global all-cores rayon pool. The store cold-build is the heaviest
6753/// background pass (parse-dominated) and runs on a separate thread off the
6754/// single-threaded request loop; left unbounded it monopolizes every core and
6755/// starves the bridge so interactive tools time out (the same starvation the
6756/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6757/// match the main thread, since the extract walks tree-sitter ASTs.
6758fn build_pool_size() -> usize {
6759    std::thread::available_parallelism()
6760        .map(|parallelism| parallelism.get())
6761        .unwrap_or(1)
6762        .div_ceil(2)
6763        .clamp(1, 8)
6764}
6765
6766fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6767    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6768        Ok(extract) => Ok(extract),
6769        Err(error) => {
6770            let abs_path =
6771                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6772            let rel_path = relative_path(project_root, &abs_path);
6773            let freshness = cache_freshness::collect(&abs_path).ok();
6774            log::debug!(
6775                "callgraph store: skipping {} during cold build: {}",
6776                abs_path.display(),
6777                error
6778            );
6779            Err(ExtractFailure {
6780                rel_path,
6781                freshness,
6782            })
6783        }
6784    };
6785
6786    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6787        files.par_iter().map(extract_one).collect()
6788    };
6789
6790    // Run inside a dedicated bounded pool when one builds; fall back to the
6791    // global pool only if the bounded pool can't be constructed.
6792    let results = match rayon::ThreadPoolBuilder::new()
6793        .num_threads(build_pool_size())
6794        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6795        .stack_size(8 * 1024 * 1024)
6796        .build()
6797    {
6798        Ok(pool) => pool.install(run),
6799        Err(error) => {
6800            log::warn!(
6801                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6802            );
6803            run()
6804        }
6805    };
6806
6807    let mut extracts = Vec::new();
6808    let mut failures = Vec::new();
6809    for result in results {
6810        match result {
6811            Ok(extract) => extracts.push(extract),
6812            Err(failure) => failures.push(failure),
6813        }
6814    }
6815    BuildExtractsResult { extracts, failures }
6816}
6817
6818fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6819    let metadata = std::fs::metadata(path)?;
6820    let size = metadata.len();
6821    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6822        cache_freshness::zero_hash()
6823    } else if source.len() as u64 == size {
6824        cache_freshness::hash_bytes(source.as_bytes())
6825    } else {
6826        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6827    };
6828    Ok(FileFreshness {
6829        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6830        size,
6831        content_hash,
6832    })
6833}
6834
6835fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6836    let abs_path = normalize_file_path(project_root, path)?;
6837    let rel_path = relative_path(project_root, &abs_path);
6838    let source = std::fs::read_to_string(&abs_path)?;
6839    let freshness = collect_source_freshness(&abs_path, &source)?;
6840    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6841    let lang = data.lang;
6842    if lang == LangId::Rust {
6843        extend_rust_imports_with_nested_uses(&source, &mut data);
6844    }
6845    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6846    let node_by_scoped: HashMap<String, String> = nodes
6847        .iter()
6848        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6849        .collect();
6850    let import_dependencies =
6851        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6852    let line_index = LineIndex::new(&source);
6853    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6854    let rust_reexports = if lang == LangId::Rust {
6855        collect_rust_pub_use_reexport_refs(
6856            project_root,
6857            &abs_path,
6858            &rel_path,
6859            &data.import_block.imports,
6860            &line_index,
6861        )
6862    } else {
6863        ReexportRefs {
6864            raw_refs: Vec::new(),
6865            surface_parts: Vec::new(),
6866        }
6867    };
6868    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6869    let mut raw_refs = Vec::new();
6870    raw_refs.extend(build_call_refs(
6871        &rel_path,
6872        &data,
6873        &node_by_scoped,
6874        &import_dependencies,
6875    ));
6876    raw_refs.extend(build_value_ref_refs(
6877        &rel_path,
6878        &data,
6879        &node_by_scoped,
6880        &import_dependencies,
6881    ));
6882    raw_refs.extend(build_import_refs(
6883        project_root,
6884        &abs_path,
6885        &rel_path,
6886        &data.import_block.imports,
6887        &line_index,
6888    ));
6889    let mut surface_parts = reexports.surface_parts;
6890    surface_parts.extend(rust_reexports.surface_parts);
6891    surface_parts.extend(source_less_exports.surface_parts);
6892    raw_refs.extend(reexports.raw_refs);
6893    raw_refs.extend(rust_reexports.raw_refs);
6894    raw_refs.extend(source_less_exports.raw_refs);
6895    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6896    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6897
6898    Ok(FileExtract {
6899        rel_path,
6900        freshness,
6901        lang,
6902        data,
6903        nodes,
6904        raw_refs,
6905        dispatch_hints,
6906        surface_fingerprint,
6907    })
6908}
6909
6910fn build_node_records(
6911    rel_path: &str,
6912    source: &str,
6913    data: &FileCallData,
6914) -> Result<Vec<NodeRecord>> {
6915    let mut records = Vec::new();
6916    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6917    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6918    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6919
6920    for (scoped_name, meta) in metadata {
6921        let name = unqualified_name(scoped_name).to_string();
6922        let range = selection_range(source, scoped_name, &name, &meta.range);
6923        let range_key = (
6924            range.start_line,
6925            range.start_col,
6926            range.end_line,
6927            range.end_col,
6928        );
6929        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6930        let range_ordinal = *ordinal;
6931        *ordinal += 1;
6932        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6933        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6934        let is_default_export = data
6935            .default_export_symbol
6936            .as_deref()
6937            .map(|default| default == scoped_name || default == name)
6938            .unwrap_or(false);
6939        records.push(NodeRecord {
6940            id,
6941            file_path: rel_path.to_string(),
6942            name: name.clone(),
6943            scoped_name: scoped_name.clone(),
6944            kind: symbol_kind_label(&meta.kind).to_string(),
6945            range,
6946            range_ordinal,
6947            signature: meta.signature.clone(),
6948            exported,
6949            is_default_export,
6950            is_type_like: is_type_like(&meta.kind),
6951            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6952                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6953        });
6954    }
6955
6956    Ok(records)
6957}
6958
6959fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6960    if scoped_name == TOP_LEVEL_SYMBOL {
6961        return Range {
6962            start_line: 0,
6963            start_col: 0,
6964            end_line: 0,
6965            end_col: 0,
6966        };
6967    }
6968    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6969        return fallback.clone();
6970    };
6971    let start_col = fallback.start_col as usize;
6972    let search_start = start_col.min(line.len());
6973    if let Some(offset) = line[search_start..].find(name) {
6974        let col = search_start + offset;
6975        return Range {
6976            start_line: fallback.start_line,
6977            start_col: col as u32,
6978            end_line: fallback.start_line,
6979            end_col: (col + name.len()) as u32,
6980        };
6981    }
6982    if let Some(offset) = line.find(name) {
6983        return Range {
6984            start_line: fallback.start_line,
6985            start_col: offset as u32,
6986            end_line: fallback.start_line,
6987            end_col: (offset + name.len()) as u32,
6988        };
6989    }
6990    Range {
6991        start_line: fallback.start_line,
6992        start_col: fallback.start_col,
6993        end_line: fallback.start_line,
6994        end_col: fallback.start_col.saturating_add(name.len() as u32),
6995    }
6996}
6997
6998fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6999    if scoped_name == TOP_LEVEL_SYMBOL {
7000        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7001    }
7002    let input = format!(
7003        "{rel_path}:{}:{}:{}:{}:{ordinal}",
7004        range.start_line, range.start_col, range.end_line, range.end_col
7005    );
7006    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7007}
7008
7009fn build_call_refs(
7010    rel_path: &str,
7011    data: &FileCallData,
7012    node_by_scoped: &HashMap<String, String>,
7013    import_dependencies: &BTreeSet<String>,
7014) -> Vec<RawRef> {
7015    build_callable_refs(
7016        rel_path,
7017        &data.calls_by_symbol,
7018        node_by_scoped,
7019        import_dependencies,
7020        "call",
7021    )
7022}
7023
7024fn build_value_ref_refs(
7025    rel_path: &str,
7026    data: &FileCallData,
7027    node_by_scoped: &HashMap<String, String>,
7028    import_dependencies: &BTreeSet<String>,
7029) -> Vec<RawRef> {
7030    build_callable_refs(
7031        rel_path,
7032        &data.value_refs_by_symbol,
7033        node_by_scoped,
7034        import_dependencies,
7035        "value_ref",
7036    )
7037}
7038
7039fn build_callable_refs(
7040    rel_path: &str,
7041    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7042    node_by_scoped: &HashMap<String, String>,
7043    import_dependencies: &BTreeSet<String>,
7044    kind: &str,
7045) -> Vec<RawRef> {
7046    let mut refs = Vec::new();
7047    let mut ordinal = 0usize;
7048    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7049    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7050    for (caller_symbol, call_sites) in symbols {
7051        let caller_node = node_by_scoped.get(caller_symbol).cloned();
7052        for call_site in call_sites {
7053            ordinal += 1;
7054            let ref_id = ref_id(&[
7055                rel_path,
7056                kind,
7057                caller_symbol,
7058                &call_site.line.to_string(),
7059                &call_site.byte_start.to_string(),
7060                &call_site.byte_end.to_string(),
7061                &call_site.full_callee,
7062                &ordinal.to_string(),
7063            ]);
7064            refs.push(RawRef {
7065                ref_id,
7066                caller_node: caller_node.clone(),
7067                caller_symbol: Some(caller_symbol.clone()),
7068                caller_file: rel_path.to_string(),
7069                kind: kind.to_string(),
7070                short_name: Some(call_site.callee_name.clone()),
7071                full_ref: Some(call_site.full_callee.clone()),
7072                module_path: None,
7073                import_kind: None,
7074                local_name: Some(call_site.callee_name.clone()),
7075                requested_name: Some(call_site.callee_name.clone()),
7076                namespace_alias: namespace_alias(&call_site.full_callee),
7077                wildcard: false,
7078                line: call_site.line,
7079                byte_start: call_site.byte_start,
7080                byte_end: call_site.byte_end,
7081                dependencies: import_dependencies.clone(),
7082            });
7083        }
7084    }
7085    refs
7086}
7087
7088fn build_import_refs(
7089    project_root: &Path,
7090    abs_path: &Path,
7091    rel_path: &str,
7092    imports: &[ImportStatement],
7093    line_index: &LineIndex,
7094) -> Vec<RawRef> {
7095    let mut refs = Vec::new();
7096    for (index, import) in imports.iter().enumerate() {
7097        let import_kind = import_kind_label(import.kind).to_string();
7098        let local_name = import_local_names(import).join(",");
7099        let requested_name = import_requested_names(import).join(",");
7100        let ref_id = ref_id(&[
7101            rel_path,
7102            "import",
7103            &import.byte_range.start.to_string(),
7104            &import.byte_range.end.to_string(),
7105            &import.module_path,
7106            &index.to_string(),
7107        ]);
7108        refs.push(RawRef {
7109            ref_id,
7110            caller_node: None,
7111            caller_symbol: None,
7112            caller_file: rel_path.to_string(),
7113            kind: "import".to_string(),
7114            short_name: None,
7115            full_ref: Some(import.raw_text.clone()),
7116            module_path: Some(import.module_path.clone()),
7117            import_kind: Some(import_kind),
7118            local_name: empty_to_none(local_name),
7119            requested_name: empty_to_none(requested_name),
7120            namespace_alias: import.namespace_import.clone(),
7121            wildcard: import_is_wildcard(import),
7122            line: line_index.byte_to_line(import.byte_range.start),
7123            byte_start: import.byte_range.start,
7124            byte_end: import.byte_range.end,
7125            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7126        });
7127    }
7128    refs
7129}
7130
7131fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7132    let grammar = grammar_for(LangId::Rust);
7133    let mut parser = Parser::new();
7134    if parser.set_language(&grammar).is_err() {
7135        return;
7136    }
7137    let Some(tree) = parser.parse(source, None) else {
7138        return;
7139    };
7140
7141    let mut seen = data
7142        .import_block
7143        .imports
7144        .iter()
7145        .map(|import| (import.byte_range.start, import.byte_range.end))
7146        .collect::<HashSet<_>>();
7147    let mut nested_imports = Vec::new();
7148    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
7149    if nested_imports.is_empty() {
7150        return;
7151    }
7152
7153    data.import_block.imports.extend(nested_imports);
7154    data.import_block
7155        .imports
7156        .sort_by_key(|import| import.byte_range.start);
7157    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
7158}
7159
7160fn collect_rust_use_imports(
7161    source: &str,
7162    node: Node<'_>,
7163    seen: &mut HashSet<(usize, usize)>,
7164    imports: &mut Vec<ImportStatement>,
7165) {
7166    if node.kind() == "use_declaration" {
7167        let range = node.byte_range();
7168        if seen.insert((range.start, range.end)) {
7169            if let Some(import) = rust_import_from_use_node(source, node) {
7170                imports.push(import);
7171            }
7172        }
7173    }
7174
7175    let mut cursor = node.walk();
7176    if !cursor.goto_first_child() {
7177        return;
7178    }
7179    loop {
7180        collect_rust_use_imports(source, cursor.node(), seen, imports);
7181        if !cursor.goto_next_sibling() {
7182            break;
7183        }
7184    }
7185}
7186
7187fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
7188    let raw_text = source[node.byte_range()].to_string();
7189    let body = rust_use_body(&raw_text)?.to_string();
7190    let visibility = rust_use_visibility(&raw_text);
7191    let names = rust_use_list_names(&body);
7192    let group = classify_rust_import_group(&body);
7193    let byte_range = node.byte_range();
7194
7195    Some(ImportStatement {
7196        module_path: body,
7197        names: names.clone(),
7198        default_import: visibility.clone(),
7199        namespace_import: None,
7200        kind: ImportKind::Value,
7201        group,
7202        byte_range,
7203        raw_text,
7204        form: ImportForm::RustUse {
7205            visibility,
7206            named: names,
7207        },
7208    })
7209}
7210
7211fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
7212    let start = imports.iter().map(|import| import.byte_range.start).min()?;
7213    let end = imports.iter().map(|import| import.byte_range.end).max()?;
7214    Some(start..end)
7215}
7216
7217fn rust_use_visibility(raw_text: &str) -> Option<String> {
7218    let use_pos = raw_text.find("use ")?;
7219    let prefix = raw_text[..use_pos].trim();
7220    if prefix.is_empty() {
7221        None
7222    } else {
7223        Some(prefix.to_string())
7224    }
7225}
7226
7227fn rust_use_body(raw_text: &str) -> Option<&str> {
7228    let use_pos = raw_text.find("use ")?;
7229    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
7230}
7231
7232fn rust_use_list_names(body: &str) -> Vec<String> {
7233    let Some(open) = body.find("::{") else {
7234        return Vec::new();
7235    };
7236    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
7237        return Vec::new();
7238    };
7239    body[open + 3..close]
7240        .split(',')
7241        .filter_map(|spec| {
7242            let spec = spec.trim();
7243            if spec.is_empty() {
7244                None
7245            } else {
7246                Some(spec.to_string())
7247            }
7248        })
7249        .collect()
7250}
7251
7252fn classify_rust_import_group(body: &str) -> ImportGroup {
7253    let first = body
7254        .split("::")
7255        .next()
7256        .unwrap_or(body)
7257        .split_whitespace()
7258        .next()
7259        .unwrap_or(body);
7260    match first.trim() {
7261        "std" | "core" | "alloc" => ImportGroup::Stdlib,
7262        "crate" | "self" | "super" => ImportGroup::Internal,
7263        _ => ImportGroup::External,
7264    }
7265}
7266
7267#[derive(Debug, Clone)]
7268struct ReexportRefs {
7269    raw_refs: Vec<RawRef>,
7270    surface_parts: Vec<String>,
7271}
7272
7273fn collect_reexport_refs(
7274    project_root: &Path,
7275    abs_path: &Path,
7276    rel_path: &str,
7277    source: &str,
7278) -> ReexportRefs {
7279    let mut raw_refs = Vec::new();
7280    let mut surface_parts = Vec::new();
7281    let mut search_start = 0usize;
7282    let mut ordinal = 0usize;
7283    while let Some(export_offset) = source[search_start..].find("export") {
7284        let start = search_start + export_offset;
7285        let Some(statement_end_offset) = source[start..].find(';') else {
7286            break;
7287        };
7288        let end = start + statement_end_offset + 1;
7289        let statement = &source[start..end];
7290        search_start = end;
7291        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7292            continue;
7293        }
7294        let Some(module_path) = quoted_module_path(statement) else {
7295            continue;
7296        };
7297        ordinal += 1;
7298        let wildcard = statement.contains('*');
7299        let line = source[..start]
7300            .bytes()
7301            .filter(|byte| *byte == b'\n')
7302            .count() as u32
7303            + 1;
7304        let ref_id = ref_id(&[
7305            rel_path,
7306            "reexport",
7307            &start.to_string(),
7308            &end.to_string(),
7309            &module_path,
7310            &ordinal.to_string(),
7311        ]);
7312        surface_parts.push(format!("reexport\t{statement}"));
7313        raw_refs.push(RawRef {
7314            ref_id,
7315            caller_node: None,
7316            caller_symbol: None,
7317            caller_file: rel_path.to_string(),
7318            kind: "reexport".to_string(),
7319            short_name: None,
7320            full_ref: Some(statement.to_string()),
7321            module_path: Some(module_path.clone()),
7322            import_kind: Some("reexport".to_string()),
7323            local_name: None,
7324            requested_name: None,
7325            namespace_alias: None,
7326            wildcard,
7327            line,
7328            byte_start: start,
7329            byte_end: end,
7330            dependencies: module_dependencies(project_root, abs_path, &module_path),
7331        });
7332    }
7333    ReexportRefs {
7334        raw_refs,
7335        surface_parts,
7336    }
7337}
7338
7339fn collect_rust_pub_use_reexport_refs(
7340    project_root: &Path,
7341    abs_path: &Path,
7342    rel_path: &str,
7343    imports: &[ImportStatement],
7344    line_index: &LineIndex,
7345) -> ReexportRefs {
7346    let mut raw_refs = Vec::new();
7347    let mut surface_parts = Vec::new();
7348    let mut ordinal = 0usize;
7349
7350    for import in imports {
7351        let Some(visibility) = &import.default_import else {
7352            continue;
7353        };
7354        if !visibility.starts_with("pub") {
7355            continue;
7356        }
7357        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7358            continue;
7359        };
7360        ordinal += 1;
7361        let ref_id = ref_id(&[
7362            rel_path,
7363            "rust_reexport",
7364            &import.byte_range.start.to_string(),
7365            &import.byte_range.end.to_string(),
7366            &module_path,
7367            &ordinal.to_string(),
7368        ]);
7369        surface_parts.push(format!("reexport\t{}", import.raw_text));
7370        raw_refs.push(RawRef {
7371            ref_id,
7372            caller_node: None,
7373            caller_symbol: None,
7374            caller_file: rel_path.to_string(),
7375            kind: "reexport".to_string(),
7376            short_name: None,
7377            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7378            module_path: Some(module_path.clone()),
7379            import_kind: Some("reexport".to_string()),
7380            local_name: None,
7381            requested_name: None,
7382            namespace_alias: None,
7383            wildcard,
7384            line: line_index.byte_to_line(import.byte_range.start),
7385            byte_start: import.byte_range.start,
7386            byte_end: import.byte_range.end,
7387            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7388        });
7389    }
7390
7391    ReexportRefs {
7392        raw_refs,
7393        surface_parts,
7394    }
7395}
7396
7397fn rust_pub_use_reexport_parts(
7398    import: &ImportStatement,
7399) -> Option<(String, HashMap<String, String>, bool)> {
7400    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7401    let body = body.trim();
7402    if let Some(module_path) = body.strip_suffix("::*") {
7403        return Some((module_path.trim().to_string(), HashMap::new(), true));
7404    }
7405
7406    if let Some(brace_start) = body.find("::{") {
7407        let module_path = body[..brace_start].trim().to_string();
7408        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7409        if names.is_empty() {
7410            return None;
7411        }
7412        return Some((module_path, names, false));
7413    }
7414
7415    let (module_path, spec) = body.rsplit_once("::")?;
7416    let names = rust_reexport_names_from_specs(spec);
7417    if names.is_empty() {
7418        return None;
7419    }
7420    Some((module_path.trim().to_string(), names, false))
7421}
7422
7423fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7424    let mut names = HashMap::new();
7425    for spec in specs.split(',') {
7426        let spec = spec.trim();
7427        if spec.is_empty() || spec == "self" {
7428            continue;
7429        }
7430        if let Some((source, local)) = spec.split_once(" as ") {
7431            let source = source.trim();
7432            let local = local.trim();
7433            if !source.is_empty() && !local.is_empty() && source != "self" {
7434                names.insert(local.to_string(), source.to_string());
7435            }
7436        } else {
7437            names.insert(spec.to_string(), spec.to_string());
7438        }
7439    }
7440    names
7441}
7442
7443fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7444    if named.is_empty() {
7445        return fallback.to_string();
7446    }
7447    let mut specs = named
7448        .iter()
7449        .map(|(local, source)| {
7450            if local == source {
7451                source.clone()
7452            } else {
7453                format!("{source} as {local}")
7454            }
7455        })
7456        .collect::<Vec<_>>();
7457    specs.sort();
7458    format!("pub use {{{}}};", specs.join(", "))
7459}
7460
7461fn quoted_module_path(statement: &str) -> Option<String> {
7462    let quote = match (statement.find('\''), statement.find('"')) {
7463        (Some(single), Some(double)) if single < double => '\'',
7464        (Some(_), Some(_)) => '"',
7465        (Some(_), None) => '\'',
7466        (None, Some(_)) => '"',
7467        (None, None) => return None,
7468    };
7469    let start = statement.find(quote)? + 1;
7470    let end = statement[start..].find(quote)? + start;
7471    Some(statement[start..end].to_string())
7472}
7473
7474#[derive(Debug, Clone)]
7475struct SourceLessExportRefs {
7476    raw_refs: Vec<RawRef>,
7477    surface_parts: Vec<String>,
7478}
7479
7480fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7481    let mut raw_refs = Vec::new();
7482    let mut surface_parts = Vec::new();
7483    let mut search_start = 0usize;
7484    let mut ordinal = 0usize;
7485    while let Some(export_offset) = source[search_start..].find("export") {
7486        let start = search_start + export_offset;
7487        let Some(statement_end_offset) = source[start..].find(';') else {
7488            break;
7489        };
7490        let end = start + statement_end_offset + 1;
7491        let statement = &source[start..end];
7492        search_start = end;
7493        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7494            continue;
7495        }
7496        let aliases = parse_reexport_names(statement);
7497        if aliases.is_empty() {
7498            continue;
7499        }
7500        let line = source[..start]
7501            .bytes()
7502            .filter(|byte| *byte == b'\n')
7503            .count() as u32
7504            + 1;
7505        for (exported, source_symbol) in aliases {
7506            ordinal += 1;
7507            let ref_id = ref_id(&[
7508                rel_path,
7509                "export_alias",
7510                &start.to_string(),
7511                &end.to_string(),
7512                &exported,
7513                &source_symbol,
7514                &ordinal.to_string(),
7515            ]);
7516            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7517            raw_refs.push(RawRef {
7518                ref_id,
7519                caller_node: None,
7520                caller_symbol: None,
7521                caller_file: rel_path.to_string(),
7522                kind: "export_alias".to_string(),
7523                short_name: None,
7524                full_ref: Some(statement.to_string()),
7525                module_path: None,
7526                import_kind: Some("export_alias".to_string()),
7527                local_name: Some(exported),
7528                requested_name: Some(source_symbol),
7529                namespace_alias: None,
7530                wildcard: false,
7531                line,
7532                byte_start: start,
7533                byte_end: end,
7534                dependencies: BTreeSet::new(),
7535            });
7536        }
7537    }
7538    SourceLessExportRefs {
7539        raw_refs,
7540        surface_parts,
7541    }
7542}
7543
7544fn build_dispatch_hints(
7545    rel_path: &str,
7546    data: &FileCallData,
7547    node_by_scoped: &HashMap<String, String>,
7548) -> Vec<DispatchHint> {
7549    let mut hints = Vec::new();
7550    let mut ordinal = 0usize;
7551    for (caller_symbol, call_sites) in &data.calls_by_symbol {
7552        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7553            continue;
7554        };
7555        for call_site in call_sites {
7556            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7557                continue;
7558            }
7559            ordinal += 1;
7560            hints.push(DispatchHint {
7561                id: ref_id(&[
7562                    rel_path,
7563                    "dispatch",
7564                    caller_symbol,
7565                    &call_site.line.to_string(),
7566                    &call_site.byte_start.to_string(),
7567                    &call_site.byte_end.to_string(),
7568                    &ordinal.to_string(),
7569                ]),
7570                method_name: call_site.callee_name.clone(),
7571                caller_node: caller_node.clone(),
7572                file: rel_path.to_string(),
7573                line: call_site.line,
7574                byte_start: call_site.byte_start,
7575                byte_end: call_site.byte_end,
7576            });
7577        }
7578    }
7579    hints
7580}
7581
7582fn surface_fingerprint(
7583    nodes: &mut [NodeRecord],
7584    data: &FileCallData,
7585    reexport_parts: &[String],
7586) -> String {
7587    nodes.sort_by(|left, right| {
7588        (left.file_path.as_str(), left.scoped_name.as_str())
7589            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7590    });
7591    let mut parts = Vec::new();
7592    for node in nodes.iter() {
7593        parts.push(format!(
7594            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7595            node.scoped_name,
7596            node.name,
7597            node.kind,
7598            node.exported,
7599            node.range.start_line,
7600            node.range.start_col,
7601            node.range.end_line,
7602            node.range.end_col,
7603            node.range_ordinal,
7604            node.signature.as_deref().unwrap_or("")
7605        ));
7606    }
7607    let mut exports = data.exported_symbols.clone();
7608    exports.sort();
7609    for export in exports {
7610        parts.push(format!("export\t{export}"));
7611    }
7612    if let Some(default_export) = &data.default_export_symbol {
7613        parts.push(format!("default\t{default_export}"));
7614    }
7615    let mut imports: Vec<String> = data
7616        .import_block
7617        .imports
7618        .iter()
7619        .map(|import| {
7620            format!(
7621                "import\t{}\t{:?}\t{}",
7622                import.module_path, import.form, import.raw_text
7623            )
7624        })
7625        .collect();
7626    imports.sort();
7627    parts.extend(imports);
7628    parts.extend(reexport_parts.iter().cloned());
7629    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7630}
7631
7632fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7633    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
7634        return Ok(ResolvedRef {
7635            dependencies: raw.dependencies.clone(),
7636            raw,
7637            status: "unresolved".to_string(),
7638            target_node: None,
7639            target_file: None,
7640            target_symbol: None,
7641            edge: None,
7642        });
7643    }
7644
7645    let caller_file = raw.caller_file.clone();
7646    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7647        CallGraphStoreError::MissingCallerData {
7648            file: caller_file.clone(),
7649        }
7650    })?;
7651    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7652    let short_name = raw.short_name.as_deref().unwrap_or_default();
7653    let mut dependencies = raw.dependencies.clone();
7654
7655    let resolved = match index.lang_for(&caller_file) {
7656        Some(LangId::Rust) => {
7657            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7658        }
7659        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7660            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7661        }
7662        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7663    };
7664
7665    let Some((status, target_file, target_symbol)) = resolved else {
7666        return Ok(ResolvedRef {
7667            raw,
7668            status: "unresolved".to_string(),
7669            target_node: None,
7670            target_file: None,
7671            target_symbol: None,
7672            dependencies,
7673            edge: None,
7674        });
7675    };
7676
7677    dependencies.insert(target_file.clone());
7678    let target_node = index.node_for_symbol(&target_file, &target_symbol);
7679    if raw.kind == "value_ref"
7680        && !target_node
7681            .as_deref()
7682            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
7683    {
7684        return Ok(ResolvedRef {
7685            raw,
7686            status: "unresolved".to_string(),
7687            target_node: None,
7688            target_file: None,
7689            target_symbol: None,
7690            dependencies,
7691            edge: None,
7692        });
7693    }
7694    let source_node = raw.caller_node.clone();
7695    let edge = if let Some(source_node) = source_node {
7696        if target_file == caller_file
7697            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7698        {
7699            None
7700        } else {
7701            Some(EdgeRecord {
7702                edge_id: ref_id(&[&raw.ref_id, "edge"]),
7703                source_node,
7704                target_node: target_node.clone(),
7705                target_file: target_file.clone(),
7706                target_symbol: target_symbol.clone(),
7707                kind: raw.kind.clone(),
7708                line: raw.line,
7709            })
7710        }
7711    } else {
7712        None
7713    };
7714
7715    Ok(ResolvedRef {
7716        raw,
7717        status,
7718        target_node,
7719        target_file: Some(target_file),
7720        target_symbol: Some(target_symbol),
7721        dependencies,
7722        edge,
7723    })
7724}
7725
7726fn resolve_js_ts_target(
7727    index: &ProjectIndex<'_>,
7728    caller_file: &str,
7729    full_ref: &str,
7730    short_name: &str,
7731    caller_data: &FileCallData,
7732) -> Option<(String, String, String)> {
7733    if let Some((namespace, member)) = full_ref.split_once('.') {
7734        for import in &caller_data.import_block.imports {
7735            if import.namespace_import.as_deref() == Some(namespace) {
7736                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7737                    if let Some((file, symbol)) =
7738                        resolve_exported_symbol(index, &target_file, member, 0)
7739                    {
7740                        return Some(("resolved".to_string(), file, symbol));
7741                    }
7742                }
7743            }
7744        }
7745    }
7746
7747    for import in &caller_data.import_block.imports {
7748        for spec in &import.names {
7749            if crate::imports::specifier_local_name(spec) == short_name {
7750                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7751                    let requested = crate::imports::specifier_imported_name(spec);
7752                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7753                        .unwrap_or_else(|| (target_file, requested.to_string()));
7754                    return Some(("resolved".to_string(), file, symbol));
7755                }
7756            }
7757        }
7758
7759        if import.default_import.as_deref() == Some(short_name) {
7760            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7761                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7762                    .or_else(|| {
7763                        index
7764                            .files
7765                            .get(&target_file)
7766                            .and_then(|file| file.default_export.clone())
7767                            .map(|symbol| (target_file.clone(), symbol))
7768                    })
7769                    .unwrap_or_else(|| {
7770                        let file_name = Path::new(&target_file)
7771                            .file_name()
7772                            .and_then(|name| name.to_str())
7773                            .unwrap_or("unknown")
7774                            .to_string();
7775                        (target_file, format!("<default:{file_name}>"))
7776                    });
7777                return Some(("resolved".to_string(), file, symbol));
7778            }
7779        }
7780    }
7781
7782    for import in &caller_data.import_block.imports {
7783        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7784            if index
7785                .files
7786                .get(&target_file)
7787                .map(|file| file.exports.contains(short_name))
7788                .unwrap_or(false)
7789            {
7790                return Some(("resolved".to_string(), target_file, short_name.to_string()));
7791            }
7792        }
7793    }
7794
7795    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7796}
7797
7798fn resolve_exported_symbol(
7799    index: &ProjectIndex<'_>,
7800    file: &str,
7801    requested: &str,
7802    depth: usize,
7803) -> Option<(String, String)> {
7804    let mut visited = std::collections::HashMap::new();
7805    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7806}
7807
7808/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7809/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7810/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7811/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7812/// (file, symbol) pair — but only when the earlier visit had at least as much
7813/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7814/// first visit had to cut off at the cap, so plain visited-set pruning would
7815/// lose resolutions the capped walk finds).
7816fn resolve_exported_symbol_inner(
7817    index: &ProjectIndex<'_>,
7818    file: &str,
7819    requested: &str,
7820    depth: usize,
7821    visited: &mut std::collections::HashMap<(String, String), usize>,
7822) -> Option<(String, String)> {
7823    if depth > 16 {
7824        return None;
7825    }
7826    if requested != "default" {
7827        if let Some(source_symbol) = index
7828            .files
7829            .get(file)
7830            .and_then(|item| item.export_aliases.get(requested))
7831        {
7832            return Some((file.to_string(), source_symbol.clone()));
7833        }
7834        if index
7835            .files
7836            .get(file)
7837            .map(|item| item.exports.contains(requested))
7838            .unwrap_or(false)
7839        {
7840            return Some((file.to_string(), requested.to_string()));
7841        }
7842    } else if let Some(default) = index
7843        .files
7844        .get(file)
7845        .and_then(|item| item.default_export.clone())
7846    {
7847        return Some((file.to_string(), default));
7848    }
7849
7850    // Memo check sits after the local-export fast paths: the common direct
7851    // hit never allocates the key, and a hit through the memo would have
7852    // returned above anyway.
7853    match visited.entry((file.to_string(), requested.to_string())) {
7854        std::collections::hash_map::Entry::Occupied(mut seen) => {
7855            if *seen.get() <= depth {
7856                return None;
7857            }
7858            seen.insert(depth);
7859        }
7860        std::collections::hash_map::Entry::Vacant(slot) => {
7861            slot.insert(depth);
7862        }
7863    }
7864
7865    for reexport in index.reexports_for(file) {
7866        let mut next_requested = requested.to_string();
7867        let matches = if reexport.wildcard {
7868            true
7869        } else if let Some(source_name) = reexport.named.get(requested) {
7870            next_requested = source_name.clone();
7871            true
7872        } else {
7873            false
7874        };
7875        if !matches {
7876            continue;
7877        }
7878        if let Some(target_file) = &reexport.target_file {
7879            if let Some(target) = resolve_exported_symbol_inner(
7880                index,
7881                target_file,
7882                &next_requested,
7883                depth + 1,
7884                visited,
7885            ) {
7886                return Some(target);
7887            }
7888        }
7889    }
7890    None
7891}
7892
7893fn resolve_rust_target(
7894    index: &ProjectIndex<'_>,
7895    caller_file: &str,
7896    full_ref: &str,
7897    short_name: &str,
7898    caller_data: &FileCallData,
7899    raw: &RawRef,
7900) -> Option<(String, String, String)> {
7901    if full_ref.contains("::") {
7902        if let Some((target_file, target_symbol)) =
7903            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7904        {
7905            return Some(("resolved".to_string(), target_file, target_symbol));
7906        }
7907    }
7908
7909    for import in &caller_data.import_block.imports {
7910        if let Some((target_file, target_symbol)) =
7911            rust_target_for_use(index, caller_file, import, short_name)
7912        {
7913            return Some(("resolved".to_string(), target_file, target_symbol));
7914        }
7915    }
7916
7917    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7918}
7919
7920fn rust_target_for_qualified(
7921    index: &ProjectIndex<'_>,
7922    caller_file: &str,
7923    full_ref: &str,
7924    short_name: &str,
7925    caller_data: &FileCallData,
7926    raw: &RawRef,
7927) -> Option<(String, String)> {
7928    let mut segments: Vec<&str> = full_ref.split("::").collect();
7929    if segments.len() < 2 {
7930        return None;
7931    }
7932    segments.pop();
7933    let requested_symbol = rust_target_symbol(full_ref, short_name);
7934
7935    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7936        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7937        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7938            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7939                return Some(rust_resolve_reexport_if_symbol_missing(
7940                    index,
7941                    target_file,
7942                    requested_symbol.clone(),
7943                ));
7944            }
7945        }
7946
7947        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7948        if let Some(target) =
7949            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7950        {
7951            return Some(target);
7952        }
7953        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7954            return Some(rust_resolve_reexport_if_symbol_missing(
7955                index,
7956                target_file,
7957                requested_symbol.clone(),
7958            ));
7959        }
7960    }
7961    None
7962}
7963
7964fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7965    full_ref
7966        .rsplit("::")
7967        .next()
7968        .filter(|name| !name.is_empty())
7969        .unwrap_or(short_name)
7970        .to_string()
7971}
7972
7973fn rust_resolve_reexport_if_symbol_missing(
7974    index: &ProjectIndex<'_>,
7975    target_file: String,
7976    target_symbol: String,
7977) -> (String, String) {
7978    if index
7979        .node_for_symbol(&target_file, &target_symbol)
7980        .is_some()
7981    {
7982        return (target_file, target_symbol);
7983    }
7984    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7985        resolved
7986    } else {
7987        (target_file, target_symbol)
7988    }
7989}
7990
7991fn rust_module_path_candidates(
7992    segments: &[&str],
7993    caller_data: &FileCallData,
7994    raw: &RawRef,
7995) -> Vec<Vec<String>> {
7996    let mut candidates = Vec::new();
7997    if let Some(first) = segments.first().copied() {
7998        for import in &caller_data.import_block.imports {
7999            if !rust_import_is_visible_to_call(import, raw) {
8000                continue;
8001            }
8002            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8003                continue;
8004            };
8005            if local_name == first {
8006                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8007                rust_push_unique_path_candidate(&mut candidates, path_segments);
8008            }
8009        }
8010    }
8011    rust_push_unique_path_candidate(
8012        &mut candidates,
8013        segments
8014            .iter()
8015            .map(|segment| (*segment).to_string())
8016            .collect(),
8017    );
8018    candidates
8019}
8020
8021fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8022    if !candidates.iter().any(|existing| existing == &candidate) {
8023        candidates.push(candidate);
8024    }
8025}
8026
8027fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8028    import.byte_range.start <= raw.byte_start
8029}
8030
8031fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8032    let path = import.module_path.trim().trim_end_matches(';').trim();
8033    if path.contains("::{") || path.contains('{') || path.contains('*') {
8034        return None;
8035    }
8036    let (path_without_alias, alias) = path
8037        .split_once(" as ")
8038        .map(|(left, right)| (left.trim(), Some(right.trim())))
8039        .unwrap_or((path, None));
8040    let segments = path_without_alias
8041        .split("::")
8042        .map(str::trim)
8043        .filter(|segment| !segment.is_empty())
8044        .collect::<Vec<_>>();
8045    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8046    if local_name.chars().next().is_some_and(char::is_uppercase) {
8047        return None;
8048    }
8049    Some((
8050        local_name,
8051        segments
8052            .into_iter()
8053            .map(|segment| segment.to_string())
8054            .collect(),
8055    ))
8056}
8057
8058fn rust_inline_scoped_target(
8059    index: &ProjectIndex<'_>,
8060    caller_file: &str,
8061    module_segments: &[String],
8062    short_name: &str,
8063) -> Option<(String, String)> {
8064    let src_prefix = rust_src_prefix(caller_file);
8065    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
8066    file_paths.sort();
8067    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
8068        let caller = file_paths.remove(position);
8069        file_paths.insert(0, caller);
8070    }
8071
8072    for file_path in file_paths {
8073        if index.lang_for(&file_path) != Some(LangId::Rust)
8074            || rust_src_prefix(&file_path) != src_prefix
8075        {
8076            continue;
8077        }
8078        let file_module_segments = rust_module_segments_for_rel(&file_path);
8079        if !module_segments.starts_with(&file_module_segments) {
8080            continue;
8081        }
8082        let scoped_segments = &module_segments[file_module_segments.len()..];
8083        if scoped_segments.is_empty() {
8084            continue;
8085        }
8086        let mut scoped_symbol = scoped_segments.join("::");
8087        scoped_symbol.push_str("::");
8088        scoped_symbol.push_str(short_name);
8089        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
8090            return Some((file_path, scoped_symbol));
8091        }
8092    }
8093    None
8094}
8095
8096fn rust_target_for_use(
8097    index: &ProjectIndex<'_>,
8098    caller_file: &str,
8099    import: &ImportStatement,
8100    short_name: &str,
8101) -> Option<(String, String)> {
8102    let path = import.module_path.trim().trim_end_matches(';');
8103    if let Some(brace_start) = path.find("::{") {
8104        let prefix = &path[..brace_start];
8105        if import.names.iter().any(|name| name == short_name) {
8106            let prefix_segments: Vec<&str> = prefix.split("::").collect();
8107            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8108            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8109            return Some((file, short_name.to_string()));
8110        }
8111        return None;
8112    }
8113
8114    let (path_without_alias, alias) = path
8115        .split_once(" as ")
8116        .map(|(left, right)| (left.trim(), Some(right.trim())))
8117        .unwrap_or((path, None));
8118    let segments: Vec<&str> = path_without_alias.split("::").collect();
8119    let imported = alias.or_else(|| segments.last().copied())?;
8120    if imported != short_name {
8121        return None;
8122    }
8123    if segments.len() < 2 {
8124        return None;
8125    }
8126    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8127    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8128    Some((file, segments.last().unwrap_or(&short_name).to_string()))
8129}
8130
8131fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
8132    let crate_name = segments.first().copied()?;
8133    let src_prefix = index.crate_src_prefix(crate_name)?;
8134    let module_segments = segments[1..]
8135        .iter()
8136        .map(|segment| segment.to_string())
8137        .collect::<Vec<_>>();
8138    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8139}
8140
8141#[cfg(test)]
8142static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8143    OnceLock::new();
8144
8145#[cfg(test)]
8146fn note_workspace_crate_prefix_build(project_root: &Path) {
8147    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8148        .get_or_init(|| Mutex::new(HashMap::new()))
8149        .lock()
8150        .expect("workspace crate prefix build counts mutex poisoned");
8151    *counts.entry(project_root.to_path_buf()).or_default() += 1;
8152}
8153
8154#[cfg(not(test))]
8155fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8156
8157#[cfg(test)]
8158fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8159    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8160        .get_or_init(|| Mutex::new(HashMap::new()))
8161        .lock()
8162        .expect("workspace crate prefix build counts mutex poisoned")
8163        .remove(project_root);
8164}
8165
8166#[cfg(test)]
8167fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8168    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8169        .get_or_init(|| Mutex::new(HashMap::new()))
8170        .lock()
8171        .expect("workspace crate prefix build counts mutex poisoned")
8172        .get(project_root)
8173        .copied()
8174        .unwrap_or(0)
8175}
8176
8177/// Walk the project tree once and map every Rust crate name (package name with
8178/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
8179/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
8180/// longer re-walks the filesystem once per ref.
8181fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8182    note_workspace_crate_prefix_build(project_root);
8183    let mut prefixes = HashMap::new();
8184    let mut stack = vec![project_root.to_path_buf()];
8185    while let Some(dir) = stack.pop() {
8186        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8187        if matches!(name, "target" | "node_modules" | ".git") {
8188            continue;
8189        }
8190        let manifest = dir.join("Cargo.toml");
8191        if manifest.is_file() {
8192            let crate_names = rust_manifest_crate_names(&manifest);
8193            if !crate_names.is_empty() {
8194                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
8195                for crate_name in crate_names {
8196                    prefixes
8197                        .entry(crate_name)
8198                        .or_insert_with(|| src_prefix.clone());
8199                }
8200            }
8201        }
8202        let Ok(entries) = std::fs::read_dir(&dir) else {
8203            continue;
8204        };
8205        for entry in entries.flatten() {
8206            let path = entry.path();
8207            if path.is_dir() {
8208                stack.push(path);
8209            }
8210        }
8211    }
8212    prefixes
8213}
8214
8215/// Extract the crate names a manifest defines: the normalized package name
8216/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
8217/// reachable by either spelling, matching the previous match semantics.
8218fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
8219    let Ok(source) = std::fs::read_to_string(manifest) else {
8220        return Vec::new();
8221    };
8222    let mut in_lib = false;
8223    let mut package_name = None;
8224    let mut lib_name = None;
8225    for line in source.lines() {
8226        let trimmed = line.trim();
8227        if trimmed.starts_with('[') {
8228            in_lib = trimmed == "[lib]";
8229            continue;
8230        }
8231        let Some((key, value)) = trimmed.split_once('=') else {
8232            continue;
8233        };
8234        let key = key.trim();
8235        let value = value.trim().trim_matches('"');
8236        if in_lib && key == "name" {
8237            lib_name = Some(value.to_string());
8238        } else if !in_lib && key == "name" && package_name.is_none() {
8239            package_name = Some(value.to_string());
8240        }
8241    }
8242    let mut names = Vec::new();
8243    if let Some(lib) = lib_name {
8244        names.push(lib);
8245    }
8246    if let Some(package) = package_name {
8247        let normalized = package.replace('-', "_");
8248        if !names.contains(&normalized) {
8249            names.push(normalized);
8250        }
8251    }
8252    names
8253}
8254
8255fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8256    if segments.is_empty() {
8257        return Some(Vec::new());
8258    }
8259    let caller_segments = rust_module_segments_for_rel(caller_file);
8260    match segments[0] {
8261        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8262        "self" => {
8263            let mut resolved = caller_segments;
8264            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8265            Some(resolved)
8266        }
8267        "super" => {
8268            let mut resolved = caller_segments;
8269            resolved.pop();
8270            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8271            Some(resolved)
8272        }
8273        _ => {
8274            let mut resolved = caller_segments;
8275            resolved.pop();
8276            resolved.extend(segments.iter().map(|item| item.to_string()));
8277            Some(resolved)
8278        }
8279    }
8280}
8281
8282fn rust_file_for_segments(
8283    index: &ProjectIndex<'_>,
8284    caller_file: &str,
8285    segments: &[String],
8286) -> Option<String> {
8287    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8288}
8289
8290fn rust_file_for_src_prefix(
8291    index: &ProjectIndex<'_>,
8292    src_prefix: &str,
8293    segments: &[String],
8294) -> Option<String> {
8295    let candidate = if segments.is_empty() {
8296        [src_prefix, "lib.rs"].join("/")
8297    } else {
8298        format!("{}/{}.rs", src_prefix, segments.join("/"))
8299    };
8300    if index.files.contains_key(&candidate) {
8301        return Some(candidate);
8302    }
8303    if !segments.is_empty() {
8304        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8305        if index.files.contains_key(&mod_candidate) {
8306            return Some(mod_candidate);
8307        }
8308    }
8309    None
8310}
8311
8312fn rust_src_prefix(rel_path: &str) -> String {
8313    rel_path
8314        .split_once("/src/")
8315        .map(|(prefix, _)| format!("{prefix}/src"))
8316        .unwrap_or_else(|| "src".to_string())
8317}
8318
8319fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8320    let after_src = rel_path
8321        .split_once("/src/")
8322        .map(|(_, rest)| rest)
8323        .or_else(|| rel_path.strip_prefix("src/"))
8324        .unwrap_or(rel_path);
8325    if matches!(after_src, "lib.rs" | "main.rs") {
8326        return Vec::new();
8327    }
8328    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8329        return prefix.split('/').map(|item| item.to_string()).collect();
8330    }
8331    after_src
8332        .strip_suffix(".rs")
8333        .unwrap_or(after_src)
8334        .split('/')
8335        .map(|item| item.to_string())
8336        .collect()
8337}
8338
8339fn resolve_local_target(
8340    _index: &ProjectIndex<'_>,
8341    caller_file: &str,
8342    full_ref: &str,
8343    short_name: &str,
8344    caller_data: &FileCallData,
8345) -> Option<(String, String, String)> {
8346    if !callgraph::is_bare_callee(full_ref, short_name) {
8347        return None;
8348    }
8349    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8350        .ok()
8351        .map(|symbol| {
8352            (
8353                "resolved_local".to_string(),
8354                caller_file.to_string(),
8355                symbol,
8356            )
8357        })
8358}
8359
8360impl<'a> ProjectIndex<'a> {
8361    fn from_parts(
8362        project_root: &Path,
8363        files: HashMap<String, DbFileIndex>,
8364        caller_data: HashMap<String, &'a FileCallData>,
8365        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8366    ) -> Self {
8367        Self {
8368            project_root: project_root.to_path_buf(),
8369            files,
8370            caller_data,
8371            workspace_crate_prefixes,
8372        }
8373    }
8374
8375    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8376        let mut files = HashMap::new();
8377        let mut caller_data = HashMap::new();
8378        for extract in extracts {
8379            let index = DbFileIndex::from_extract(project_root, extract);
8380            caller_data.insert(extract.rel_path.clone(), &extract.data);
8381            files.insert(extract.rel_path.clone(), index);
8382        }
8383        Self::from_parts(
8384            project_root,
8385            files,
8386            caller_data,
8387            WorkspaceCratePrefixCache::default(),
8388        )
8389    }
8390
8391    fn from_db_and_callers(
8392        tx: &Transaction<'_>,
8393        project_root: &Path,
8394        caller_extracts: &'a HashMap<String, FileExtract>,
8395        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8396    ) -> Result<Self> {
8397        let mut files = load_db_file_indexes(tx, project_root)?;
8398        let mut caller_data = HashMap::new();
8399        for (rel_path, extract) in caller_extracts {
8400            files.insert(
8401                rel_path.clone(),
8402                DbFileIndex::from_extract(project_root, extract),
8403            );
8404            caller_data.insert(rel_path.clone(), &extract.data);
8405        }
8406        Ok(Self::from_parts(
8407            project_root,
8408            files,
8409            caller_data,
8410            workspace_crate_prefixes,
8411        ))
8412    }
8413
8414    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8415        self.files.get(rel_path).and_then(|file| file.lang)
8416    }
8417
8418    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8419        self.files
8420            .get(caller_file)
8421            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8422    }
8423
8424    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8425        self.files
8426            .get(rel_path)
8427            .map(|file| file.reexports.as_slice())
8428            .unwrap_or(&[])
8429    }
8430
8431    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8432        self.files.get(rel_path).and_then(|file| {
8433            file.node_by_scoped
8434                .get(symbol)
8435                .cloned()
8436                .or_else(|| file.node_by_bare.get(symbol).cloned())
8437        })
8438    }
8439
8440    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
8441        self.files
8442            .get(rel_path)
8443            .and_then(|file| file.node_kind_by_id.get(node_id))
8444            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
8445    }
8446}
8447
8448impl DbFileIndex {
8449    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8450        let mut node_by_scoped = HashMap::new();
8451        let mut node_by_bare = HashMap::new();
8452        for node in &extract.nodes {
8453            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8454            node_by_bare
8455                .entry(node.name.clone())
8456                .or_insert(node.id.clone());
8457        }
8458        let node_kind_by_id = extract
8459            .nodes
8460            .iter()
8461            .map(|node| (node.id.clone(), node.kind.clone()))
8462            .collect();
8463        let mut export_aliases = HashMap::new();
8464        for raw_ref in &extract.raw_refs {
8465            if raw_ref.kind == "export_alias" {
8466                if let (Some(exported), Some(source_symbol)) =
8467                    (&raw_ref.local_name, &raw_ref.requested_name)
8468                {
8469                    export_aliases.insert(exported.clone(), source_symbol.clone());
8470                }
8471            }
8472        }
8473        let mut module_targets = HashMap::new();
8474        let mut reexports = Vec::new();
8475        for raw_ref in &extract.raw_refs {
8476            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8477                continue;
8478            }
8479            let Some(module_path) = &raw_ref.module_path else {
8480                continue;
8481            };
8482            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8483            module_targets
8484                .entry(module_path.clone())
8485                .or_insert_with(|| target_file.clone());
8486            if raw_ref.kind == "reexport" {
8487                reexports.push(reexport_index_from_raw(raw_ref, target_file));
8488            }
8489        }
8490        Self {
8491            lang: Some(extract.lang),
8492            exports: extract.data.exported_symbols.iter().cloned().collect(),
8493            default_export: extract.data.default_export_symbol.clone(),
8494            export_aliases,
8495            node_by_scoped,
8496            node_by_bare,
8497            node_kind_by_id,
8498            module_targets,
8499            reexports,
8500        }
8501    }
8502}
8503
8504fn load_db_file_indexes(
8505    tx: &Transaction<'_>,
8506    project_root: &Path,
8507) -> Result<HashMap<String, DbFileIndex>> {
8508    let mut files = HashMap::new();
8509    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8510    let rows = stmt.query_map([], |row| {
8511        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8512    })?;
8513    for row in rows {
8514        let (rel_path, lang) = row?;
8515        files.insert(
8516            rel_path.clone(),
8517            DbFileIndex {
8518                lang: lang_from_label(&lang),
8519                exports: HashSet::new(),
8520                default_export: None,
8521                export_aliases: HashMap::new(),
8522                node_by_scoped: HashMap::new(),
8523                node_by_bare: HashMap::new(),
8524                node_kind_by_id: HashMap::new(),
8525                module_targets: HashMap::new(),
8526                reexports: Vec::new(),
8527            },
8528        );
8529    }
8530
8531    let mut node_stmt = tx.prepare(
8532        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
8533    )?;
8534    let nodes = node_stmt.query_map([], |row| {
8535        Ok((
8536            row.get::<_, String>(0)?,
8537            row.get::<_, String>(1)?,
8538            row.get::<_, String>(2)?,
8539            row.get::<_, String>(3)?,
8540            row.get::<_, String>(4)?,
8541            row.get::<_, i64>(5)? != 0,
8542            row.get::<_, i64>(6)? != 0,
8543        ))
8544    })?;
8545    for row in nodes {
8546        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
8547        let file = files
8548            .entry(file_path.clone())
8549            .or_insert_with(|| DbFileIndex {
8550                lang: None,
8551                exports: HashSet::new(),
8552                default_export: None,
8553                export_aliases: HashMap::new(),
8554                node_by_scoped: HashMap::new(),
8555                node_by_bare: HashMap::new(),
8556                node_kind_by_id: HashMap::new(),
8557                module_targets: HashMap::new(),
8558                reexports: Vec::new(),
8559            });
8560        if exported {
8561            file.exports.insert(name.clone());
8562            file.exports.insert(scoped_name.clone());
8563        }
8564        if is_default_export {
8565            file.default_export = Some(scoped_name.clone());
8566        }
8567        file.node_by_scoped.insert(scoped_name, id.clone());
8568        file.node_by_bare.entry(name).or_insert(id.clone());
8569        file.node_kind_by_id.insert(id, kind);
8570    }
8571    let file_keys: HashSet<String> = files.keys().cloned().collect();
8572    // Persisted caller extracts supply import targets. Only reexports from other
8573    // files need dependency reconstruction, and their caller dependencies are
8574    // loaded once instead of issuing repeated SQLite queries per reference.
8575    let dependencies_by_file = load_file_dependencies_index(tx)?;
8576    let mut ref_stmt = tx.prepare(
8577        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8578         FROM refs WHERE kind IN ('reexport', 'export_alias')",
8579    )?;
8580    let ref_rows = ref_stmt.query_map([], |row| {
8581        Ok((
8582            row.get::<_, String>(0)?,
8583            row.get::<_, String>(1)?,
8584            row.get::<_, String>(2)?,
8585            row.get::<_, Option<String>>(3)?,
8586            row.get::<_, Option<String>>(4)?,
8587            row.get::<_, i64>(5)? != 0,
8588            row.get::<_, Option<String>>(6)?,
8589            row.get::<_, Option<String>>(7)?,
8590        ))
8591    })?;
8592    for row in ref_rows {
8593        let (
8594            ref_id,
8595            caller_file,
8596            kind,
8597            module_path,
8598            full_ref,
8599            wildcard,
8600            local_name,
8601            requested_name,
8602        ) = row?;
8603        if kind == "export_alias" {
8604            if let (Some(exported), Some(source_symbol), Some(file)) =
8605                (local_name, requested_name, files.get_mut(&caller_file))
8606            {
8607                file.export_aliases.insert(exported, source_symbol);
8608            }
8609            continue;
8610        }
8611        let Some(module_path) = module_path else {
8612            continue;
8613        };
8614        let file_deps = dependencies_by_file
8615            .get(&caller_file)
8616            .cloned()
8617            .unwrap_or_default();
8618        let deps = stored_dependencies_for_module(
8619            project_root,
8620            &caller_file,
8621            &module_path,
8622            &file_deps,
8623            &file_keys,
8624        );
8625        let target_file = deps
8626            .iter()
8627            .find(|dep| file_keys.contains(*dep))
8628            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8629        if let Some(file) = files.get_mut(&caller_file) {
8630            file.module_targets
8631                .entry(module_path.clone())
8632                .or_insert_with(|| target_file.clone());
8633            if kind == "reexport" {
8634                let raw = RawRef {
8635                    ref_id,
8636                    caller_node: None,
8637                    caller_symbol: None,
8638                    caller_file,
8639                    kind,
8640                    short_name: None,
8641                    full_ref,
8642                    module_path: Some(module_path),
8643                    import_kind: Some("reexport".to_string()),
8644                    local_name: None,
8645                    requested_name: None,
8646                    namespace_alias: None,
8647                    wildcard,
8648                    line: 0,
8649                    byte_start: 0,
8650                    byte_end: 0,
8651                    dependencies: deps,
8652                };
8653                file.reexports
8654                    .push(reexport_index_from_raw(&raw, target_file));
8655            }
8656        }
8657    }
8658
8659    Ok(files)
8660}
8661
8662fn stored_dependencies_for_module(
8663    project_root: &Path,
8664    caller_file: &str,
8665    module_path: &str,
8666    caller_dependencies: &BTreeSet<String>,
8667    indexed_files: &HashSet<String>,
8668) -> BTreeSet<String> {
8669    let caller_path = project_root.join(caller_file);
8670    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8671    if module_path.starts_with('.') {
8672        let caller_dir = caller_path.parent().unwrap_or(project_root);
8673        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8674            let normalized = if candidate.is_file() {
8675                canonicalize_path(&candidate)
8676            } else {
8677                candidate
8678            };
8679            candidates.insert(relative_path(project_root, &normalized));
8680        }
8681    }
8682    let exact = candidates
8683        .intersection(caller_dependencies)
8684        .filter(|dependency| indexed_files.contains(*dependency))
8685        .cloned()
8686        .collect::<BTreeSet<_>>();
8687    if !exact.is_empty() || module_path.starts_with('.') {
8688        return exact;
8689    }
8690
8691    let module_path = rust_module_path_without_alias_or_use_list(module_path)
8692        .trim_matches(|character| matches!(character, '\'' | '"'));
8693    let package_name = module_path
8694        .split('/')
8695        .next_back()
8696        .unwrap_or(module_path)
8697        .replace('_', "-");
8698    let matched = caller_dependencies
8699        .iter()
8700        .filter(|dependency| indexed_files.contains(*dependency))
8701        .filter(|dependency| {
8702            dependency.as_str() == module_path
8703                || dependency.ends_with(&format!("/{module_path}"))
8704                || Path::new(dependency).components().any(|component| {
8705                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8706                })
8707        })
8708        .cloned()
8709        .collect::<BTreeSet<_>>();
8710    if matched.len() == 1 {
8711        matched
8712    } else {
8713        BTreeSet::new()
8714    }
8715}
8716
8717fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8718    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8719    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8720    let rows = stmt.query_map([], |row| {
8721        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8722    })?;
8723    for row in rows {
8724        let (file_path, dependency) = row?;
8725        by_file.entry(file_path).or_default().insert(dependency);
8726    }
8727    Ok(by_file)
8728}
8729
8730struct ColdBuildInsertStatements<'stmt> {
8731    file: Statement<'stmt>,
8732    node: Statement<'stmt>,
8733    file_dependency: Statement<'stmt>,
8734    dispatch_hint: Statement<'stmt>,
8735    backend_state: Statement<'stmt>,
8736    reference: Statement<'stmt>,
8737    edge: Statement<'stmt>,
8738}
8739
8740impl<'stmt> ColdBuildInsertStatements<'stmt> {
8741    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8742        Ok(Self {
8743            file: tx.prepare(
8744                "INSERT OR REPLACE INTO files(
8745                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8746                    is_public_api, surface_fingerprint, indexed_at
8747                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8748            )?,
8749            node: tx.prepare(
8750                "INSERT OR REPLACE INTO nodes(
8751                    id, file_path, name, scoped_name, kind, start_line, start_col,
8752                    end_line, end_col, range_ordinal, signature, exported,
8753                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
8754                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8755            )?,
8756            file_dependency: tx.prepare(
8757                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8758            )?,
8759            dispatch_hint: tx.prepare(
8760                "INSERT OR REPLACE INTO dispatch_hints(
8761                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8762                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8763            )?,
8764            backend_state: tx.prepare(
8765                "INSERT OR REPLACE INTO backend_file_state(
8766                    backend, workspace_root, file_path, content_hash, status, updated_at
8767                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8768            )?,
8769            reference: tx.prepare(
8770                "INSERT OR REPLACE INTO refs(
8771                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8772                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8773                    byte_start, byte_end, status, target_node, target_file, target_symbol,
8774                    provenance
8775                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8776            )?,
8777            edge: tx.prepare(
8778                "INSERT OR REPLACE INTO edges(
8779                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8780                    kind, line, provenance
8781                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8782            )?,
8783        })
8784    }
8785}
8786
8787fn insert_file_extract_prepared(
8788    statements: &mut ColdBuildInsertStatements<'_>,
8789    workspace_root: &str,
8790    extract: &FileExtract,
8791) -> Result<()> {
8792    statements.file.execute(params![
8793        extract.rel_path,
8794        hash_to_hex(extract.freshness.content_hash),
8795        system_time_to_ns(extract.freshness.mtime),
8796        extract.freshness.size as i64,
8797        lang_label(extract.lang),
8798        extract.surface_fingerprint,
8799        unix_seconds_now(),
8800    ])?;
8801    for node in &extract.nodes {
8802        statements.node.execute(params![
8803            node.id,
8804            node.file_path,
8805            node.name,
8806            node.scoped_name,
8807            node.kind,
8808            node.range.start_line as i64,
8809            node.range.start_col as i64,
8810            node.range.end_line as i64,
8811            node.range.end_col as i64,
8812            node.range_ordinal as i64,
8813            node.signature,
8814            bool_int(node.exported),
8815            bool_int(node.is_default_export),
8816            bool_int(node.is_type_like),
8817            bool_int(node.is_callgraph_entry_point),
8818            PROVENANCE_TREESITTER,
8819        ])?;
8820    }
8821
8822    let mut dependencies = BTreeSet::new();
8823    for raw_ref in &extract.raw_refs {
8824        dependencies.extend(raw_ref.dependencies.iter().cloned());
8825    }
8826    for dep_file in &dependencies {
8827        statements
8828            .file_dependency
8829            .execute(params![extract.rel_path, dep_file])?;
8830    }
8831
8832    for hint in &extract.dispatch_hints {
8833        statements.dispatch_hint.execute(params![
8834            hint.id,
8835            hint.method_name,
8836            hint.caller_node,
8837            hint.file,
8838            hint.line as i64,
8839            hint.byte_start as i64,
8840            hint.byte_end as i64,
8841            PROVENANCE_TREESITTER,
8842        ])?;
8843    }
8844    insert_backend_state_prepared(
8845        &mut statements.backend_state,
8846        workspace_root,
8847        &extract.rel_path,
8848        Some(&extract.freshness.content_hash),
8849        "fresh",
8850    )?;
8851    Ok(())
8852}
8853
8854fn insert_backend_state_prepared(
8855    stmt: &mut Statement<'_>,
8856    workspace_root: &str,
8857    rel_path: &str,
8858    content_hash: Option<&blake3::Hash>,
8859    status: &str,
8860) -> Result<()> {
8861    let hash = content_hash
8862        .map(|hash| hash_to_hex(*hash))
8863        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8864    stmt.execute(params![
8865        BACKEND_TREESITTER,
8866        workspace_root,
8867        rel_path,
8868        hash,
8869        status,
8870        unix_seconds_now(),
8871    ])?;
8872    Ok(())
8873}
8874
8875fn insert_resolved_ref_prepared(
8876    statements: &mut ColdBuildInsertStatements<'_>,
8877    resolved: &ResolvedRef,
8878) -> Result<()> {
8879    let raw = &resolved.raw;
8880    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8881    statements.reference.execute(params![
8882        raw.ref_id,
8883        raw.caller_node,
8884        raw.caller_file,
8885        raw.kind,
8886        raw.short_name,
8887        raw.full_ref,
8888        raw.module_path,
8889        raw.import_kind,
8890        raw.local_name,
8891        raw.requested_name,
8892        raw.namespace_alias,
8893        bool_int(raw.wildcard),
8894        raw.line as i64,
8895        raw.byte_start as i64,
8896        raw.byte_end as i64,
8897        resolved.status,
8898        resolved.target_node,
8899        resolved.target_file,
8900        resolved.target_symbol,
8901        ref_provenance(raw),
8902    ])?;
8903    if let Some(edge) = &resolved.edge {
8904        statements.edge.execute(params![
8905            edge.edge_id,
8906            raw.ref_id,
8907            edge.source_node,
8908            edge.target_node,
8909            edge.target_file,
8910            edge.target_symbol,
8911            edge.kind,
8912            edge.line as i64,
8913            ref_provenance(raw),
8914        ])?;
8915    }
8916    Ok(())
8917}
8918
8919#[cfg(test)]
8920fn insert_file_extract(
8921    tx: &Transaction<'_>,
8922    project_root: &Path,
8923    extract: &FileExtract,
8924) -> Result<()> {
8925    tx.execute(
8926        "INSERT OR REPLACE INTO files(
8927            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8928            is_public_api, surface_fingerprint, indexed_at
8929        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8930        params![
8931            extract.rel_path,
8932            hash_to_hex(extract.freshness.content_hash),
8933            system_time_to_ns(extract.freshness.mtime),
8934            extract.freshness.size as i64,
8935            lang_label(extract.lang),
8936            extract.surface_fingerprint,
8937            unix_seconds_now(),
8938        ],
8939    )?;
8940    for node in &extract.nodes {
8941        tx.execute(
8942            "INSERT OR REPLACE INTO nodes(
8943                id, file_path, name, scoped_name, kind, start_line, start_col,
8944                end_line, end_col, range_ordinal, signature, exported,
8945                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8946            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8947            params![
8948                node.id,
8949                node.file_path,
8950                node.name,
8951                node.scoped_name,
8952                node.kind,
8953                node.range.start_line as i64,
8954                node.range.start_col as i64,
8955                node.range.end_line as i64,
8956                node.range.end_col as i64,
8957                node.range_ordinal as i64,
8958                node.signature,
8959                bool_int(node.exported),
8960                bool_int(node.is_default_export),
8961                bool_int(node.is_type_like),
8962                bool_int(node.is_callgraph_entry_point),
8963                PROVENANCE_TREESITTER,
8964            ],
8965        )?;
8966    }
8967    let mut dependencies = BTreeSet::new();
8968    for raw_ref in &extract.raw_refs {
8969        dependencies.extend(raw_ref.dependencies.iter().cloned());
8970    }
8971    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8972
8973    for hint in &extract.dispatch_hints {
8974        tx.execute(
8975            "INSERT OR REPLACE INTO dispatch_hints(
8976                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8977            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8978            params![
8979                hint.id,
8980                hint.method_name,
8981                hint.caller_node,
8982                hint.file,
8983                hint.line as i64,
8984                hint.byte_start as i64,
8985                hint.byte_end as i64,
8986                PROVENANCE_TREESITTER,
8987            ],
8988        )?;
8989    }
8990    mark_backend_state(
8991        tx,
8992        project_root,
8993        &extract.rel_path,
8994        Some(&extract.freshness.content_hash),
8995        "fresh",
8996    )?;
8997    Ok(())
8998}
8999
9000#[cfg(test)]
9001fn insert_file_dependencies(
9002    tx: &Transaction<'_>,
9003    file_path: &str,
9004    dependencies: &BTreeSet<String>,
9005) -> Result<()> {
9006    for dep_file in dependencies {
9007        tx.execute(
9008            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9009            params![file_path, dep_file],
9010        )?;
9011    }
9012    Ok(())
9013}
9014
9015fn ref_provenance(raw: &RawRef) -> &'static str {
9016    if raw.kind == "value_ref" {
9017        PROVENANCE_VALUE_REF
9018    } else {
9019        PROVENANCE_TREESITTER
9020    }
9021}
9022
9023#[cfg(test)]
9024fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9025    let raw = &resolved.raw;
9026    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9027    tx.execute(
9028        "INSERT OR REPLACE INTO refs(
9029            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9030            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9031            byte_start, byte_end, status, target_node, target_file, target_symbol,
9032            provenance
9033        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9034        params![
9035            raw.ref_id,
9036            raw.caller_node,
9037            raw.caller_file,
9038            raw.kind,
9039            raw.short_name,
9040            raw.full_ref,
9041            raw.module_path,
9042            raw.import_kind,
9043            raw.local_name,
9044            raw.requested_name,
9045            raw.namespace_alias,
9046            bool_int(raw.wildcard),
9047            raw.line as i64,
9048            raw.byte_start as i64,
9049            raw.byte_end as i64,
9050            resolved.status,
9051            resolved.target_node,
9052            resolved.target_file,
9053            resolved.target_symbol,
9054            ref_provenance(raw),
9055        ],
9056    )?;
9057    if let Some(edge) = &resolved.edge {
9058        tx.execute(
9059            "INSERT OR REPLACE INTO edges(
9060                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9061                kind, line, provenance
9062            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9063            params![
9064                edge.edge_id,
9065                raw.ref_id,
9066                edge.source_node,
9067                edge.target_node,
9068                edge.target_file,
9069                edge.target_symbol,
9070                edge.kind,
9071                edge.line as i64,
9072                ref_provenance(raw),
9073            ],
9074        )?;
9075    }
9076    Ok(())
9077}
9078
9079fn insert_method_dispatch_edges(
9080    tx: &Transaction<'_>,
9081    project_root: &Path,
9082    caller_files: Option<&BTreeSet<String>>,
9083) -> Result<usize> {
9084    let references = load_name_match_refs(tx, caller_files)?;
9085    if references.is_empty() {
9086        return Ok(0);
9087    }
9088
9089    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9090    let mut source_cache: DispatchSourceCache = HashMap::new();
9091    let mut inserted = 0usize;
9092    for reference in references {
9093        let key = (reference.method_name.clone(), reference.lang.clone());
9094        let candidates = match candidates_by_name.entry(key) {
9095            Entry::Occupied(entry) => entry.into_mut(),
9096            Entry::Vacant(entry) => {
9097                let candidates =
9098                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9099                entry.insert(candidates)
9100            }
9101        };
9102
9103        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9104            ReceiverTypeInference::Known(receiver_type) => {
9105                let Some(candidate) =
9106                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9107                else {
9108                    continue;
9109                };
9110                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9111                inserted += 1;
9112                continue;
9113            }
9114            ReceiverTypeInference::RustDirectSelfField {
9115                receiver_type,
9116                declaration_file,
9117                module_scope,
9118            } => {
9119                let Some(candidate) = select_rust_direct_self_field_candidate(
9120                    project_root,
9121                    &reference,
9122                    candidates.as_slice(),
9123                    &receiver_type,
9124                    &declaration_file,
9125                    &module_scope,
9126                    &mut source_cache,
9127                ) else {
9128                    continue;
9129                };
9130                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9131                inserted += 1;
9132                continue;
9133            }
9134            ReceiverTypeInference::KnownButUnresolved => continue,
9135            ReceiverTypeInference::Unknown => {}
9136        }
9137
9138        if method_name_match_denylisted(&reference.method_name) {
9139            continue;
9140        }
9141
9142        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9143            continue;
9144        };
9145        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9146        inserted += 1;
9147    }
9148    Ok(inserted)
9149}
9150
9151fn insert_method_dispatch_edges_chunked(
9152    tx: &Transaction<'_>,
9153    project_root: &Path,
9154    caller_files: &BTreeSet<String>,
9155    chunk_size: usize,
9156) -> Result<usize> {
9157    if caller_files.is_empty() {
9158        return Ok(0);
9159    }
9160    if chunk_size == 0 || caller_files.len() <= chunk_size {
9161        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
9162    }
9163
9164    let mut inserted = 0usize;
9165    let mut batch = BTreeSet::new();
9166    for caller_file in caller_files {
9167        batch.insert(caller_file.clone());
9168        if batch.len() == chunk_size {
9169            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9170            batch.clear();
9171        }
9172    }
9173    if !batch.is_empty() {
9174        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
9175    }
9176    Ok(inserted)
9177}
9178
9179fn insert_method_dispatch_edge(
9180    tx: &Transaction<'_>,
9181    reference: &NameMatchRef,
9182    candidate: &NameMatchCandidate,
9183    provenance: &str,
9184) -> Result<()> {
9185    tx.execute(
9186        "INSERT OR REPLACE INTO edges(
9187            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9188            kind, line, provenance
9189        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
9190        params![
9191            ref_id(&[&reference.ref_id, provenance, "edge"]),
9192            &reference.ref_id,
9193            &reference.caller_node,
9194            &candidate.node_id,
9195            &candidate.file_path,
9196            &candidate.scoped_name,
9197            reference.line as i64,
9198            provenance,
9199        ],
9200    )?;
9201    Ok(())
9202}
9203
9204fn delete_method_dispatch_edges_for_callers(
9205    tx: &Transaction<'_>,
9206    caller_files: &BTreeSet<String>,
9207) -> Result<()> {
9208    if caller_files.is_empty() {
9209        return Ok(());
9210    }
9211
9212    let mut stmt = tx.prepare(
9213        "DELETE FROM edges
9214         WHERE provenance IN (?1, ?2)
9215           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
9216    )?;
9217    for caller_file in caller_files {
9218        stmt.execute(params![
9219            PROVENANCE_NAME_MATCH,
9220            PROVENANCE_TYPE_MATCH,
9221            caller_file
9222        ])?;
9223    }
9224    Ok(())
9225}
9226
9227fn load_name_match_refs(
9228    tx: &Transaction<'_>,
9229    caller_files: Option<&BTreeSet<String>>,
9230) -> Result<Vec<NameMatchRef>> {
9231    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
9232                           n.signature, r.short_name, r.full_ref, r.line, f.lang
9233                    FROM refs r
9234                    JOIN files f ON f.path = r.caller_file
9235                    JOIN nodes n ON n.id = r.caller_node
9236                    WHERE r.kind = 'call'
9237                      AND r.status = 'unresolved'
9238                      AND r.caller_node IS NOT NULL
9239                      AND r.full_ref IS NOT NULL
9240                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
9241                      AND NOT EXISTS (
9242                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
9243                      )";
9244    let mut references = Vec::new();
9245
9246    if let Some(caller_files) = caller_files {
9247        if caller_files.is_empty() {
9248            return Ok(references);
9249        }
9250        let sql = format!(
9251            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
9252        );
9253        let mut stmt = tx.prepare(&sql)?;
9254        for caller_file in caller_files {
9255            let rows = stmt.query_map(params![caller_file], |row| {
9256                Ok((
9257                    row.get::<_, String>(0)?,
9258                    row.get::<_, Option<String>>(1)?,
9259                    row.get::<_, String>(2)?,
9260                    row.get::<_, String>(3)?,
9261                    row.get::<_, Option<String>>(4)?,
9262                    row.get::<_, Option<String>>(5)?,
9263                    row.get::<_, Option<String>>(6)?,
9264                    row.get::<_, i64>(7)?,
9265                    row.get::<_, String>(8)?,
9266                ))
9267            })?;
9268            for row in rows {
9269                let (
9270                    ref_id,
9271                    caller_node,
9272                    caller_file,
9273                    caller_symbol,
9274                    caller_signature,
9275                    short_name,
9276                    full_ref,
9277                    line,
9278                    lang,
9279                ) = row?;
9280                if let Some(reference) = name_match_ref_from_parts(
9281                    ref_id,
9282                    caller_node,
9283                    caller_file,
9284                    caller_symbol,
9285                    caller_signature,
9286                    short_name,
9287                    full_ref,
9288                    line,
9289                    lang,
9290                ) {
9291                    references.push(reference);
9292                }
9293            }
9294        }
9295        return Ok(references);
9296    }
9297
9298    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9299    let mut stmt = tx.prepare(&sql)?;
9300    let rows = stmt.query_map([], |row| {
9301        Ok((
9302            row.get::<_, String>(0)?,
9303            row.get::<_, Option<String>>(1)?,
9304            row.get::<_, String>(2)?,
9305            row.get::<_, String>(3)?,
9306            row.get::<_, Option<String>>(4)?,
9307            row.get::<_, Option<String>>(5)?,
9308            row.get::<_, Option<String>>(6)?,
9309            row.get::<_, i64>(7)?,
9310            row.get::<_, String>(8)?,
9311        ))
9312    })?;
9313    for row in rows {
9314        let (
9315            ref_id,
9316            caller_node,
9317            caller_file,
9318            caller_symbol,
9319            caller_signature,
9320            short_name,
9321            full_ref,
9322            line,
9323            lang,
9324        ) = row?;
9325        if let Some(reference) = name_match_ref_from_parts(
9326            ref_id,
9327            caller_node,
9328            caller_file,
9329            caller_symbol,
9330            caller_signature,
9331            short_name,
9332            full_ref,
9333            line,
9334            lang,
9335        ) {
9336            references.push(reference);
9337        }
9338    }
9339    Ok(references)
9340}
9341
9342#[allow(clippy::too_many_arguments)]
9343fn name_match_ref_from_parts(
9344    ref_id: String,
9345    caller_node: Option<String>,
9346    caller_file: String,
9347    caller_symbol: String,
9348    caller_signature: Option<String>,
9349    short_name: Option<String>,
9350    full_ref: Option<String>,
9351    line: i64,
9352    lang: String,
9353) -> Option<NameMatchRef> {
9354    let caller_node = caller_node?;
9355    let full_ref = full_ref?;
9356    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9357    let method_name = if member.is_empty() {
9358        short_name.as_deref()?.to_string()
9359    } else {
9360        member
9361    };
9362    Some(NameMatchRef {
9363        ref_id,
9364        caller_node,
9365        caller_file,
9366        caller_symbol,
9367        caller_signature,
9368        receiver_expression,
9369        receiver,
9370        method_name,
9371        colon_dispatch,
9372        line: line.max(0) as u32,
9373        lang,
9374    })
9375}
9376
9377fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9378    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9379    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9380    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9381    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9382        .into_iter()
9383        .flatten()
9384        .max_by_key(|(index, _, _)| *index)?;
9385    if delimiter == 0 {
9386        return None;
9387    }
9388    let member_start = delimiter + delimiter_len;
9389    if member_start >= full_ref.len() {
9390        return None;
9391    }
9392    let receiver_expression = full_ref[..delimiter].trim();
9393    let receiver = last_name_segment(receiver_expression).trim();
9394    let member = &full_ref[member_start..];
9395    if receiver.is_empty() || member.is_empty() {
9396        return None;
9397    }
9398    Some((
9399        receiver_expression.to_string(),
9400        receiver.to_string(),
9401        member.to_string(),
9402        colon_dispatch,
9403    ))
9404}
9405
9406fn last_name_segment(value: &str) -> &str {
9407    value
9408        .rsplit(['.', ':', '/', '\\', '-', '>'])
9409        .find(|segment| !segment.is_empty())
9410        .unwrap_or(value)
9411}
9412
9413fn load_name_match_candidates(
9414    tx: &Transaction<'_>,
9415    method_name: &str,
9416    lang: &str,
9417) -> Result<Vec<NameMatchCandidate>> {
9418    let mut stmt = tx.prepare(
9419        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9420         FROM nodes n JOIN files f ON f.path = n.file_path
9421         WHERE n.name = ?1
9422           AND f.lang = ?2
9423           AND n.kind IN ('method', 'function')
9424         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9425    )?;
9426    let rows = stmt.query_map(params![method_name, lang], |row| {
9427        Ok(NameMatchCandidate {
9428            node_id: row.get(0)?,
9429            file_path: row.get(1)?,
9430            scoped_name: row.get(2)?,
9431            kind: row.get(3)?,
9432            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9433        })
9434    })?;
9435    rows.collect::<std::result::Result<Vec<_>, _>>()
9436        .map_err(Into::into)
9437}
9438
9439struct ParsedDispatchSource {
9440    source: String,
9441    tree: tree_sitter::Tree,
9442}
9443
9444type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9445
9446#[derive(Debug, Clone, PartialEq, Eq)]
9447enum ReceiverTypeInference {
9448    Unknown,
9449    Known(String),
9450    RustDirectSelfField {
9451        receiver_type: String,
9452        declaration_file: String,
9453        module_scope: Vec<(usize, usize)>,
9454    },
9455    KnownButUnresolved,
9456}
9457
9458#[cfg(test)]
9459fn infer_receiver_type(
9460    project_root: &Path,
9461    reference: &NameMatchRef,
9462    source_cache: &mut DispatchSourceCache,
9463) -> Option<String> {
9464    match infer_receiver_type_state(project_root, reference, source_cache) {
9465        ReceiverTypeInference::Known(receiver_type)
9466        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9467        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9468    }
9469}
9470
9471fn infer_receiver_type_state(
9472    project_root: &Path,
9473    reference: &NameMatchRef,
9474    source_cache: &mut DispatchSourceCache,
9475) -> ReceiverTypeInference {
9476    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9477    match reference.lang.as_str() {
9478        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9479        "java" => {
9480            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9481                .map(known)
9482                .unwrap_or(ReceiverTypeInference::Unknown)
9483        }
9484        "kotlin" => {
9485            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9486                .map(known)
9487                .unwrap_or(ReceiverTypeInference::Unknown)
9488        }
9489        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9490            .map(known)
9491            .unwrap_or(ReceiverTypeInference::Unknown),
9492        _ => ReceiverTypeInference::Unknown,
9493    }
9494}
9495
9496fn parse_dispatch_source(
9497    project_root: &Path,
9498    caller_file: &str,
9499    lang: LangId,
9500) -> Option<ParsedDispatchSource> {
9501    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9502    let grammar = crate::parser::grammar_for(lang);
9503    let mut parser = tree_sitter::Parser::new();
9504    parser.set_language(&grammar).ok()?;
9505    let tree = parser.parse(&source, None)?;
9506    Some(ParsedDispatchSource { source, tree })
9507}
9508
9509fn parsed_dispatch_source<'a>(
9510    project_root: &Path,
9511    reference: &NameMatchRef,
9512    lang: LangId,
9513    source_cache: &'a mut DispatchSourceCache,
9514) -> Option<&'a ParsedDispatchSource> {
9515    parsed_dispatch_source_for_file(
9516        project_root,
9517        &reference.caller_file,
9518        &reference.lang,
9519        lang,
9520        source_cache,
9521    )
9522}
9523
9524fn parsed_dispatch_source_for_file<'a>(
9525    project_root: &Path,
9526    file_path: &str,
9527    lang_label: &str,
9528    lang: LangId,
9529    source_cache: &'a mut DispatchSourceCache,
9530) -> Option<&'a ParsedDispatchSource> {
9531    let key = (file_path.to_string(), lang_label.to_string());
9532    source_cache
9533        .entry(key)
9534        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9535        .as_ref()
9536}
9537
9538fn infer_java_like_receiver_type(
9539    project_root: &Path,
9540    reference: &NameMatchRef,
9541    lang: LangId,
9542    source_cache: &mut DispatchSourceCache,
9543) -> Option<String> {
9544    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9545        return None;
9546    }
9547
9548    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9549    let root = parsed.tree.root_node();
9550    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9551
9552    let callable_scope = type_node
9553        .and_then(|node| {
9554            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9555        })
9556        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9557
9558    if let Some(callable_scope) = callable_scope {
9559        if let Some(receiver_type) = infer_java_like_local_receiver_type(
9560            callable_scope,
9561            &parsed.source,
9562            &reference.receiver,
9563            reference.line.max(1),
9564            lang,
9565        ) {
9566            return Some(receiver_type);
9567        }
9568    }
9569
9570    type_node.and_then(|node| {
9571        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9572    })
9573}
9574
9575fn infer_cpp_receiver_type(
9576    project_root: &Path,
9577    reference: &NameMatchRef,
9578    source_cache: &mut DispatchSourceCache,
9579) -> Option<String> {
9580    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9581        return None;
9582    }
9583
9584    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9585    let root = parsed.tree.root_node();
9586    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9587    infer_cpp_receiver_type_from_scope(
9588        scope,
9589        &parsed.source,
9590        &reference.receiver,
9591        reference.line.max(1),
9592    )
9593}
9594
9595fn find_enclosing_java_like_type_node<'tree>(
9596    root: tree_sitter::Node<'tree>,
9597    source: &str,
9598    reference: &NameMatchRef,
9599    lang: LangId,
9600) -> Option<tree_sitter::Node<'tree>> {
9601    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9602        .and_then(|name| simple_type_name(&name));
9603    let line = reference.line.max(1);
9604    let mut best = None;
9605    let mut stack = vec![root];
9606    while let Some(node) = stack.pop() {
9607        if !node_contains_line(node, line) {
9608            continue;
9609        }
9610        if is_java_like_type_kind(node.kind(), lang) {
9611            let name = declaration_name(node, source);
9612            if expected_type
9613                .as_deref()
9614                .is_none_or(|expected| name == Some(expected))
9615            {
9616                best = tighter_node(best, node);
9617            }
9618        }
9619        push_named_children(node, &mut stack);
9620    }
9621    best
9622}
9623
9624fn find_enclosing_java_like_callable_node<'tree>(
9625    root: tree_sitter::Node<'tree>,
9626    source: &str,
9627    reference: &NameMatchRef,
9628    lang: LangId,
9629) -> Option<tree_sitter::Node<'tree>> {
9630    let expected_name = reference.caller_symbol.rsplit("::").next();
9631    let line = reference.line.max(1);
9632    let mut best = None;
9633    let mut stack = vec![root];
9634    while let Some(node) = stack.pop() {
9635        if !node_contains_line(node, line) {
9636            continue;
9637        }
9638        if is_java_like_callable_kind(node.kind(), lang) {
9639            let name = declaration_name(node, source);
9640            if expected_name.is_none_or(|expected| name == Some(expected)) {
9641                best = tighter_node(best, node);
9642            }
9643        }
9644        push_named_children(node, &mut stack);
9645    }
9646    best
9647}
9648
9649fn find_enclosing_cpp_callable_node<'tree>(
9650    root: tree_sitter::Node<'tree>,
9651    _source: &str,
9652    reference: &NameMatchRef,
9653) -> Option<tree_sitter::Node<'tree>> {
9654    let line = reference.line.max(1);
9655    let mut best = None;
9656    let mut stack = vec![root];
9657    while let Some(node) = stack.pop() {
9658        if !node_contains_line(node, line) {
9659            continue;
9660        }
9661        if node.kind() == "function_definition" {
9662            best = tighter_node(best, node);
9663        }
9664        push_named_children(node, &mut stack);
9665    }
9666    best
9667}
9668
9669fn tighter_node<'tree>(
9670    current: Option<tree_sitter::Node<'tree>>,
9671    candidate: tree_sitter::Node<'tree>,
9672) -> Option<tree_sitter::Node<'tree>> {
9673    match current {
9674        Some(current)
9675            if current.start_byte() > candidate.start_byte()
9676                || (current.start_byte() == candidate.start_byte()
9677                    && current.end_byte() <= candidate.end_byte()) =>
9678        {
9679            Some(current)
9680        }
9681        _ => Some(candidate),
9682    }
9683}
9684
9685fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9686    let start = node.start_position().row as u32 + 1;
9687    let end = node.end_position().row as u32 + 1;
9688    start <= line && line <= end
9689}
9690
9691fn push_named_children<'tree>(
9692    node: tree_sitter::Node<'tree>,
9693    stack: &mut Vec<tree_sitter::Node<'tree>>,
9694) {
9695    for index in 0..node.named_child_count() {
9696        if let Some(child) = node.named_child(index as u32) {
9697            stack.push(child);
9698        }
9699    }
9700}
9701
9702fn declaration_name<'source>(
9703    node: tree_sitter::Node<'_>,
9704    source: &'source str,
9705) -> Option<&'source str> {
9706    node.child_by_field_name("name")
9707        .map(|name| node_text(name, source))
9708        .or_else(|| {
9709            first_named_child_text(
9710                node,
9711                source,
9712                &["identifier", "type_identifier", "simple_identifier"],
9713            )
9714        })
9715}
9716
9717fn first_named_child_text<'source>(
9718    node: tree_sitter::Node<'_>,
9719    source: &'source str,
9720    kinds: &[&str],
9721) -> Option<&'source str> {
9722    for index in 0..node.named_child_count() {
9723        let child = node.named_child(index as u32)?;
9724        if kinds.contains(&child.kind()) {
9725            return Some(node_text(child, source));
9726        }
9727    }
9728    None
9729}
9730
9731fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9732    &source[node.byte_range()]
9733}
9734
9735fn infer_java_like_field_receiver_type(
9736    type_node: tree_sitter::Node<'_>,
9737    source: &str,
9738    receiver: &str,
9739    lang: LangId,
9740) -> Option<String> {
9741    let mut stack = Vec::new();
9742    push_named_children(type_node, &mut stack);
9743    while let Some(node) = stack.pop() {
9744        if is_java_like_field_kind(node.kind(), lang) {
9745            if let Some(receiver_type) =
9746                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9747            {
9748                return Some(receiver_type);
9749            }
9750        }
9751        if is_java_like_type_kind(node.kind(), lang)
9752            || is_java_like_callable_kind(node.kind(), lang)
9753        {
9754            continue;
9755        }
9756        push_named_children(node, &mut stack);
9757    }
9758    None
9759}
9760
9761fn infer_java_like_local_receiver_type(
9762    callable_node: tree_sitter::Node<'_>,
9763    source: &str,
9764    receiver: &str,
9765    call_line: u32,
9766    lang: LangId,
9767) -> Option<String> {
9768    let mut best: Option<(u32, String)> = None;
9769    let mut stack = Vec::new();
9770    push_named_children(callable_node, &mut stack);
9771    while let Some(node) = stack.pop() {
9772        let start_line = node.start_position().row as u32 + 1;
9773        if start_line > call_line {
9774            continue;
9775        }
9776        if is_java_like_local_kind(node.kind(), lang) {
9777            if let Some(receiver_type) =
9778                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9779            {
9780                if best
9781                    .as_ref()
9782                    .is_none_or(|(best_line, _)| start_line >= *best_line)
9783                {
9784                    best = Some((start_line, receiver_type));
9785                }
9786            }
9787        }
9788        if is_java_like_type_kind(node.kind(), lang)
9789            || is_java_like_callable_kind(node.kind(), lang)
9790        {
9791            continue;
9792        }
9793        push_named_children(node, &mut stack);
9794    }
9795    best.map(|(_, receiver_type)| receiver_type)
9796}
9797
9798fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9799    match lang {
9800        LangId::Java => matches!(
9801            kind,
9802            "class_declaration"
9803                | "interface_declaration"
9804                | "enum_declaration"
9805                | "record_declaration"
9806                | "annotation_type_declaration"
9807        ),
9808        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9809        _ => false,
9810    }
9811}
9812
9813fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9814    match lang {
9815        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9816        LangId::Kotlin => kind == "function_declaration",
9817        _ => false,
9818    }
9819}
9820
9821fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9822    match lang {
9823        LangId::Java => kind == "field_declaration",
9824        LangId::Kotlin => kind == "property_declaration",
9825        _ => false,
9826    }
9827}
9828
9829fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9830    match lang {
9831        LangId::Java => kind == "local_variable_declaration",
9832        LangId::Kotlin => kind == "property_declaration",
9833        _ => false,
9834    }
9835}
9836
9837fn extract_java_like_declared_type(
9838    declaration: &str,
9839    receiver: &str,
9840    lang: LangId,
9841) -> Option<String> {
9842    match lang {
9843        LangId::Java => extract_java_declared_type(declaration, receiver),
9844        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9845        _ => None,
9846    }
9847}
9848
9849fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9850    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9851    let after = declaration[receiver_start + receiver.len()..].trim_start();
9852    if after
9853        .chars()
9854        .next()
9855        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9856    {
9857        return None;
9858    }
9859
9860    let before = declaration[..receiver_start].trim_end();
9861    if before.contains(',') {
9862        return None;
9863    }
9864    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9865}
9866
9867fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9868    loop {
9869        value = value.trim_start();
9870        if let Some(stripped) = strip_leading_java_annotation(value) {
9871            value = stripped;
9872            continue;
9873        }
9874        if let Some(stripped) = strip_leading_java_modifier(value) {
9875            value = stripped;
9876            continue;
9877        }
9878        return value.trim();
9879    }
9880}
9881
9882fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9883    let value = value.trim_start();
9884    let mut chars = value.char_indices();
9885    let (_, first) = chars.next()?;
9886    if first != '@' {
9887        return None;
9888    }
9889    let mut end = first.len_utf8();
9890    for (index, ch) in chars {
9891        if !(is_code_ident_char(ch) || ch == '.') {
9892            end = index;
9893            break;
9894        }
9895        end = index + ch.len_utf8();
9896    }
9897    let rest = value[end..].trim_start();
9898    if let Some(stripped) = rest.strip_prefix('(') {
9899        let mut depth = 1usize;
9900        for (index, ch) in stripped.char_indices() {
9901            match ch {
9902                '(' => depth += 1,
9903                ')' => {
9904                    depth = depth.saturating_sub(1);
9905                    if depth == 0 {
9906                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9907                    }
9908                }
9909                _ => {}
9910            }
9911        }
9912        return Some("");
9913    }
9914    Some(rest)
9915}
9916
9917fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9918    const MODIFIERS: &[&str] = &[
9919        "public",
9920        "protected",
9921        "private",
9922        "abstract",
9923        "static",
9924        "final",
9925        "transient",
9926        "volatile",
9927        "synchronized",
9928        "native",
9929        "strictfp",
9930    ];
9931    MODIFIERS
9932        .iter()
9933        .find_map(|modifier| strip_leading_word(value, modifier))
9934}
9935
9936fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9937    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9938    let before = &declaration[..receiver_start];
9939    if find_identifier_occurrence(before, "val").is_none()
9940        && find_identifier_occurrence(before, "var").is_none()
9941    {
9942        return None;
9943    }
9944
9945    let after = declaration[receiver_start + receiver.len()..].trim_start();
9946    if let Some(type_text) = after.strip_prefix(':') {
9947        return normalize_receiver_type_name(read_type_prefix(type_text));
9948    }
9949    after
9950        .strip_prefix('=')
9951        .and_then(infer_kotlin_constructor_type)
9952}
9953
9954fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9955    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9956    if rest.trim_start().starts_with('(') {
9957        normalize_receiver_type_name(head)
9958    } else {
9959        None
9960    }
9961}
9962
9963fn read_type_prefix(value: &str) -> &str {
9964    let mut angle_depth = 0usize;
9965    for (index, ch) in value.char_indices() {
9966        match ch {
9967            '<' => angle_depth += 1,
9968            '>' => angle_depth = angle_depth.saturating_sub(1),
9969            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9970                return value[..index].trim();
9971            }
9972            _ => {}
9973        }
9974    }
9975    value.trim()
9976}
9977
9978fn infer_cpp_receiver_type_from_scope(
9979    scope: tree_sitter::Node<'_>,
9980    source: &str,
9981    receiver: &str,
9982    call_line: u32,
9983) -> Option<String> {
9984    let lines = source.lines().collect::<Vec<_>>();
9985    if lines.is_empty() {
9986        return None;
9987    }
9988    let scope_start = scope.start_position().row as usize;
9989    let call_index = (call_line as usize)
9990        .saturating_sub(1)
9991        .min(lines.len().saturating_sub(1));
9992    for index in (scope_start..=call_index).rev() {
9993        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9994            return Some(receiver_type);
9995        }
9996    }
9997    None
9998}
9999
10000fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10001    for receiver_start in identifier_occurrences(line, receiver) {
10002        let after = line[receiver_start + receiver.len()..].trim_start();
10003        if after
10004            .chars()
10005            .next()
10006            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10007        {
10008            continue;
10009        }
10010        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10011        let normalized = normalize_cpp_type_name(type_text)?;
10012        if normalized == "auto" {
10013            if let Some(rhs) = after.strip_prefix('=') {
10014                return infer_cpp_auto_receiver_type(rhs);
10015            }
10016            continue;
10017        }
10018        return Some(normalized);
10019    }
10020    None
10021}
10022
10023fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10024    let candidate = prefix
10025        .rsplit([';', '{', '}', '('])
10026        .next()
10027        .unwrap_or(prefix)
10028        .trim();
10029    if candidate.is_empty() || candidate.ends_with(',') {
10030        None
10031    } else {
10032        Some(candidate)
10033    }
10034}
10035
10036fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10037    let without_templates = strip_angle_groups(type_text);
10038    let mut cleaned = String::with_capacity(without_templates.len());
10039    for token in without_templates.split_whitespace() {
10040        if matches!(
10041            token,
10042            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10043        ) {
10044            continue;
10045        }
10046        if !cleaned.is_empty() {
10047            cleaned.push(' ');
10048        }
10049        cleaned.push_str(token);
10050    }
10051    let token = cleaned
10052        .split_whitespace()
10053        .last()
10054        .unwrap_or(cleaned.trim())
10055        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10056        .trim_matches(['*', '&']);
10057    let simple = token.rsplit("::").next().unwrap_or(token).trim();
10058    if simple.is_empty() || cpp_non_type_token(simple) {
10059        None
10060    } else {
10061        Some(simple.to_string())
10062    }
10063}
10064
10065fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10066    let rhs = rhs.trim_start();
10067    if let Some(after_new) = rhs.strip_prefix("new ") {
10068        return infer_cpp_constructor_type(after_new);
10069    }
10070    infer_cpp_make_template_type(rhs)
10071        .or_else(|| infer_cpp_constructor_type(rhs))
10072        .or_else(|| infer_cpp_factory_type(rhs))
10073}
10074
10075fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10076    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10077    let normalized = normalize_cpp_type_name(head)?;
10078    if !normalized
10079        .chars()
10080        .next()
10081        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10082    {
10083        return None;
10084    }
10085    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10086        Some(normalized)
10087    } else {
10088        None
10089    }
10090}
10091
10092fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10093    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10094    if !rest.trim_start().starts_with('(') {
10095        return None;
10096    }
10097    let base = head.split('<').next().unwrap_or(head);
10098    let base_simple = base.rsplit("::").next().unwrap_or(base);
10099    if !matches!(base_simple, "make_unique" | "make_shared") {
10100        return None;
10101    }
10102    first_angle_arg(head).and_then(normalize_cpp_type_name)
10103}
10104
10105fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10106    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10107    if !rest.trim_start().starts_with('(') {
10108        return None;
10109    }
10110    let simple = head
10111        .split('<')
10112        .next()
10113        .unwrap_or(head)
10114        .rsplit("::")
10115        .next()
10116        .unwrap_or(head);
10117    for prefix in ["make", "create", "build"] {
10118        if let Some(suffix) = simple.strip_prefix(prefix) {
10119            if suffix
10120                .chars()
10121                .next()
10122                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10123            {
10124                return normalize_cpp_type_name(suffix);
10125            }
10126        }
10127    }
10128    None
10129}
10130
10131#[derive(Debug, Clone, Copy)]
10132enum JavaLikeInvocation {
10133    Kotlin,
10134    Cpp,
10135}
10136
10137fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10138    let value = value.trim_start();
10139    let mut end = 0usize;
10140    for (index, ch) in value.char_indices() {
10141        let allowed_separator = match flavor {
10142            JavaLikeInvocation::Kotlin => ch == '.',
10143            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10144        };
10145        if is_code_ident_char(ch) || allowed_separator {
10146            end = index + ch.len_utf8();
10147            continue;
10148        }
10149        break;
10150    }
10151    if end == 0 {
10152        return None;
10153    }
10154    let mut rest = &value[end..];
10155    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10156        let skipped = skip_balanced_angle(stripped)?;
10157        let rest_start = rest.len() - rest.trim_start().len();
10158        let angle_len = 1 + skipped;
10159        end += rest_start + angle_len;
10160        rest = &value[end..];
10161    }
10162    Some((value[..end].trim(), rest))
10163}
10164
10165fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
10166    let mut depth = 1usize;
10167    for (index, ch) in value_after_open.char_indices() {
10168        match ch {
10169            '<' => depth += 1,
10170            '>' => {
10171                depth = depth.saturating_sub(1);
10172                if depth == 0 {
10173                    return Some(index + ch.len_utf8());
10174                }
10175            }
10176            _ => {}
10177        }
10178    }
10179    None
10180}
10181
10182fn first_angle_arg(value: &str) -> Option<&str> {
10183    let open = value.find('<')?;
10184    let inner_len = skip_balanced_angle(&value[open + 1..])?;
10185    let inner = &value[open + 1..open + inner_len];
10186    split_top_level_commas(inner).into_iter().next()
10187}
10188
10189fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
10190    let without_generics = strip_angle_groups(type_text);
10191    let cleaned = without_generics
10192        .replace("[]", " ")
10193        .replace("...", " ")
10194        .replace(['?', '&', '*'], " ");
10195    let token = cleaned
10196        .split_whitespace()
10197        .last()
10198        .unwrap_or(cleaned.trim())
10199        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
10200    let token = token.rsplit("::").next().unwrap_or(token);
10201    let simple = token.rsplit('.').next().unwrap_or(token).trim();
10202    if simple.is_empty()
10203        || java_like_primitive_type(simple)
10204        || !simple
10205            .chars()
10206            .next()
10207            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10208    {
10209        None
10210    } else {
10211        Some(simple.to_string())
10212    }
10213}
10214
10215fn simple_type_name(scoped_name: &str) -> Option<String> {
10216    scoped_name
10217        .rsplit("::")
10218        .find(|segment| !segment.is_empty())
10219        .and_then(normalize_receiver_type_name)
10220}
10221
10222fn strip_angle_groups(value: &str) -> String {
10223    let mut output = String::with_capacity(value.len());
10224    let mut depth = 0usize;
10225    for ch in value.chars() {
10226        match ch {
10227            '<' => {
10228                if depth == 0 {
10229                    output.push(' ');
10230                }
10231                depth += 1;
10232            }
10233            '>' => depth = depth.saturating_sub(1),
10234            _ if depth == 0 => output.push(ch),
10235            _ => {}
10236        }
10237    }
10238    output
10239}
10240
10241fn java_like_primitive_type(value: &str) -> bool {
10242    matches!(
10243        value,
10244        "boolean"
10245            | "byte"
10246            | "char"
10247            | "double"
10248            | "float"
10249            | "int"
10250            | "long"
10251            | "short"
10252            | "void"
10253            | "Boolean"
10254            | "Byte"
10255            | "Char"
10256            | "Double"
10257            | "Float"
10258            | "Int"
10259            | "Long"
10260            | "Short"
10261            | "Unit"
10262    )
10263}
10264
10265fn cpp_non_type_token(value: &str) -> bool {
10266    matches!(
10267        value,
10268        "return"
10269            | "if"
10270            | "else"
10271            | "for"
10272            | "while"
10273            | "do"
10274            | "switch"
10275            | "case"
10276            | "default"
10277            | "break"
10278            | "continue"
10279            | "goto"
10280            | "throw"
10281            | "new"
10282            | "delete"
10283            | "co_await"
10284            | "co_yield"
10285            | "co_return"
10286            | "static_cast"
10287            | "const_cast"
10288            | "dynamic_cast"
10289            | "reinterpret_cast"
10290            | "sizeof"
10291            | "alignof"
10292            | "typeid"
10293            | "and"
10294            | "or"
10295            | "not"
10296            | "xor"
10297    )
10298}
10299
10300fn receiver_is_bare_identifier(value: &str) -> bool {
10301    let mut chars = value.chars();
10302    let Some(first) = chars.next() else {
10303        return false;
10304    };
10305    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10306}
10307
10308fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10309    identifier_occurrences(value, needle).into_iter().next()
10310}
10311
10312fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10313    value
10314        .match_indices(needle)
10315        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10316        .collect()
10317}
10318
10319fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10320    let before = value[..start].chars().next_back();
10321    let after = value[start + len..].chars().next();
10322    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10323}
10324
10325fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10326    let stripped = value.strip_prefix(word)?;
10327    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10328        Some(stripped.trim_start())
10329    } else {
10330        None
10331    }
10332}
10333
10334fn is_code_ident_char(ch: char) -> bool {
10335    ch == '_' || ch.is_ascii_alphanumeric()
10336}
10337
10338fn infer_rust_receiver_type(
10339    project_root: &Path,
10340    reference: &NameMatchRef,
10341    source_cache: &mut DispatchSourceCache,
10342) -> ReceiverTypeInference {
10343    if matches!(reference.receiver.as_str(), "self" | "Self") {
10344        return enclosing_type_from_scoped_name(&reference.caller_symbol)
10345            .map(ReceiverTypeInference::Known)
10346            .unwrap_or(ReceiverTypeInference::Unknown);
10347    }
10348
10349    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10350        return ReceiverTypeInference::Known(reference.receiver.clone());
10351    }
10352
10353    if let Some(receiver_type) = reference
10354        .caller_signature
10355        .as_deref()
10356        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10357    {
10358        return ReceiverTypeInference::Known(receiver_type);
10359    }
10360
10361    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10362}
10363
10364fn infer_rust_direct_self_field_receiver_type(
10365    project_root: &Path,
10366    reference: &NameMatchRef,
10367    source_cache: &mut DispatchSourceCache,
10368) -> ReceiverTypeInference {
10369    if reference.colon_dispatch {
10370        return ReceiverTypeInference::Unknown;
10371    }
10372    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10373        return ReceiverTypeInference::Unknown;
10374    };
10375    if field_name != reference.receiver {
10376        return ReceiverTypeInference::Unknown;
10377    }
10378
10379    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10380        return ReceiverTypeInference::Unknown;
10381    };
10382    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10383        return ReceiverTypeInference::KnownButUnresolved;
10384    };
10385    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10386    else {
10387        return ReceiverTypeInference::Unknown;
10388    };
10389    let Some(impl_node) =
10390        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10391    else {
10392        return ReceiverTypeInference::Unknown;
10393    };
10394    if impl_node.child_by_field_name("trait").is_some()
10395        || impl_node.child_by_field_name("type_parameters").is_some()
10396    {
10397        return ReceiverTypeInference::KnownButUnresolved;
10398    }
10399    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10400        return ReceiverTypeInference::KnownButUnresolved;
10401    };
10402    if impl_target.kind() != "type_identifier"
10403        || node_text(impl_target, &parsed.source) != impl_type
10404    {
10405        return ReceiverTypeInference::KnownButUnresolved;
10406    }
10407
10408    let module_scope = rust_module_scope(impl_node);
10409    let Some(struct_node) = find_unique_rust_struct(
10410        parsed.tree.root_node(),
10411        &parsed.source,
10412        struct_name,
10413        &module_scope,
10414    ) else {
10415        return ReceiverTypeInference::KnownButUnresolved;
10416    };
10417    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10418    else {
10419        return ReceiverTypeInference::KnownButUnresolved;
10420    };
10421    if field_type.kind() != "type_identifier" {
10422        return ReceiverTypeInference::KnownButUnresolved;
10423    }
10424    let field_type_name = node_text(field_type, &parsed.source);
10425    if find_unique_rust_struct(
10426        parsed.tree.root_node(),
10427        &parsed.source,
10428        field_type_name,
10429        &module_scope,
10430    )
10431    .is_none()
10432    {
10433        return ReceiverTypeInference::KnownButUnresolved;
10434    }
10435
10436    ReceiverTypeInference::RustDirectSelfField {
10437        receiver_type: field_type_name.to_string(),
10438        declaration_file: reference.caller_file.clone(),
10439        module_scope,
10440    }
10441}
10442
10443fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10444    let (base, field) = receiver_expression.split_once('.')?;
10445    let base = base.trim();
10446    let field = field.trim();
10447    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10448}
10449
10450fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10451    let name = value.rsplit("::").next()?.trim();
10452    (!name.is_empty()
10453        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10454        && name.chars().all(is_rust_ident_char))
10455    .then_some(name)
10456}
10457
10458fn find_enclosing_rust_impl_node<'tree>(
10459    root: tree_sitter::Node<'tree>,
10460    line: u32,
10461) -> Option<tree_sitter::Node<'tree>> {
10462    let mut best = None;
10463    let mut stack = vec![root];
10464    while let Some(node) = stack.pop() {
10465        if !node_contains_line(node, line) {
10466            continue;
10467        }
10468        if node.kind() == "impl_item" {
10469            best = tighter_node(best, node);
10470        }
10471        push_named_children(node, &mut stack);
10472    }
10473    best
10474}
10475
10476fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10477    let mut scope = Vec::new();
10478    let mut current = node.parent();
10479    while let Some(parent) = current {
10480        if parent.kind() == "mod_item" {
10481            scope.push((parent.start_byte(), parent.end_byte()));
10482        }
10483        current = parent.parent();
10484    }
10485    scope.reverse();
10486    scope
10487}
10488
10489fn find_unique_rust_struct<'tree>(
10490    root: tree_sitter::Node<'tree>,
10491    source: &str,
10492    expected_name: &str,
10493    module_scope: &[(usize, usize)],
10494) -> Option<tree_sitter::Node<'tree>> {
10495    let mut found = None;
10496    let mut stack = vec![root];
10497    while let Some(node) = stack.pop() {
10498        if node.kind() == "struct_item"
10499            && rust_module_scope(node) == module_scope
10500            && node.child_by_field_name("type_parameters").is_none()
10501            && declaration_name(node, source) == Some(expected_name)
10502        {
10503            if found.is_some() {
10504                return None;
10505            }
10506            found = Some(node);
10507        }
10508        push_named_children(node, &mut stack);
10509    }
10510    found
10511}
10512
10513fn rust_struct_field_type_node<'tree>(
10514    struct_node: tree_sitter::Node<'tree>,
10515    source: &str,
10516    field_name: &str,
10517) -> Option<tree_sitter::Node<'tree>> {
10518    let fields = struct_node.child_by_field_name("body")?;
10519    if fields.kind() != "field_declaration_list" {
10520        return None;
10521    }
10522    for index in 0..fields.named_child_count() {
10523        let field = fields.named_child(index as u32)?;
10524        if field.kind() != "field_declaration"
10525            || declaration_name(field, source) != Some(field_name)
10526        {
10527            continue;
10528        }
10529        return field.child_by_field_name("type");
10530    }
10531    None
10532}
10533
10534fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10535    receiver
10536        .chars()
10537        .next()
10538        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10539}
10540
10541fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10542    scoped_name
10543        .rsplit_once("::")
10544        .map(|(enclosing, _)| enclosing)
10545        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10546        .map(ToString::to_string)
10547}
10548
10549fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10550    let params = signature_parameter_text(signature)?;
10551    for param in split_top_level_commas(params) {
10552        let Some((pattern, type_text)) = param.split_once(':') else {
10553            continue;
10554        };
10555        let Some(name) = rust_parameter_name(pattern) else {
10556            continue;
10557        };
10558        if name == receiver {
10559            return normalize_rust_receiver_type(type_text);
10560        }
10561    }
10562    None
10563}
10564
10565fn signature_parameter_text(signature: &str) -> Option<&str> {
10566    let open = signature.find('(')?;
10567    let mut depth = 0usize;
10568    for (offset, ch) in signature[open..].char_indices() {
10569        match ch {
10570            '(' => depth += 1,
10571            ')' => {
10572                depth = depth.saturating_sub(1);
10573                if depth == 0 {
10574                    return Some(&signature[open + 1..open + offset]);
10575                }
10576            }
10577            _ => {}
10578        }
10579    }
10580    None
10581}
10582
10583fn split_top_level_commas(value: &str) -> Vec<&str> {
10584    let mut parts = Vec::new();
10585    let mut start = 0usize;
10586    let mut angle_depth = 0usize;
10587    let mut paren_depth = 0usize;
10588    let mut bracket_depth = 0usize;
10589    for (index, ch) in value.char_indices() {
10590        match ch {
10591            '<' => angle_depth += 1,
10592            '>' => angle_depth = angle_depth.saturating_sub(1),
10593            '(' => paren_depth += 1,
10594            ')' => paren_depth = paren_depth.saturating_sub(1),
10595            '[' => bracket_depth += 1,
10596            ']' => bracket_depth = bracket_depth.saturating_sub(1),
10597            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10598                let part = value[start..index].trim();
10599                if !part.is_empty() {
10600                    parts.push(part);
10601                }
10602                start = index + ch.len_utf8();
10603            }
10604            _ => {}
10605        }
10606    }
10607    let part = value[start..].trim();
10608    if !part.is_empty() {
10609        parts.push(part);
10610    }
10611    parts
10612}
10613
10614fn rust_parameter_name(pattern: &str) -> Option<&str> {
10615    let mut pattern = pattern.trim();
10616    if let Some(stripped) = pattern.strip_prefix("mut ") {
10617        pattern = stripped.trim_start();
10618    }
10619    pattern
10620        .rsplit(|ch: char| !is_rust_ident_char(ch))
10621        .find(|part| !part.is_empty())
10622}
10623
10624fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10625    let mut ty = strip_leading_rust_type_modifiers(type_text);
10626    let owned_inner;
10627    if let Some(inner) = single_outer_generic_arg(ty) {
10628        owned_inner = inner.trim().to_string();
10629        ty = strip_leading_rust_type_modifiers(&owned_inner);
10630    }
10631    rust_base_type_ident(ty)
10632}
10633
10634fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10635    loop {
10636        ty = ty.trim_start();
10637        if let Some(stripped) = ty.strip_prefix('&') {
10638            ty = stripped.trim_start();
10639            if let Some(stripped) = strip_leading_lifetime(ty) {
10640                ty = stripped.trim_start();
10641            }
10642            if let Some(stripped) = ty.strip_prefix("mut ") {
10643                ty = stripped.trim_start();
10644            }
10645            continue;
10646        }
10647        if let Some(stripped) = ty.strip_prefix("mut ") {
10648            ty = stripped.trim_start();
10649            continue;
10650        }
10651        if let Some(stripped) = ty.strip_prefix("dyn ") {
10652            ty = stripped.trim_start();
10653            continue;
10654        }
10655        if let Some(stripped) = ty.strip_prefix("impl ") {
10656            ty = stripped.trim_start();
10657            continue;
10658        }
10659        break ty.trim();
10660    }
10661}
10662
10663fn strip_leading_lifetime(value: &str) -> Option<&str> {
10664    let mut chars = value.char_indices();
10665    let (_, first) = chars.next()?;
10666    if first != '\'' {
10667        return None;
10668    }
10669    for (index, ch) in chars {
10670        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10671            return Some(&value[index..]);
10672        }
10673    }
10674    Some("")
10675}
10676
10677fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10678    let ty = ty.trim();
10679    let open = ty.find('<')?;
10680    let mut depth = 0usize;
10681    let mut close = None;
10682    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10683        match ch {
10684            '<' => depth += 1,
10685            '>' => {
10686                depth = depth.saturating_sub(1);
10687                if depth == 0 {
10688                    close = Some(index);
10689                    break;
10690                }
10691            }
10692            _ => {}
10693        }
10694    }
10695    let close = close?;
10696    if !ty[close + 1..].trim().is_empty() {
10697        return None;
10698    }
10699    let inner = &ty[open + 1..close];
10700    let args = split_top_level_commas(inner);
10701    match args.as_slice() {
10702        [arg] => Some(*arg),
10703        _ => None,
10704    }
10705}
10706
10707fn rust_base_type_ident(ty: &str) -> Option<String> {
10708    let ty = ty.trim();
10709    let head = ty
10710        .split([' ', '+', '='])
10711        .find(|part| !part.is_empty())
10712        .unwrap_or(ty);
10713    let head = head.split('<').next().unwrap_or(head).trim();
10714    let ident = head
10715        .rsplit("::")
10716        .next()
10717        .unwrap_or(head)
10718        .trim_matches(|ch: char| !is_rust_ident_char(ch));
10719    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10720        None
10721    } else {
10722        Some(ident.to_string())
10723    }
10724}
10725
10726fn is_rust_ident_char(ch: char) -> bool {
10727    ch == '_' || ch.is_ascii_alphanumeric()
10728}
10729
10730fn select_rust_direct_self_field_candidate(
10731    project_root: &Path,
10732    reference: &NameMatchRef,
10733    candidates: &[NameMatchCandidate],
10734    receiver_type: &str,
10735    declaration_file: &str,
10736    declaration_scope: &[(usize, usize)],
10737    source_cache: &mut DispatchSourceCache,
10738) -> Option<NameMatchCandidate> {
10739    let eligible = candidates
10740        .iter()
10741        .filter(|candidate| candidate.node_id != reference.caller_node)
10742        .filter(|candidate| {
10743            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10744        })
10745        .filter(|candidate| {
10746            rust_direct_self_field_candidate_matches_scope(
10747                project_root,
10748                candidate,
10749                receiver_type,
10750                declaration_file,
10751                declaration_scope,
10752                source_cache,
10753            )
10754        })
10755        .collect::<Vec<_>>();
10756    match eligible.as_slice() {
10757        [candidate] => Some((**candidate).clone()),
10758        _ => None,
10759    }
10760}
10761
10762fn rust_direct_self_field_candidate_matches_scope(
10763    project_root: &Path,
10764    candidate: &NameMatchCandidate,
10765    receiver_type: &str,
10766    declaration_file: &str,
10767    declaration_scope: &[(usize, usize)],
10768    source_cache: &mut DispatchSourceCache,
10769) -> bool {
10770    if candidate.file_path != declaration_file {
10771        return false;
10772    }
10773    let Some(parsed) = parsed_dispatch_source_for_file(
10774        project_root,
10775        &candidate.file_path,
10776        "rust",
10777        LangId::Rust,
10778        source_cache,
10779    ) else {
10780        return false;
10781    };
10782    let Some(impl_node) =
10783        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10784    else {
10785        return false;
10786    };
10787    if impl_node.child_by_field_name("trait").is_some()
10788        || impl_node.child_by_field_name("type_parameters").is_some()
10789    {
10790        return false;
10791    }
10792    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10793        return false;
10794    };
10795    impl_target.kind() == "type_identifier"
10796        && node_text(impl_target, &parsed.source) == receiver_type
10797        && rust_module_scope(impl_node) == declaration_scope
10798}
10799
10800fn select_type_match_candidate(
10801    reference: &NameMatchRef,
10802    candidates: &[NameMatchCandidate],
10803    receiver_type: &str,
10804) -> Option<NameMatchCandidate> {
10805    let candidates = candidates
10806        .iter()
10807        .filter(|candidate| candidate.node_id != reference.caller_node)
10808        .filter(|candidate| {
10809            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10810        })
10811        .collect::<Vec<_>>();
10812    match candidates.as_slice() {
10813        [candidate] => Some((**candidate).clone()),
10814        _ => None,
10815    }
10816}
10817
10818fn type_candidate_matches(
10819    candidate: &NameMatchCandidate,
10820    receiver_type: &str,
10821    method_name: &str,
10822) -> bool {
10823    let normalized_type = receiver_type.replace('.', "::");
10824    let suffix = format!("{normalized_type}::{method_name}");
10825    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10826}
10827
10828fn select_name_match_candidate(
10829    reference: &NameMatchRef,
10830    candidates: &[NameMatchCandidate],
10831) -> Option<NameMatchCandidate> {
10832    let candidates = candidates
10833        .iter()
10834        .filter(|candidate| candidate.node_id != reference.caller_node)
10835        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10836        .collect::<Vec<_>>();
10837    match candidates.as_slice() {
10838        [] => None,
10839        [candidate] => Some((**candidate).clone()),
10840        _ => select_scored_name_match_candidate(reference, &candidates),
10841    }
10842}
10843
10844fn candidate_allowed_for_reference(
10845    reference: &NameMatchRef,
10846    candidate: &NameMatchCandidate,
10847) -> bool {
10848    if !reference.colon_dispatch {
10849        return true;
10850    }
10851
10852    candidate.kind == "method"
10853        && candidate
10854            .scoped_name
10855            .split("::")
10856            .any(|segment| segment == reference.receiver)
10857}
10858
10859fn select_scored_name_match_candidate(
10860    reference: &NameMatchRef,
10861    candidates: &[&NameMatchCandidate],
10862) -> Option<NameMatchCandidate> {
10863    let receiver_words = split_camel_case(&reference.receiver);
10864    if receiver_words.is_empty() {
10865        return None;
10866    }
10867
10868    let mut best: Option<(&NameMatchCandidate, f64)> = None;
10869    let mut tied_best = false;
10870    for candidate in candidates {
10871        let candidate_words = split_camel_case(&candidate.scoped_name);
10872        let overlap = receiver_words
10873            .iter()
10874            .filter(|receiver_word| {
10875                candidate_words
10876                    .iter()
10877                    .any(|candidate_word| candidate_word == *receiver_word)
10878            })
10879            .count() as f64;
10880        let score =
10881            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10882        match best {
10883            None => {
10884                best = Some((*candidate, score));
10885                tied_best = false;
10886            }
10887            Some((_, best_score)) if score > best_score => {
10888                best = Some((*candidate, score));
10889                tied_best = false;
10890            }
10891            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10892                tied_best = true;
10893            }
10894            _ => {}
10895        }
10896    }
10897
10898    let (candidate, score) = best?;
10899    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10900        Some(candidate.clone())
10901    } else {
10902        None
10903    }
10904}
10905
10906fn method_name_match_denylisted(method_name: &str) -> bool {
10907    matches!(
10908        method_name,
10909        "and_then"
10910            | "as_bytes"
10911            | "as_deref"
10912            | "as_mut"
10913            | "as_ref"
10914            | "as_str"
10915            | "borrow"
10916            | "borrow_mut"
10917            | "clear"
10918            | "clone"
10919            | "collect"
10920            | "contains"
10921            | "contains_key"
10922            | "count"
10923            | "dedup"
10924            | "default"
10925            | "drain"
10926            | "ends_with"
10927            | "entry"
10928            | "err"
10929            | "expect"
10930            | "extend"
10931            | "filter"
10932            | "filter_map"
10933            | "find"
10934            | "from"
10935            | "get"
10936            | "get_mut"
10937            | "insert"
10938            | "into"
10939            | "into_iter"
10940            | "is_empty"
10941            | "is_err"
10942            | "is_none"
10943            | "is_ok"
10944            | "is_some"
10945            | "iter"
10946            | "iter_mut"
10947            | "join"
10948            | "len"
10949            | "lock"
10950            | "map"
10951            | "map_err"
10952            | "max"
10953            | "min"
10954            | "new"
10955            | "next"
10956            | "ok"
10957            | "or_default"
10958            | "or_else"
10959            | "or_insert"
10960            | "or_insert_with"
10961            | "parse"
10962            | "pop"
10963            | "position"
10964            | "push"
10965            | "read"
10966            | "recv"
10967            | "remove"
10968            | "replace"
10969            | "retain"
10970            | "send"
10971            | "sort"
10972            | "sort_by"
10973            | "split"
10974            | "starts_with"
10975            | "sum"
10976            | "take"
10977            | "to_owned"
10978            | "to_string"
10979            | "trim"
10980            | "try_from"
10981            | "try_into"
10982            | "unwrap"
10983            | "unwrap_or"
10984            | "unwrap_or_default"
10985            | "unwrap_or_else"
10986            | "with_capacity"
10987            | "write"
10988    )
10989}
10990
10991fn split_camel_case(value: &str) -> Vec<String> {
10992    let chars = value.chars().collect::<Vec<_>>();
10993    let mut normalized = String::with_capacity(value.len() + 8);
10994    for (index, ch) in chars.iter().enumerate() {
10995        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10996        let next = chars.get(index + 1);
10997        let is_separator = ch.is_whitespace()
10998            || matches!(
10999                ch,
11000                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11001            );
11002        if is_separator {
11003            normalized.push(' ');
11004            continue;
11005        }
11006        let camel_boundary = previous.is_some_and(|prev| {
11007            (prev.is_lowercase() && ch.is_uppercase())
11008                || (prev.is_ascii_digit() && ch.is_alphabetic())
11009                || (prev.is_uppercase()
11010                    && ch.is_uppercase()
11011                    && next.is_some_and(|next| next.is_lowercase()))
11012        });
11013        if camel_boundary {
11014            normalized.push(' ');
11015        }
11016        normalized.push(*ch);
11017    }
11018
11019    normalized
11020        .split_whitespace()
11021        .filter(|word| word.len() > 1)
11022        .map(|word| word.to_ascii_lowercase())
11023        .collect()
11024}
11025
11026fn compute_path_proximity(left: &str, right: &str) -> f64 {
11027    let left_dirs = left
11028        .rsplit_once('/')
11029        .map(|(dir, _)| dir)
11030        .unwrap_or_default()
11031        .split('/')
11032        .filter(|part| !part.is_empty());
11033    let right_dirs = right
11034        .rsplit_once('/')
11035        .map(|(dir, _)| dir)
11036        .unwrap_or_default()
11037        .split('/')
11038        .filter(|part| !part.is_empty());
11039
11040    let shared = left_dirs
11041        .zip(right_dirs)
11042        .take_while(|(left, right)| left == right)
11043        .count();
11044    ((shared as f64) * 0.05).min(0.5)
11045}
11046
11047fn mark_backend_state(
11048    tx: &Transaction<'_>,
11049    project_root: &Path,
11050    rel_path: &str,
11051    content_hash: Option<&blake3::Hash>,
11052    status: &str,
11053) -> Result<()> {
11054    clear_backend_state_for_file(tx, project_root, rel_path)?;
11055    let hash = content_hash
11056        .map(|hash| hash_to_hex(*hash))
11057        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11058    tx.execute(
11059        "INSERT OR REPLACE INTO backend_file_state(
11060            backend, workspace_root, file_path, content_hash, status, updated_at
11061        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11062        params![
11063            BACKEND_TREESITTER,
11064            project_root.display().to_string(),
11065            rel_path,
11066            hash,
11067            status,
11068            unix_seconds_now(),
11069        ],
11070    )?;
11071    Ok(())
11072}
11073
11074fn clear_backend_state_for_file(
11075    tx: &Transaction<'_>,
11076    project_root: &Path,
11077    rel_path: &str,
11078) -> Result<()> {
11079    tx.execute(
11080        "DELETE FROM backend_file_state
11081         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11082        params![
11083            BACKEND_TREESITTER,
11084            project_root.display().to_string(),
11085            rel_path
11086        ],
11087    )?;
11088    Ok(())
11089}
11090
11091fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11092    conn.query_row(
11093        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11094        params![rel_path],
11095        |row| {
11096            let hash_text: String = row.get(1)?;
11097            Ok(FileRow {
11098                surface_fingerprint: row.get(0)?,
11099                freshness: FileFreshness {
11100                    content_hash: hash_from_hex(&hash_text)
11101                        .unwrap_or_else(cache_freshness::zero_hash),
11102                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11103                    size: row.get::<_, i64>(3)? as u64,
11104                },
11105            })
11106        },
11107    )
11108    .optional()
11109    .map_err(CallGraphStoreError::from)
11110}
11111
11112fn stored_node_ids_match_extract(
11113    tx: &Transaction<'_>,
11114    rel_path: &str,
11115    extract: &FileExtract,
11116) -> Result<bool> {
11117    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11118    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11119    let mut stored = BTreeSet::new();
11120    for row in rows {
11121        stored.insert(row?);
11122    }
11123    let extracted = extract
11124        .nodes
11125        .iter()
11126        .map(|node| node.id.clone())
11127        .collect::<BTreeSet<_>>();
11128    Ok(stored == extracted)
11129}
11130
11131/// Compare every persisted graph row that comes from this file before rewriting it.
11132/// Ranges and reference byte offsets are part of the key because queries expose
11133/// source locations; equal names and edges are not enough after a body shift.
11134fn stored_extract_matches(
11135    tx: &Transaction<'_>,
11136    rel_path: &str,
11137    extract: &FileExtract,
11138    index: &ProjectIndex<'_>,
11139) -> Result<bool> {
11140    let stored_file = tx
11141        .query_row(
11142            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
11143            params![rel_path],
11144            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
11145        )
11146        .optional()?;
11147    if stored_file
11148        != Some((
11149            lang_label(extract.lang).to_string(),
11150            extract.surface_fingerprint.clone(),
11151        ))
11152    {
11153        return Ok(false);
11154    }
11155
11156    let mut stored_nodes_stmt = tx.prepare(
11157        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
11158                end_line, end_col, range_ordinal, signature, exported,
11159                is_default_export, is_type_like, is_callgraph_entry_point, provenance
11160         FROM nodes WHERE file_path = ?1",
11161    )?;
11162    let stored_nodes = stored_nodes_stmt
11163        .query_map(params![rel_path], |row| {
11164            Ok(serde_json::json!([
11165                row.get::<_, String>(0)?,
11166                row.get::<_, String>(1)?,
11167                row.get::<_, String>(2)?,
11168                row.get::<_, String>(3)?,
11169                row.get::<_, String>(4)?,
11170                row.get::<_, i64>(5)?,
11171                row.get::<_, i64>(6)?,
11172                row.get::<_, i64>(7)?,
11173                row.get::<_, i64>(8)?,
11174                row.get::<_, i64>(9)?,
11175                row.get::<_, Option<String>>(10)?,
11176                row.get::<_, i64>(11)?,
11177                row.get::<_, i64>(12)?,
11178                row.get::<_, i64>(13)?,
11179                row.get::<_, i64>(14)?,
11180                row.get::<_, String>(15)?,
11181            ])
11182            .to_string())
11183        })?
11184        .collect::<rusqlite::Result<Vec<_>>>()?;
11185    let expected_nodes = extract
11186        .nodes
11187        .iter()
11188        .map(|node| {
11189            serde_json::json!([
11190                node.id,
11191                node.file_path,
11192                node.name,
11193                node.scoped_name,
11194                node.kind,
11195                node.range.start_line,
11196                node.range.start_col,
11197                node.range.end_line,
11198                node.range.end_col,
11199                node.range_ordinal,
11200                node.signature,
11201                bool_int(node.exported),
11202                bool_int(node.is_default_export),
11203                bool_int(node.is_type_like),
11204                bool_int(node.is_callgraph_entry_point),
11205                PROVENANCE_TREESITTER,
11206            ])
11207            .to_string()
11208        })
11209        .collect::<Vec<_>>();
11210    let mut stored_nodes = stored_nodes;
11211    let mut expected_nodes = expected_nodes;
11212    stored_nodes.sort();
11213    expected_nodes.sort();
11214    if stored_nodes != expected_nodes {
11215        return Ok(false);
11216    }
11217
11218    let resolved_refs = extract
11219        .raw_refs
11220        .iter()
11221        .cloned()
11222        .map(|raw| resolve_ref(raw, index))
11223        .collect::<Result<Vec<_>>>()?;
11224    let mut stored_refs_stmt = tx.prepare(
11225        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
11226                module_path, import_kind, local_name, requested_name, namespace_alias,
11227                wildcard, line, byte_start, byte_end, status, target_node,
11228                target_file, target_symbol, provenance
11229         FROM refs WHERE caller_file = ?1",
11230    )?;
11231    let stored_refs = stored_refs_stmt
11232        .query_map(params![rel_path], |row| {
11233            Ok(serde_json::json!([
11234                row.get::<_, String>(0)?,
11235                row.get::<_, Option<String>>(1)?,
11236                row.get::<_, String>(2)?,
11237                row.get::<_, String>(3)?,
11238                row.get::<_, Option<String>>(4)?,
11239                row.get::<_, Option<String>>(5)?,
11240                row.get::<_, Option<String>>(6)?,
11241                row.get::<_, Option<String>>(7)?,
11242                row.get::<_, Option<String>>(8)?,
11243                row.get::<_, Option<String>>(9)?,
11244                row.get::<_, Option<String>>(10)?,
11245                row.get::<_, i64>(11)?,
11246                row.get::<_, i64>(12)?,
11247                row.get::<_, i64>(13)?,
11248                row.get::<_, i64>(14)?,
11249                row.get::<_, String>(15)?,
11250                row.get::<_, Option<String>>(16)?,
11251                row.get::<_, Option<String>>(17)?,
11252                row.get::<_, Option<String>>(18)?,
11253                row.get::<_, String>(19)?,
11254            ])
11255            .to_string())
11256        })?
11257        .collect::<rusqlite::Result<Vec<_>>>()?;
11258    let expected_refs = resolved_refs
11259        .iter()
11260        .map(|resolved| {
11261            let raw = &resolved.raw;
11262            serde_json::json!([
11263                raw.ref_id,
11264                raw.caller_node,
11265                raw.caller_file,
11266                raw.kind,
11267                raw.short_name,
11268                raw.full_ref,
11269                raw.module_path,
11270                raw.import_kind,
11271                raw.local_name,
11272                raw.requested_name,
11273                raw.namespace_alias,
11274                bool_int(raw.wildcard),
11275                raw.line,
11276                raw.byte_start,
11277                raw.byte_end,
11278                resolved.status,
11279                resolved.target_node,
11280                resolved.target_file,
11281                resolved.target_symbol,
11282                PROVENANCE_TREESITTER,
11283            ])
11284            .to_string()
11285        })
11286        .collect::<Vec<_>>();
11287    let mut stored_refs = stored_refs;
11288    let mut expected_refs = expected_refs;
11289    stored_refs.sort();
11290    expected_refs.sort();
11291    if stored_refs != expected_refs {
11292        return Ok(false);
11293    }
11294
11295    let mut stored_edges_stmt = tx.prepare(
11296        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11297                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11298         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11299         WHERE r.caller_file = ?1 AND e.provenance = ?2",
11300    )?;
11301    let stored_edges = stored_edges_stmt
11302        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11303            Ok(serde_json::json!([
11304                row.get::<_, String>(0)?,
11305                row.get::<_, String>(1)?,
11306                row.get::<_, String>(2)?,
11307                row.get::<_, Option<String>>(3)?,
11308                row.get::<_, String>(4)?,
11309                row.get::<_, String>(5)?,
11310                row.get::<_, String>(6)?,
11311                row.get::<_, i64>(7)?,
11312                row.get::<_, String>(8)?,
11313            ])
11314            .to_string())
11315        })?
11316        .collect::<rusqlite::Result<Vec<_>>>()?;
11317    let expected_edges = resolved_refs
11318        .iter()
11319        .filter_map(|resolved| {
11320            resolved.edge.as_ref().map(|edge| {
11321                serde_json::json!([
11322                    edge.edge_id,
11323                    resolved.raw.ref_id,
11324                    edge.source_node,
11325                    edge.target_node,
11326                    edge.target_file,
11327                    edge.target_symbol,
11328                    edge.kind,
11329                    edge.line,
11330                    PROVENANCE_TREESITTER,
11331                ])
11332                .to_string()
11333            })
11334        })
11335        .collect::<Vec<_>>();
11336    let mut stored_edges = stored_edges;
11337    let mut expected_edges = expected_edges;
11338    stored_edges.sort();
11339    expected_edges.sort();
11340    if stored_edges != expected_edges {
11341        return Ok(false);
11342    }
11343
11344    let mut stored_dependencies_stmt =
11345        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11346    let stored_dependencies = stored_dependencies_stmt
11347        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11348        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11349    let expected_dependencies = extract
11350        .raw_refs
11351        .iter()
11352        .flat_map(|raw| raw.dependencies.iter().cloned())
11353        .collect::<BTreeSet<_>>();
11354    if stored_dependencies != expected_dependencies {
11355        return Ok(false);
11356    }
11357
11358    let mut stored_hints_stmt = tx.prepare(
11359        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11360         FROM dispatch_hints WHERE file = ?1",
11361    )?;
11362    let stored_hints = stored_hints_stmt
11363        .query_map(params![rel_path], |row| {
11364            Ok(serde_json::json!([
11365                row.get::<_, String>(0)?,
11366                row.get::<_, String>(1)?,
11367                row.get::<_, String>(2)?,
11368                row.get::<_, String>(3)?,
11369                row.get::<_, i64>(4)?,
11370                row.get::<_, i64>(5)?,
11371                row.get::<_, i64>(6)?,
11372                row.get::<_, String>(7)?,
11373            ])
11374            .to_string())
11375        })?
11376        .collect::<rusqlite::Result<Vec<_>>>()?;
11377    let expected_hints = extract
11378        .dispatch_hints
11379        .iter()
11380        .map(|hint| {
11381            serde_json::json!([
11382                hint.id,
11383                hint.method_name,
11384                hint.caller_node,
11385                hint.file,
11386                hint.line,
11387                hint.byte_start,
11388                hint.byte_end,
11389                PROVENANCE_TREESITTER,
11390            ])
11391            .to_string()
11392        })
11393        .collect::<Vec<_>>();
11394    let mut stored_hints = stored_hints;
11395    let mut expected_hints = expected_hints;
11396    stored_hints.sort();
11397    expected_hints.sort();
11398    Ok(stored_hints == expected_hints)
11399}
11400
11401fn update_file_fresh_metadata(
11402    tx: &Transaction<'_>,
11403    project_root: &Path,
11404    rel_path: &str,
11405    hash: &blake3::Hash,
11406    mtime: SystemTime,
11407    size: u64,
11408) -> Result<()> {
11409    tx.execute(
11410        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11411         WHERE path = ?1",
11412        params![
11413            rel_path,
11414            hash_to_hex(*hash),
11415            system_time_to_ns(mtime),
11416            size as i64,
11417            unix_seconds_now()
11418        ],
11419    )?;
11420    tx.execute(
11421        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11422         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11423        params![
11424            BACKEND_TREESITTER,
11425            rel_path,
11426            hash_to_hex(*hash),
11427            project_root.display().to_string(),
11428            unix_seconds_now(),
11429        ],
11430    )?;
11431    Ok(())
11432}
11433
11434#[derive(Debug, Clone, PartialEq, Eq)]
11435struct DependentRefSelection {
11436    ref_id: String,
11437    caller_file: String,
11438}
11439
11440fn ref_ids_depending_on(
11441    conn: &Connection,
11442    project_root: &Path,
11443    rel_path: &str,
11444) -> Result<Vec<DependentRefSelection>> {
11445    let mut stmt = conn.prepare(
11446        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11447         FROM refs r
11448         WHERE r.caller_file IN (
11449             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11450         )
11451            OR r.target_file = ?1
11452         ORDER BY r.ref_id",
11453    )?;
11454    let rows = stmt.query_map(params![rel_path], |row| {
11455        Ok(RefDependencyRow {
11456            ref_id: row.get(0)?,
11457            kind: row.get(1)?,
11458            caller_file: row.get(2)?,
11459            module_path: row.get(3)?,
11460            target_file: row.get(4)?,
11461        })
11462    })?;
11463    let mut ids = Vec::new();
11464    for row in rows {
11465        let row = row?;
11466        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11467            ids.push(DependentRefSelection {
11468                ref_id: row.ref_id,
11469                caller_file: row.caller_file,
11470            });
11471        }
11472    }
11473    Ok(ids)
11474}
11475
11476fn record_dependent_refs(
11477    selected_ref_ids: &mut BTreeSet<String>,
11478    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11479    dependent_refs: Vec<DependentRefSelection>,
11480) {
11481    for dependent_ref in dependent_refs {
11482        let DependentRefSelection {
11483            ref_id,
11484            caller_file,
11485        } = dependent_ref;
11486        selected_ref_ids.insert(ref_id.clone());
11487        selected_refs_by_caller
11488            .entry(caller_file)
11489            .or_default()
11490            .insert(ref_id);
11491    }
11492}
11493
11494#[cfg(test)]
11495fn refs_by_caller_for_ref_ids(
11496    tx: &Transaction<'_>,
11497    ref_ids: &BTreeSet<String>,
11498) -> Result<BTreeMap<String, BTreeSet<String>>> {
11499    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11500    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11501    for ref_id in ref_ids {
11502        if let Some(caller) = stmt
11503            .query_row(params![ref_id], |row| row.get::<_, String>(0))
11504            .optional()?
11505        {
11506            by_caller.entry(caller).or_default().insert(ref_id.clone());
11507        }
11508    }
11509    Ok(by_caller)
11510}
11511
11512fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11513    tx.execute(
11514        "DELETE FROM file_dependencies WHERE file_path = ?1",
11515        params![rel_path],
11516    )?;
11517    delete_refs_for_caller(tx, rel_path)?;
11518    tx.execute(
11519        "DELETE FROM dispatch_hints WHERE file = ?1",
11520        params![rel_path],
11521    )?;
11522    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11523    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11524    Ok(())
11525}
11526
11527fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11528    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11529    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11530    let mut ids = BTreeSet::new();
11531    for row in rows {
11532        ids.insert(row?);
11533    }
11534    delete_ref_ids(tx, &ids)
11535}
11536
11537fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11538    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
11539    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
11540    for ref_id in ref_ids {
11541        delete_edges.execute(params![ref_id])?;
11542        delete_refs.execute(params![ref_id])?;
11543    }
11544    Ok(())
11545}
11546
11547fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11548    let mut stmt = conn.prepare(
11549        "SELECT source.file_path, source.scoped_name, edges.target_file,
11550                edges.target_symbol, edges.kind, edges.line
11551         FROM edges
11552         JOIN nodes AS source ON source.id = edges.source_node
11553         ORDER BY source.file_path, source.scoped_name, edges.target_file,
11554                  edges.target_symbol, edges.kind, edges.line",
11555    )?;
11556    let rows = stmt.query_map([], |row| {
11557        Ok(StoredEdge {
11558            source_file: row.get(0)?,
11559            source_symbol: row.get(1)?,
11560            target_file: row.get(2)?,
11561            target_symbol: row.get(3)?,
11562            kind: row.get(4)?,
11563            line: row.get::<_, i64>(5)? as u32,
11564        })
11565    })?;
11566    let mut edges = BTreeSet::new();
11567    for row in rows {
11568        edges.insert(row?);
11569    }
11570    Ok(edges)
11571}
11572
11573fn module_target_from_dependencies(
11574    project_root: &Path,
11575    dependencies: &BTreeSet<String>,
11576) -> Option<String> {
11577    dependencies.iter().find_map(|dep| {
11578        let path = project_root.join(dep);
11579        if path.is_file() {
11580            Some(relative_path(project_root, &canonicalize_path(&path)))
11581        } else {
11582            None
11583        }
11584    })
11585}
11586
11587fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11588    let mut named = HashMap::new();
11589    if let Some(full_ref) = &raw_ref.full_ref {
11590        named = parse_reexport_names(full_ref);
11591    }
11592    ReexportIndex {
11593        target_file,
11594        named,
11595        wildcard: raw_ref.wildcard,
11596    }
11597}
11598
11599fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11600    let mut names = HashMap::new();
11601    let Some(open) = statement.find('{') else {
11602        return names;
11603    };
11604    let Some(close) = statement[open + 1..]
11605        .find('}')
11606        .map(|offset| open + 1 + offset)
11607    else {
11608        return names;
11609    };
11610    for spec in statement[open + 1..close].split(',') {
11611        let spec = spec.trim();
11612        if spec.is_empty() {
11613            continue;
11614        }
11615        if let Some((source, local)) = spec.split_once(" as ") {
11616            names.insert(local.trim().to_string(), source.trim().to_string());
11617        } else {
11618            names.insert(spec.to_string(), spec.to_string());
11619        }
11620    }
11621    names
11622}
11623
11624#[derive(Debug)]
11625struct RefDependencyRow {
11626    ref_id: String,
11627    kind: String,
11628    caller_file: String,
11629    module_path: Option<String>,
11630    target_file: Option<String>,
11631}
11632
11633fn ref_dependency_row_depends_on(
11634    project_root: &Path,
11635    row: &RefDependencyRow,
11636    rel_path: &str,
11637) -> bool {
11638    if row.target_file.as_deref() == Some(rel_path) {
11639        return true;
11640    }
11641
11642    match row.kind.as_str() {
11643        "call" => true,
11644        "import" | "reexport" => row
11645            .module_path
11646            .as_deref()
11647            .map(|module_path| {
11648                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11649                    .contains(rel_path)
11650            })
11651            .unwrap_or(false),
11652        "export_alias" => false,
11653        _ => false,
11654    }
11655}
11656
11657fn module_dependencies_for_ref(
11658    project_root: &Path,
11659    caller_file: &str,
11660    module_path: &str,
11661) -> BTreeSet<String> {
11662    module_dependencies(project_root, &project_root.join(caller_file), module_path)
11663}
11664
11665fn import_dependencies(
11666    project_root: &Path,
11667    abs_path: &Path,
11668    imports: &[ImportStatement],
11669) -> BTreeSet<String> {
11670    let mut deps = BTreeSet::new();
11671    for import in imports {
11672        deps.extend(module_dependencies(
11673            project_root,
11674            abs_path,
11675            &import.module_path,
11676        ));
11677    }
11678    deps
11679}
11680
11681fn module_dependencies(
11682    project_root: &Path,
11683    abs_path: &Path,
11684    module_path: &str,
11685) -> BTreeSet<String> {
11686    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11687    let caller_dir = abs_path.parent().unwrap_or(project_root);
11688    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11689        deps.insert(relative_path(project_root, &resolved));
11690    }
11691    if module_path.starts_with('.') {
11692        let base = caller_dir.join(module_path);
11693        for candidate in relative_module_candidates(&base) {
11694            deps.insert(relative_path(project_root, &candidate));
11695        }
11696    }
11697    deps
11698}
11699
11700fn rust_module_dependencies(
11701    project_root: &Path,
11702    abs_path: &Path,
11703    module_path: &str,
11704) -> BTreeSet<String> {
11705    let mut deps = BTreeSet::new();
11706    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11707    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11708        return deps;
11709    };
11710    let src_prefix = rust_src_prefix(&rel_path);
11711    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11712    if !path_segments.is_empty() {
11713        rust_push_module_dependency_candidate(
11714            project_root,
11715            &mut deps,
11716            &src_prefix,
11717            &path_segments[..path_segments.len() - 1],
11718        );
11719    }
11720    deps
11721}
11722
11723fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11724    let path = rust_module_path_without_alias_or_use_list(module_path);
11725    let segments = path
11726        .split("::")
11727        .map(str::trim)
11728        .filter(|segment| !segment.is_empty())
11729        .collect::<Vec<_>>();
11730    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11731        return None;
11732    }
11733    rust_resolve_segments(rel_path, &segments)
11734}
11735
11736fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11737    let path = module_path
11738        .trim()
11739        .trim_end_matches(';')
11740        .split_once(" as ")
11741        .map(|(left, _)| left.trim())
11742        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11743    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11744}
11745
11746fn rust_push_module_dependency_candidate(
11747    project_root: &Path,
11748    deps: &mut BTreeSet<String>,
11749    src_prefix: &str,
11750    segments: &[String],
11751) {
11752    let candidates = if segments.is_empty() {
11753        vec![
11754            format!("{src_prefix}/lib.rs"),
11755            format!("{src_prefix}/main.rs"),
11756        ]
11757    } else {
11758        vec![
11759            format!("{}/{}.rs", src_prefix, segments.join("/")),
11760            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11761        ]
11762    };
11763    for candidate in candidates {
11764        if project_root.join(&candidate).is_file() {
11765            deps.insert(candidate);
11766        }
11767    }
11768}
11769
11770fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11771    let mut candidates = Vec::new();
11772    if base.extension().is_some() {
11773        candidates.push(base.to_path_buf());
11774        return candidates;
11775    }
11776    for ext in JS_TS_EXTENSIONS {
11777        candidates.push(base.with_extension(ext));
11778    }
11779    for ext in JS_TS_EXTENSIONS {
11780        candidates.push(base.join(format!("index.{ext}")));
11781    }
11782    candidates
11783}
11784
11785fn import_local_names(import: &ImportStatement) -> Vec<String> {
11786    let mut names = Vec::new();
11787    if let Some(default) = &import.default_import {
11788        names.push(default.clone());
11789    }
11790    if let Some(namespace) = &import.namespace_import {
11791        names.push(namespace.clone());
11792    }
11793    for name in &import.names {
11794        names.push(crate::imports::specifier_local_name(name).to_string());
11795    }
11796    names
11797}
11798
11799fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11800    import
11801        .names
11802        .iter()
11803        .map(|name| crate::imports::specifier_imported_name(name).to_string())
11804        .collect()
11805}
11806
11807fn import_is_wildcard(import: &ImportStatement) -> bool {
11808    import.namespace_import.is_some() || import.raw_text.contains('*')
11809}
11810
11811fn namespace_alias(full_ref: &str) -> Option<String> {
11812    full_ref
11813        .split_once('.')
11814        .map(|(namespace, _)| namespace.to_string())
11815}
11816
11817fn import_kind_label(kind: ImportKind) -> &'static str {
11818    match kind {
11819        ImportKind::Value => "value",
11820        ImportKind::Type => "type",
11821        ImportKind::SideEffect => "side_effect",
11822    }
11823}
11824
11825fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11826    match kind {
11827        SymbolKind::Function => "function",
11828        SymbolKind::Class => "class",
11829        SymbolKind::Method => "method",
11830        SymbolKind::Struct => "struct",
11831        SymbolKind::Interface => "interface",
11832        SymbolKind::Enum => "enum",
11833        SymbolKind::TypeAlias => "type_alias",
11834        SymbolKind::Variable => "variable",
11835        SymbolKind::Heading => "heading",
11836        SymbolKind::FileSummary => "file_summary",
11837    }
11838}
11839
11840fn is_type_like(kind: &SymbolKind) -> bool {
11841    matches!(
11842        kind,
11843        SymbolKind::Class
11844            | SymbolKind::Struct
11845            | SymbolKind::Interface
11846            | SymbolKind::Enum
11847            | SymbolKind::TypeAlias
11848    )
11849}
11850
11851fn lang_label(lang: LangId) -> &'static str {
11852    match lang {
11853        LangId::TypeScript => "typescript",
11854        LangId::Tsx => "tsx",
11855        LangId::JavaScript => "javascript",
11856        LangId::Python => "python",
11857        LangId::Rust => "rust",
11858        LangId::Go => "go",
11859        LangId::C => "c",
11860        LangId::Cpp => "cpp",
11861        LangId::Zig => "zig",
11862        LangId::CSharp => "csharp",
11863        LangId::Bash => "bash",
11864        LangId::Html => "html",
11865        LangId::Markdown => "markdown",
11866        LangId::Solidity => "solidity",
11867        LangId::Scss => "scss",
11868        LangId::Vue => "vue",
11869        LangId::Json => "json",
11870        LangId::Scala => "scala",
11871        LangId::Java => "java",
11872        LangId::Ruby => "ruby",
11873        LangId::Kotlin => "kotlin",
11874        LangId::Swift => "swift",
11875        LangId::Php => "php",
11876        LangId::Lua => "lua",
11877        LangId::Perl => "perl",
11878        LangId::Yaml => "yaml",
11879        LangId::Pascal => "pascal",
11880        LangId::R => "r",
11881        LangId::Groovy => "groovy",
11882        LangId::ObjC => "objc",
11883    }
11884}
11885
11886fn lang_from_label(label: &str) -> Option<LangId> {
11887    match label {
11888        "typescript" => Some(LangId::TypeScript),
11889        "tsx" => Some(LangId::Tsx),
11890        "javascript" => Some(LangId::JavaScript),
11891        "python" => Some(LangId::Python),
11892        "rust" => Some(LangId::Rust),
11893        "go" => Some(LangId::Go),
11894        "c" => Some(LangId::C),
11895        "cpp" => Some(LangId::Cpp),
11896        "zig" => Some(LangId::Zig),
11897        "csharp" => Some(LangId::CSharp),
11898        "bash" => Some(LangId::Bash),
11899        "html" => Some(LangId::Html),
11900        "markdown" => Some(LangId::Markdown),
11901        "solidity" => Some(LangId::Solidity),
11902        "scss" => Some(LangId::Scss),
11903        "vue" => Some(LangId::Vue),
11904        "json" => Some(LangId::Json),
11905        "scala" => Some(LangId::Scala),
11906        "java" => Some(LangId::Java),
11907        "ruby" => Some(LangId::Ruby),
11908        "kotlin" => Some(LangId::Kotlin),
11909        "swift" => Some(LangId::Swift),
11910        "php" => Some(LangId::Php),
11911        "lua" => Some(LangId::Lua),
11912        "perl" => Some(LangId::Perl),
11913        "yaml" => Some(LangId::Yaml),
11914        "pascal" => Some(LangId::Pascal),
11915        "r" => Some(LangId::R),
11916        "groovy" => Some(LangId::Groovy),
11917        "objc" => Some(LangId::ObjC),
11918        _ => None,
11919    }
11920}
11921
11922fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11923    let mut normalized = if files.is_empty() {
11924        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11925    } else {
11926        files
11927            .iter()
11928            .map(|path| normalize_file_path(project_root, path))
11929            .collect::<Result<Vec<_>>>()?
11930    };
11931    normalized.sort();
11932    normalized.dedup();
11933    Ok(normalized)
11934}
11935
11936fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11937    let full_path = if path.is_relative() {
11938        project_root.join(path)
11939    } else {
11940        path.to_path_buf()
11941    };
11942    Ok(canonicalize_path(&full_path))
11943}
11944
11945fn canonicalize_path(path: &Path) -> PathBuf {
11946    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11947}
11948
11949fn relative_path(project_root: &Path, path: &Path) -> String {
11950    if let Ok(stripped) = path.strip_prefix(project_root) {
11951        return stripped.to_string_lossy().replace('\\', "/");
11952    }
11953    let canon_root = canonicalize_path(project_root);
11954    let canon_path = canonicalize_path(path);
11955    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11956        return stripped.to_string_lossy().replace('\\', "/");
11957    }
11958    canon_path.to_string_lossy().replace('\\', "/")
11959}
11960
11961fn unqualified_name(scoped: &str) -> &str {
11962    if scoped == TOP_LEVEL_SYMBOL {
11963        return scoped;
11964    }
11965    scoped
11966        .rsplit("::")
11967        .next()
11968        .unwrap_or(scoped)
11969        .rsplit('.')
11970        .next()
11971        .unwrap_or(scoped)
11972        .rsplit('#')
11973        .next()
11974        .unwrap_or(scoped)
11975}
11976
11977fn ref_id(parts: &[&str]) -> String {
11978    let joined = parts.join("\0");
11979    hash_to_hex(blake3::hash(joined.as_bytes()))
11980}
11981
11982fn hash_to_hex(hash: blake3::Hash) -> String {
11983    hash.to_hex().to_string()
11984}
11985
11986fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11987    let bytes = hex_to_bytes(value)?;
11988    Some(blake3::Hash::from_bytes(bytes))
11989}
11990
11991fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11992    if value.len() != 64 {
11993        return None;
11994    }
11995    let mut bytes = [0u8; 32];
11996    for (index, slot) in bytes.iter_mut().enumerate() {
11997        let start = index * 2;
11998        let end = start + 2;
11999        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
12000    }
12001    Some(bytes)
12002}
12003
12004#[derive(Debug, Clone)]
12005struct LineIndex {
12006    newline_offsets: Vec<usize>,
12007    source_len: usize,
12008}
12009
12010impl LineIndex {
12011    fn new(source: &str) -> Self {
12012        Self {
12013            newline_offsets: source
12014                .bytes()
12015                .enumerate()
12016                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
12017                .collect(),
12018            source_len: source.len(),
12019        }
12020    }
12021
12022    fn byte_to_line(&self, byte_offset: usize) -> u32 {
12023        let byte_offset = byte_offset.min(self.source_len);
12024        self.newline_offsets
12025            .partition_point(|offset| *offset < byte_offset) as u32
12026            + 1
12027    }
12028}
12029
12030fn empty_to_none(value: String) -> Option<String> {
12031    if value.is_empty() {
12032        None
12033    } else {
12034        Some(value)
12035    }
12036}
12037
12038fn bool_int(value: bool) -> i64 {
12039    if value {
12040        1
12041    } else {
12042        0
12043    }
12044}
12045
12046fn system_time_to_ns(time: SystemTime) -> i64 {
12047    time.duration_since(UNIX_EPOCH)
12048        .unwrap_or_default()
12049        .as_nanos()
12050        .min(i64::MAX as u128) as i64
12051}
12052
12053fn ns_to_system_time(value: i64) -> SystemTime {
12054    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
12055}
12056
12057fn unix_millis_now() -> u64 {
12058    SystemTime::now()
12059        .duration_since(UNIX_EPOCH)
12060        .unwrap_or_default()
12061        .as_millis()
12062        .min(u128::from(u64::MAX)) as u64
12063}
12064
12065fn unix_seconds_now() -> i64 {
12066    SystemTime::now()
12067        .duration_since(UNIX_EPOCH)
12068        .unwrap_or_default()
12069        .as_secs() as i64
12070}
12071
12072/// Serializes every test that drives the process-wide refresh worker
12073/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
12074/// worker down between another test's enqueue and its flush, deferring the
12075/// batch and zeroing that test's seam counts).
12076#[cfg(test)]
12077pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12078
12079#[cfg(test)]
12080mod refresh_worker_tests {
12081    use super::*;
12082    use std::fs;
12083    use tempfile::tempdir;
12084
12085    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12086        let temp = tempdir().unwrap();
12087        let root = temp.path().join("root");
12088        fs::create_dir_all(&root).unwrap();
12089        let artifact_key = crate::search_index::artifact_cache_key(&root);
12090        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12091        let callgraph_dir = temp
12092            .path()
12093            .join("storage")
12094            .join("callgraph")
12095            .join(artifact_key);
12096        let source = root.join("main.rs");
12097        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
12098        let (store, _) = CallGraphStore::cold_build_with_lease(
12099            callgraph_dir.clone(),
12100            root.clone(),
12101            std::slice::from_ref(&source),
12102        )
12103        .unwrap();
12104        drop(store);
12105        (temp, root, callgraph_dir, source)
12106    }
12107
12108    fn pending_paths() -> PendingCallGraphStorePaths {
12109        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
12110    }
12111
12112    fn wait_for_refresh_calls(root: &Path, expected: usize) {
12113        let deadline = Instant::now() + Duration::from_secs(12);
12114        while callgraph_refresh_worker_test_counts(root).0 < expected {
12115            assert!(
12116                Instant::now() < deadline,
12117                "timed out waiting for {expected} callgraph refresh worker call(s)"
12118            );
12119            std::thread::sleep(Duration::from_millis(5));
12120        }
12121    }
12122
12123    fn wait_for_refresh_worker_idle() {
12124        let deadline = Instant::now() + Duration::from_secs(12);
12125        loop {
12126            let worker = CALLGRAPH_REFRESH_WORKER
12127                .get_or_init(|| Mutex::new(None))
12128                .lock()
12129                .expect("callgraph refresh worker mutex poisoned")
12130                .clone();
12131            let idle = worker.is_none_or(|worker| {
12132                let queue = worker
12133                    .shared
12134                    .queue
12135                    .lock()
12136                    .expect("callgraph refresh queue mutex poisoned");
12137                queue.active.is_none() && queue.order.is_empty()
12138            });
12139            if idle {
12140                return;
12141            }
12142            assert!(
12143                Instant::now() < deadline,
12144                "timed out waiting for callgraph refresh worker to become idle"
12145            );
12146            std::thread::sleep(Duration::from_millis(5));
12147        }
12148    }
12149
12150    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
12151        let temp = tempdir().unwrap();
12152        let root = temp.path().join("workspace");
12153        fs::create_dir_all(root.join("app/src")).unwrap();
12154        let artifact_key = crate::search_index::artifact_cache_key(&root);
12155        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
12156        let callgraph_dir = temp
12157            .path()
12158            .join("storage")
12159            .join("callgraph")
12160            .join(artifact_key);
12161        fs::write(
12162            root.join("Cargo.toml"),
12163            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
12164        )
12165        .unwrap();
12166        fs::write(
12167            root.join("app/Cargo.toml"),
12168            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12169        )
12170        .unwrap();
12171        let caller = root.join("app/src/lib.rs");
12172        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
12173        let (store, _) = CallGraphStore::cold_build_with_lease(
12174            callgraph_dir.clone(),
12175            root.clone(),
12176            std::slice::from_ref(&caller),
12177        )
12178        .unwrap();
12179        drop(store);
12180        (temp, root, callgraph_dir, caller)
12181    }
12182
12183    #[test]
12184    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
12185        let _guard = REFRESH_WORKER_TEST_LOCK
12186            .lock()
12187            .unwrap_or_else(std::sync::PoisonError::into_inner);
12188        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12189        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12190        reset_workspace_crate_prefix_build_count(&root);
12191        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12192
12193        for revision in ["first", "second"] {
12194            fs::write(
12195                &caller,
12196                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
12197            )
12198            .unwrap();
12199            enqueue_callgraph_store_refresh(
12200                callgraph_dir.clone(),
12201                root.clone(),
12202                vec![caller.clone()],
12203                pending_paths(),
12204            );
12205            wait_for_refresh_worker_idle();
12206        }
12207
12208        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12209        assert!(flush_callgraph_store_refreshes_with_budget(
12210            Duration::from_secs(5)
12211        ));
12212        clear_callgraph_refresh_worker_test_seam(&root);
12213    }
12214
12215    #[test]
12216    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
12217        let _guard = REFRESH_WORKER_TEST_LOCK
12218            .lock()
12219            .unwrap_or_else(std::sync::PoisonError::into_inner);
12220        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12221        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
12222        reset_workspace_crate_prefix_build_count(&root);
12223        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12224
12225        fs::write(
12226            &caller,
12227            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
12228        )
12229        .unwrap();
12230        enqueue_callgraph_store_refresh(
12231            callgraph_dir.clone(),
12232            root.clone(),
12233            vec![caller.clone()],
12234            pending_paths(),
12235        );
12236        wait_for_refresh_worker_idle();
12237        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
12238
12239        let added_manifest = root.join("added/Cargo.toml");
12240        let added_source = root.join("added/src/lib.rs");
12241        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
12242        fs::write(
12243            root.join("Cargo.toml"),
12244            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
12245        )
12246        .unwrap();
12247        fs::write(
12248            &added_manifest,
12249            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
12250        )
12251        .unwrap();
12252        fs::write(&added_source, "pub fn target() {}\n").unwrap();
12253        fs::write(
12254            &caller,
12255            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
12256        )
12257        .unwrap();
12258
12259        enqueue_callgraph_store_refresh(
12260            callgraph_dir.clone(),
12261            root.clone(),
12262            vec![
12263                root.join("Cargo.toml"),
12264                added_manifest,
12265                added_source,
12266                caller,
12267            ],
12268            pending_paths(),
12269        );
12270        assert!(flush_callgraph_store_refreshes_with_budget(
12271            Duration::from_secs(12)
12272        ));
12273
12274        // This is the negative control for a permanently-static cache: without
12275        // manifest invalidation the build count stays at one and the call remains
12276        // unresolved because `added_crate` was absent when the map was primed.
12277        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
12278        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12279            .unwrap()
12280            .expect("refreshed workspace store");
12281        let tree = store
12282            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
12283            .unwrap();
12284        assert_eq!(tree.children.len(), 1);
12285        assert_eq!(tree.children[0].file, "added/src/lib.rs");
12286        assert_eq!(tree.children[0].name, "target");
12287        assert!(tree.children[0].resolved);
12288        clear_callgraph_refresh_worker_test_seam(&root);
12289    }
12290
12291    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12292        let temp = tempdir().unwrap();
12293        let main = temp.path().join("main");
12294        let worktree = temp.path().join("worktree");
12295        fs::create_dir_all(&main).unwrap();
12296        let mut git = std::process::Command::new("git");
12297        assert!(
12298            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12299                .status()
12300                .unwrap()
12301                .success()
12302        );
12303        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12304        for args in [
12305            vec![
12306                "-C",
12307                main.to_str().unwrap(),
12308                "config",
12309                "user.email",
12310                "test@example.com",
12311            ],
12312            vec![
12313                "-C",
12314                main.to_str().unwrap(),
12315                "config",
12316                "user.name",
12317                "AFT Test",
12318            ],
12319            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12320            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12321        ] {
12322            let mut command = std::process::Command::new("git");
12323            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12324                .status()
12325                .unwrap()
12326                .success());
12327        }
12328        let mut add_worktree = std::process::Command::new("git");
12329        assert!(crate::test_env::apply_hermetic_git_env(
12330            add_worktree
12331                .arg("-C")
12332                .arg(&main)
12333                .args(["worktree", "add", "--detach"])
12334                .arg(&worktree),
12335        )
12336        .status()
12337        .unwrap()
12338        .success());
12339        let main = fs::canonicalize(main).unwrap();
12340        let worktree = fs::canonicalize(worktree).unwrap();
12341        let project_key = crate::search_index::artifact_cache_key(&main);
12342        assert_eq!(
12343            crate::search_index::artifact_cache_key(&worktree),
12344            project_key
12345        );
12346        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12347        (temp, main, worktree, project_key, callgraph_dir)
12348    }
12349
12350    #[test]
12351    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12352        let _git_env = crate::test_env::hermetic_git_env_guard();
12353        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12354        crate::root_cache::configure_artifact_access(&root, &project_key, true);
12355        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12356        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12357        let publications_for_observer = Arc::clone(&publications);
12358        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12359            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12360        })));
12361        let source = root.join("lib.rs");
12362
12363        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12364            .expect_err("borrow-only writable open must remain unavailable");
12365        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12366        assert!(
12367            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12368                .unwrap()
12369                .is_none()
12370        );
12371        assert!(
12372            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12373                .unwrap()
12374                .is_none()
12375        );
12376        assert!(matches!(
12377            CallGraphStore::cold_build_with_lease(
12378                callgraph_dir.clone(),
12379                root.clone(),
12380                std::slice::from_ref(&source),
12381            ),
12382            Err(CallGraphStoreError::Unavailable(_))
12383        ));
12384        assert!(matches!(
12385            CallGraphStore::ensure_built_with_lease(
12386                callgraph_dir.clone(),
12387                root.clone(),
12388                std::slice::from_ref(&source),
12389            ),
12390            Err(CallGraphStoreError::Unavailable(_))
12391        ));
12392        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12393            callgraph_dir.clone(),
12394            root.clone(),
12395            &[source],
12396            1,
12397        )
12398        .expect_err("borrow-only forced rebuild must remain unsatisfied");
12399        set_cold_build_swap_observer(None);
12400
12401        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12402        assert_eq!(
12403            crate::root_cache::writer_lease_acquisition_count_for_test(
12404                crate::root_cache::RootCacheDomain::Callgraph,
12405                &project_key,
12406                &root,
12407            ),
12408            0
12409        );
12410        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12411        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12412    }
12413
12414    #[test]
12415    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12416        let _git_env = crate::test_env::hermetic_git_env_guard();
12417        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12418        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12419        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12420        let source = owner.join("lib.rs");
12421        let (store, _) = CallGraphStore::cold_build_with_lease(
12422            callgraph_dir.clone(),
12423            owner.clone(),
12424            std::slice::from_ref(&source),
12425        )
12426        .unwrap();
12427        let sqlite_path = store.sqlite_path().to_path_buf();
12428        drop(store);
12429
12430        let conn = Connection::open(&sqlite_path).unwrap();
12431        conn.execute(
12432            "UPDATE backend_file_state SET workspace_root = ?1",
12433            [worktree.display().to_string()],
12434        )
12435        .unwrap();
12436        drop(conn);
12437
12438        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12439        let publications_for_observer = Arc::clone(&publications);
12440        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12441            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12442        })));
12443        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12444
12445        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12446            .unwrap()
12447            .expect("owner should purge the storm-era worktree root");
12448        drop(repaired);
12449        for _ in 0..3 {
12450            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12451                .unwrap()
12452                .expect("linked worktree should borrow the owner generation");
12453            drop(borrower);
12454            assert!(
12455                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12456                    .unwrap()
12457                    .is_none()
12458            );
12459            let owner_store =
12460                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12461                    .unwrap()
12462                    .expect("owner generation should remain ready");
12463            drop(owner_store);
12464        }
12465        set_cold_build_swap_observer(None);
12466
12467        assert_eq!(
12468            publications.load(AtomicOrdering::SeqCst),
12469            1,
12470            "the owner performs one expected post-storm purge and alternation stays read-only"
12471        );
12472        assert_eq!(
12473            crate::root_cache::writer_lease_acquisition_count_for_test(
12474                crate::root_cache::RootCacheDomain::Callgraph,
12475                &project_key,
12476                &worktree,
12477            ),
12478            0
12479        );
12480    }
12481
12482    #[test]
12483    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12484        let temp = tempdir().unwrap();
12485        let root = temp.path().join("owner");
12486        let other_root = temp.path().join("other");
12487        fs::create_dir_all(&root).unwrap();
12488        fs::create_dir_all(&other_root).unwrap();
12489        let source = root.join("lib.rs");
12490        fs::write(&source, "pub fn marker() {}\n").unwrap();
12491        let project_key = crate::search_index::artifact_cache_key(&root);
12492        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12493        crate::root_cache::configure_artifact_access(&root, &project_key, false);
12494        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12495        rebuild_cooldown_records()
12496            .lock()
12497            .unwrap_or_else(std::sync::PoisonError::into_inner)
12498            .remove(&cooldown_key);
12499        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12500        let stale_epoch = epoch.current();
12501        epoch.next();
12502
12503        let failed = with_publish_epoch(epoch, stale_epoch, || {
12504            CallGraphStore::cold_build_with_lease(
12505                callgraph_dir.clone(),
12506                root.clone(),
12507                std::slice::from_ref(&source),
12508            )
12509        });
12510        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12511        assert!(
12512            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12513                .is_none()
12514        );
12515
12516        let (store, _) = CallGraphStore::cold_build_with_lease(
12517            callgraph_dir.clone(),
12518            root.clone(),
12519            std::slice::from_ref(&source),
12520        )
12521        .unwrap();
12522        drop(store);
12523        assert!(
12524            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12525                .is_none()
12526        );
12527
12528        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12529        assert!(
12530            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12531        );
12532    }
12533
12534    #[test]
12535    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12536        let _guard = REFRESH_WORKER_TEST_LOCK
12537            .lock()
12538            .unwrap_or_else(std::sync::PoisonError::into_inner);
12539        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12540        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12541        let pending = pending_paths();
12542        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12543
12544        let lifecycle = SubcLifecycleAdmission::default();
12545        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12546        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12547        let ticket = CallgraphRefreshTicket::new(
12548            lifecycle,
12549            Arc::clone(&generation),
12550            7,
12551            publish_epoch.clone(),
12552            publish_epoch.current(),
12553        );
12554        // Supersede before the worker runs: the batch must defer, not commit.
12555        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12556        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12557            .unwrap()
12558            .expect("ready store snapshot");
12559        let refresh_state = CallgraphRefreshState::new(
12560            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12561            Arc::new(AtomicBool::new(true)),
12562        );
12563
12564        enqueue_callgraph_store_refresh_fenced_with_state(
12565            callgraph_dir,
12566            root.clone(),
12567            vec![source.clone()],
12568            Arc::clone(&pending),
12569            refresh_state,
12570            ticket,
12571        );
12572        assert!(flush_callgraph_store_refreshes_with_budget(
12573            Duration::from_secs(5)
12574        ));
12575        assert_eq!(
12576            callgraph_refresh_worker_test_counts(&root).0,
12577            0,
12578            "superseded batch must not reach refresh_files or self-replay"
12579        );
12580        assert!(
12581            pending.lock().contains(&source),
12582            "superseded batch must defer its paths to the pending sink"
12583        );
12584        clear_callgraph_refresh_worker_test_seam(&root);
12585    }
12586
12587    #[test]
12588    fn superseded_open_failure_defers_without_self_replay() {
12589        let _guard = REFRESH_WORKER_TEST_LOCK
12590            .lock()
12591            .unwrap_or_else(std::sync::PoisonError::into_inner);
12592        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12593        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12594        let pending = pending_paths();
12595        let installed = Arc::new(
12596            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12597                .unwrap()
12598                .expect("ready store snapshot"),
12599        );
12600        let refresh_state = CallgraphRefreshState::new(
12601            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12602            Arc::new(AtomicBool::new(true)),
12603        );
12604        assert!(!installed.is_legacy_fallback());
12605        assert!(installed.is_current());
12606        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12607        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12608        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12609        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12610
12611        let lifecycle = SubcLifecycleAdmission::default();
12612        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12613        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12614        let ticket = CallgraphRefreshTicket::new(
12615            lifecycle,
12616            Arc::clone(&generation),
12617            7,
12618            publish_epoch.clone(),
12619            publish_epoch.current(),
12620        );
12621        enqueue_callgraph_store_refresh_fenced_with_state(
12622            callgraph_dir,
12623            root.clone(),
12624            vec![source.clone()],
12625            Arc::clone(&pending),
12626            refresh_state,
12627            ticket,
12628        );
12629        held_rx
12630            .recv_timeout(Duration::from_secs(12))
12631            .expect("refresh worker must hold after injected open failure");
12632
12633        // Mark the refresh request obsolete after the injected open failure,
12634        // then unblock the worker before its deferred retry can run.
12635        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12636        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12637        release_tx
12638            .send(())
12639            .expect("release superseded refresh worker");
12640        wait_for_refresh_worker_idle();
12641
12642        assert_eq!(
12643            callgraph_refresh_worker_test_counts(&root).0,
12644            1,
12645            "superseded open-failure batch must not self-replay"
12646        );
12647        assert_eq!(
12648            callgraph_refresh_worker_test_worker_calls(&root),
12649            1,
12650            "superseded open-failure batch must not create another worker call"
12651        );
12652        assert!(
12653            pending.lock().contains(&source),
12654            "superseded open-failure paths must remain in the pending sink"
12655        );
12656        let tree = installed
12657            .call_tree(Path::new("main.rs"), "entry", 1)
12658            .unwrap();
12659        assert_eq!(
12660            tree.children[0].name, "old_leaf",
12661            "superseded open-failure batch must not converge the store"
12662        );
12663        clear_callgraph_refresh_worker_test_seam(&root);
12664    }
12665
12666    #[test]
12667    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12668        let _guard = REFRESH_WORKER_TEST_LOCK
12669            .lock()
12670            .unwrap_or_else(std::sync::PoisonError::into_inner);
12671        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12672        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12673        let pending = pending_paths();
12674        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12675
12676        let lifecycle = SubcLifecycleAdmission::default();
12677        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12678        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12679        let expected_epoch = publish_epoch.current();
12680        let ticket = CallgraphRefreshTicket::new(
12681            lifecycle,
12682            generation,
12683            3,
12684            publish_epoch.clone(),
12685            expected_epoch,
12686        );
12687        // A cold build published a replacement generation after enqueue.
12688        publish_epoch.next();
12689
12690        enqueue_callgraph_store_refresh_fenced(
12691            callgraph_dir,
12692            root.clone(),
12693            vec![source.clone()],
12694            Arc::clone(&pending),
12695            ticket,
12696        );
12697        assert!(flush_callgraph_store_refreshes_with_budget(
12698            Duration::from_secs(5)
12699        ));
12700        assert_eq!(
12701            callgraph_refresh_worker_test_counts(&root).0,
12702            0,
12703            "epoch-superseded batch must not reach refresh_files"
12704        );
12705        assert!(
12706            pending.lock().contains(&source),
12707            "epoch-superseded batch must defer its paths to the pending sink"
12708        );
12709        clear_callgraph_refresh_worker_test_seam(&root);
12710    }
12711
12712    #[test]
12713    fn fenced_refresh_with_current_ticket_commits_normally() {
12714        let _guard = REFRESH_WORKER_TEST_LOCK
12715            .lock()
12716            .unwrap_or_else(std::sync::PoisonError::into_inner);
12717        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12718        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12719        let pending = pending_paths();
12720        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12721
12722        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12723
12724        let lifecycle = SubcLifecycleAdmission::default();
12725        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12726        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12727        let ticket = CallgraphRefreshTicket::new(
12728            lifecycle,
12729            generation,
12730            5,
12731            publish_epoch.clone(),
12732            publish_epoch.current(),
12733        );
12734
12735        enqueue_callgraph_store_refresh_fenced(
12736            callgraph_dir.clone(),
12737            root.clone(),
12738            vec![source.clone()],
12739            Arc::clone(&pending),
12740            ticket,
12741        );
12742        assert!(flush_callgraph_store_refreshes_with_budget(
12743            Duration::from_secs(5)
12744        ));
12745        assert_eq!(
12746            callgraph_refresh_worker_test_counts(&root).0,
12747            1,
12748            "current ticket must run the refresh"
12749        );
12750        assert!(
12751            pending.lock().is_empty(),
12752            "committed batch must not defer paths"
12753        );
12754
12755        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12756            .unwrap()
12757            .expect("published generation must remain readable");
12758        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12759        assert_eq!(
12760            tree.children[0].name, "new_leaf",
12761            "fenced commit must actually persist the refreshed content"
12762        );
12763        clear_callgraph_refresh_worker_test_seam(&root);
12764    }
12765
12766    #[test]
12767    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12768        let _guard = REFRESH_WORKER_TEST_LOCK
12769            .lock()
12770            .unwrap_or_else(std::sync::PoisonError::into_inner);
12771        // Generous pre-drain: the refresh worker is process-wide, so a prior
12772        // test's still-running batch (slow Windows CI) must fully settle
12773        // before this test enqueues, or its wait deadline absorbs the
12774        // leftover work. Idle workers return immediately.
12775        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12776        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12777        let pending = pending_paths();
12778        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12779
12780        enqueue_callgraph_store_refresh(
12781            callgraph_dir.clone(),
12782            root.clone(),
12783            vec![source.clone()],
12784            Arc::clone(&pending),
12785        );
12786        wait_for_refresh_calls(&root, 1);
12787        for _ in 0..3 {
12788            enqueue_callgraph_store_refresh(
12789                callgraph_dir.clone(),
12790                root.clone(),
12791                vec![source.clone()],
12792                Arc::clone(&pending),
12793            );
12794        }
12795
12796        assert!(flush_callgraph_store_refreshes_with_budget(
12797            Duration::from_secs(2)
12798        ));
12799        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12800        assert!(pending.lock().is_empty());
12801        clear_callgraph_refresh_worker_test_seam(&root);
12802    }
12803
12804    #[test]
12805    fn queued_refresh_opens_generation_published_after_enqueue() {
12806        let _guard = REFRESH_WORKER_TEST_LOCK
12807            .lock()
12808            .unwrap_or_else(std::sync::PoisonError::into_inner);
12809        // Generous pre-drain: the refresh worker is process-wide, so a prior
12810        // test's still-running batch (slow Windows CI) must fully settle
12811        // before this test enqueues, or its wait deadline absorbs the
12812        // leftover work. Idle workers return immediately.
12813        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12814        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12815        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12816        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12817        let (active_held_rx, active_release_tx) =
12818            install_callgraph_refresh_worker_test_gate(active_root.clone());
12819        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12820        enqueue_callgraph_store_refresh(
12821            active_dir,
12822            active_root.clone(),
12823            vec![active_source],
12824            pending_paths(),
12825        );
12826        active_held_rx
12827            .recv_timeout(Duration::from_secs(12))
12828            .expect("active refresh worker holds the queue");
12829
12830        fs::write(
12831            &target_source,
12832            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12833        )
12834        .unwrap();
12835        enqueue_callgraph_store_refresh(
12836            target_dir.clone(),
12837            target_root.clone(),
12838            vec![target_source.clone()],
12839            pending_paths(),
12840        );
12841        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12842            target_dir.clone(),
12843            target_root.clone(),
12844            std::slice::from_ref(&target_source),
12845        )
12846        .unwrap();
12847        fs::write(
12848            &target_source,
12849            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12850        )
12851        .unwrap();
12852        drop(new_generation);
12853
12854        active_release_tx
12855            .send(())
12856            .expect("release active refresh worker");
12857        wait_for_refresh_calls(&target_root, 1);
12858        assert!(flush_callgraph_store_refreshes_with_budget(
12859            Duration::from_secs(12)
12860        ));
12861        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12862            .unwrap()
12863            .expect("current callgraph generation");
12864        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12865        assert_eq!(tree.children[0].name, "worker_leaf");
12866        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12867        clear_callgraph_refresh_worker_test_seam(&active_root);
12868        clear_callgraph_refresh_worker_test_seam(&target_root);
12869    }
12870
12871    #[test]
12872    fn refresh_failure_marks_files_stale() {
12873        let _guard = REFRESH_WORKER_TEST_LOCK
12874            .lock()
12875            .unwrap_or_else(std::sync::PoisonError::into_inner);
12876        // Generous pre-drain: the refresh worker is process-wide, so a prior
12877        // test's still-running batch (slow Windows CI) must fully settle
12878        // before this test enqueues, or its wait deadline absorbs the
12879        // leftover work. Idle workers return immediately.
12880        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12881        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12882        let pending = pending_paths();
12883        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12884
12885        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12886        assert!(flush_callgraph_store_refreshes_with_budget(
12887            Duration::from_secs(2)
12888        ));
12889
12890        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12891        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12892            .unwrap()
12893            .expect("ready callgraph store");
12894        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12895        clear_callgraph_refresh_worker_test_seam(&root);
12896    }
12897
12898    #[test]
12899    fn idle_refresh_truncates_wal() {
12900        let _guard = REFRESH_WORKER_TEST_LOCK
12901            .lock()
12902            .unwrap_or_else(std::sync::PoisonError::into_inner);
12903        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12904        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12905        let generation = read_pointer(
12906            &callgraph_dir,
12907            &crate::search_index::artifact_cache_key(&root),
12908        )
12909        .expect("fixture publishes a generation");
12910        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12911        let pending = pending_paths();
12912        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12913
12914        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12915        enqueue_callgraph_store_refresh(
12916            callgraph_dir.clone(),
12917            root.clone(),
12918            vec![source.clone()],
12919            Arc::clone(&pending),
12920        );
12921        wait_for_refresh_calls(&root, 1);
12922        wait_for_refresh_worker_idle();
12923        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12924        while fs::metadata(&wal_path)
12925            .map(|metadata| metadata.len())
12926            .unwrap_or(0)
12927            != 0
12928        {
12929            assert!(
12930                Instant::now() < checkpoint_deadline,
12931                "idle checkpoint did not truncate WAL"
12932            );
12933            std::thread::sleep(Duration::from_millis(5));
12934        }
12935        assert_eq!(
12936            fs::metadata(&wal_path)
12937                .map(|metadata| metadata.len())
12938                .unwrap_or(0),
12939            0,
12940            "idle transition truncates the refresh WAL"
12941        );
12942
12943        clear_callgraph_refresh_worker_test_seam(&root);
12944    }
12945
12946    #[test]
12947    fn bounded_shutdown_defers_unprocessed_batches() {
12948        let _guard = REFRESH_WORKER_TEST_LOCK
12949            .lock()
12950            .unwrap_or_else(std::sync::PoisonError::into_inner);
12951        // Generous pre-drain: the refresh worker is process-wide, so a prior
12952        // test's still-running batch (slow Windows CI) must fully settle
12953        // before this test enqueues, or its wait deadline absorbs the
12954        // leftover work. Idle workers return immediately.
12955        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12956        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12957        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12958        let active_pending = pending_paths();
12959        let queued_pending = pending_paths();
12960        set_callgraph_refresh_worker_test_seam(
12961            active_root.clone(),
12962            Duration::from_millis(300),
12963            false,
12964        );
12965
12966        enqueue_callgraph_store_refresh(
12967            active_dir,
12968            active_root.clone(),
12969            vec![active_source.clone()],
12970            Arc::clone(&active_pending),
12971        );
12972        wait_for_refresh_calls(&active_root, 1);
12973        enqueue_callgraph_store_refresh(
12974            queued_dir,
12975            queued_root.clone(),
12976            vec![queued_source.clone()],
12977            Arc::clone(&queued_pending),
12978        );
12979
12980        assert!(!flush_callgraph_store_refreshes_with_budget(
12981            Duration::from_millis(20)
12982        ));
12983        assert!(active_pending.lock().contains(&active_source));
12984        assert!(queued_pending.lock().contains(&queued_source));
12985        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12986        clear_callgraph_refresh_worker_test_seam(&active_root);
12987    }
12988}
12989
12990#[cfg(test)]
12991mod cold_build_insert_tests {
12992    use super::*;
12993    use crate::imports::ImportBlock;
12994    use std::cell::Cell;
12995    use std::fs;
12996    use std::path::{Path, PathBuf};
12997    use tempfile::tempdir;
12998
12999    thread_local! {
13000        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
13001        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
13002        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
13003    }
13004
13005    fn count_caller_traversal_selects(sql: &str) {
13006        let sql = sql.trim_start();
13007        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
13008            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
13009        }
13010        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
13011            && sql.contains("e.target_file =")
13012        {
13013            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
13014        }
13015        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
13016            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
13017        }
13018    }
13019
13020    #[test]
13021    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
13022        let dir = tempdir().unwrap();
13023        let previous_root = dir.path().join("previous-root");
13024        let current_root = dir.path().join("current-root");
13025        fs::create_dir_all(&previous_root).unwrap();
13026        fs::create_dir_all(&current_root).unwrap();
13027        fs::remove_dir(&previous_root).unwrap();
13028        let mut conn = Connection::open_in_memory().unwrap();
13029        initialize_schema(&conn).unwrap();
13030        conn.execute(
13031            "INSERT INTO backend_file_state(
13032                backend, workspace_root, file_path, content_hash, status, updated_at
13033             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13034            params![previous_root.display().to_string()],
13035        )
13036        .unwrap();
13037
13038        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
13039
13040        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13041        assert_eq!(
13042            stored_workspace_roots(&conn).unwrap(),
13043            vec![previous_root.display().to_string()]
13044        );
13045    }
13046
13047    #[test]
13048    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
13049        assert_eq!(
13050            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
13051            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
13052        );
13053    }
13054
13055    #[test]
13056    fn legacy_migration_completion_log_has_operator_fields() {
13057        assert_eq!(
13058            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
13059            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
13060        );
13061    }
13062
13063    fn write_generation_with_age(
13064        dir: &Path,
13065        project_key: &str,
13066        ordinal: u64,
13067        age: Duration,
13068    ) -> String {
13069        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
13070        let path = dir.join(&generation);
13071        fs::write(&path, b"sqlite placeholder").unwrap();
13072        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13073        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13074        generation
13075    }
13076
13077    #[test]
13078    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
13079        let dir = tempfile::tempdir().unwrap();
13080        let project_key = "project";
13081        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13082        let previous =
13083            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13084        let pinned =
13085            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13086        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13087
13088        gc_old_generations(dir.path(), project_key, &current);
13089
13090        assert!(dir.path().join(&previous).is_file());
13091        assert!(dir.path().join(&pinned).is_file());
13092
13093        drop(marker);
13094        gc_old_generations(dir.path(), project_key, &current);
13095
13096        assert!(dir.path().join(&previous).is_file());
13097        assert!(!dir.path().join(&pinned).exists());
13098    }
13099
13100    #[test]
13101    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
13102        let dir = tempfile::tempdir().unwrap();
13103        let project_key = "project";
13104        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13105        let _previous =
13106            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
13107        let pinned =
13108            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
13109        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
13110        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
13111
13112        gc_old_generations(dir.path(), project_key, &current);
13113
13114        assert!(dir.path().join(&pinned).is_file());
13115    }
13116
13117    #[test]
13118    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
13119        let dir = tempfile::tempdir().unwrap();
13120        let project_key = "project";
13121        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
13122        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
13123        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
13124        let old = write_generation_with_age(
13125            dir.path(),
13126            project_key,
13127            200,
13128            expired + Duration::from_secs(60),
13129        );
13130        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
13131
13132        gc_old_generations(dir.path(), project_key, &current);
13133
13134        assert!(dir.path().join(&current).is_file());
13135        assert!(dir.path().join(&previous).is_file());
13136        assert!(!dir.path().join(&old).exists());
13137    }
13138
13139    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
13140        let path = dir.join(name);
13141        fs::write(&path, b"temp placeholder").unwrap();
13142        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
13143        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
13144        path
13145    }
13146
13147    #[test]
13148    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
13149        let dir = tempdir().unwrap();
13150        // One directory holds both an aged orphan (with its journal sidecar) and a
13151        // fresh temporary, so this proves the sweep SELECTS by age rather than
13152        // deleting everything in the directory.
13153        let aged = "project.g100.1.sqlite.tmp.1.200";
13154        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
13155        let fresh = "project.g300.1.sqlite.tmp.1.400";
13156        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
13157        write_build_temp_with_age(dir.path(), aged, aged_age);
13158        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
13159        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13160
13161        sweep_orphaned_build_temps(dir.path());
13162
13163        assert!(
13164            !dir.path().join(aged).exists(),
13165            "aged orphan must be removed"
13166        );
13167        assert!(
13168            !dir.path().join(aged_journal).exists(),
13169            "aged journal sidecar must be removed"
13170        );
13171        assert!(
13172            dir.path().join(fresh).is_file(),
13173            "fresh temporary must survive"
13174        );
13175    }
13176
13177    #[test]
13178    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
13179        let storage = tempdir().unwrap();
13180        let storage_root = storage.path();
13181        // The production shape: a legacy per-harness store whose root no longer
13182        // builds there — no `.current` pointer, no running build — so the per-root
13183        // cleanup never fires for it. A sibling root still building in the
13184        // root-keyed store triggers the store-wide sweep, which must reach into the
13185        // legacy directory and reclaim the orphan.
13186        let legacy_dir = storage_root.join("opencode").join("callgraph");
13187        fs::create_dir_all(&legacy_dir).unwrap();
13188        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
13189        write_build_temp_with_age(
13190            &legacy_dir,
13191            orphan,
13192            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13193        );
13194        assert!(
13195            !legacy_dir.join("deadbeef.current").exists(),
13196            "the dead root has no current pointer"
13197        );
13198
13199        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
13200        fs::create_dir_all(&root_keyed_dir).unwrap();
13201
13202        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
13203
13204        assert!(
13205            !legacy_dir.join(orphan).exists(),
13206            "legacy orphan must be reclaimed by the store-wide sweep"
13207        );
13208    }
13209
13210    #[test]
13211    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
13212        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
13213        // everything (min_age = 0) removes the fresh temporary that the real 24h
13214        // threshold spares in the test above. If a mutation to the age check leaves
13215        // the fresh file in place here, the predicate is no longer doing the
13216        // selection work the fresh-survives assertion relies on.
13217        let dir = tempdir().unwrap();
13218        let fresh = "project.g300.1.sqlite.tmp.1.400";
13219        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
13220
13221        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
13222
13223        assert!(
13224            !dir.path().join(fresh).exists(),
13225            "with the age predicate forced open, the fresh temporary is removed"
13226        );
13227    }
13228
13229    #[test]
13230    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
13231        let dir = tempdir().unwrap();
13232        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
13233        // to be swept, plus a live read marker, is generation GC's jurisdiction.
13234        // The orphan sweep must not intersect it.
13235        let generation = write_generation_with_age(
13236            dir.path(),
13237            "project",
13238            400,
13239            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
13240        );
13241        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13242
13243        sweep_orphaned_build_temps(dir.path());
13244
13245        assert!(
13246            dir.path().join(&generation).is_file(),
13247            "completed generation must survive the orphan sweep"
13248        );
13249        assert!(
13250            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
13251            "read marker must survive the orphan sweep"
13252        );
13253    }
13254
13255    #[test]
13256    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
13257        let dir = tempfile::tempdir().unwrap();
13258        let project_key = "project".to_string();
13259        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
13260        let sqlite_path = dir.path().join(&generation);
13261        fs::remove_file(&sqlite_path).unwrap();
13262        let conn = Connection::open(&sqlite_path).unwrap();
13263        let store = CallGraphStore::from_connection(
13264            dir.path().to_path_buf(),
13265            project_key,
13266            sqlite_path,
13267            dir.path().to_path_buf(),
13268            false,
13269            Some(generation.clone()),
13270            None,
13271            None,
13272            conn,
13273        );
13274
13275        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
13276        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
13277
13278        drop(marker);
13279        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
13280    }
13281
13282    #[test]
13283    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13284        let dir = tempdir().expect("temp dir");
13285        let file = dir.path().join("main.ts");
13286        fs::write(&file, "export function main() {}\n").expect("write fixture");
13287        let store = CallGraphStore::open(
13288            dir.path().join(".store-readiness-cache"),
13289            dir.path().to_path_buf(),
13290        )
13291        .expect("open store");
13292        {
13293            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13294            conn.trace(Some(count_caller_traversal_selects));
13295        }
13296
13297        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13298        assert!(store.indexed_file_count().is_err());
13299        assert!(store.indexed_file_count().is_err());
13300        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13301
13302        store
13303            .cold_build(std::slice::from_ref(&file))
13304            .expect("cold build");
13305        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13306        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13307        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13308        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13309
13310        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13311        conn.trace(None);
13312    }
13313
13314    #[test]
13315    fn direct_caller_frontier_chunks_sqlite_selects() {
13316        let dir = tempdir().expect("temp dir");
13317        let file = dir.path().join("main.ts");
13318        fs::write(
13319            &file,
13320            "export function caller() { target(); }\nexport function target() {}\n",
13321        )
13322        .expect("write fixture");
13323        let store = CallGraphStore::open(
13324            dir.path().join(".store-caller-frontier-query"),
13325            dir.path().to_path_buf(),
13326        )
13327        .expect("open store");
13328        store
13329            .cold_build(std::slice::from_ref(&file))
13330            .expect("cold build");
13331        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
13332        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
13333
13334        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13335        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13336        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13337        {
13338            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13339            conn.trace(Some(count_caller_traversal_selects));
13340        }
13341        let callers = store
13342            .direct_callers_for_symbols(&targets)
13343            .expect("batched callers");
13344        {
13345            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13346            conn.trace(None);
13347        }
13348
13349        assert_eq!(callers.len(), 1_000);
13350        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
13351        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
13352        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
13353        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13354    }
13355
13356    #[test]
13357    fn callers_depth_boundary_batches_sqlite_counts() {
13358        const CALLER_COUNT: usize = 1_000;
13359
13360        let dir = tempdir().expect("temp dir");
13361        let file = dir.path().join("main.ts");
13362        let mut source = String::from("export function sharedHotHelper() {}\n");
13363        for index in 0..CALLER_COUNT {
13364            source.push_str(&format!(
13365                "export function caller{index}() {{ sharedHotHelper(); }}\n"
13366            ));
13367        }
13368        fs::write(&file, source).expect("write fixture");
13369
13370        let store = CallGraphStore::open(
13371            dir.path().join(".store-callers-query-fanout"),
13372            dir.path().to_path_buf(),
13373        )
13374        .expect("open store");
13375        store
13376            .cold_build(std::slice::from_ref(&file))
13377            .expect("cold build");
13378
13379        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13380        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13381        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13382        {
13383            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13384            conn.trace(Some(count_caller_traversal_selects));
13385        }
13386
13387        let started = Instant::now();
13388        let result = crate::commands::callgraph_store_adapter::callers_result(
13389            &store,
13390            Path::new("main.ts"),
13391            "sharedHotHelper",
13392            1,
13393            true,
13394        )
13395        .expect("callers result");
13396        let elapsed = started.elapsed();
13397
13398        {
13399            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13400            conn.trace(None);
13401        }
13402        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13403        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13404        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13405        eprintln!(
13406            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13407            result.total_callers,
13408            caller_queries,
13409            boundary_queries,
13410            total_selects,
13411            elapsed.as_secs_f64() * 1_000.0
13412        );
13413
13414        assert_eq!(result.total_callers, CALLER_COUNT);
13415        assert_eq!(caller_queries, 1);
13416        assert_eq!(boundary_queries, 3);
13417        assert_eq!(total_selects, 9);
13418    }
13419
13420    #[test]
13421    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13422        let dir = tempdir().expect("temp dir");
13423        let file = dir.path().join("main.ts");
13424        fs::write(
13425            &file,
13426            r#"export function topA() {
13427  root();
13428}
13429
13430export function topB() {
13431  root();
13432}
13433
13434export function root() {
13435  leaf();
13436  missing();
13437}
13438
13439export function leaf() {}
13440"#,
13441        )
13442        .expect("write fixture");
13443
13444        let store = CallGraphStore::open(
13445            dir.path().join(".store-depth-boundary-counts"),
13446            dir.path().to_path_buf(),
13447        )
13448        .expect("open store");
13449        store
13450            .cold_build(std::slice::from_ref(&file))
13451            .expect("cold build");
13452
13453        let root = store
13454            .node_for(Path::new("main.ts"), "root")
13455            .expect("root node");
13456        let leaf = store
13457            .node_for(Path::new("main.ts"), "leaf")
13458            .expect("leaf node");
13459
13460        let (full_forward_len, full_direct_len) = {
13461            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13462            conn.execute(
13463                "INSERT INTO edges (
13464                    edge_id, ref_id, source_node, target_node, target_file,
13465                    target_symbol, kind, line, provenance
13466                 ) VALUES (
13467                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13468                    ?2, ?3, 'call', 98, ?4
13469                 )",
13470                rusqlite::params![
13471                    &root.node_id,
13472                    &leaf.file,
13473                    &leaf.symbol,
13474                    PROVENANCE_TREESITTER
13475                ],
13476            )
13477            .expect("insert dangling forward edge");
13478            conn.execute(
13479                "INSERT INTO edges (
13480                    edge_id, ref_id, source_node, target_node, target_file,
13481                    target_symbol, kind, line, provenance
13482                 ) VALUES (
13483                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13484                    ?1, ?2, ?3, 'call', 99, ?4
13485                 )",
13486                rusqlite::params![
13487                    &root.node_id,
13488                    &root.file,
13489                    &root.symbol,
13490                    PROVENANCE_TREESITTER
13491                ],
13492            )
13493            .expect("insert dangling direct-caller edge");
13494
13495            let full_forward_len = forward_calls_for_node(&conn, &root)
13496                .expect("full forward calls")
13497                .len();
13498            let counted_forward_len =
13499                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13500            assert_eq!(
13501                counted_forward_len, full_forward_len,
13502                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13503            );
13504
13505            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13506                .expect("full direct callers");
13507            let full_direct_len = full_direct.len();
13508            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13509                .expect("counted direct callers");
13510            assert_eq!(
13511                counted_direct_len, full_direct_len,
13512                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13513            );
13514
13515            let distinct_direct_len = full_direct
13516                .iter()
13517                .map(|site| {
13518                    (
13519                        site.caller.file.clone(),
13520                        site.line,
13521                        site.target_file.clone(),
13522                        site.target_symbol.clone(),
13523                    )
13524                })
13525                .collect::<BTreeSet<_>>()
13526                .len();
13527            let batch_counts = direct_caller_counts_for_tuples(
13528                &conn,
13529                &[
13530                    (root.file.clone(), root.symbol.clone()),
13531                    (root.file.clone(), root.symbol.clone()),
13532                    (leaf.file.clone(), leaf.symbol.clone()),
13533                ],
13534            )
13535            .expect("batched direct-caller counts");
13536            assert_eq!(batch_counts.len(), 2);
13537            assert_eq!(
13538                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13539                Some(&distinct_direct_len)
13540            );
13541
13542            (full_forward_len, full_direct_len)
13543        };
13544
13545        assert_eq!(
13546            full_forward_len, 2,
13547            "fixture root should have one resolved and one unresolved outgoing call"
13548        );
13549        assert_eq!(
13550            full_direct_len, 2,
13551            "fixture root should have two real direct callers"
13552        );
13553
13554        let tree = store
13555            .call_tree(Path::new("main.ts"), "root", 0)
13556            .expect("call tree");
13557        assert!(tree.depth_limited);
13558        assert_eq!(tree.children.len(), 0);
13559        assert_eq!(
13560            tree.truncated, full_forward_len,
13561            "call_tree depth boundary must report the full forward-call list length"
13562        );
13563
13564        let callers = store
13565            .callers_of(Path::new("main.ts"), "leaf", 0)
13566            .expect("callers");
13567        assert!(callers.depth_limited);
13568        assert_eq!(callers.callers.len(), 1);
13569        assert_eq!(callers.callers[0].caller.symbol, "root");
13570        assert_eq!(
13571            callers.truncated, full_direct_len,
13572            "callers depth boundary must report the full direct-caller list length"
13573        );
13574    }
13575
13576    #[test]
13577    fn source_freshness_matches_cache_collect_for_same_bytes() {
13578        let dir = tempdir().expect("temp dir");
13579        let path = dir.path().join("fixture.ts");
13580        let source = "export function main() { return helper(); }\n";
13581        fs::write(&path, source).expect("write fixture");
13582
13583        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13584        let actual =
13585            collect_source_freshness(&path, source).expect("collect freshness from source");
13586
13587        assert_eq!(actual, expected);
13588    }
13589
13590    #[test]
13591    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13592        let root = tempfile::tempdir().unwrap();
13593        let callgraph_dir = tempfile::tempdir().unwrap();
13594        let source_dir = root.path().join("src");
13595        std::fs::create_dir_all(&source_dir).unwrap();
13596        let source = source_dir.join("lib.rs");
13597        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13598        let files = vec![source.clone()];
13599        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13600        let old_epoch = epoch.next();
13601        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13602        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13603        let old_epoch_flag = epoch.clone();
13604        let old_dir = callgraph_dir.path().to_path_buf();
13605        let old_root = root.path().to_path_buf();
13606        let old_files = files.clone();
13607        let old = std::thread::spawn(move || {
13608            set_cold_build_before_publish_observer(Some(Arc::new(move || {
13609                reached_tx.send(()).unwrap();
13610                release_rx.recv().unwrap();
13611            })));
13612            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13613                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13614            });
13615            set_cold_build_before_publish_observer(None);
13616            result
13617        });
13618        // Positive wait: the older build runs a real cold build (git probe +
13619        // SQLite schema init) before the barrier, which can exceed 5s on a
13620        // contended Windows CI runner. Only negative waits stay short.
13621        reached_rx
13622            .recv_timeout(Duration::from_secs(30))
13623            .expect("older build did not reach its publication barrier");
13624
13625        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13626        let new_epoch = epoch.next();
13627        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13628            CallGraphStore::cold_build_with_lease(
13629                callgraph_dir.path().to_path_buf(),
13630                root.path().to_path_buf(),
13631                &files,
13632            )
13633        })
13634        .expect("newer build should publish");
13635        drop(new_store);
13636
13637        release_tx.send(()).unwrap();
13638        assert!(matches!(
13639            old.join().unwrap(),
13640            Err(CallGraphStoreError::Superseded)
13641        ));
13642
13643        let current = CallGraphStore::open_readonly(
13644            callgraph_dir.path().to_path_buf(),
13645            root.path().to_path_buf(),
13646        )
13647        .unwrap()
13648        .expect("current callgraph generation");
13649        assert_eq!(
13650            current
13651                .nodes_matching("new_generation_marker")
13652                .unwrap()
13653                .len(),
13654            1
13655        );
13656        assert!(current
13657            .nodes_matching("old_generation_marker")
13658            .unwrap()
13659            .is_empty());
13660    }
13661
13662    #[test]
13663    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13664        let dir = tempdir().expect("temp dir");
13665        let project_root = dir.path();
13666        let extract = fixture_extract(project_root);
13667        let resolved = fixture_resolved(&extract);
13668
13669        let reference = build_reference_connection(project_root, &extract, &resolved);
13670        let optimized = build_optimized_connection(project_root, &extract, &resolved);
13671
13672        for table in [
13673            "files",
13674            "nodes",
13675            "file_dependencies",
13676            "dispatch_hints",
13677            "refs",
13678            "edges",
13679        ] {
13680            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
13681            // the reference and optimized builds run sequentially and can straddle a
13682            // one-second tick under load, so it is legitimately allowed to differ.
13683            // This mirrors the existing exclusions of `backend_file_state.updated_at`
13684            // and the chunked-vs-unchunked sibling test. The check is for structural
13685            // row equivalence of the optimized bulk insert, not wall-clock equality.
13686            let excluded: &[&str] = if table == "files" {
13687                &["indexed_at"]
13688            } else {
13689                &[]
13690            };
13691            assert_eq!(
13692                table_rows_without(&reference, table, excluded),
13693                table_rows_without(&optimized, table, excluded),
13694                "table `{table}` rows must match apart from wall-clock columns"
13695            );
13696        }
13697        assert_eq!(
13698            backend_state_rows(&reference),
13699            backend_state_rows(&optimized),
13700            "backend freshness rows must match apart from updated_at"
13701        );
13702        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13703    }
13704
13705    #[test]
13706    fn cold_build_chunked_matches_unchunked_logical_rows() {
13707        let dir = tempdir().expect("temp dir");
13708        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13709        write_chunked_equivalence_fixture(&project_root);
13710        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13711        assert!(
13712            files.len() > 6,
13713            "fixture should be large enough to split into multiple chunks"
13714        );
13715
13716        let unchunked = CallGraphStore::open(
13717            project_root.join(".store-unchunked"),
13718            project_root.to_path_buf(),
13719        )
13720        .expect("open unchunked store");
13721        let unchunked_stats = unchunked
13722            .cold_build_chunked(&files, 0)
13723            .expect("unchunked cold build");
13724
13725        let chunked = CallGraphStore::open(
13726            project_root.join(".store-chunked"),
13727            project_root.to_path_buf(),
13728        )
13729        .expect("open chunked store");
13730        let chunked_stats = chunked
13731            .cold_build_chunked(&files, 3)
13732            .expect("chunked cold build");
13733
13734        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13735        assert_eq!(
13736            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13737            chunked.edge_snapshot().expect("chunked edge snapshot"),
13738            "public edge snapshots must match"
13739        );
13740
13741        let dispatch_edges = {
13742            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13743            conn.query_row(
13744                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13745                [],
13746                |row| row.get::<_, i64>(0),
13747            )
13748            .expect("count dispatch edges")
13749        };
13750        assert!(
13751            dispatch_edges > 0,
13752            "fixture must exercise method-dispatch edge insertion"
13753        );
13754
13755        for table in [
13756            "edges",
13757            "refs",
13758            "nodes",
13759            "file_dependencies",
13760            "dispatch_hints",
13761        ] {
13762            assert_eq!(
13763                graph_table_rows(&unchunked, table),
13764                graph_table_rows(&chunked, table),
13765                "chunked cold build must match unchunked rows for {table}"
13766            );
13767        }
13768        assert_eq!(
13769            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13770            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13771            "files rows must match apart from indexed_at"
13772        );
13773        assert_eq!(
13774            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13775            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13776            "backend freshness rows must match apart from updated_at"
13777        );
13778
13779        let published_dir = project_root.join(".store-published");
13780        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13781            published_dir.clone(),
13782            project_root.to_path_buf(),
13783            &files,
13784            0,
13785        )
13786        .expect("published unchunked cold build");
13787        assert!(
13788            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13789                .expect("needs_cold_build after publish"),
13790            "published store should be ready"
13791        );
13792        drop(_published);
13793        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13794            published_dir,
13795            project_root.to_path_buf(),
13796            &files,
13797            3,
13798        )
13799        .expect("ensure with a different chunk size");
13800        assert!(
13801            rebuild_stats.is_none(),
13802            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13803        );
13804    }
13805
13806    // Perf A/B bench (not a gate): measures cold_build wall time at a given
13807    // chunk size against a real repo. Driven by env so the same binary can A/B
13808    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
13809    // memory work. Run:
13810    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
13811    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
13812    #[test]
13813    #[ignore]
13814    fn bench_cold_build_chunk() {
13815        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13816        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13817            .expect("AFT_PERF_CHUNK")
13818            .parse()
13819            .expect("AFT_PERF_CHUNK must be a non-negative integer");
13820        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13821        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13822        let dir = tempdir().expect("temp dir");
13823        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13824            .expect("open store");
13825        let started = Instant::now();
13826        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13827        let ms = started.elapsed().as_millis();
13828        println!(
13829            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13830            stats.files, stats.nodes, stats.refs, stats.edges
13831        );
13832    }
13833
13834    #[test]
13835    fn persisted_workspace_reexport_selects_its_package_dependency() {
13836        let root = tempdir().expect("temp dir");
13837        let dependencies = BTreeSet::from([
13838            "packages/aft-bridge/src/index.ts".to_string(),
13839            "packages/opencode-plugin/src/types.ts".to_string(),
13840        ]);
13841        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13842
13843        assert_eq!(
13844            stored_dependencies_for_module(
13845                root.path(),
13846                "packages/opencode-plugin/src/shared/bash-hints.ts",
13847                "@cortexkit/aft-bridge",
13848                &dependencies,
13849                &indexed_files,
13850            ),
13851            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13852        );
13853    }
13854
13855    #[test]
13856    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13857        let dir = tempdir().expect("temp dir");
13858        let project_root = dir.path();
13859        let files =
13860            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13861        let index_path = project_root.join("src/index.ts");
13862
13863        let store = CallGraphStore::open(
13864            project_root.join(".store-incremental-barrel"),
13865            project_root.to_path_buf(),
13866        )
13867        .expect("open incremental store");
13868        store.cold_build(&files).expect("initial cold build");
13869
13870        {
13871            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13872            let tx = conn.transaction().expect("dependency transaction");
13873            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13874                .expect("dependent refs for barrel");
13875            let selected_ref_ids = dependent_refs
13876                .iter()
13877                .map(|dependent_ref| dependent_ref.ref_id.clone())
13878                .collect::<BTreeSet<_>>();
13879            let mut threaded_ref_ids = BTreeSet::new();
13880            let mut threaded_by_caller = BTreeMap::new();
13881            record_dependent_refs(
13882                &mut threaded_ref_ids,
13883                &mut threaded_by_caller,
13884                dependent_refs,
13885            );
13886            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13887                .expect("old per-ref caller lookup");
13888
13889            assert_eq!(threaded_ref_ids, selected_ref_ids);
13890            assert_eq!(threaded_by_caller, old_by_caller);
13891            for consumer in [
13892                "src/consumer_a.ts",
13893                "src/consumer_b.ts",
13894                "src/consumer_c.ts",
13895            ] {
13896                assert!(
13897                    threaded_by_caller.contains_key(consumer),
13898                    "barrel edit should select dependent refs from {consumer}"
13899                );
13900            }
13901        }
13902
13903        fs::write(
13904            &index_path,
13905            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13906        )
13907        .expect("edit barrel");
13908        let stats = store
13909            .refresh_files(std::slice::from_ref(&index_path))
13910            .expect("incremental refresh");
13911        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13912        assert!(
13913            stats.dependency_selected_refs > 0,
13914            "barrel surface edit should select dependent refs"
13915        );
13916
13917        let cold_store = CallGraphStore::open(
13918            project_root.join(".store-cold-barrel"),
13919            project_root.to_path_buf(),
13920        )
13921        .expect("open cold rebuild store");
13922        cold_store
13923            .cold_build(&files)
13924            .expect("comparison cold build");
13925
13926        for table in [
13927            "nodes",
13928            "refs",
13929            "file_dependencies",
13930            "edges",
13931            "dispatch_hints",
13932        ] {
13933            assert_eq!(
13934                graph_table_rows(&store, table),
13935                graph_table_rows(&cold_store, table),
13936                "incremental refresh {table} rows must match cold rebuild"
13937            );
13938        }
13939
13940        let consumer_path = project_root.join("src/consumer_a.ts");
13941        fs::write(
13942            &consumer_path,
13943            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13944        )
13945        .expect("edit barrel consumer");
13946        store
13947            .refresh_files(std::slice::from_ref(&consumer_path))
13948            .expect("refresh consumer through unchanged barrel");
13949        cold_store
13950            .cold_build(&files)
13951            .expect("comparison cold rebuild after consumer refresh");
13952        for table in [
13953            "nodes",
13954            "refs",
13955            "file_dependencies",
13956            "edges",
13957            "dispatch_hints",
13958        ] {
13959            assert_eq!(
13960                graph_table_rows(&store, table),
13961                graph_table_rows(&cold_store, table),
13962                "refresh through a persisted barrel must preserve cold-build {table} rows"
13963            );
13964        }
13965    }
13966
13967    fn build_reference_connection(
13968        project_root: &Path,
13969        extract: &FileExtract,
13970        resolved: &ResolvedRef,
13971    ) -> Connection {
13972        let mut conn = Connection::open_in_memory().expect("open reference db");
13973        configure_build_connection(&conn).expect("configure reference db");
13974        initialize_schema(&conn).expect("initialize reference schema");
13975        {
13976            let tx = conn.transaction().expect("reference transaction");
13977            clear_tables(&tx).expect("reference clear");
13978            insert_meta(&tx).expect("reference meta");
13979            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13980            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13981            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13982                .expect("reference dispatch edges");
13983            assert_eq!(supplemental, 0);
13984            tx.commit().expect("reference commit");
13985        }
13986        conn
13987    }
13988
13989    fn build_optimized_connection(
13990        project_root: &Path,
13991        extract: &FileExtract,
13992        resolved: &ResolvedRef,
13993    ) -> Connection {
13994        let mut conn = Connection::open_in_memory().expect("open optimized db");
13995        configure_build_connection(&conn).expect("configure optimized db");
13996        initialize_schema(&conn).expect("initialize optimized schema");
13997        {
13998            let tx = conn.transaction().expect("optimized transaction");
13999            clear_tables(&tx).expect("optimized clear");
14000            insert_meta(&tx).expect("optimized meta");
14001            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
14002            {
14003                let workspace_root = project_root.display().to_string();
14004                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
14005                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
14006                    .expect("optimized file extract");
14007                insert_resolved_ref_prepared(&mut inserts, resolved)
14008                    .expect("optimized resolved ref");
14009            }
14010            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
14011            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14012                .expect("optimized dispatch edges");
14013            assert_eq!(supplemental, 0);
14014            tx.commit().expect("optimized commit");
14015        }
14016        conn
14017    }
14018
14019    fn fixture_extract(_project_root: &Path) -> FileExtract {
14020        let rel_path = "src/main.ts".to_string();
14021        let target_path = "src/helper.ts".to_string();
14022        let node = NodeRecord {
14023            id: "node-main".to_string(),
14024            file_path: rel_path.clone(),
14025            name: "main".to_string(),
14026            scoped_name: "main".to_string(),
14027            kind: "function".to_string(),
14028            range: Range {
14029                start_line: 0,
14030                start_col: 0,
14031                end_line: 0,
14032                end_col: 32,
14033            },
14034            range_ordinal: 0,
14035            signature: Some("export function main()".to_string()),
14036            exported: true,
14037            is_default_export: false,
14038            is_type_like: false,
14039            is_callgraph_entry_point: true,
14040        };
14041        let mut dependencies = BTreeSet::new();
14042        dependencies.insert(target_path.clone());
14043        let raw_ref = RawRef {
14044            ref_id: "ref-main-helper".to_string(),
14045            caller_node: Some(node.id.clone()),
14046            caller_symbol: Some(node.scoped_name.clone()),
14047            caller_file: rel_path.clone(),
14048            kind: "call".to_string(),
14049            short_name: Some("helper".to_string()),
14050            full_ref: Some("helper".to_string()),
14051            module_path: None,
14052            import_kind: None,
14053            local_name: Some("helper".to_string()),
14054            requested_name: Some("helper".to_string()),
14055            namespace_alias: None,
14056            wildcard: false,
14057            line: 1,
14058            byte_start: 24,
14059            byte_end: 32,
14060            dependencies,
14061        };
14062        FileExtract {
14063            rel_path,
14064            freshness: FileFreshness {
14065                mtime: UNIX_EPOCH + Duration::from_secs(123),
14066                size: 40,
14067                content_hash: cache_freshness::hash_bytes(b"fixture source"),
14068            },
14069            lang: LangId::TypeScript,
14070            data: FileCallData {
14071                calls_by_symbol: HashMap::new(),
14072                value_refs_by_symbol: HashMap::new(),
14073                exported_symbols: Vec::new(),
14074                symbol_metadata: HashMap::new(),
14075                default_export_symbol: None,
14076                import_block: ImportBlock::empty(),
14077                lang: LangId::TypeScript,
14078            },
14079            nodes: vec![node.clone()],
14080            raw_refs: vec![raw_ref],
14081            dispatch_hints: vec![DispatchHint {
14082                id: "dispatch-main-helper".to_string(),
14083                method_name: "helper".to_string(),
14084                caller_node: node.id,
14085                file: "src/main.ts".to_string(),
14086                line: 1,
14087                byte_start: 24,
14088                byte_end: 32,
14089            }],
14090            surface_fingerprint: "surface".to_string(),
14091        }
14092    }
14093
14094    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
14095        let raw = extract.raw_refs[0].clone();
14096        let mut dependencies = raw.dependencies.clone();
14097        dependencies.insert("src/helper.ts".to_string());
14098        ResolvedRef {
14099            edge: Some(EdgeRecord {
14100                edge_id: "edge-main-helper".to_string(),
14101                source_node: raw.caller_node.clone().expect("caller node"),
14102                target_node: Some("node-helper".to_string()),
14103                target_file: "src/helper.ts".to_string(),
14104                target_symbol: "helper".to_string(),
14105                kind: "call".to_string(),
14106                line: raw.line,
14107            }),
14108            raw,
14109            status: "resolved".to_string(),
14110            target_node: Some("node-helper".to_string()),
14111            target_file: Some("src/helper.ts".to_string()),
14112            target_symbol: Some("helper".to_string()),
14113            dependencies,
14114        }
14115    }
14116
14117    fn write_chunked_equivalence_fixture(project_root: &Path) {
14118        let ts_dir = project_root.join("ts");
14119        fs::create_dir_all(&ts_dir).expect("create ts dir");
14120        fs::write(
14121            ts_dir.join("leaf.ts"),
14122            "export function leaf(value: number) {\n  return value + 1;\n}\n",
14123        )
14124        .expect("write ts leaf");
14125        fs::write(
14126            ts_dir.join("mid.ts"),
14127            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
14128        )
14129        .expect("write ts mid");
14130        fs::write(
14131            ts_dir.join("entry.ts"),
14132            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
14133        )
14134        .expect("write ts entry");
14135        fs::write(
14136            ts_dir.join("worker.ts"),
14137            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
14138        )
14139        .expect("write ts worker");
14140        for idx in 0..4 {
14141            fs::write(
14142                ts_dir.join(format!("extra_{idx}.ts")),
14143                format!(
14144                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
14145                ),
14146            )
14147            .expect("write ts extra");
14148        }
14149
14150        let rust_dir = project_root.join("src");
14151        let commands_dir = rust_dir.join("commands");
14152        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
14153        fs::write(
14154            rust_dir.join("context.rs"),
14155            r#"pub struct AppContext;
14156
14157impl AppContext {
14158    pub fn callgraph_store_for_ops(&self) -> usize {
14159        1
14160    }
14161}
14162"#,
14163        )
14164        .expect("write rust context");
14165        fs::write(
14166            rust_dir.join("lib.rs"),
14167            "pub mod context;\npub mod commands;\n",
14168        )
14169        .expect("write rust lib");
14170        fs::write(
14171            commands_dir.join("mod.rs"),
14172            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
14173        )
14174        .expect("write rust commands mod");
14175        for name in ["callers", "impact", "trace_to"] {
14176            fs::write(
14177                commands_dir.join(format!("{name}.rs")),
14178                format!(
14179                    r#"use crate::context::AppContext;
14180
14181pub fn handle_{name}(ctx: &AppContext) -> usize {{
14182    ctx.callgraph_store_for_ops()
14183}}
14184"#
14185                ),
14186            )
14187            .expect("write rust command");
14188        }
14189    }
14190
14191    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
14192        let src_dir = project_root.join("src");
14193        fs::create_dir_all(&src_dir).expect("create src dir");
14194
14195        let target_path = src_dir.join("target.ts");
14196        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
14197            .expect("write target");
14198
14199        let index_path = src_dir.join("index.ts");
14200        fs::write(&index_path, barrel_source).expect("write barrel");
14201
14202        let mut files = vec![target_path, index_path];
14203        for (file_name, function_name) in [
14204            ("consumer_a.ts", "consumerA"),
14205            ("consumer_b.ts", "consumerB"),
14206            ("consumer_c.ts", "consumerC"),
14207        ] {
14208            let path = src_dir.join(file_name);
14209            fs::write(
14210                &path,
14211                format!(
14212                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
14213                ),
14214            )
14215            .expect("write consumer");
14216            files.push(path);
14217        }
14218        files
14219    }
14220
14221    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
14222        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14223        table_rows(&conn, table)
14224    }
14225
14226    fn graph_table_rows_without(
14227        store: &CallGraphStore,
14228        table: &str,
14229        excluded_columns: &[&str],
14230    ) -> Vec<String> {
14231        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14232        table_rows_without(&conn, table, excluded_columns)
14233    }
14234
14235    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
14236        table_rows_without(conn, table, &[])
14237    }
14238
14239    fn table_rows_without(
14240        conn: &Connection,
14241        table: &str,
14242        excluded_columns: &[&str],
14243    ) -> Vec<String> {
14244        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
14245        let columns: Vec<String> = conn
14246            .prepare(&format!("PRAGMA table_info({table})"))
14247            .expect("prepare table_info")
14248            .query_map([], |row| row.get::<_, String>(1))
14249            .expect("query table_info")
14250            .collect::<std::result::Result<Vec<String>, _>>()
14251            .expect("collect columns")
14252            .into_iter()
14253            .filter(|column| !excluded_columns.contains(column.as_str()))
14254            .collect();
14255        let sql = format!(
14256            "SELECT {} FROM {table} ORDER BY {}",
14257            columns.join(", "),
14258            columns.join(", ")
14259        );
14260        conn.prepare(&sql)
14261            .expect("prepare table rows")
14262            .query_map([], |row| row_to_strings(row, columns.len()))
14263            .expect("query table rows")
14264            .collect::<std::result::Result<_, _>>()
14265            .expect("collect table rows")
14266    }
14267
14268    fn assert_cold_build_stats_match_except_elapsed(
14269        expected: &ColdBuildStats,
14270        actual: &ColdBuildStats,
14271    ) {
14272        assert_eq!(actual.files, expected.files, "file counts must match");
14273        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
14274        assert_eq!(actual.refs, expected.refs, "ref counts must match");
14275        assert_eq!(actual.edges, expected.edges, "edge counts must match");
14276        assert_eq!(
14277            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
14278            expected
14279                .failed_files
14280                .iter()
14281                .cloned()
14282                .collect::<BTreeSet<_>>(),
14283            "failed file sets must match"
14284        );
14285    }
14286
14287    fn backend_state_rows(conn: &Connection) -> Vec<String> {
14288        conn.prepare(
14289            "SELECT backend, workspace_root, file_path, content_hash, status
14290             FROM backend_file_state
14291             ORDER BY backend, workspace_root, file_path, content_hash, status",
14292        )
14293        .expect("prepare backend rows")
14294        .query_map([], |row| row_to_strings(row, 5))
14295        .expect("query backend rows")
14296        .collect::<std::result::Result<_, _>>()
14297        .expect("collect backend rows")
14298    }
14299
14300    fn secondary_indexes(conn: &Connection) -> Vec<String> {
14301        let mut indexes = Vec::new();
14302        for table in [
14303            "files",
14304            "nodes",
14305            "refs",
14306            "file_dependencies",
14307            "edges",
14308            "dispatch_hints",
14309            "type_ref_names",
14310            "backend_file_state",
14311            "meta",
14312        ] {
14313            let sql = format!("PRAGMA index_list({table})");
14314            let mut stmt = conn.prepare(&sql).expect("prepare index list");
14315            let rows = stmt
14316                .query_map([], |row| row.get::<_, String>(1))
14317                .expect("query index list");
14318            for name in rows {
14319                let name = name.expect("index name");
14320                if name.starts_with("idx_") {
14321                    indexes.push(format!("{table}:{name}"));
14322                }
14323            }
14324        }
14325        indexes.sort();
14326        indexes
14327    }
14328
14329    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14330        let mut values = Vec::with_capacity(len);
14331        for index in 0..len {
14332            let value = row.get_ref(index)?;
14333            values.push(match value {
14334                rusqlite::types::ValueRef::Null => "NULL".to_string(),
14335                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14336                rusqlite::types::ValueRef::Real(value) => value.to_string(),
14337                rusqlite::types::ValueRef::Text(value) => {
14338                    String::from_utf8_lossy(value).into_owned()
14339                }
14340                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14341            });
14342        }
14343        Ok(values.join("\u{1f}"))
14344    }
14345}
14346
14347#[cfg(test)]
14348mod rust_resolution_tests {
14349    use super::*;
14350    use crate::inspect::job::CallgraphSnapshot;
14351    use std::fs;
14352    use tempfile::tempdir;
14353
14354    #[test]
14355    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14356        let dir = tempdir().expect("tempdir");
14357        let root = dir.path();
14358        write_rust_manifest(root, "scoped-alias-fixture");
14359        write_file(
14360            root,
14361            "src/lib.rs",
14362            r#"pub mod finalization_contract;
14363
14364pub fn run_alias() {
14365    use crate::finalization_contract as fc;
14366    fc::check_mason_contract();
14367}
14368"#,
14369        );
14370        write_file(
14371            root,
14372            "src/finalization_contract.rs",
14373            r#"pub fn check_mason_contract() {}
14374fn planted_dead() {}
14375"#,
14376        );
14377
14378        let (store, snapshot) = cold_build_twice(root);
14379        assert_direct_caller(
14380            &store,
14381            "src/finalization_contract.rs",
14382            "check_mason_contract",
14383            "src/lib.rs",
14384            "run_alias",
14385        );
14386        assert_projected_call(
14387            root,
14388            &snapshot,
14389            "src/finalization_contract.rs",
14390            "check_mason_contract",
14391        );
14392        assert_no_projected_call(
14393            root,
14394            &snapshot,
14395            "src/finalization_contract.rs",
14396            "planted_dead",
14397        );
14398        assert!(
14399            store
14400                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14401                .expect("planted dead callers")
14402                .is_empty(),
14403            "planted-dead guard should stay without callers"
14404        );
14405    }
14406
14407    #[test]
14408    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14409        let dir = tempdir().expect("tempdir");
14410        let root = dir.path();
14411        write_rust_manifest(root, "inline-module-fixture");
14412        write_file(
14413            root,
14414            "src/lib.rs",
14415            r#"mod work_graph { fn operations() {} }
14416mod manifest { fn operations() {} }
14417mod audit { fn operations() {} }
14418mod dispatch { fn operations() {} }
14419mod finalization { fn operations() {} }
14420
14421pub fn run_inline_operations() {
14422    work_graph::operations();
14423    manifest::operations();
14424    audit::operations();
14425    dispatch::operations();
14426    finalization::operations();
14427}
14428
14429fn planted_dead() {}
14430"#,
14431        );
14432
14433        let (store, snapshot) = cold_build_twice(root);
14434        for module in [
14435            "work_graph",
14436            "manifest",
14437            "audit",
14438            "dispatch",
14439            "finalization",
14440        ] {
14441            assert_direct_caller(
14442                &store,
14443                "src/lib.rs",
14444                &format!("{module}::operations"),
14445                "src/lib.rs",
14446                "run_inline_operations",
14447            );
14448        }
14449        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14450        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14451    }
14452
14453    #[test]
14454    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14455        let dir = tempdir().expect("tempdir");
14456        let root = dir.path();
14457        fs::write(
14458            root.join("Cargo.toml"),
14459            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14460        )
14461        .expect("write workspace manifest");
14462        write_file(
14463            root,
14464            "crates/but-action/Cargo.toml",
14465            r#"[package]
14466name = "but-action"
14467version = "0.1.0"
14468edition = "2021"
14469"#,
14470        );
14471        write_file(
14472            root,
14473            "crates/but-action/src/lib.rs",
14474            "mod action;\npub use action::{list_actions};\n",
14475        );
14476        write_file(
14477            root,
14478            "crates/but-action/src/action.rs",
14479            "pub fn list_actions() {}\nfn planted_dead() {}\n",
14480        );
14481        write_file(
14482            root,
14483            "crates/app/Cargo.toml",
14484            r#"[package]
14485name = "app"
14486version = "0.1.0"
14487edition = "2021"
14488"#,
14489        );
14490        write_file(
14491            root,
14492            "crates/app/src/lib.rs",
14493            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
14494        );
14495
14496        let (store, snapshot) = cold_build_twice(root);
14497        assert_direct_caller(
14498            &store,
14499            "crates/but-action/src/action.rs",
14500            "list_actions",
14501            "crates/app/src/lib.rs",
14502            "run_actions",
14503        );
14504        assert!(
14505            store
14506                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14507                .expect("lib reexport callers")
14508                .is_empty(),
14509            "call should target the reexported source function, not lib.rs"
14510        );
14511        assert_projected_call(
14512            root,
14513            &snapshot,
14514            "crates/but-action/src/action.rs",
14515            "list_actions",
14516        );
14517        assert_no_projected_call(
14518            root,
14519            &snapshot,
14520            "crates/but-action/src/action.rs",
14521            "planted_dead",
14522        );
14523    }
14524
14525    #[test]
14526    fn rust_generic_self_turbofish_method_dispatch_resolves() {
14527        let dir = tempdir().expect("tempdir");
14528        let root = dir.path();
14529        write_rust_manifest(root, "generic-self-fixture");
14530        write_file(
14531            root,
14532            "src/lib.rs",
14533            r#"pub struct Matcher;
14534
14535impl Matcher {
14536    pub fn run(&self) -> bool {
14537        self.fuzzy_match_optimal::<usize>("needle")
14538    }
14539
14540    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14541        let _ = std::marker::PhantomData::<T>;
14542        true
14543    }
14544
14545    fn planted_dead(&self) {}
14546}
14547
14548pub fn entry() -> bool {
14549    let matcher = Matcher;
14550    matcher.run()
14551}
14552"#,
14553        );
14554
14555        let (store, snapshot) = cold_build_twice(root);
14556        assert_direct_caller(
14557            &store,
14558            "src/lib.rs",
14559            "Matcher::fuzzy_match_optimal",
14560            "src/lib.rs",
14561            "Matcher::run",
14562        );
14563        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14564        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14565    }
14566
14567    #[test]
14568    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14569        let dir = tempdir().expect("tempdir");
14570        let root = dir.path();
14571        write_rust_manifest(root, "manifest-operations-fixture");
14572        write_file(
14573            root,
14574            "src/main.rs",
14575            r#"mod dispatch;
14576use dispatch::{manifest_operations};
14577
14578fn main() {
14579    manifest_operations();
14580}
14581"#,
14582        );
14583        write_file(
14584            root,
14585            "src/dispatch.rs",
14586            r#"mod work_graph { fn operations() {} }
14587mod manifest { fn operations() {} }
14588mod audit { fn operations() {} }
14589mod descriptor { fn operations() {} }
14590mod writer { fn operations() {} }
14591
14592pub fn manifest_operations() {
14593    manifest::operations();
14594}
14595
14596pub fn work_graph_operations() {
14597    work_graph::operations();
14598}
14599
14600pub fn audit_operations() {
14601    audit::operations();
14602}
14603
14604pub fn descriptor_operations() {
14605    descriptor::operations();
14606}
14607
14608pub fn writer_operations() {
14609    writer::operations();
14610}
14611
14612fn planted_dead() {}
14613"#,
14614        );
14615
14616        let (store, snapshot) = cold_build_twice(root);
14617        assert_direct_caller(
14618            &store,
14619            "src/dispatch.rs",
14620            "manifest_operations",
14621            "src/main.rs",
14622            "main",
14623        );
14624        assert_direct_caller(
14625            &store,
14626            "src/dispatch.rs",
14627            "manifest::operations",
14628            "src/dispatch.rs",
14629            "manifest_operations",
14630        );
14631        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14632        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14633        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14634    }
14635
14636    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14637        let files = rust_files(root);
14638        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14639            .expect("open first store");
14640        first.cold_build(&files).expect("first cold build");
14641        let first_snapshot =
14642            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14643
14644        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14645            .expect("open second store");
14646        second.cold_build(&files).expect("second cold build");
14647        let second_snapshot =
14648            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14649
14650        assert_eq!(
14651            projection_rows(&first_snapshot),
14652            projection_rows(&second_snapshot),
14653            "cold-build projection should be deterministic"
14654        );
14655        (first, first_snapshot)
14656    }
14657
14658    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14659        let mut rows = Vec::new();
14660        for export in &snapshot.exported_symbols {
14661            rows.push(format!(
14662                "export\t{}\t{}\t{}\t{}",
14663                export.file.display(),
14664                export.symbol,
14665                export.kind,
14666                export.line
14667            ));
14668        }
14669        for call in &snapshot.outbound_calls {
14670            rows.push(format!(
14671                "call\t{}\t{}\t{}\t{}\t{}",
14672                call.caller_file.display(),
14673                call.caller_symbol,
14674                call.target,
14675                call.line,
14676                call.provenance
14677            ));
14678        }
14679        for file in &snapshot.entry_points {
14680            rows.push(format!("entry_file\t{}", file.display()));
14681        }
14682        for (file, symbols) in &snapshot.entry_point_symbols {
14683            for symbol in symbols {
14684                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14685            }
14686        }
14687        rows.sort();
14688        rows
14689    }
14690
14691    fn assert_direct_caller(
14692        store: &CallGraphStore,
14693        target_rel: &str,
14694        target_symbol: &str,
14695        caller_rel: &str,
14696        caller_symbol: &str,
14697    ) {
14698        let callers = store
14699            .direct_callers_of(Path::new(target_rel), target_symbol)
14700            .unwrap_or_else(|error| {
14701                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14702            });
14703        assert!(
14704            callers.iter().any(|site| {
14705                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14706            }),
14707            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14708        );
14709    }
14710
14711    fn assert_projected_call(
14712        root: &Path,
14713        snapshot: &CallgraphSnapshot,
14714        target_rel: &str,
14715        symbol: &str,
14716    ) {
14717        let target = projected_target(root, target_rel, symbol);
14718        assert!(
14719            snapshot.outbound_calls.iter().any(|call| {
14720                call.target == target
14721                    || call.target.starts_with(&format!(
14722                        "{target}{}",
14723                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14724                    ))
14725            }),
14726            "expected projected call to {target}; calls: {:#?}",
14727            snapshot.outbound_calls
14728        );
14729    }
14730
14731    fn assert_no_projected_call(
14732        root: &Path,
14733        snapshot: &CallgraphSnapshot,
14734        target_rel: &str,
14735        symbol: &str,
14736    ) {
14737        let target = projected_target(root, target_rel, symbol);
14738        assert!(
14739            snapshot.outbound_calls.iter().all(|call| {
14740                call.target != target
14741                    && !call.target.starts_with(&format!(
14742                        "{target}{}",
14743                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14744                    ))
14745            }),
14746            "did not expect projected call to {target}; calls: {:#?}",
14747            snapshot.outbound_calls
14748        );
14749    }
14750
14751    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14752        // Projection targets carry the normalized (verbatim-stripped)
14753        // canonical form; bare fs::canonicalize diverges on Windows.
14754        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14755        format!("{}::{symbol}", path.display())
14756    }
14757
14758    fn write_rust_manifest(root: &Path, name: &str) {
14759        write_file(
14760            root,
14761            "Cargo.toml",
14762            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14763        );
14764    }
14765
14766    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14767        let path = root.join(rel_path);
14768        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14769        fs::write(&path, source).expect("write fixture file");
14770        path
14771    }
14772
14773    fn rust_files(root: &Path) -> Vec<PathBuf> {
14774        let mut files = Vec::new();
14775        collect_rust_files(root, &mut files);
14776        files.sort();
14777        files
14778    }
14779
14780    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14781        for entry in fs::read_dir(dir).expect("read fixture dir") {
14782            let entry = entry.expect("read fixture entry");
14783            let path = entry.path();
14784            if path.is_dir() {
14785                let name = path
14786                    .file_name()
14787                    .and_then(|name| name.to_str())
14788                    .unwrap_or("");
14789                if !name.starts_with(".store") {
14790                    collect_rust_files(&path, files);
14791                }
14792            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14793                files.push(path);
14794            }
14795        }
14796    }
14797}
14798
14799#[cfg(test)]
14800mod build_pool_tests {
14801    use super::build_pool_size;
14802
14803    #[test]
14804    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14805        let size = build_pool_size();
14806        // Never zero, never the full core count, never above the 8 cap — this is
14807        // the starvation guard for the cold-build's all-cores tree-sitter pass.
14808        assert!(size >= 1, "pool size must be at least 1");
14809        assert!(size <= 8, "pool size must be capped at 8, got {size}");
14810
14811        let cores = std::thread::available_parallelism()
14812            .map(|p| p.get())
14813            .unwrap_or(1);
14814        let expected = cores.div_ceil(2).clamp(1, 8);
14815        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14816    }
14817}
14818
14819#[cfg(test)]
14820mod reexport_resolution_tests {
14821    use super::*;
14822
14823    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14824        ProjectIndex {
14825            project_root: PathBuf::from("/fixture"),
14826            files: files.into_iter().collect(),
14827            caller_data: HashMap::new(),
14828            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14829        }
14830    }
14831
14832    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14833        DbFileIndex {
14834            lang: None,
14835            exports: HashSet::new(),
14836            default_export: None,
14837            export_aliases: HashMap::new(),
14838            node_by_scoped: HashMap::new(),
14839            node_by_bare: HashMap::new(),
14840            node_kind_by_id: HashMap::new(),
14841            module_targets: HashMap::new(),
14842            reexports: reexport_targets
14843                .iter()
14844                .map(|target| ReexportIndex {
14845                    target_file: Some((*target).to_string()),
14846                    named: HashMap::new(),
14847                    wildcard: true,
14848                })
14849                .collect(),
14850        }
14851    }
14852
14853    /// A dense wildcard re-export cycle (barrel files re-exporting each
14854    /// other) must resolve in O(files), not O(branching^depth). Without the
14855    /// resolver's memoization, resolving a MISSING symbol through this
14856    /// 12-file complete digraph explores ~11^16 paths and this test never
14857    /// finishes: the depth cap bounds path length, not path count, and one
14858    /// such resolution can pin a worker thread at 100% CPU indefinitely.
14859    #[test]
14860    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14861        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14862        let files = names
14863            .iter()
14864            .map(|name| {
14865                let targets: Vec<&str> = names
14866                    .iter()
14867                    .filter(|other| *other != name)
14868                    .map(String::as_str)
14869                    .collect();
14870                (name.clone(), barrel_file(&targets))
14871            })
14872            .collect();
14873        let index = barrel_index(files);
14874
14875        assert_eq!(
14876            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14877            None
14878        );
14879    }
14880
14881    /// Depth-dominance counterexample: the walk first reaches `shared` down a
14882    /// 16-hop chain (no budget left for its leaf), then reaches it again
14883    /// directly at depth 1. Plain visited-set pruning would skip the second
14884    /// visit and lose a resolution the capped resolver finds; the
14885    /// depth-dominance memo revisits because the second arrival is shallower.
14886    #[test]
14887    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14888        let mut leaf = barrel_file(&[]);
14889        leaf.exports.insert("deep_symbol".to_string());
14890        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14891        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
14892        // entry's SECOND reexport goes straight to shared.
14893        files.push((
14894            "src/entry.ts".to_string(),
14895            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14896        ));
14897        for i in 0..15 {
14898            let next = if i == 14 {
14899                "src/shared.ts".to_string()
14900            } else {
14901                format!("src/chain{}.ts", i + 1)
14902            };
14903            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14904        }
14905        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14906        files.push(("src/leaf.ts".to_string(), leaf));
14907        let index = barrel_index(files);
14908
14909        assert_eq!(
14910            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14911            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14912            "a shallower re-visit must not be pruned by a deeper capped visit"
14913        );
14914    }
14915
14916    #[test]
14917    fn symbol_reachable_through_reexport_cycle_still_resolves() {
14918        let mut leaf = barrel_file(&[]);
14919        leaf.exports.insert("real_symbol".to_string());
14920        let index = barrel_index(vec![
14921            (
14922                "src/a.ts".to_string(),
14923                barrel_file(&["src/b.ts", "src/a.ts"]),
14924            ),
14925            (
14926                "src/b.ts".to_string(),
14927                barrel_file(&["src/a.ts", "src/leaf.ts"]),
14928            ),
14929            ("src/leaf.ts".to_string(), leaf),
14930        ]);
14931
14932        assert_eq!(
14933            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14934            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14935        );
14936    }
14937}
14938
14939#[cfg(test)]
14940mod method_dispatch_inference_tests {
14941    use super::*;
14942    use std::fs;
14943    use tempfile::tempdir;
14944
14945    #[test]
14946    fn java_field_receiver_type_selects_declared_class_method() {
14947        let source = r#"class EntryPoint {
14948    private UserService userService;
14949
14950    void handle() {
14951        userService.find();
14952    }
14953}
14954
14955class UserService {
14956    void find() {}
14957}
14958
14959class AuditService {
14960    void find() {}
14961}
14962"#;
14963        let dir = tempdir().expect("temp dir");
14964        let root = dir.path();
14965        write_fixture(root, "src/EntryPoint.java", source);
14966        let reference = reference(
14967            "java",
14968            "src/EntryPoint.java",
14969            "EntryPoint::handle",
14970            "userService",
14971            "find",
14972            line_of(source, "userService.find()"),
14973        );
14974        let mut cache = DispatchSourceCache::new();
14975
14976        let receiver_type =
14977            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14978        assert_eq!(receiver_type, "UserService");
14979
14980        let candidates = vec![
14981            method_candidate("audit", "AuditService::find"),
14982            method_candidate("user", "UserService::find"),
14983        ];
14984        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14985            .expect("type candidate");
14986        assert_eq!(selected.scoped_name, "UserService::find");
14987
14988        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14989        assert!(
14990            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14991        );
14992    }
14993
14994    #[test]
14995    fn kotlin_property_and_local_value_types_are_inferred() {
14996        let source = r#"class Handler {
14997    private val auditService: AuditService = AuditService()
14998
14999    fun handle() {
15000        auditService.find()
15001        val userService: UserService = UserService()
15002        userService.find()
15003        val billingService = BillingService()
15004        billingService.find()
15005    }
15006}
15007
15008class UserService { fun find() {} }
15009class AuditService { fun find() {} }
15010class BillingService { fun find() {} }
15011"#;
15012        let dir = tempdir().expect("temp dir");
15013        let root = dir.path();
15014        write_fixture(root, "src/Handler.kt", source);
15015        let mut cache = DispatchSourceCache::new();
15016
15017        let audit_ref = reference(
15018            "kotlin",
15019            "src/Handler.kt",
15020            "Handler::handle",
15021            "auditService",
15022            "find",
15023            line_of(source, "auditService.find()"),
15024        );
15025        assert_eq!(
15026            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15027            Some("AuditService")
15028        );
15029
15030        let user_ref = reference(
15031            "kotlin",
15032            "src/Handler.kt",
15033            "Handler::handle",
15034            "userService",
15035            "find",
15036            line_of(source, "userService.find()"),
15037        );
15038        assert_eq!(
15039            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15040            Some("UserService")
15041        );
15042
15043        let billing_ref = reference(
15044            "kotlin",
15045            "src/Handler.kt",
15046            "Handler::handle",
15047            "billingService",
15048            "find",
15049            line_of(source, "billingService.find()"),
15050        );
15051        assert_eq!(
15052            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
15053            Some("BillingService")
15054        );
15055    }
15056
15057    #[test]
15058    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
15059        let source = r#"struct Foo { void run(); };
15060struct PointerFoo { void run(); };
15061struct FactoryFoo { void run(); };
15062FactoryFoo makeFactoryFoo();
15063
15064void handle() {
15065    Foo foo;
15066    foo.run();
15067    PointerFoo* pointerFoo = nullptr;
15068    pointerFoo->run();
15069    auto factoryFoo = makeFactoryFoo();
15070    factoryFoo.run();
15071}
15072"#;
15073        let dir = tempdir().expect("temp dir");
15074        let root = dir.path();
15075        write_fixture(root, "src/fixture.cpp", source);
15076        let mut cache = DispatchSourceCache::new();
15077
15078        let foo_ref = reference(
15079            "cpp",
15080            "src/fixture.cpp",
15081            "handle",
15082            "foo",
15083            "run",
15084            line_of(source, "foo.run()"),
15085        );
15086        assert_eq!(
15087            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
15088            Some("Foo")
15089        );
15090
15091        let pointer_ref = reference(
15092            "cpp",
15093            "src/fixture.cpp",
15094            "handle",
15095            "pointerFoo",
15096            "run",
15097            line_of(source, "pointerFoo->run()"),
15098        );
15099        assert_eq!(
15100            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
15101            Some("PointerFoo")
15102        );
15103
15104        let factory_ref = reference(
15105            "cpp",
15106            "src/fixture.cpp",
15107            "handle",
15108            "factoryFoo",
15109            "run",
15110            line_of(source, "factoryFoo.run()"),
15111        );
15112        assert_eq!(
15113            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
15114            Some("FactoryFoo")
15115        );
15116    }
15117
15118    #[test]
15119    fn rust_direct_self_field_name_trims_separator_whitespace() {
15120        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
15121            assert_eq!(
15122                rust_direct_self_field_name(receiver_expression),
15123                Some("engine")
15124            );
15125        }
15126    }
15127
15128    #[test]
15129    fn rust_direct_self_field_receiver_type_is_conservative() {
15130        let source = r#"struct Engine;
15131
15132struct Car {
15133    engine: Engine,
15134}
15135
15136impl Car {
15137    fn run(&self) {
15138        self.engine.start();
15139    }
15140}
15141
15142struct NestedCar {
15143    engine: Engine,
15144}
15145
15146impl NestedCar {
15147    fn run(&self) {
15148        self.inner.engine.start();
15149    }
15150}
15151
15152struct WrappedCar {
15153    engine: Option<Engine>,
15154}
15155
15156impl WrappedCar {
15157    fn run(&self) {
15158        self.engine.start(); // wrapped
15159    }
15160}
15161
15162struct GenericCar<T> {
15163    engine: T,
15164}
15165
15166impl<T> GenericCar<T> {
15167    fn run(&self) {
15168        self.engine.start(); // generic
15169    }
15170}
15171
15172type EngineAlias = Engine;
15173
15174struct AliasCar {
15175    engine: EngineAlias,
15176}
15177
15178impl AliasCar {
15179    fn run(&self) {
15180        self.engine.start(); // alias
15181    }
15182}
15183"#;
15184        let dir = tempdir().expect("temp dir");
15185        let root = dir.path();
15186        write_fixture(root, "src/lib.rs", source);
15187        let mut cache = DispatchSourceCache::new();
15188
15189        let mut direct = reference(
15190            "rust",
15191            "src/lib.rs",
15192            "Car::run",
15193            "engine",
15194            "start",
15195            line_of(source, "self.engine.start()"),
15196        );
15197        direct.receiver_expression = "self.engine".to_string();
15198        assert_eq!(
15199            infer_receiver_type(root, &direct, &mut cache).as_deref(),
15200            Some("Engine")
15201        );
15202
15203        let mut mismatched_impl_target = direct.clone();
15204        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
15205        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
15206
15207        let mut nested = reference(
15208            "rust",
15209            "src/lib.rs",
15210            "NestedCar::run",
15211            "engine",
15212            "start",
15213            line_of(source, "self.inner.engine.start()"),
15214        );
15215        nested.receiver_expression = "self.inner.engine".to_string();
15216        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
15217
15218        let mut wrapped = reference(
15219            "rust",
15220            "src/lib.rs",
15221            "WrappedCar::run",
15222            "engine",
15223            "start",
15224            line_of(source, "self.engine.start(); // wrapped"),
15225        );
15226        wrapped.receiver_expression = "self.engine".to_string();
15227        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
15228
15229        let mut generic = reference(
15230            "rust",
15231            "src/lib.rs",
15232            "GenericCar::run",
15233            "engine",
15234            "start",
15235            line_of(source, "self.engine.start(); // generic"),
15236        );
15237        generic.receiver_expression = "self.engine".to_string();
15238        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
15239
15240        let mut alias = reference(
15241            "rust",
15242            "src/lib.rs",
15243            "AliasCar::run",
15244            "engine",
15245            "start",
15246            line_of(source, "self.engine.start(); // alias"),
15247        );
15248        alias.receiver_expression = "self.engine".to_string();
15249        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
15250    }
15251
15252    #[test]
15253    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
15254        let source = r#"struct Engine;
15255
15256struct Car {
15257    engine: &'static Engine,
15258}
15259
15260impl Car {
15261    fn run(&self) {
15262        self.engine.start();
15263    }
15264}
15265"#;
15266        let dir = tempdir().expect("temp dir");
15267        let root = dir.path();
15268        write_fixture(root, "src/lib.rs", source);
15269        let mut cache = DispatchSourceCache::new();
15270        let mut reference = reference(
15271            "rust",
15272            "src/lib.rs",
15273            "Car::run",
15274            "engine",
15275            "start",
15276            line_of(source, "self.engine.start()"),
15277        );
15278        reference.receiver_expression = "self.engine".to_string();
15279
15280        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15281    }
15282
15283    #[test]
15284    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
15285        let source = r#"trait Drive {
15286    fn run(&self);
15287}
15288
15289struct Engine;
15290
15291struct Car {
15292    engine: Engine,
15293}
15294
15295impl Drive for Car {
15296    fn run(&self) {
15297        self.engine.start();
15298    }
15299}
15300"#;
15301        let dir = tempdir().expect("temp dir");
15302        let root = dir.path();
15303        write_fixture(root, "src/lib.rs", source);
15304        let mut cache = DispatchSourceCache::new();
15305        let mut reference = reference(
15306            "rust",
15307            "src/lib.rs",
15308            "Car::run",
15309            "engine",
15310            "start",
15311            line_of(source, "self.engine.start()"),
15312        );
15313        reference.receiver_expression = "self.engine".to_string();
15314
15315        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15316    }
15317
15318    #[test]
15319    fn rust_self_field_does_not_bind_struct_from_another_module() {
15320        let source = r#"struct Engine;
15321
15322mod unrelated {
15323    struct Car {
15324        engine: Engine,
15325    }
15326}
15327
15328impl Car {
15329    fn run(&self) {
15330        self.engine.start();
15331    }
15332}
15333"#;
15334        let dir = tempdir().expect("temp dir");
15335        let root = dir.path();
15336        write_fixture(root, "src/lib.rs", source);
15337        let mut cache = DispatchSourceCache::new();
15338        let mut reference = reference(
15339            "rust",
15340            "src/lib.rs",
15341            "Car::run",
15342            "engine",
15343            "start",
15344            line_of(source, "self.engine.start()"),
15345        );
15346        reference.receiver_expression = "self.engine".to_string();
15347
15348        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15349    }
15350
15351    #[test]
15352    fn unknown_java_receiver_still_uses_name_match_fallback() {
15353        let source = r#"class EntryPoint {
15354    void handle() {
15355        service.runSpecial();
15356    }
15357}
15358
15359class OnlyService {
15360    void runSpecial() {}
15361}
15362"#;
15363        let dir = tempdir().expect("temp dir");
15364        let root = dir.path();
15365        write_fixture(root, "src/EntryPoint.java", source);
15366        let reference = reference(
15367            "java",
15368            "src/EntryPoint.java",
15369            "EntryPoint::handle",
15370            "service",
15371            "runSpecial",
15372            line_of(source, "service.runSpecial()"),
15373        );
15374        let mut cache = DispatchSourceCache::new();
15375
15376        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15377        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15378        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15379        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15380    }
15381
15382    fn reference(
15383        lang: &str,
15384        caller_file: &str,
15385        caller_symbol: &str,
15386        receiver: &str,
15387        method_name: &str,
15388        line: u32,
15389    ) -> NameMatchRef {
15390        NameMatchRef {
15391            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15392            caller_node: format!("{caller_symbol}:node"),
15393            caller_file: caller_file.to_string(),
15394            caller_symbol: caller_symbol.to_string(),
15395            caller_signature: None,
15396            receiver_expression: receiver.to_string(),
15397            receiver: receiver.to_string(),
15398            method_name: method_name.to_string(),
15399            colon_dispatch: false,
15400            line,
15401            lang: lang.to_string(),
15402        }
15403    }
15404
15405    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15406        NameMatchCandidate {
15407            node_id: node_id.to_string(),
15408            file_path: "src/targets.fixture".to_string(),
15409            scoped_name: scoped_name.to_string(),
15410            kind: "method".to_string(),
15411            start_line: 1,
15412        }
15413    }
15414
15415    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15416        let path = root.join(rel_path);
15417        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15418        fs::write(path, source).expect("write fixture");
15419    }
15420
15421    fn line_of(source: &str, needle: &str) -> u32 {
15422        source
15423            .lines()
15424            .position(|line| line.contains(needle))
15425            .map(|index| index as u32 + 1)
15426            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15427    }
15428}