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::{params, Connection, OpenFlags, OptionalExtension, Statement, Transaction};
17use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
18use std::fmt;
19use std::io::Read;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Condvar, Mutex, OnceLock};
22use std::thread::JoinHandle;
23use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
24use tree_sitter::{Node, Parser};
25
26const SCHEMA_VERSION: i64 = 1;
27const BACKEND_TREESITTER: &str = "treesitter";
28const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
29const PROVENANCE_NAME_MATCH: &str = "name_match";
30const PROVENANCE_TYPE_MATCH: &str = "type_match";
31const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
32const TOP_LEVEL_SYMBOL: &str = "<top-level>";
33const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
34const MIGRATION_MANIFEST_VERSION: u32 = 1;
35const MIGRATION_GENERATION_TAG: &str = ".migrated.";
36const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
37const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
38const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
39const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
40/// Marker-protected generations older than this absolute age are reclaimed even
41/// if a stale reader marker remains. Current and newest-previous generations are
42/// always retained, bounding the root-keyed callgraph store to roughly two or
43/// three large generations without adding user-visible configuration.
44const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
45const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
46const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
47pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
48
49type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
50#[cfg(test)]
51type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
52// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
53// thread running the cold build, and the only caller (a test) installs and
54// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
55// across parallel tests — one test's installed observer fired during ANOTHER
56// test's `cold_build_with_lease`, asserting against the wrong build's edges
57// (flaked on Windows CI under parallel scheduling). Production never sets it.
58thread_local! {
59    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
60        const { std::cell::RefCell::new(None) };
61    #[cfg(test)]
62    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
63        const { std::cell::RefCell::new(None) };
64    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
65        const { std::cell::RefCell::new(None) };
66    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
67    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
68        const { std::cell::Cell::new(false) };
69    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
70        const { std::cell::RefCell::new(None) };
71    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
72        const { std::cell::RefCell::new(None) };
73}
74
75mod dead_code_projection;
76pub use dead_code_projection::project_dead_code_snapshot;
77
78#[doc(hidden)]
79pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
80    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
81}
82
83#[cfg(test)]
84fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
85    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
86}
87
88#[cfg(test)]
89fn notify_cold_build_before_publish_observer() {
90    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
91    if let Some(observer) = observer {
92        observer();
93    }
94}
95
96#[cfg(not(test))]
97fn notify_cold_build_before_publish_observer() {}
98
99#[doc(hidden)]
100pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
101    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
102}
103
104#[doc(hidden)]
105pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
106    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
107}
108
109#[doc(hidden)]
110pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
111    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
112}
113
114struct PublishAdmissionGuard {
115    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
116}
117
118impl Drop for PublishAdmissionGuard {
119    fn drop(&mut self) {
120        PUBLISH_ADMISSION.with(|slot| {
121            *slot.borrow_mut() = self.previous.take();
122        });
123    }
124}
125
126pub(crate) fn with_publish_epoch<R>(
127    epoch: crate::root_cache::ArtifactPublishEpoch,
128    expected: u64,
129    run: impl FnOnce() -> R,
130) -> R {
131    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
132    let _guard = PublishAdmissionGuard { previous };
133    run()
134}
135
136fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
137    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
138    match admission {
139        Some((epoch, expected)) => epoch
140            .run_if_current(expected, publish)
141            .unwrap_or(Err(CallGraphStoreError::Superseded)),
142        None => publish(),
143    }
144}
145
146struct RefreshCommitAdmissionGuard {
147    previous: Option<(
148        SubcLifecycleAdmission,
149        Arc<std::sync::atomic::AtomicU64>,
150        u64,
151    )>,
152}
153
154impl Drop for RefreshCommitAdmissionGuard {
155    fn drop(&mut self) {
156        REFRESH_COMMIT_ADMISSION.with(|slot| {
157            *slot.borrow_mut() = self.previous.take();
158        });
159    }
160}
161
162fn with_refresh_commit_admission<R>(
163    lifecycle: SubcLifecycleAdmission,
164    generation_flag: Arc<std::sync::atomic::AtomicU64>,
165    expected_generation: u64,
166    run: impl FnOnce() -> R,
167) -> R {
168    let previous = REFRESH_COMMIT_ADMISSION
169        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
170    let _guard = RefreshCommitAdmissionGuard { previous };
171    run()
172}
173
174fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
175    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
176    let commit = || {
177        publish_if_current(|| {
178            tx.commit()?;
179            Ok(())
180        })
181    };
182    match admission {
183        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
184            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
185            .unwrap_or(Err(CallGraphStoreError::Superseded)),
186        None => commit(),
187    }
188}
189
190fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
191    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
192    if let Some(observer) = observer {
193        observer(temp_path, target_path);
194    }
195}
196
197#[derive(Debug)]
198pub enum CallGraphStoreError {
199    Io(std::io::Error),
200    Sqlite(rusqlite::Error),
201    Json(serde_json::Error),
202    Aft(AftError),
203    Lock(crate::fs_lock::AcquireError),
204    MissingCallerData { file: String },
205    Unavailable(String),
206    Superseded,
207    StaleFiles(Vec<String>),
208}
209
210impl fmt::Display for CallGraphStoreError {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            Self::Io(error) => write!(formatter, "I/O error: {error}"),
214            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
215            Self::Json(error) => write!(formatter, "json error: {error}"),
216            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
217            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
218            Self::MissingCallerData { file } => {
219                write!(formatter, "missing extracted caller data for {file}")
220            }
221            Self::Unavailable(message) => {
222                write!(formatter, "callgraph store unavailable: {message}")
223            }
224            Self::Superseded => {
225                write!(formatter, "callgraph store build superseded before publish")
226            }
227            Self::StaleFiles(files) => {
228                write!(
229                    formatter,
230                    "callgraph store has stale files: {}",
231                    files.join(", ")
232                )
233            }
234        }
235    }
236}
237
238impl std::error::Error for CallGraphStoreError {}
239
240impl From<std::io::Error> for CallGraphStoreError {
241    fn from(error: std::io::Error) -> Self {
242        Self::Io(error)
243    }
244}
245
246impl From<rusqlite::Error> for CallGraphStoreError {
247    fn from(error: rusqlite::Error) -> Self {
248        Self::Sqlite(error)
249    }
250}
251
252impl From<serde_json::Error> for CallGraphStoreError {
253    fn from(error: serde_json::Error) -> Self {
254        Self::Json(error)
255    }
256}
257
258impl From<AftError> for CallGraphStoreError {
259    fn from(error: AftError) -> Self {
260        Self::Aft(error)
261    }
262}
263
264impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
265    fn from(error: crate::fs_lock::AcquireError) -> Self {
266        Self::Lock(error)
267    }
268}
269
270pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
271
272/// Config flag name gating whether the store is opened (default on). Production
273/// commands open it through `open_if_enabled` so the substrate can be disabled
274/// without code changes.
275pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct CallGraphStoreOptions {
279    pub enabled: bool,
280}
281
282pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
283
284#[derive(Clone, Debug, Hash, PartialEq, Eq)]
285struct RefreshRoot {
286    callgraph_dir: PathBuf,
287    project_root: PathBuf,
288}
289
290#[derive(Clone)]
291pub(crate) struct CallgraphRefreshTicket {
292    lifecycle: SubcLifecycleAdmission,
293    generation_flag: Arc<std::sync::atomic::AtomicU64>,
294    expected_generation: u64,
295    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
296    expected_publish_epoch: u64,
297}
298
299impl CallgraphRefreshTicket {
300    pub(crate) fn new(
301        lifecycle: SubcLifecycleAdmission,
302        generation_flag: Arc<std::sync::atomic::AtomicU64>,
303        expected_generation: u64,
304        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
305        expected_publish_epoch: u64,
306    ) -> Self {
307        Self {
308            lifecycle,
309            generation_flag,
310            expected_generation,
311            publish_epoch,
312            expected_publish_epoch,
313        }
314    }
315
316    fn is_current(&self) -> bool {
317        self.lifecycle
318            .is_current(self.generation_flag.as_ref(), self.expected_generation)
319            && self.publish_epoch.current() == self.expected_publish_epoch
320    }
321}
322
323#[derive(Clone)]
324struct RefreshBatch {
325    root: RefreshRoot,
326    paths: BTreeSet<PathBuf>,
327    pending_sinks: Vec<PendingCallGraphStorePaths>,
328    ticket: Option<CallgraphRefreshTicket>,
329}
330
331impl RefreshBatch {
332    fn defer(&self) {
333        for sink in &self.pending_sinks {
334            sink.lock().extend(self.paths.iter().cloned());
335        }
336    }
337
338    fn merge(
339        &mut self,
340        paths: impl IntoIterator<Item = PathBuf>,
341        sink: PendingCallGraphStorePaths,
342        ticket: Option<CallgraphRefreshTicket>,
343    ) {
344        self.paths.extend(paths);
345        if ticket.is_some() {
346            self.ticket = ticket;
347        }
348        if !self
349            .pending_sinks
350            .iter()
351            .any(|existing| Arc::ptr_eq(existing, &sink))
352        {
353            self.pending_sinks.push(sink);
354        }
355    }
356}
357
358#[derive(Default)]
359struct RefreshQueue {
360    order: VecDeque<RefreshRoot>,
361    queued: HashMap<RefreshRoot, RefreshBatch>,
362    active: Option<RefreshBatch>,
363    shutdown_requested: bool,
364}
365
366struct RefreshWorkerShared {
367    queue: Mutex<RefreshQueue>,
368    wake: Condvar,
369}
370
371struct RefreshWorker {
372    shared: Arc<RefreshWorkerShared>,
373    thread: Mutex<Option<JoinHandle<()>>>,
374}
375
376struct RefreshWorkerWatchdog {
377    first_path: PathBuf,
378    batch_len: usize,
379    started: Instant,
380}
381
382impl RefreshWorkerWatchdog {
383    fn start(paths: &[PathBuf]) -> Self {
384        Self {
385            first_path: paths
386                .first()
387                .expect("non-empty callgraph refresh batch has a first path")
388                .clone(),
389            batch_len: paths.len(),
390            started: Instant::now(),
391        }
392    }
393}
394
395impl Drop for RefreshWorkerWatchdog {
396    fn drop(&mut self) {
397        let elapsed = self.started.elapsed();
398        if elapsed < REFRESH_WORKER_WARN_AFTER {
399            return;
400        }
401        let path = if self.batch_len == 1 {
402            self.first_path.display().to_string()
403        } else {
404            format!(
405                "{} (+{} paths)",
406                self.first_path.display(),
407                self.batch_len - 1
408            )
409        };
410        log::warn!(
411            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
412            path,
413            elapsed.as_millis()
414        );
415        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
416            log::warn!(
417                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
418                path,
419                elapsed.as_millis()
420            );
421        }
422    }
423}
424
425impl RefreshWorker {
426    fn spawn() -> Arc<Self> {
427        let shared = Arc::new(RefreshWorkerShared {
428            queue: Mutex::new(RefreshQueue::default()),
429            wake: Condvar::new(),
430        });
431        let thread_shared = Arc::clone(&shared);
432        let thread = std::thread::Builder::new()
433            .name("aft-callgraph-refresh".to_string())
434            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
435            .expect("failed to spawn callgraph refresh worker");
436        Arc::new(Self {
437            shared,
438            thread: Mutex::new(Some(thread)),
439        })
440    }
441
442    fn enqueue(
443        &self,
444        root: RefreshRoot,
445        paths: Vec<PathBuf>,
446        pending_sink: PendingCallGraphStorePaths,
447        ticket: Option<CallgraphRefreshTicket>,
448    ) -> bool {
449        let mut queue = self
450            .shared
451            .queue
452            .lock()
453            .expect("callgraph refresh queue mutex poisoned");
454        if queue.shutdown_requested {
455            pending_sink.lock().extend(paths);
456            return false;
457        }
458        if let Some(batch) = queue.queued.get_mut(&root) {
459            batch.merge(paths, pending_sink, ticket);
460        } else {
461            queue.order.push_back(root.clone());
462            queue.queued.insert(
463                root.clone(),
464                RefreshBatch {
465                    root,
466                    paths: paths.into_iter().collect(),
467                    pending_sinks: vec![pending_sink],
468                    ticket,
469                },
470            );
471        }
472        self.shared.wake.notify_one();
473        true
474    }
475
476    fn shutdown_with_budget(&self, budget: Duration) -> bool {
477        let deadline = Instant::now() + budget;
478        let mut queue = self
479            .shared
480            .queue
481            .lock()
482            .expect("callgraph refresh queue mutex poisoned");
483        queue.shutdown_requested = true;
484        self.shared.wake.notify_one();
485        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
486            let remaining = deadline.saturating_duration_since(Instant::now());
487            let (next, _) = self
488                .shared
489                .wake
490                .wait_timeout(queue, remaining)
491                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
492            queue = next;
493        }
494        let drained = queue.active.is_none() && queue.order.is_empty();
495        if !drained {
496            if let Some(active) = queue.active.as_ref() {
497                active.defer();
498            }
499            for batch in queue.queued.values() {
500                batch.defer();
501            }
502            queue.order.clear();
503            queue.queued.clear();
504        }
505        drop(queue);
506
507        if drained {
508            if let Some(thread) = self
509                .thread
510                .lock()
511                .expect("callgraph refresh worker thread mutex poisoned")
512                .take()
513            {
514                let _ = thread.join();
515            }
516        }
517        drained
518    }
519}
520
521static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
522
523pub fn enqueue_callgraph_store_refresh(
524    callgraph_dir: PathBuf,
525    project_root: PathBuf,
526    paths: Vec<PathBuf>,
527    pending_sink: PendingCallGraphStorePaths,
528) -> bool {
529    enqueue_callgraph_store_refresh_inner(callgraph_dir, project_root, paths, pending_sink, None)
530}
531
532pub(crate) fn enqueue_callgraph_store_refresh_fenced(
533    callgraph_dir: PathBuf,
534    project_root: PathBuf,
535    paths: Vec<PathBuf>,
536    pending_sink: PendingCallGraphStorePaths,
537    ticket: CallgraphRefreshTicket,
538) -> bool {
539    enqueue_callgraph_store_refresh_inner(
540        callgraph_dir,
541        project_root,
542        paths,
543        pending_sink,
544        Some(ticket),
545    )
546}
547
548fn enqueue_callgraph_store_refresh_inner(
549    callgraph_dir: PathBuf,
550    project_root: PathBuf,
551    paths: Vec<PathBuf>,
552    pending_sink: PendingCallGraphStorePaths,
553    ticket: Option<CallgraphRefreshTicket>,
554) -> bool {
555    if paths.is_empty() {
556        return true;
557    }
558    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
559    let worker = {
560        let mut worker = slot
561            .lock()
562            .expect("callgraph refresh worker mutex poisoned");
563        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
564    };
565    worker.enqueue(
566        RefreshRoot {
567            callgraph_dir,
568            project_root,
569        },
570        paths,
571        pending_sink,
572        ticket,
573    )
574}
575
576pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
577    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
578}
579
580#[doc(hidden)]
581pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
582    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
583    let worker = slot
584        .lock()
585        .expect("callgraph refresh worker mutex poisoned")
586        .clone();
587    let Some(worker) = worker else {
588        return true;
589    };
590    let drained = worker.shutdown_with_budget(budget);
591    if drained {
592        let mut current = slot
593            .lock()
594            .expect("callgraph refresh worker mutex poisoned");
595        if current
596            .as_ref()
597            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
598        {
599            *current = None;
600        }
601    }
602    drained
603}
604
605fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
606    loop {
607        let batch = {
608            let mut queue = shared
609                .queue
610                .lock()
611                .expect("callgraph refresh queue mutex poisoned");
612            loop {
613                if let Some(root) = queue.order.pop_front() {
614                    let batch = queue
615                        .queued
616                        .remove(&root)
617                        .expect("queued callgraph refresh root has a batch");
618                    queue.active = Some(batch.clone());
619                    break batch;
620                }
621                if queue.shutdown_requested {
622                    return;
623                }
624                queue = shared
625                    .wake
626                    .wait(queue)
627                    .expect("callgraph refresh queue mutex poisoned while waiting");
628            }
629        };
630
631        process_callgraph_refresh_batch(&batch);
632
633        let mut queue = shared
634            .queue
635            .lock()
636            .expect("callgraph refresh queue mutex poisoned");
637        queue.active = None;
638        shared.wake.notify_all();
639    }
640}
641
642fn process_callgraph_refresh_batch(batch: &RefreshBatch) {
643    if batch
644        .ticket
645        .as_ref()
646        .is_some_and(|ticket| !ticket.is_current())
647    {
648        // Superseded before starting: park the paths so the next configure's
649        // pending replay (or unbind cleanup) decides their fate.
650        batch.defer();
651        return;
652    }
653    let paths = batch.paths.iter().cloned().collect::<Vec<_>>();
654    let _watchdog = RefreshWorkerWatchdog::start(&paths);
655    let store = match CallGraphStore::open_ready(
656        batch.root.callgraph_dir.clone(),
657        batch.root.project_root.clone(),
658    ) {
659        Ok(Some(store)) => store,
660        Ok(None) => {
661            batch.defer();
662            return;
663        }
664        Err(error) => {
665            batch.defer();
666            crate::slog_warn!(
667                "callgraph store writer open failed during refresh; deferred paths: {}",
668                error
669            );
670            return;
671        }
672    };
673
674    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
675    note_refresh_worker_call_for_test(&batch.root.project_root);
676    #[cfg(test)]
677    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
678        let _ = gate.held_tx.send(());
679        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
680    }
681    if !test_seam.delay.is_zero() {
682        std::thread::sleep(test_seam.delay);
683    }
684    if batch
685        .ticket
686        .as_ref()
687        .is_some_and(|ticket| !ticket.is_current())
688    {
689        batch.defer();
690        return;
691    }
692    let refresh_result = if test_seam.fail_refresh {
693        Err(CallGraphStoreError::Unavailable(
694            "injected refresh worker failure".to_string(),
695        ))
696    } else if let Some(ticket) = &batch.ticket {
697        with_publish_epoch(
698            ticket.publish_epoch.clone(),
699            ticket.expected_publish_epoch,
700            || {
701                with_refresh_commit_admission(
702                    ticket.lifecycle.clone(),
703                    Arc::clone(&ticket.generation_flag),
704                    ticket.expected_generation,
705                    || store.refresh_files(&paths).map(|_| ()),
706                )
707            },
708        )
709    } else {
710        store.refresh_files(&paths).map(|_| ())
711    };
712    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
713        // The commit lost the fence race: a newer configure or publication
714        // owns the store now. Defer instead of stale-marking — the paths were
715        // never committed, and the replacement generation re-indexes them.
716        batch.defer();
717        return;
718    }
719    if let Err(error) = refresh_result {
720        crate::slog_warn!("callgraph store refresh failed: {}", error);
721        match store.mark_files_stale(&paths) {
722            Ok(marked) => {
723                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
724                crate::slog_warn!(
725                    "marked {} callgraph store file(s) stale after refresh failure",
726                    marked.len()
727                );
728            }
729            Err(mark_error) => crate::slog_warn!(
730                "failed to mark callgraph store files stale after refresh failure: {}",
731                mark_error
732            ),
733        }
734    } else {
735        crate::logging::note_callgraph_invalidations(paths.len());
736    }
737}
738
739#[derive(Clone, Copy, Default)]
740struct RefreshWorkerTestSeam {
741    delay: Duration,
742    fail_refresh: bool,
743    refresh_calls: usize,
744    stale_marks: usize,
745}
746
747static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
748    OnceLock::new();
749
750#[cfg(test)]
751struct RefreshWorkerTestGate {
752    held_tx: crossbeam_channel::Sender<()>,
753    release_rx: crossbeam_channel::Receiver<()>,
754}
755
756#[cfg(test)]
757static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
758    OnceLock::new();
759
760#[cfg(test)]
761fn install_refresh_worker_test_gate(
762    project_root: PathBuf,
763) -> (
764    crossbeam_channel::Receiver<()>,
765    crossbeam_channel::Sender<()>,
766) {
767    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
768    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
769    REFRESH_WORKER_TEST_GATES
770        .get_or_init(|| Mutex::new(HashMap::new()))
771        .lock()
772        .expect("callgraph refresh test gate mutex poisoned")
773        .insert(
774            project_root,
775            RefreshWorkerTestGate {
776                held_tx,
777                release_rx,
778            },
779        );
780    (held_rx, release_tx)
781}
782
783#[cfg(test)]
784fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
785    REFRESH_WORKER_TEST_GATES
786        .get_or_init(|| Mutex::new(HashMap::new()))
787        .lock()
788        .expect("callgraph refresh test gate mutex poisoned")
789        .remove(project_root)
790}
791
792fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
793    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
794        return RefreshWorkerTestSeam::default();
795    };
796    seams
797        .lock()
798        .expect("callgraph refresh test seam mutex poisoned")
799        .get(project_root)
800        .copied()
801        .unwrap_or_default()
802}
803
804fn note_refresh_worker_call_for_test(project_root: &Path) {
805    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
806        if let Some(seam) = seams
807            .lock()
808            .expect("callgraph refresh test seam mutex poisoned")
809            .get_mut(project_root)
810        {
811            seam.refresh_calls += 1;
812        }
813    }
814}
815
816fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
817    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
818        if let Some(seam) = seams
819            .lock()
820            .expect("callgraph refresh test seam mutex poisoned")
821            .get_mut(project_root)
822        {
823            seam.stale_marks += 1;
824        }
825    }
826}
827
828#[doc(hidden)]
829pub fn set_callgraph_refresh_worker_test_seam(
830    project_root: PathBuf,
831    delay: Duration,
832    fail_refresh: bool,
833) {
834    REFRESH_WORKER_TEST_SEAMS
835        .get_or_init(|| Mutex::new(HashMap::new()))
836        .lock()
837        .expect("callgraph refresh test seam mutex poisoned")
838        .insert(
839            project_root,
840            RefreshWorkerTestSeam {
841                delay,
842                fail_refresh,
843                ..RefreshWorkerTestSeam::default()
844            },
845        );
846}
847
848#[doc(hidden)]
849pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
850    let seam = refresh_worker_test_seam(project_root);
851    (seam.refresh_calls, seam.stale_marks)
852}
853
854#[doc(hidden)]
855pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
856    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
857        seams
858            .lock()
859            .expect("callgraph refresh test seam mutex poisoned")
860            .remove(project_root);
861    }
862}
863
864#[derive(Debug)]
865pub struct CallGraphStore {
866    project_root: PathBuf,
867    project_key: String,
868    /// The concrete on-disk DB file this store opened. With the generation
869    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
870    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
871    sqlite_path: PathBuf,
872    /// Root-keyed directory whose pointer controls this store. For a legacy
873    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
874    /// newly published root-keyed generation invalidates the fallback reader.
875    publication_dir: PathBuf,
876    /// True only when the root-keyed read path opened data from a legacy
877    /// harness partition. Writer-capable callers use this to schedule migration
878    /// without making read-only/worktree callers acquire a writer lease.
879    legacy_fallback: bool,
880    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
881    /// or `None` when it opened the legacy single-file DB. Used to detect when
882    /// another process has published a newer generation so this process can
883    /// drop its connection and reopen (see `current_generation`).
884    generation: Option<String>,
885    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
886    read_marker: Option<crate::root_cache::ReadMarker>,
887    conn: Mutex<Connection>,
888}
889
890#[derive(Debug)]
891pub struct ReadonlyCallGraphStore {
892    inner: CallGraphStore,
893}
894
895pub trait CallGraphRead {
896    fn project_root(&self) -> &Path;
897    fn project_key(&self) -> &str;
898    fn sqlite_path(&self) -> &Path;
899    fn is_current(&self) -> bool;
900    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
901    fn indexed_file_count(&self) -> Result<usize>;
902    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
903    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
904    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
905    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
906    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
907        -> Result<StoreCallersResult>;
908    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
909    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
910    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
911    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
912    fn call_tree(
913        &self,
914        file_rel: &Path,
915        symbol: &str,
916        depth: usize,
917    ) -> Result<callgraph::CallTreeNode>;
918    fn trace_to(
919        &self,
920        file_rel: &Path,
921        symbol: &str,
922        max_depth: usize,
923    ) -> Result<callgraph::TraceToResult>;
924    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
925    fn trace_to_symbol(
926        &self,
927        file_rel: &Path,
928        symbol: &str,
929        to_symbol: &str,
930        to_file: Option<&Path>,
931        max_depth: usize,
932    ) -> Result<callgraph::TraceToSymbolResult>;
933}
934
935#[derive(Debug, Clone, PartialEq, Eq)]
936enum OpenRootRepair {
937    None,
938    ReRooted,
939    NeedsRebuild {
940        previous_roots: Vec<String>,
941        current_root: String,
942        reason: String,
943    },
944}
945
946struct OpenedStore {
947    store: CallGraphStore,
948    root_repair: OpenRootRepair,
949}
950
951#[derive(Clone, Debug)]
952struct LegacyCallgraphPartition {
953    harness: String,
954    dir: PathBuf,
955    key: String,
956    bytes: u64,
957    freshness: Option<SystemTime>,
958}
959
960#[derive(Clone, Debug)]
961struct LegacyCallgraphTarget {
962    partition: LegacyCallgraphPartition,
963    sqlite_path: PathBuf,
964    generation: Option<String>,
965    source_bytes: u64,
966    source_blake3: String,
967}
968
969#[derive(Clone, Debug)]
970struct SourceFingerprint {
971    bytes: u64,
972    blake3: String,
973}
974
975#[derive(Clone, Debug)]
976struct PublishedLegacyMigration {
977    generation: String,
978    migrated_bytes: u64,
979}
980
981#[derive(Debug, Clone)]
982pub struct ColdBuildStats {
983    pub files: usize,
984    pub nodes: usize,
985    pub refs: usize,
986    pub edges: usize,
987    pub failed_files: Vec<String>,
988    pub elapsed_ms: u128,
989}
990
991#[derive(Debug, Clone)]
992pub struct IncrementalStats {
993    pub changed_files: Vec<String>,
994    pub surface_changed: Vec<String>,
995    pub deleted_files: Vec<String>,
996    pub dependency_selected_refs: usize,
997    pub refreshed_own_files: usize,
998}
999
1000/// Phase timings for the copy-based incremental refresh benchmark.
1001#[doc(hidden)]
1002#[derive(Debug, Clone, Default, PartialEq, Eq)]
1003pub struct RefreshFilesProfile {
1004    pub parse: Duration,
1005    pub dependency_selection: Duration,
1006    pub row_deletes: Duration,
1007    pub row_inserts: Duration,
1008    pub dependent_parse: Duration,
1009    pub index_load: Duration,
1010    pub ref_resolution: Duration,
1011    pub method_dispatch: Duration,
1012    pub commit: Duration,
1013    pub total: Duration,
1014}
1015
1016impl RefreshFilesProfile {
1017    pub fn report(&self) -> String {
1018        format!(
1019            "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",
1020            self.parse.as_millis(),
1021            self.dependency_selection.as_millis(),
1022            self.row_deletes.as_millis(),
1023            self.row_inserts.as_millis(),
1024            self.dependent_parse.as_millis(),
1025            self.index_load.as_millis(),
1026            self.ref_resolution.as_millis(),
1027            self.method_dispatch.as_millis(),
1028            self.commit.as_millis(),
1029            self.total.as_millis(),
1030        )
1031    }
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1035pub struct StoredEdge {
1036    pub source_file: String,
1037    pub source_symbol: String,
1038    pub target_file: String,
1039    pub target_symbol: String,
1040    pub kind: String,
1041    pub line: u32,
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct StoreNode {
1046    node_id: String,
1047    pub file: String,
1048    pub symbol: String,
1049    pub name: String,
1050    pub kind: String,
1051    pub line: u32,
1052    pub end_line: u32,
1053    pub signature: Option<String>,
1054    pub exported: bool,
1055    pub is_entry_point: bool,
1056    pub lang: LangId,
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq)]
1060pub struct StoreCallSite {
1061    pub caller: StoreNode,
1062    pub target_file: String,
1063    pub target_symbol: String,
1064    pub target: Option<StoreNode>,
1065    pub line: u32,
1066    pub byte_start: usize,
1067    pub byte_end: usize,
1068    pub resolved: bool,
1069    pub provenance: String,
1070}
1071
1072impl StoreCallSite {
1073    pub fn approximate(&self) -> bool {
1074        self.provenance == PROVENANCE_NAME_MATCH
1075    }
1076
1077    pub fn resolved_by(&self) -> &str {
1078        &self.provenance
1079    }
1080
1081    pub fn supplemental_resolution(&self) -> Option<&str> {
1082        match self.provenance.as_str() {
1083            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1084            _ => None,
1085        }
1086    }
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq)]
1090pub struct StoreUnresolvedCall {
1091    pub caller: StoreNode,
1092    pub symbol: String,
1093    pub full_ref: Option<String>,
1094    pub line: u32,
1095    pub byte_start: usize,
1096    pub byte_end: usize,
1097}
1098
1099#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct StoreCallersResult {
1101    pub target: StoreNode,
1102    pub callers: Vec<StoreCallSite>,
1103    pub scanned_files: usize,
1104    pub depth_limited: bool,
1105    pub truncated: usize,
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq)]
1109pub struct StoreImpactCaller {
1110    pub site: StoreCallSite,
1111    pub signature: Option<String>,
1112    pub is_entry_point: bool,
1113    pub call_expression: Option<String>,
1114    pub parameters: Vec<String>,
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1118pub struct StoreImpactResult {
1119    pub target: StoreNode,
1120    pub parameters: Vec<String>,
1121    pub callers: Vec<StoreImpactCaller>,
1122    pub depth_limited: bool,
1123    pub truncated: usize,
1124}
1125
1126#[derive(Debug, Clone)]
1127struct ExtractFailure {
1128    rel_path: String,
1129    freshness: Option<FileFreshness>,
1130}
1131
1132#[derive(Debug, Clone)]
1133struct BuildExtractsResult {
1134    extracts: Vec<FileExtract>,
1135    failures: Vec<ExtractFailure>,
1136}
1137
1138#[derive(Debug, Clone)]
1139enum StoreForwardCall {
1140    Resolved(StoreCallSite),
1141    Unresolved(StoreUnresolvedCall),
1142}
1143
1144impl StoreForwardCall {
1145    fn byte_start(&self) -> usize {
1146        match self {
1147            Self::Resolved(site) => site.byte_start,
1148            Self::Unresolved(call) => call.byte_start,
1149        }
1150    }
1151
1152    fn line(&self) -> u32 {
1153        match self {
1154            Self::Resolved(site) => site.line,
1155            Self::Unresolved(call) => call.line,
1156        }
1157    }
1158}
1159
1160#[derive(Debug, Clone)]
1161struct FileExtract {
1162    rel_path: String,
1163    freshness: FileFreshness,
1164    lang: LangId,
1165    data: FileCallData,
1166    nodes: Vec<NodeRecord>,
1167    raw_refs: Vec<RawRef>,
1168    dispatch_hints: Vec<DispatchHint>,
1169    surface_fingerprint: String,
1170}
1171
1172#[derive(Debug, Clone)]
1173struct NodeRecord {
1174    id: String,
1175    file_path: String,
1176    name: String,
1177    scoped_name: String,
1178    kind: String,
1179    range: Range,
1180    range_ordinal: u32,
1181    signature: Option<String>,
1182    exported: bool,
1183    is_default_export: bool,
1184    is_type_like: bool,
1185    is_callgraph_entry_point: bool,
1186}
1187
1188#[derive(Debug, Clone)]
1189struct RawRef {
1190    ref_id: String,
1191    caller_node: Option<String>,
1192    caller_symbol: Option<String>,
1193    caller_file: String,
1194    kind: String,
1195    short_name: Option<String>,
1196    full_ref: Option<String>,
1197    module_path: Option<String>,
1198    import_kind: Option<String>,
1199    local_name: Option<String>,
1200    requested_name: Option<String>,
1201    namespace_alias: Option<String>,
1202    wildcard: bool,
1203    line: u32,
1204    byte_start: usize,
1205    byte_end: usize,
1206    dependencies: BTreeSet<String>,
1207}
1208
1209#[derive(Debug, Clone)]
1210struct ResolvedRef {
1211    raw: RawRef,
1212    status: String,
1213    target_node: Option<String>,
1214    target_file: Option<String>,
1215    target_symbol: Option<String>,
1216    dependencies: BTreeSet<String>,
1217    edge: Option<EdgeRecord>,
1218}
1219
1220#[derive(Debug, Clone)]
1221struct EdgeRecord {
1222    edge_id: String,
1223    source_node: String,
1224    target_node: Option<String>,
1225    target_file: String,
1226    target_symbol: String,
1227    kind: String,
1228    line: u32,
1229}
1230
1231#[derive(Debug, Clone)]
1232struct DispatchHint {
1233    id: String,
1234    method_name: String,
1235    caller_node: String,
1236    file: String,
1237    line: u32,
1238    byte_start: usize,
1239    byte_end: usize,
1240}
1241
1242#[derive(Debug, Clone)]
1243struct NameMatchRef {
1244    ref_id: String,
1245    caller_node: String,
1246    caller_file: String,
1247    caller_symbol: String,
1248    caller_signature: Option<String>,
1249    receiver: String,
1250    method_name: String,
1251    colon_dispatch: bool,
1252    line: u32,
1253    lang: String,
1254}
1255
1256#[derive(Debug, Clone)]
1257struct NameMatchCandidate {
1258    node_id: String,
1259    file_path: String,
1260    scoped_name: String,
1261    kind: String,
1262}
1263
1264#[derive(Debug, Clone)]
1265struct FileRow {
1266    surface_fingerprint: String,
1267    freshness: FileFreshness,
1268}
1269
1270#[derive(Debug, Clone)]
1271struct DbFileIndex {
1272    lang: Option<LangId>,
1273    exports: HashSet<String>,
1274    default_export: Option<String>,
1275    export_aliases: HashMap<String, String>,
1276    node_by_scoped: HashMap<String, String>,
1277    node_by_bare: HashMap<String, String>,
1278    module_targets: HashMap<String, Option<String>>,
1279    reexports: Vec<ReexportIndex>,
1280}
1281
1282#[derive(Debug, Clone)]
1283struct ReexportIndex {
1284    target_file: Option<String>,
1285    named: HashMap<String, String>,
1286    wildcard: bool,
1287}
1288
1289#[derive(Debug, Clone)]
1290struct ProjectIndex<'a> {
1291    project_root: PathBuf,
1292    files: HashMap<String, DbFileIndex>,
1293    caller_data: HashMap<String, &'a FileCallData>,
1294    /// Lazily-built `crate_name -> src prefix` map for Rust workspace resolution.
1295    /// Built once (whole-tree walk) on first qualified-ref resolution and reused,
1296    /// instead of re-walking the project per ref. Skipped entirely when no Rust
1297    /// workspace ref is resolved (e.g. warm query path with no Rust changes).
1298    workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
1299}
1300
1301impl ProjectIndex<'_> {
1302    /// Resolve a crate name to its `src` prefix, building the workspace map on
1303    /// first use. The map walks the project tree exactly once per index.
1304    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1305        self.workspace_crate_prefixes
1306            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1307            .get(crate_name)
1308            .cloned()
1309    }
1310}
1311
1312impl CallGraphStore {
1313    pub fn open_if_enabled(
1314        options: CallGraphStoreOptions,
1315        callgraph_dir: PathBuf,
1316        project_root: PathBuf,
1317    ) -> Result<Option<Self>> {
1318        if !options.enabled {
1319            return Ok(None);
1320        }
1321        Self::open(callgraph_dir, project_root).map(Some)
1322    }
1323
1324    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1325        let project_key = crate::search_index::artifact_cache_key(&project_root);
1326        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1327        else {
1328            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1329                Some(store) => Ok(store.into_inner()),
1330                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1331            };
1332        };
1333        std::fs::create_dir_all(&callgraph_dir)?;
1334        // Resolve the current generation via the pointer (falling back to the
1335        // legacy single-file DB). If nothing is published yet, open the legacy
1336        // path so a brand-new store still gets a writable DB + schema.
1337        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1338            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1339        let OpenedStore { store, root_repair } = Self::open_at_path(
1340            project_root.clone(),
1341            project_key,
1342            sqlite_path,
1343            generation,
1344            true,
1345            Some(Arc::clone(&writer_lease)),
1346            None,
1347        )?;
1348        match root_repair {
1349            OpenRootRepair::NeedsRebuild { .. } => {
1350                log_root_repair_rebuild(&root_repair);
1351                drop(store);
1352                drop(writer_lease);
1353                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1354                let (store, _stats) =
1355                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1356                Ok(store)
1357            }
1358            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1359        }
1360    }
1361
1362    pub fn open_readonly(
1363        callgraph_dir: PathBuf,
1364        project_root: PathBuf,
1365    ) -> Result<Option<ReadonlyCallGraphStore>> {
1366        let project_key = crate::search_index::artifact_cache_key(&project_root);
1367        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1368        {
1369            let conn = open_readonly_connection(&sqlite_path)?;
1370            if !database_ready(&conn).unwrap_or(false) {
1371                return Ok(None);
1372            }
1373            let marker_label = generation.as_deref().unwrap_or("legacy");
1374            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1375            return Ok(Some(ReadonlyCallGraphStore::from_inner(
1376                Self::from_connection(
1377                    project_root,
1378                    project_key,
1379                    sqlite_path,
1380                    callgraph_dir,
1381                    false,
1382                    generation,
1383                    None,
1384                    Some(read_marker),
1385                    conn,
1386                ),
1387            )));
1388        }
1389
1390        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1391            return Ok(None);
1392        };
1393        crate::slog_warn!(
1394            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1395            target.partition.harness,
1396            target.sqlite_path.display()
1397        );
1398        let conn = open_readonly_connection(&target.sqlite_path)?;
1399        if !database_ready(&conn).unwrap_or(false) {
1400            return Ok(None);
1401        }
1402        let marker_label =
1403            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1404        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1405        Ok(Some(ReadonlyCallGraphStore::from_inner(
1406            Self::from_connection(
1407                project_root,
1408                project_key,
1409                target.sqlite_path,
1410                callgraph_dir,
1411                true,
1412                target.generation,
1413                None,
1414                Some(read_marker),
1415                conn,
1416            ),
1417        )))
1418    }
1419
1420    /// Open the currently-published ready store with write access so moved-root
1421    /// metadata can be repaired before projection readers consume it. Unlike
1422    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
1423    /// ready generation exists, it returns `Ok(None)` instead of creating an
1424    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
1425    pub fn open_ready_repairing(
1426        callgraph_dir: PathBuf,
1427        project_root: PathBuf,
1428    ) -> Result<Option<Self>> {
1429        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1430    }
1431
1432    /// Open a ready store for bounded maintenance work without repairing root
1433    /// metadata or starting a cold rebuild. A store that needs either action is
1434    /// reported as unavailable so a background build can own that work.
1435    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1436        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1437    }
1438
1439    pub fn open_ready_no_rebuild(
1440        callgraph_dir: PathBuf,
1441        project_root: PathBuf,
1442    ) -> Result<Option<Self>> {
1443        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1444    }
1445
1446    fn open_ready_with_rebuild_policy(
1447        callgraph_dir: PathBuf,
1448        project_root: PathBuf,
1449        allow_cold_build: bool,
1450        allow_root_repair: bool,
1451        allow_borrow_only: bool,
1452    ) -> Result<Option<Self>> {
1453        let project_key = crate::search_index::artifact_cache_key(&project_root);
1454        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1455        else {
1456            if !allow_borrow_only {
1457                return Ok(None);
1458            }
1459            return Self::open_readonly(callgraph_dir, project_root)
1460                .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1461        };
1462        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1463        else {
1464            return Ok(None);
1465        };
1466        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1467            project_root.clone(),
1468            project_key,
1469            sqlite_path,
1470            generation,
1471            true,
1472            Some(Arc::clone(&writer_lease)),
1473            None,
1474            allow_root_repair,
1475        )?;
1476        match root_repair {
1477            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1478                log_root_repair_rebuild(&root_repair);
1479                drop(store);
1480                drop(writer_lease);
1481                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1482                let (store, _stats) =
1483                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1484                Ok(Some(store))
1485            }
1486            OpenRootRepair::NeedsRebuild { .. } => {
1487                crate::slog_info!(
1488                    "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1489                );
1490                Ok(None)
1491            }
1492            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1493        }
1494    }
1495
1496    pub fn cold_build_with_lease(
1497        callgraph_dir: PathBuf,
1498        project_root: PathBuf,
1499        files: &[PathBuf],
1500    ) -> Result<(Self, ColdBuildStats)> {
1501        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1502    }
1503
1504    pub fn cold_build_with_lease_chunked(
1505        callgraph_dir: PathBuf,
1506        project_root: PathBuf,
1507        files: &[PathBuf],
1508        chunk_size: usize,
1509    ) -> Result<(Self, ColdBuildStats)> {
1510        Self::cold_build_with_lease_chunked_inner(
1511            callgraph_dir,
1512            project_root,
1513            files,
1514            chunk_size,
1515            false,
1516        )
1517    }
1518
1519    pub(crate) fn force_cold_build_with_lease_chunked(
1520        callgraph_dir: PathBuf,
1521        project_root: PathBuf,
1522        files: &[PathBuf],
1523        chunk_size: usize,
1524    ) -> Result<(Self, ColdBuildStats)> {
1525        Self::cold_build_with_lease_chunked_inner(
1526            callgraph_dir,
1527            project_root,
1528            files,
1529            chunk_size,
1530            true,
1531        )
1532    }
1533
1534    fn cold_build_with_lease_chunked_inner(
1535        callgraph_dir: PathBuf,
1536        project_root: PathBuf,
1537        files: &[PathBuf],
1538        chunk_size: usize,
1539        require_new_publication: bool,
1540    ) -> Result<(Self, ColdBuildStats)> {
1541        let project_key = crate::search_index::artifact_cache_key(&project_root);
1542        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1543        else {
1544            if require_new_publication {
1545                return Err(CallGraphStoreError::Unavailable(
1546                    "forced rebuild could not acquire the writer lease".to_string(),
1547                ));
1548            }
1549            let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1550                Some(store) => store.into_inner(),
1551                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1552            };
1553            return Ok((
1554                store,
1555                ColdBuildStats {
1556                    files: 0,
1557                    nodes: 0,
1558                    refs: 0,
1559                    edges: 0,
1560                    failed_files: Vec::new(),
1561                    elapsed_ms: 0,
1562                },
1563            ));
1564        };
1565        std::fs::create_dir_all(&callgraph_dir)?;
1566        let (stats, generation) = Self::cold_build_publish_locked(
1567            &callgraph_dir,
1568            &project_root,
1569            &project_key,
1570            files,
1571            chunk_size,
1572            Arc::clone(&writer_lease),
1573        )?;
1574        let store = Self::open_generation(
1575            &callgraph_dir,
1576            project_root,
1577            project_key,
1578            generation,
1579            writer_lease,
1580        )?;
1581        Ok((store, stats))
1582    }
1583
1584    pub fn ensure_built_with_lease(
1585        callgraph_dir: PathBuf,
1586        project_root: PathBuf,
1587        files: &[PathBuf],
1588    ) -> Result<(Self, Option<ColdBuildStats>)> {
1589        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1590    }
1591
1592    pub fn ensure_built_with_lease_chunked(
1593        callgraph_dir: PathBuf,
1594        project_root: PathBuf,
1595        files: &[PathBuf],
1596        chunk_size: usize,
1597    ) -> Result<(Self, Option<ColdBuildStats>)> {
1598        let project_key = crate::search_index::artifact_cache_key(&project_root);
1599        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1600        else {
1601            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1602                Some(store) => Ok((store.into_inner(), None)),
1603                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1604                    .map(|store| (store, None)),
1605            };
1606        };
1607        std::fs::create_dir_all(&callgraph_dir)?;
1608        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1609        // Another process may have published a ready generation while we waited
1610        // for the lock — open it instead of rebuilding. If that generation is
1611        // from this same project at an older filesystem root, repair the root
1612        // metadata in-place while still holding the build lease. If data rows
1613        // contain absolute paths, publish a fresh generation under this lease
1614        // rather than recursively reacquiring the same lock.
1615        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1616        {
1617            let OpenedStore { store, root_repair } = Self::open_at_path(
1618                project_root.clone(),
1619                project_key.clone(),
1620                sqlite_path,
1621                generation,
1622                true,
1623                Some(Arc::clone(&writer_lease)),
1624                None,
1625            )?;
1626            match root_repair {
1627                OpenRootRepair::NeedsRebuild { .. } => {
1628                    log_root_repair_rebuild(&root_repair);
1629                    drop(store);
1630                    let (stats, generation) = Self::cold_build_publish_locked(
1631                        &callgraph_dir,
1632                        &project_root,
1633                        &project_key,
1634                        files,
1635                        chunk_size,
1636                        Arc::clone(&writer_lease),
1637                    )?;
1638                    let store = Self::open_generation(
1639                        &callgraph_dir,
1640                        project_root,
1641                        project_key,
1642                        generation,
1643                        writer_lease,
1644                    )?;
1645                    return Ok((store, Some(stats)));
1646                }
1647                OpenRootRepair::None | OpenRootRepair::ReRooted => {
1648                    return Ok((store, None));
1649                }
1650            }
1651        }
1652        if let Some(store) = try_legacy_migration_or_fallback(
1653            &callgraph_dir,
1654            &project_root,
1655            &project_key,
1656            Arc::clone(&writer_lease),
1657        )? {
1658            return Ok((store, None));
1659        }
1660        let (stats, generation) = Self::cold_build_publish_locked(
1661            &callgraph_dir,
1662            &project_root,
1663            &project_key,
1664            files,
1665            chunk_size,
1666            Arc::clone(&writer_lease),
1667        )?;
1668        let store = Self::open_generation(
1669            &callgraph_dir,
1670            project_root,
1671            project_key,
1672            generation,
1673            writer_lease,
1674        )?;
1675        Ok((store, Some(stats)))
1676    }
1677
1678    /// Migrate a legacy harness-partition store without falling through to a
1679    /// cold build. This is used after a query has already opened a read-only
1680    /// fallback: the caller runs it on the same limited background lane as cold
1681    /// builds while queries continue using that fallback. Public so crash/retry
1682    /// tests can drive the migration synchronously on a thread where the
1683    /// thread-local failure seams apply.
1684    pub fn migrate_legacy_with_lease(
1685        callgraph_dir: PathBuf,
1686        project_root: PathBuf,
1687    ) -> Result<Option<Self>> {
1688        let project_key = crate::search_index::artifact_cache_key(&project_root);
1689        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1690        else {
1691            return Ok(None);
1692        };
1693        std::fs::create_dir_all(&callgraph_dir)?;
1694        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1695
1696        // Another writer may have completed the migration while this worker was
1697        // waiting for the lease. Adopt its root-keyed generation rather than
1698        // copying the legacy source a second time.
1699        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1700        {
1701            let OpenedStore { store, root_repair } = Self::open_at_path(
1702                project_root,
1703                project_key,
1704                sqlite_path,
1705                generation,
1706                true,
1707                Some(writer_lease),
1708                None,
1709            )?;
1710            return match root_repair {
1711                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1712                OpenRootRepair::NeedsRebuild { reason, .. } => {
1713                    Err(CallGraphStoreError::Unavailable(format!(
1714                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1715                    )))
1716                }
1717            };
1718        }
1719
1720        let store = try_legacy_migration_or_fallback(
1721            &callgraph_dir,
1722            &project_root,
1723            &project_key,
1724            writer_lease,
1725        )?;
1726        // A disk-floor or backup-budget failure returns a readable legacy store.
1727        // Keep the already-resident fallback instead of sending this duplicate
1728        // reader through the background-install channel.
1729        Ok(store.filter(|store| !store.is_legacy_fallback()))
1730    }
1731
1732    /// Build a fresh DB and publish it as a new generation, then atomically flip
1733    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
1734    /// succeeds even when other processes hold an older generation open (the
1735    /// multi-TUI Windows case). The builder owns the temp + generation files
1736    /// exclusively (unique pid+nanos names), so it can rename/replace them
1737    /// freely; only the tiny pointer is shared, and only Rust std touches it.
1738    ///
1739    /// Returns the published generation file name so callers open exactly the
1740    /// generation they built (avoiding a race where a concurrent build's flip
1741    /// would otherwise reopen a different generation).
1742    fn cold_build_publish_locked(
1743        callgraph_dir: &Path,
1744        project_root: &Path,
1745        project_key: &str,
1746        files: &[PathBuf],
1747        chunk_size: usize,
1748        writer_lease: Arc<crate::root_cache::WriterLease>,
1749    ) -> Result<(ColdBuildStats, String)> {
1750        let generation = generation_file_name(project_key);
1751        let gen_path = callgraph_dir.join(&generation);
1752        let temp_path = callgraph_dir.join(format!(
1753            "{generation}.tmp.{}.{}",
1754            std::process::id(),
1755            now_nanos()
1756        ));
1757        remove_sqlite_file_set(&temp_path);
1758
1759        let stats = {
1760            let temp_store = Self::open_at_path(
1761                project_root.to_path_buf(),
1762                project_key.to_string(),
1763                temp_path.clone(),
1764                None,
1765                false,
1766                Some(Arc::clone(&writer_lease)),
1767                None,
1768            )?
1769            .store;
1770            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
1771            temp_store.prepare_for_atomic_swap()?;
1772            stats
1773        };
1774
1775        notify_cold_build_before_publish_observer();
1776        let publication = publish_if_current(|| {
1777            verify_writer_lease(&writer_lease)?;
1778            // Move the finished build to its final generation path. This target is
1779            // brand-new and owned by us, so the rename never hits an open file.
1780            remove_sqlite_file_set(&gen_path);
1781            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
1782            crate::fs_lock::sync_parent(&gen_path);
1783            remove_sqlite_sidecars(&gen_path);
1784
1785            notify_cold_build_swap_observer(&temp_path, &gen_path);
1786
1787            // Atomically publish the new generation, then best-effort GC old ones.
1788            verify_writer_lease(&writer_lease)?;
1789            publish_pointer(callgraph_dir, project_key, &generation)?;
1790            gc_old_generations(callgraph_dir, project_key, &generation);
1791            Ok(())
1792        });
1793        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
1794            remove_sqlite_file_set(&temp_path);
1795        }
1796        publication?;
1797        Ok((stats, generation))
1798    }
1799
1800    /// Open a specific just-published generation (read-write, WAL) so a builder
1801    /// returns a store pinned to exactly what it built.
1802    fn open_generation(
1803        callgraph_dir: &Path,
1804        project_root: PathBuf,
1805        project_key: String,
1806        generation: String,
1807        writer_lease: Arc<crate::root_cache::WriterLease>,
1808    ) -> Result<Self> {
1809        let gen_path = callgraph_dir.join(&generation);
1810        Ok(Self::open_at_path(
1811            project_root,
1812            project_key,
1813            gen_path,
1814            Some(generation),
1815            true,
1816            Some(writer_lease),
1817            None,
1818        )?
1819        .store)
1820    }
1821
1822    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
1823        let project_key = crate::search_index::artifact_cache_key(project_root);
1824        // A cold build is needed unless a ready generation (or ready legacy DB)
1825        // is currently published.
1826        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
1827    }
1828
1829    fn open_at_path(
1830        project_root: PathBuf,
1831        project_key: String,
1832        sqlite_path: PathBuf,
1833        generation: Option<String>,
1834        use_wal: bool,
1835        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1836        read_marker: Option<crate::root_cache::ReadMarker>,
1837    ) -> Result<OpenedStore> {
1838        Self::open_at_path_with_root_repair(
1839            project_root,
1840            project_key,
1841            sqlite_path,
1842            generation,
1843            use_wal,
1844            writer_lease,
1845            read_marker,
1846            true,
1847        )
1848    }
1849
1850    fn open_at_path_with_root_repair(
1851        project_root: PathBuf,
1852        project_key: String,
1853        sqlite_path: PathBuf,
1854        generation: Option<String>,
1855        use_wal: bool,
1856        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1857        read_marker: Option<crate::root_cache::ReadMarker>,
1858        allow_root_repair: bool,
1859    ) -> Result<OpenedStore> {
1860        if let Some(lease) = writer_lease.as_ref() {
1861            verify_writer_lease(lease)?;
1862        }
1863        if let Some(parent) = sqlite_path.parent() {
1864            std::fs::create_dir_all(parent)?;
1865        }
1866        let mut conn = Connection::open(&sqlite_path)?;
1867        if use_wal {
1868            configure_connection(&conn)?;
1869        } else {
1870            configure_build_connection(&conn)?;
1871        }
1872        if let Some(lease) = writer_lease.as_ref() {
1873            verify_writer_lease(lease)?;
1874        }
1875        initialize_schema(&conn)?;
1876        if let Some(lease) = writer_lease.as_ref() {
1877            verify_writer_lease(lease)?;
1878        }
1879        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
1880        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
1881            (Some(marker), _, _) => Some(marker),
1882            (None, Some(label), Some(cache_dir)) => {
1883                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
1884            }
1885            (None, _, _) => None,
1886        };
1887        let publication_dir = sqlite_path
1888            .parent()
1889            .map(Path::to_path_buf)
1890            .unwrap_or_default();
1891        let store = Self::from_connection(
1892            project_root,
1893            project_key,
1894            sqlite_path,
1895            publication_dir,
1896            false,
1897            generation,
1898            writer_lease,
1899            read_marker,
1900            conn,
1901        );
1902        Ok(OpenedStore { store, root_repair })
1903    }
1904
1905    fn borrow_only_empty(
1906        callgraph_dir: PathBuf,
1907        project_root: PathBuf,
1908        project_key: String,
1909    ) -> Result<Self> {
1910        let conn = Connection::open_in_memory()?;
1911        initialize_schema(&conn)?;
1912        conn.pragma_update(None, "query_only", true)?;
1913        Ok(Self::from_connection(
1914            project_root,
1915            project_key.clone(),
1916            callgraph_dir.join(format!("{project_key}.borrow-only")),
1917            callgraph_dir,
1918            false,
1919            None,
1920            None,
1921            None,
1922            conn,
1923        ))
1924    }
1925
1926    fn prepare_for_atomic_swap(&self) -> Result<()> {
1927        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1928        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
1929        Ok(())
1930    }
1931
1932    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
1933        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
1934            self.sqlite_path
1935                .parent()
1936                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
1937        });
1938        if protected_reader {
1939            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
1940        } else {
1941            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
1942        }
1943    }
1944
1945    fn from_connection(
1946        project_root: PathBuf,
1947        project_key: String,
1948        sqlite_path: PathBuf,
1949        publication_dir: PathBuf,
1950        legacy_fallback: bool,
1951        generation: Option<String>,
1952        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1953        read_marker: Option<crate::root_cache::ReadMarker>,
1954        conn: Connection,
1955    ) -> Self {
1956        Self {
1957            project_root,
1958            project_key,
1959            sqlite_path,
1960            publication_dir,
1961            legacy_fallback,
1962            generation,
1963            writer_lease,
1964            read_marker,
1965            conn: Mutex::new(conn),
1966        }
1967    }
1968
1969    pub fn project_root(&self) -> &Path {
1970        &self.project_root
1971    }
1972
1973    pub fn project_key(&self) -> &str {
1974        &self.project_key
1975    }
1976
1977    pub fn sqlite_path(&self) -> &Path {
1978        &self.sqlite_path
1979    }
1980
1981    /// Whether this store is reading from a legacy harness partition because
1982    /// the root-keyed store has not published a generation yet.
1983    pub fn is_legacy_fallback(&self) -> bool {
1984        self.legacy_fallback
1985    }
1986
1987    pub(crate) fn is_legacy_migration(&self) -> bool {
1988        self.generation.as_deref().is_some_and(|generation| {
1989            migration_generation_requires_manifest(generation)
1990                && migration_manifest_valid(&self.publication_dir, generation)
1991        })
1992    }
1993
1994    pub fn writer_epoch_for_test(&self) -> Option<&str> {
1995        self.writer_lease.as_ref().map(|lease| lease.epoch())
1996    }
1997
1998    fn verify_writer_lease(&self) -> Result<()> {
1999        let Some(lease) = self.writer_lease.as_ref() else {
2000            return Err(CallGraphStoreError::Unavailable(
2001                "callgraph store opened read-only; write API is unavailable".to_string(),
2002            ));
2003        };
2004        verify_writer_lease(lease)
2005    }
2006
2007    fn refresh_read_marker(&self) -> Result<()> {
2008        if let Some(marker) = self.read_marker.as_ref() {
2009            marker.touch_if_due()?;
2010        }
2011        Ok(())
2012    }
2013
2014    /// True if this store still reflects the currently-published generation.
2015    /// Cheap (one small pointer-file read). When false, another process (or a
2016    /// local cold rebuild) has published a newer generation and the holder
2017    /// should drop this store and reopen via the pointer to converge. A missing
2018    /// pointer keeps the current store (legacy DB still valid, or transient).
2019    pub fn is_current(&self) -> bool {
2020        let _ = self.refresh_read_marker();
2021        match (
2022            read_pointer(&self.publication_dir, &self.project_key),
2023            &self.generation,
2024        ) {
2025            // Even when both generations happen to have the same filename, the
2026            // root-keyed pointer names a different directory from the fallback.
2027            (Some(_), _) if self.legacy_fallback => false,
2028            (Some(published), Some(opened)) => &published == opened,
2029            // A generation now supersedes the legacy single-file DB we opened.
2030            (Some(_), None) => false,
2031            // No pointer: keep serving (legacy DB, or an anomalous pointer
2032            // removal where our open generation file is still valid).
2033            (None, _) => true,
2034        }
2035    }
2036
2037    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2038        self.cold_build_chunked(files, 0)
2039    }
2040
2041    pub fn cold_build_chunked(
2042        &self,
2043        files: &[PathBuf],
2044        chunk_size: usize,
2045    ) -> Result<ColdBuildStats> {
2046        let started = Instant::now();
2047        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2048        macro_rules! phase {
2049            ($label:expr, $t:expr) => {
2050                if bench {
2051                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2052                    let _ = std::io::Write::flush(&mut std::io::stderr());
2053                }
2054            };
2055        }
2056        let files = normalize_file_list(&self.project_root, files)?;
2057
2058        if chunk_size == 0 {
2059            let t = Instant::now();
2060            let build = build_extracts_parallel(&self.project_root, &files);
2061            phase!("extract_parallel", t);
2062            let extracts = build.extracts;
2063            let failures = build.failures;
2064            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2065
2066            let t = Instant::now();
2067            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2068            phase!("build_index", t);
2069            let t = Instant::now();
2070            let mut resolved_refs = Vec::new();
2071            for extract in &extracts {
2072                for raw_ref in &extract.raw_refs {
2073                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2074                }
2075            }
2076            phase!("resolve_refs", t);
2077            let ref_count = resolved_refs.len();
2078            let edge_count = resolved_refs
2079                .iter()
2080                .filter(|item| item.edge.is_some())
2081                .count();
2082
2083            let t = Instant::now();
2084            self.verify_writer_lease()?;
2085            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2086            let tx = conn.transaction()?;
2087            clear_tables(&tx)?;
2088            insert_meta(&tx)?;
2089            drop_cold_build_secondary_indexes(&tx)?;
2090            {
2091                let workspace_root = self.project_root.display().to_string();
2092                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2093                for extract in &extracts {
2094                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2095                }
2096                for failure in &failures {
2097                    insert_backend_state_prepared(
2098                        &mut inserts.backend_state,
2099                        &workspace_root,
2100                        &failure.rel_path,
2101                        failure
2102                            .freshness
2103                            .as_ref()
2104                            .map(|freshness| &freshness.content_hash),
2105                        "stale",
2106                    )?;
2107                }
2108                for resolved in &resolved_refs {
2109                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2110                }
2111            }
2112            create_cold_build_secondary_indexes(&tx)?;
2113            let supplemental_edge_count =
2114                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2115            set_meta_ready(&tx, true)?;
2116            tx.commit()?;
2117            phase!("sqlite_insert", t);
2118
2119            let elapsed_ms = started.elapsed().as_millis();
2120            crate::slog_info!(
2121                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2122                extracts.len(),
2123                node_count,
2124                ref_count,
2125                edge_count + supplemental_edge_count,
2126                elapsed_ms
2127            );
2128            return Ok(ColdBuildStats {
2129                files: extracts.len(),
2130                nodes: node_count,
2131                refs: ref_count,
2132                edges: edge_count + supplemental_edge_count,
2133                failed_files: failures
2134                    .into_iter()
2135                    .map(|failure| failure.rel_path)
2136                    .collect(),
2137                elapsed_ms,
2138            });
2139        }
2140
2141        // Chunked implementation: parse and resolve in batches to reduce peak
2142        // memory during cold build without changing the persisted graph.
2143        let t = Instant::now();
2144        self.verify_writer_lease()?;
2145        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2146        let tx = conn.transaction()?;
2147        clear_tables(&tx)?;
2148        insert_meta(&tx)?;
2149        drop_cold_build_secondary_indexes(&tx)?;
2150
2151        let mut all_raw_refs = Vec::new();
2152        let mut failures = Vec::new();
2153        let mut node_count = 0;
2154        let mut files_parsed = 0;
2155
2156        let mut persistent_call_data = Vec::new();
2157        let mut file_to_call_data_index = HashMap::new();
2158        let mut files_index = HashMap::new();
2159
2160        let workspace_root = self.project_root.display().to_string();
2161
2162        {
2163            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2164            for chunk in files.chunks(chunk_size) {
2165                let build = build_extracts_parallel(&self.project_root, chunk);
2166                failures.extend(build.failures.clone());
2167
2168                for extract in build.extracts {
2169                    files_parsed += 1;
2170                    node_count += extract.nodes.len();
2171                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2172
2173                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2174                    files_index.insert(extract.rel_path.clone(), db_file_index);
2175
2176                    persistent_call_data.push(extract.data);
2177                    let idx = persistent_call_data.len() - 1;
2178                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2179
2180                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2181                }
2182                for failure in &build.failures {
2183                    insert_backend_state_prepared(
2184                        &mut inserts.backend_state,
2185                        &workspace_root,
2186                        &failure.rel_path,
2187                        failure
2188                            .freshness
2189                            .as_ref()
2190                            .map(|freshness| &freshness.content_hash),
2191                        "stale",
2192                    )?;
2193                }
2194            }
2195        }
2196
2197        let mut caller_data = HashMap::new();
2198        for (rel_path, idx) in &file_to_call_data_index {
2199            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2200        }
2201        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2202        let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
2203
2204        let mut resolved_refs = Vec::new();
2205        for (_, raw_refs) in all_raw_refs {
2206            for raw_ref in raw_refs {
2207                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2208            }
2209        }
2210
2211        let ref_count = resolved_refs.len();
2212        let edge_count = resolved_refs
2213            .iter()
2214            .filter(|item| item.edge.is_some())
2215            .count();
2216
2217        {
2218            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2219            for resolved in &resolved_refs {
2220                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2221            }
2222        }
2223        create_cold_build_secondary_indexes(&tx)?;
2224        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2225            &tx,
2226            &self.project_root,
2227            &indexed_caller_files,
2228            chunk_size,
2229        )?;
2230        set_meta_ready(&tx, true)?;
2231        tx.commit()?;
2232        phase!("sqlite_insert", t);
2233
2234        let elapsed_ms = started.elapsed().as_millis();
2235        crate::slog_info!(
2236            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2237            files_parsed,
2238            node_count,
2239            ref_count,
2240            edge_count + supplemental_edge_count,
2241            elapsed_ms
2242        );
2243        Ok(ColdBuildStats {
2244            files: files_parsed,
2245            nodes: node_count,
2246            refs: ref_count,
2247            edges: edge_count + supplemental_edge_count,
2248            failed_files: failures
2249                .into_iter()
2250                .map(|failure| failure.rel_path)
2251                .collect(),
2252            elapsed_ms,
2253        })
2254    }
2255
2256    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2257        let (stats, profile) = self.refresh_files_profiled(changed_files)?;
2258        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2259            eprintln!("refresh_files phases: {}", profile.report());
2260        }
2261        Ok(stats)
2262    }
2263
2264    /// Run an incremental refresh and return phase timings for an offline store copy.
2265    #[doc(hidden)]
2266    pub fn refresh_files_profiled(
2267        &self,
2268        changed_files: &[PathBuf],
2269    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2270        let total_started = Instant::now();
2271        let mut profile = RefreshFilesProfile::default();
2272        self.verify_writer_lease()?;
2273        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2274        let tx = conn.transaction()?;
2275        ensure_database_ready(&tx)?;
2276        let mut changed = Vec::new();
2277        let mut surface_changed = BTreeSet::new();
2278        let mut deleted = BTreeSet::new();
2279        let mut own_refresh = BTreeSet::new();
2280        let mut selected_ref_ids = BTreeSet::new();
2281        let mut selected_refs_by_caller = BTreeMap::new();
2282        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2283
2284        for input in changed_files {
2285            let abs_path = normalize_file_path(&self.project_root, input)?;
2286            let rel_path = relative_path(&self.project_root, &abs_path);
2287            changed.push(rel_path.clone());
2288            let old_row = load_file_row(&tx, &rel_path)?;
2289            if !abs_path.exists() {
2290                if old_row.is_some() {
2291                    surface_changed.insert(rel_path.clone());
2292                    deleted.insert(rel_path.clone());
2293                    let started = Instant::now();
2294                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2295                    profile.dependency_selection += started.elapsed();
2296                    record_dependent_refs(
2297                        &mut selected_ref_ids,
2298                        &mut selected_refs_by_caller,
2299                        dependent_refs,
2300                    );
2301                    let started = Instant::now();
2302                    delete_file_rows(&tx, &rel_path)?;
2303                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2304                    profile.row_deletes += started.elapsed();
2305                }
2306                continue;
2307            }
2308
2309            if let Some(row) = &old_row {
2310                match cache_freshness::verify_file(&abs_path, &row.freshness) {
2311                    FreshnessVerdict::HotFresh => continue,
2312                    FreshnessVerdict::ContentFresh {
2313                        new_mtime,
2314                        new_size,
2315                    } => {
2316                        update_file_fresh_metadata(
2317                            &tx,
2318                            &rel_path,
2319                            &row.freshness.content_hash,
2320                            new_mtime,
2321                            new_size,
2322                        )?;
2323                        continue;
2324                    }
2325                    FreshnessVerdict::Deleted => {
2326                        surface_changed.insert(rel_path.clone());
2327                        deleted.insert(rel_path.clone());
2328                        let started = Instant::now();
2329                        let dependent_refs =
2330                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2331                        profile.dependency_selection += started.elapsed();
2332                        record_dependent_refs(
2333                            &mut selected_ref_ids,
2334                            &mut selected_refs_by_caller,
2335                            dependent_refs,
2336                        );
2337                        let started = Instant::now();
2338                        delete_file_rows(&tx, &rel_path)?;
2339                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2340                        profile.row_deletes += started.elapsed();
2341                        continue;
2342                    }
2343                    FreshnessVerdict::Stale => {}
2344                }
2345            }
2346
2347            let started = Instant::now();
2348            let extract = build_file_extract(&self.project_root, &abs_path)?;
2349            profile.parse += started.elapsed();
2350            let surface_is_changed = old_row
2351                .as_ref()
2352                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2353                .unwrap_or(true);
2354            if surface_is_changed {
2355                surface_changed.insert(rel_path.clone());
2356                let started = Instant::now();
2357                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2358                profile.dependency_selection += started.elapsed();
2359                record_dependent_refs(
2360                    &mut selected_ref_ids,
2361                    &mut selected_refs_by_caller,
2362                    dependent_refs,
2363                );
2364            }
2365            own_refresh.insert(rel_path.clone());
2366            let started = Instant::now();
2367            delete_file_rows(&tx, &rel_path)?;
2368            profile.row_deletes += started.elapsed();
2369            let started = Instant::now();
2370            insert_file_extract(&tx, &self.project_root, &extract)?;
2371            profile.row_inserts += started.elapsed();
2372            changed_extracts.insert(rel_path, extract);
2373        }
2374
2375        let dependency_selected_refs = selected_ref_ids.len();
2376        let mut touched_callers: BTreeSet<String> =
2377            selected_refs_by_caller.keys().cloned().collect();
2378        touched_callers.extend(own_refresh.iter().cloned());
2379
2380        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2381        for rel_path in &touched_callers {
2382            if deleted.contains(rel_path) {
2383                continue;
2384            }
2385            if let Some(extract) = changed_extracts.get(rel_path) {
2386                caller_extracts.insert(rel_path.clone(), extract.clone());
2387                continue;
2388            }
2389            let abs_path = self.project_root.join(rel_path);
2390            if abs_path.exists() {
2391                let started = Instant::now();
2392                let extract = build_file_extract(&self.project_root, &abs_path)?;
2393                profile.dependent_parse += started.elapsed();
2394                caller_extracts.insert(rel_path.clone(), extract);
2395            }
2396        }
2397
2398        let dependency_callers = touched_callers
2399            .iter()
2400            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2401            .cloned()
2402            .collect::<Vec<_>>();
2403        for rel_path in dependency_callers {
2404            let Some(extract) = caller_extracts.get(&rel_path) else {
2405                continue;
2406            };
2407            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2408                continue;
2409            }
2410
2411            own_refresh.insert(rel_path.clone());
2412            let started = Instant::now();
2413            delete_file_rows(&tx, &rel_path)?;
2414            profile.row_deletes += started.elapsed();
2415            let started = Instant::now();
2416            insert_file_extract(&tx, &self.project_root, extract)?;
2417            profile.row_inserts += started.elapsed();
2418        }
2419
2420        let started = Instant::now();
2421        let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
2422        profile.index_load += started.elapsed();
2423        let started = Instant::now();
2424        for rel_path in &touched_callers {
2425            if deleted.contains(rel_path) {
2426                continue;
2427            }
2428            let Some(extract) = caller_extracts.get(rel_path) else {
2429                continue;
2430            };
2431            if own_refresh.contains(rel_path) {
2432                delete_refs_for_caller(&tx, rel_path)?;
2433                for raw_ref in &extract.raw_refs {
2434                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2435                    insert_resolved_ref(&tx, &resolved)?;
2436                }
2437                continue;
2438            }
2439
2440            let selected_for_caller = selected_refs_by_caller
2441                .get(rel_path)
2442                .cloned()
2443                .unwrap_or_default();
2444            delete_ref_ids(&tx, &selected_for_caller)?;
2445            for raw_ref in &extract.raw_refs {
2446                if selected_for_caller.contains(&raw_ref.ref_id) {
2447                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2448                    insert_resolved_ref(&tx, &resolved)?;
2449                }
2450            }
2451        }
2452        profile.ref_resolution += started.elapsed();
2453
2454        let started = Instant::now();
2455        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2456        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2457        profile.method_dispatch += started.elapsed();
2458
2459        let started = Instant::now();
2460        commit_incremental_if_current(tx)?;
2461        profile.commit += started.elapsed();
2462        profile.total = total_started.elapsed();
2463        Ok((
2464            IncrementalStats {
2465                changed_files: changed,
2466                surface_changed: surface_changed.into_iter().collect(),
2467                deleted_files: deleted.into_iter().collect(),
2468                dependency_selected_refs,
2469                refreshed_own_files: own_refresh.len(),
2470            },
2471            profile,
2472        ))
2473    }
2474
2475    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2476        self.cold_build(current_files)
2477    }
2478
2479    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2480        self.verify_writer_lease()?;
2481        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2482        let tx = conn.transaction()?;
2483        let mut marked = Vec::new();
2484        for path in files {
2485            let abs_path = normalize_file_path(&self.project_root, path)?;
2486            let rel_path = relative_path(&self.project_root, &abs_path);
2487            let freshness = cache_freshness::collect(&abs_path).ok();
2488            mark_backend_state(
2489                &tx,
2490                &self.project_root,
2491                &rel_path,
2492                freshness.as_ref().map(|freshness| &freshness.content_hash),
2493                "stale",
2494            )?;
2495            marked.push(rel_path);
2496        }
2497        tx.commit()?;
2498        marked.sort();
2499        marked.dedup();
2500        Ok(marked)
2501    }
2502
2503    pub fn stale_files(&self) -> Result<Vec<String>> {
2504        self.refresh_read_marker()?;
2505        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2506        let mut stmt = conn.prepare(
2507            "SELECT DISTINCT file_path FROM backend_file_state
2508             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2509             ORDER BY file_path",
2510        )?;
2511        let rows = stmt.query_map(
2512            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2513            |row| row.get::<_, String>(0),
2514        )?;
2515        rows.collect::<std::result::Result<Vec<_>, _>>()
2516            .map_err(Into::into)
2517    }
2518
2519    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2520        self.refresh_read_marker()?;
2521        let rel_path = relative_path(
2522            &self.project_root,
2523            &normalize_file_path(&self.project_root, file)?,
2524        );
2525        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2526        conn.query_row(
2527            "SELECT status FROM backend_file_state
2528             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2529             ORDER BY updated_at DESC LIMIT 1",
2530            params![
2531                BACKEND_TREESITTER,
2532                self.project_root.display().to_string(),
2533                rel_path
2534            ],
2535            |row| row.get(0),
2536        )
2537        .optional()
2538        .map_err(Into::into)
2539    }
2540
2541    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2542        self.refresh_read_marker()?;
2543        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2544        ensure_database_ready(&conn)?;
2545        edge_snapshot_with_conn(&conn)
2546    }
2547
2548    pub fn indexed_file_count(&self) -> Result<usize> {
2549        self.refresh_read_marker()?;
2550        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2551        ensure_database_ready(&conn)?;
2552        indexed_file_count(&conn)
2553    }
2554
2555    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2556        self.refresh_read_marker()?;
2557        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2558        let rel_path = relative_path(&self.project_root, &abs_path);
2559        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2560        ensure_database_ready(&conn)?;
2561        resolve_node_for_rel(&conn, &rel_path, symbol)
2562    }
2563
2564    /// Return all positional nodes matching a legacy symbol query in a file.
2565    ///
2566    /// Consumers that need legacy compatibility can collapse these by
2567    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
2568    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2569        self.refresh_read_marker()?;
2570        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2571        let rel_path = relative_path(&self.project_root, &abs_path);
2572        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2573        ensure_database_ready(&conn)?;
2574        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2575    }
2576
2577    /// Return all positional nodes matching a symbol query anywhere in the store.
2578    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2579        self.refresh_read_marker()?;
2580        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2581        ensure_database_ready(&conn)?;
2582        nodes_matching_symbol(&conn, symbol)
2583    }
2584
2585    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
2586    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2587        self.refresh_read_marker()?;
2588        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2589        let rel_path = relative_path(&self.project_root, &abs_path);
2590        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2591        ensure_database_ready(&conn)?;
2592        direct_callers_for_tuple(&conn, &rel_path, symbol)
2593    }
2594
2595    pub fn callers_of(
2596        &self,
2597        file_rel: &Path,
2598        symbol: &str,
2599        depth: usize,
2600    ) -> Result<StoreCallersResult> {
2601        let target = self.node_for(file_rel, symbol)?;
2602        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2603        ensure_database_ready(&conn)?;
2604        let effective_depth = depth.max(1);
2605        let mut visited = HashSet::new();
2606        let mut callers = Vec::new();
2607        let mut depth_limited = false;
2608        let mut truncated = 0usize;
2609        collect_callers_recursive(
2610            &conn,
2611            &target.file,
2612            &target.symbol,
2613            effective_depth,
2614            0,
2615            &mut visited,
2616            &mut callers,
2617            &mut depth_limited,
2618            &mut truncated,
2619        )?;
2620        Ok(StoreCallersResult {
2621            target,
2622            callers,
2623            scanned_files: indexed_file_count(&conn)?,
2624            depth_limited,
2625            truncated,
2626        })
2627    }
2628
2629    pub fn impact_of(
2630        &self,
2631        file_rel: &Path,
2632        symbol: &str,
2633        depth: usize,
2634    ) -> Result<StoreImpactResult> {
2635        let callers = self.callers_of(file_rel, symbol, depth)?;
2636        let target_parameters = callers
2637            .target
2638            .signature
2639            .as_deref()
2640            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2641            .unwrap_or_default();
2642        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2643        for site in &callers.callers {
2644            source_lines_by_file
2645                .entry(site.caller.file.clone())
2646                .or_insert_with(|| {
2647                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2648                });
2649        }
2650        let enriched = callers
2651            .callers
2652            .iter()
2653            .map(|site| StoreImpactCaller {
2654                site: site.clone(),
2655                signature: site.caller.signature.clone(),
2656                is_entry_point: site.caller.is_entry_point,
2657                call_expression: source_lines_by_file
2658                    .get(&site.caller.file)
2659                    .and_then(|lines| lines.as_ref())
2660                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2661                    .cloned(),
2662                parameters: site
2663                    .caller
2664                    .signature
2665                    .as_deref()
2666                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2667                    .unwrap_or_default(),
2668            })
2669            .collect();
2670        Ok(StoreImpactResult {
2671            target: callers.target,
2672            parameters: target_parameters,
2673            callers: enriched,
2674            depth_limited: callers.depth_limited,
2675            truncated: callers.truncated,
2676        })
2677    }
2678
2679    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2680        self.refresh_read_marker()?;
2681        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2682        ensure_database_ready(&conn)?;
2683        outgoing_calls_for_node(&conn, node)
2684    }
2685
2686    /// Return resolved direct self-call refs suppressed from the general edge table.
2687    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2688        self.refresh_read_marker()?;
2689        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2690        ensure_database_ready(&conn)?;
2691        resolved_self_calls_for_node(&conn, node)
2692    }
2693
2694    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
2695        self.refresh_read_marker()?;
2696        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2697        ensure_database_ready(&conn)?;
2698        unresolved_calls_for_node(&conn, node)
2699    }
2700
2701    pub fn call_tree(
2702        &self,
2703        file_rel: &Path,
2704        symbol: &str,
2705        max_depth: usize,
2706    ) -> Result<callgraph::CallTreeNode> {
2707        let node = self.node_for(file_rel, symbol)?;
2708        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2709        ensure_database_ready(&conn)?;
2710        let mut visited = HashSet::new();
2711        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
2712    }
2713
2714    pub fn trace_to(
2715        &self,
2716        file_rel: &Path,
2717        symbol: &str,
2718        max_depth: usize,
2719    ) -> Result<callgraph::TraceToResult> {
2720        let target = self.node_for(file_rel, symbol)?;
2721        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2722        ensure_database_ready(&conn)?;
2723        let effective_max = if max_depth == 0 { 10 } else { max_depth };
2724
2725        #[derive(Clone)]
2726        struct PathElem {
2727            node: StoreNode,
2728        }
2729
2730        let initial = vec![PathElem {
2731            node: target.clone(),
2732        }];
2733        let mut complete_paths = Vec::new();
2734        if target.is_entry_point {
2735            complete_paths.push(initial.clone());
2736        }
2737
2738        let mut queue = vec![(initial, 0usize)];
2739        let mut max_depth_reached = false;
2740        let mut truncated_paths = 0usize;
2741
2742        while let Some((path, depth)) = queue.pop() {
2743            if depth >= effective_max {
2744                max_depth_reached = true;
2745                continue;
2746            }
2747            let Some(current) = path.last() else {
2748                continue;
2749            };
2750            let callers =
2751                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
2752            if callers.is_empty() {
2753                if path.len() > 1 {
2754                    truncated_paths += 1;
2755                }
2756                continue;
2757            }
2758
2759            let mut has_new_path = false;
2760            for site in callers {
2761                if path.iter().any(|elem| {
2762                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
2763                }) {
2764                    continue;
2765                }
2766                has_new_path = true;
2767                let mut new_path = path.clone();
2768                new_path.push(PathElem {
2769                    node: site.caller.clone(),
2770                });
2771                if site.caller.is_entry_point {
2772                    complete_paths.push(new_path.clone());
2773                }
2774                queue.push((new_path, depth + 1));
2775            }
2776            if !has_new_path && path.len() > 1 {
2777                truncated_paths += 1;
2778            }
2779        }
2780
2781        let mut paths: Vec<callgraph::TracePath> = complete_paths
2782            .into_iter()
2783            .map(|mut elems| {
2784                elems.reverse();
2785                let hops = elems
2786                    .iter()
2787                    .enumerate()
2788                    .map(|(index, elem)| callgraph::TraceHop {
2789                        symbol: elem.node.symbol.clone(),
2790                        file: elem.node.file.clone(),
2791                        line: elem.node.line,
2792                        signature: elem.node.signature.clone(),
2793                        is_entry_point: index == 0 && elem.node.is_entry_point,
2794                    })
2795                    .collect();
2796                callgraph::TracePath { hops }
2797            })
2798            .collect();
2799        paths.sort_by(|left, right| {
2800            let left_entry = left
2801                .hops
2802                .first()
2803                .map(|hop| hop.symbol.as_str())
2804                .unwrap_or("");
2805            let right_entry = right
2806                .hops
2807                .first()
2808                .map(|hop| hop.symbol.as_str())
2809                .unwrap_or("");
2810            left_entry
2811                .cmp(right_entry)
2812                .then(left.hops.len().cmp(&right.hops.len()))
2813        });
2814        let entry_points_found = paths
2815            .iter()
2816            .filter_map(|path| path.hops.first())
2817            .filter(|hop| hop.is_entry_point)
2818            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
2819            .collect::<HashSet<_>>()
2820            .len();
2821
2822        Ok(callgraph::TraceToResult {
2823            target_symbol: target.symbol,
2824            target_file: target.file,
2825            total_paths: paths.len(),
2826            paths,
2827            entry_points_found,
2828            max_depth_reached,
2829            truncated_paths,
2830        })
2831    }
2832
2833    pub fn trace_to_symbol_candidates(
2834        &self,
2835        to_symbol: &str,
2836    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
2837        self.refresh_read_marker()?;
2838        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2839        ensure_database_ready(&conn)?;
2840        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
2841        for node in nodes_matching_symbol(&conn, to_symbol)? {
2842            candidates_by_file
2843                .entry(node.file)
2844                .and_modify(|line| *line = (*line).min(node.line))
2845                .or_insert(node.line);
2846        }
2847        let mut candidates: Vec<_> = candidates_by_file
2848            .into_iter()
2849            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
2850            .collect();
2851        candidates
2852            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
2853        Ok(candidates)
2854    }
2855
2856    pub fn trace_to_symbol(
2857        &self,
2858        file_rel: &Path,
2859        symbol: &str,
2860        to_symbol: &str,
2861        to_file: Option<&Path>,
2862        max_depth: usize,
2863    ) -> Result<callgraph::TraceToSymbolResult> {
2864        let origin = self.node_for(file_rel, symbol)?;
2865        let target_file = to_file
2866            .map(|path| normalize_file_path(&self.project_root, path))
2867            .transpose()?
2868            .map(|path| relative_path(&self.project_root, &path));
2869        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2870        ensure_database_ready(&conn)?;
2871        let effective_max = if max_depth == 0 {
2872            10
2873        } else {
2874            max_depth.min(16)
2875        };
2876
2877        let start_hop = trace_to_symbol_hop(&origin);
2878        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
2879            return Ok(callgraph::TraceToSymbolResult {
2880                path: Some(vec![start_hop]),
2881                complete: true,
2882                reason: None,
2883            });
2884        }
2885
2886        let mut queue = VecDeque::new();
2887        queue.push_back((origin.clone(), vec![start_hop], 0usize));
2888        let mut visited = HashSet::new();
2889        visited.insert((origin.file.clone(), origin.symbol.clone()));
2890        let mut max_depth_exhausted = false;
2891
2892        while let Some((current, path, depth)) = queue.pop_front() {
2893            let callees = outgoing_calls_for_node(&conn, &current)?
2894                .into_iter()
2895                .filter_map(|site| site.target)
2896                .collect::<Vec<_>>();
2897
2898            if depth >= effective_max {
2899                if callees
2900                    .iter()
2901                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
2902                {
2903                    max_depth_exhausted = true;
2904                }
2905                continue;
2906            }
2907
2908            for callee in callees {
2909                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
2910                    continue;
2911                }
2912                let mut next_path = path.clone();
2913                next_path.push(trace_to_symbol_hop(&callee));
2914                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
2915                    return Ok(callgraph::TraceToSymbolResult {
2916                        path: Some(next_path),
2917                        complete: true,
2918                        reason: None,
2919                    });
2920                }
2921                queue.push_back((callee, next_path, depth + 1));
2922            }
2923        }
2924
2925        if max_depth_exhausted {
2926            Ok(callgraph::TraceToSymbolResult {
2927                path: None,
2928                complete: false,
2929                reason: Some("max_depth_exhausted".to_string()),
2930            })
2931        } else {
2932            Ok(callgraph::TraceToSymbolResult {
2933                path: None,
2934                complete: true,
2935                reason: Some("no_path_found".to_string()),
2936            })
2937        }
2938    }
2939}
2940
2941impl ReadonlyCallGraphStore {
2942    fn from_inner(inner: CallGraphStore) -> Self {
2943        Self { inner }
2944    }
2945
2946    fn into_inner(self) -> CallGraphStore {
2947        self.inner
2948    }
2949
2950    pub fn project_root(&self) -> &Path {
2951        self.inner.project_root()
2952    }
2953
2954    pub fn project_key(&self) -> &str {
2955        self.inner.project_key()
2956    }
2957
2958    pub fn sqlite_path(&self) -> &Path {
2959        self.inner.sqlite_path()
2960    }
2961
2962    /// Report the open generation handle. SQLite-owned allocations are measured
2963    /// once by the process-wide SQLite allocator counters.
2964    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2965        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
2966    }
2967
2968    /// Whether this reader is temporarily serving a legacy harness partition.
2969    pub fn is_legacy_fallback(&self) -> bool {
2970        self.inner.is_legacy_fallback()
2971    }
2972
2973    pub fn is_current(&self) -> bool {
2974        self.inner.is_current()
2975    }
2976
2977    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2978        self.inner.edge_snapshot()
2979    }
2980
2981    pub fn indexed_file_count(&self) -> Result<usize> {
2982        self.inner.indexed_file_count()
2983    }
2984
2985    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2986        self.inner.node_for(file_rel, symbol)
2987    }
2988
2989    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2990        self.inner.nodes_for(file_rel, symbol)
2991    }
2992
2993    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2994        self.inner.nodes_matching(symbol)
2995    }
2996
2997    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2998        self.inner.direct_callers_of(file_rel, symbol)
2999    }
3000
3001    pub fn callers_of(
3002        &self,
3003        file_rel: &Path,
3004        symbol: &str,
3005        depth: usize,
3006    ) -> Result<StoreCallersResult> {
3007        self.inner.callers_of(file_rel, symbol, depth)
3008    }
3009
3010    pub fn impact_of(
3011        &self,
3012        file_rel: &Path,
3013        symbol: &str,
3014        depth: usize,
3015    ) -> Result<StoreImpactResult> {
3016        self.inner.impact_of(file_rel, symbol, depth)
3017    }
3018
3019    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3020        self.inner.outgoing_calls_of(node)
3021    }
3022
3023    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3024        self.inner.resolved_self_calls_of(node)
3025    }
3026
3027    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3028        self.inner.unresolved_calls_of(node)
3029    }
3030
3031    pub fn call_tree(
3032        &self,
3033        file_rel: &Path,
3034        symbol: &str,
3035        depth: usize,
3036    ) -> Result<callgraph::CallTreeNode> {
3037        self.inner.call_tree(file_rel, symbol, depth)
3038    }
3039
3040    pub fn trace_to(
3041        &self,
3042        file_rel: &Path,
3043        symbol: &str,
3044        max_depth: usize,
3045    ) -> Result<callgraph::TraceToResult> {
3046        self.inner.trace_to(file_rel, symbol, max_depth)
3047    }
3048
3049    pub fn trace_to_symbol_candidates(
3050        &self,
3051        to_symbol: &str,
3052    ) -> Result<Vec<TraceToSymbolCandidate>> {
3053        self.inner.trace_to_symbol_candidates(to_symbol)
3054    }
3055
3056    pub fn trace_to_symbol(
3057        &self,
3058        file_rel: &Path,
3059        symbol: &str,
3060        to_symbol: &str,
3061        to_file: Option<&Path>,
3062        max_depth: usize,
3063    ) -> Result<callgraph::TraceToSymbolResult> {
3064        self.inner
3065            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3066    }
3067}
3068
3069impl CallGraphRead for CallGraphStore {
3070    fn project_root(&self) -> &Path {
3071        CallGraphStore::project_root(self)
3072    }
3073    fn project_key(&self) -> &str {
3074        CallGraphStore::project_key(self)
3075    }
3076    fn sqlite_path(&self) -> &Path {
3077        CallGraphStore::sqlite_path(self)
3078    }
3079    fn is_current(&self) -> bool {
3080        CallGraphStore::is_current(self)
3081    }
3082    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3083        CallGraphStore::edge_snapshot(self)
3084    }
3085    fn indexed_file_count(&self) -> Result<usize> {
3086        CallGraphStore::indexed_file_count(self)
3087    }
3088    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3089        CallGraphStore::node_for(self, file_rel, symbol)
3090    }
3091    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3092        CallGraphStore::nodes_for(self, file_rel, symbol)
3093    }
3094    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3095        CallGraphStore::nodes_matching(self, symbol)
3096    }
3097    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3098        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3099    }
3100    fn callers_of(
3101        &self,
3102        file_rel: &Path,
3103        symbol: &str,
3104        depth: usize,
3105    ) -> Result<StoreCallersResult> {
3106        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3107    }
3108    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3109        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3110    }
3111    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3112        CallGraphStore::outgoing_calls_of(self, node)
3113    }
3114    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3115        CallGraphStore::resolved_self_calls_of(self, node)
3116    }
3117    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3118        CallGraphStore::unresolved_calls_of(self, node)
3119    }
3120    fn call_tree(
3121        &self,
3122        file_rel: &Path,
3123        symbol: &str,
3124        depth: usize,
3125    ) -> Result<callgraph::CallTreeNode> {
3126        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3127    }
3128    fn trace_to(
3129        &self,
3130        file_rel: &Path,
3131        symbol: &str,
3132        max_depth: usize,
3133    ) -> Result<callgraph::TraceToResult> {
3134        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3135    }
3136    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3137        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3138    }
3139    fn trace_to_symbol(
3140        &self,
3141        file_rel: &Path,
3142        symbol: &str,
3143        to_symbol: &str,
3144        to_file: Option<&Path>,
3145        max_depth: usize,
3146    ) -> Result<callgraph::TraceToSymbolResult> {
3147        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3148    }
3149}
3150
3151impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3152    fn project_root(&self) -> &Path {
3153        (**self).project_root()
3154    }
3155    fn project_key(&self) -> &str {
3156        (**self).project_key()
3157    }
3158    fn sqlite_path(&self) -> &Path {
3159        (**self).sqlite_path()
3160    }
3161    fn is_current(&self) -> bool {
3162        (**self).is_current()
3163    }
3164    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3165        (**self).edge_snapshot()
3166    }
3167    fn indexed_file_count(&self) -> Result<usize> {
3168        (**self).indexed_file_count()
3169    }
3170    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3171        (**self).node_for(file_rel, symbol)
3172    }
3173    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3174        (**self).nodes_for(file_rel, symbol)
3175    }
3176    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3177        (**self).nodes_matching(symbol)
3178    }
3179    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3180        (**self).direct_callers_of(file_rel, symbol)
3181    }
3182    fn callers_of(
3183        &self,
3184        file_rel: &Path,
3185        symbol: &str,
3186        depth: usize,
3187    ) -> Result<StoreCallersResult> {
3188        (**self).callers_of(file_rel, symbol, depth)
3189    }
3190    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3191        (**self).impact_of(file_rel, symbol, depth)
3192    }
3193    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3194        (**self).outgoing_calls_of(node)
3195    }
3196    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3197        (**self).resolved_self_calls_of(node)
3198    }
3199    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3200        (**self).unresolved_calls_of(node)
3201    }
3202    fn call_tree(
3203        &self,
3204        file_rel: &Path,
3205        symbol: &str,
3206        depth: usize,
3207    ) -> Result<callgraph::CallTreeNode> {
3208        (**self).call_tree(file_rel, symbol, depth)
3209    }
3210    fn trace_to(
3211        &self,
3212        file_rel: &Path,
3213        symbol: &str,
3214        max_depth: usize,
3215    ) -> Result<callgraph::TraceToResult> {
3216        (**self).trace_to(file_rel, symbol, max_depth)
3217    }
3218    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3219        (**self).trace_to_symbol_candidates(to_symbol)
3220    }
3221    fn trace_to_symbol(
3222        &self,
3223        file_rel: &Path,
3224        symbol: &str,
3225        to_symbol: &str,
3226        to_file: Option<&Path>,
3227        max_depth: usize,
3228    ) -> Result<callgraph::TraceToSymbolResult> {
3229        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3230    }
3231}
3232
3233impl CallGraphRead for ReadonlyCallGraphStore {
3234    fn project_root(&self) -> &Path {
3235        self.project_root()
3236    }
3237    fn project_key(&self) -> &str {
3238        self.project_key()
3239    }
3240    fn sqlite_path(&self) -> &Path {
3241        self.sqlite_path()
3242    }
3243    fn is_current(&self) -> bool {
3244        self.is_current()
3245    }
3246    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3247        self.edge_snapshot()
3248    }
3249    fn indexed_file_count(&self) -> Result<usize> {
3250        self.indexed_file_count()
3251    }
3252    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3253        self.node_for(file_rel, symbol)
3254    }
3255    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3256        self.nodes_for(file_rel, symbol)
3257    }
3258    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3259        self.nodes_matching(symbol)
3260    }
3261    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3262        self.direct_callers_of(file_rel, symbol)
3263    }
3264    fn callers_of(
3265        &self,
3266        file_rel: &Path,
3267        symbol: &str,
3268        depth: usize,
3269    ) -> Result<StoreCallersResult> {
3270        self.callers_of(file_rel, symbol, depth)
3271    }
3272    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3273        self.impact_of(file_rel, symbol, depth)
3274    }
3275    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3276        self.outgoing_calls_of(node)
3277    }
3278    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3279        self.resolved_self_calls_of(node)
3280    }
3281    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3282        self.unresolved_calls_of(node)
3283    }
3284    fn call_tree(
3285        &self,
3286        file_rel: &Path,
3287        symbol: &str,
3288        depth: usize,
3289    ) -> Result<callgraph::CallTreeNode> {
3290        self.call_tree(file_rel, symbol, depth)
3291    }
3292    fn trace_to(
3293        &self,
3294        file_rel: &Path,
3295        symbol: &str,
3296        max_depth: usize,
3297    ) -> Result<callgraph::TraceToResult> {
3298        self.trace_to(file_rel, symbol, max_depth)
3299    }
3300    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3301        self.trace_to_symbol_candidates(to_symbol)
3302    }
3303    fn trace_to_symbol(
3304        &self,
3305        file_rel: &Path,
3306        symbol: &str,
3307        to_symbol: &str,
3308        to_file: Option<&Path>,
3309        max_depth: usize,
3310    ) -> Result<callgraph::TraceToSymbolResult> {
3311        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3312    }
3313}
3314
3315fn indexed_file_count(conn: &Connection) -> Result<usize> {
3316    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3317    Ok(count.max(0) as usize)
3318}
3319
3320fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3321    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3322    match candidates.as_slice() {
3323        [candidate] => Ok(candidate.clone()),
3324        [] => Err(AftError::SymbolNotFound {
3325            name: symbol.to_string(),
3326            file: rel_path.to_string(),
3327        }
3328        .into()),
3329        _ => Err(AftError::AmbiguousSymbol {
3330            name: symbol.to_string(),
3331            candidates: candidates
3332                .iter()
3333                .map(|candidate| candidate.symbol.clone())
3334                .collect(),
3335        }
3336        .into()),
3337    }
3338}
3339
3340fn nodes_for_file_matching_symbol(
3341    conn: &Connection,
3342    rel_path: &str,
3343    symbol: &str,
3344) -> Result<Vec<StoreNode>> {
3345    let qualified_query = symbol.contains("::");
3346    let sql = if qualified_query {
3347        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3348                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3349         FROM nodes n JOIN files f ON f.path = n.file_path
3350         WHERE n.file_path = ?1 AND n.scoped_name = ?2
3351         ORDER BY n.scoped_name, n.start_line, n.start_col"
3352    } else {
3353        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3354                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3355         FROM nodes n JOIN files f ON f.path = n.file_path
3356         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3357         ORDER BY n.scoped_name, n.start_line, n.start_col"
3358    };
3359    let mut stmt = conn.prepare(sql)?;
3360    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3361    rows.collect::<std::result::Result<Vec<_>, _>>()
3362        .map_err(Into::into)
3363}
3364
3365fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3366    let qualified_query = symbol.contains("::");
3367    let sql = if qualified_query {
3368        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3369                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3370         FROM nodes n JOIN files f ON f.path = n.file_path
3371         WHERE n.scoped_name = ?1
3372         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3373    } else {
3374        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3375                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3376         FROM nodes n JOIN files f ON f.path = n.file_path
3377         WHERE n.scoped_name = ?1 OR n.name = ?1
3378         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3379    };
3380    let mut stmt = conn.prepare(sql)?;
3381    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3382    rows.collect::<std::result::Result<Vec<_>, _>>()
3383        .map_err(Into::into)
3384}
3385
3386fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3387    store_node_from_row_at(row, 0)
3388}
3389
3390fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3391    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3392    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3393    let lang_label_value: String = row.get(offset + 10)?;
3394    Ok(StoreNode {
3395        node_id: row.get(offset)?,
3396        file: row.get(offset + 1)?,
3397        symbol: row.get(offset + 2)?,
3398        name: row.get(offset + 3)?,
3399        kind: row.get(offset + 4)?,
3400        line: start_line.saturating_add(1),
3401        end_line: end_line.saturating_add(1),
3402        signature: row.get(offset + 7)?,
3403        exported: row.get::<_, i64>(offset + 8)? != 0,
3404        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3405        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3406    })
3407}
3408
3409fn optional_store_node_from_row_at(
3410    row: &rusqlite::Row<'_>,
3411    offset: usize,
3412) -> rusqlite::Result<Option<StoreNode>> {
3413    if row.get::<_, Option<String>>(offset)?.is_some() {
3414        store_node_from_row_at(row, offset).map(Some)
3415    } else {
3416        Ok(None)
3417    }
3418}
3419
3420#[allow(clippy::too_many_arguments)]
3421fn collect_callers_recursive(
3422    conn: &Connection,
3423    file: &str,
3424    symbol: &str,
3425    max_depth: usize,
3426    current_depth: usize,
3427    visited: &mut HashSet<(String, String)>,
3428    result: &mut Vec<StoreCallSite>,
3429    depth_limited: &mut bool,
3430    truncated: &mut usize,
3431) -> Result<()> {
3432    if current_depth >= max_depth {
3433        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3434        if omitted > 0 {
3435            *depth_limited = true;
3436            *truncated += omitted;
3437        }
3438        return Ok(());
3439    }
3440
3441    if !visited.insert((file.to_string(), symbol.to_string())) {
3442        return Ok(());
3443    }
3444
3445    let sites = direct_callers_for_tuple(conn, file, symbol)?;
3446    for site in sites {
3447        result.push(site.clone());
3448        if current_depth + 1 < max_depth {
3449            collect_callers_recursive(
3450                conn,
3451                &site.caller.file,
3452                &site.caller.symbol,
3453                max_depth,
3454                current_depth + 1,
3455                visited,
3456                result,
3457                depth_limited,
3458                truncated,
3459            )?;
3460        } else {
3461            let omitted =
3462                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3463            if omitted > 0 {
3464                *depth_limited = true;
3465                *truncated += omitted;
3466            }
3467        }
3468    }
3469    Ok(())
3470}
3471
3472fn direct_caller_count_for_tuple(
3473    conn: &Connection,
3474    target_file: &str,
3475    target_symbol: &str,
3476) -> Result<usize> {
3477    let count: i64 = conn.query_row(
3478        "SELECT COUNT(*)
3479         FROM edges e
3480         JOIN refs r ON r.ref_id = e.ref_id
3481         JOIN nodes src ON src.id = e.source_node
3482         JOIN files src_file ON src_file.path = src.file_path
3483         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3484        params![target_file, target_symbol],
3485        |row| row.get(0),
3486    )?;
3487    Ok(usize::try_from(count).unwrap_or(usize::MAX))
3488}
3489
3490fn direct_callers_for_tuple(
3491    conn: &Connection,
3492    target_file: &str,
3493    target_symbol: &str,
3494) -> Result<Vec<StoreCallSite>> {
3495    let mut stmt = conn.prepare(
3496        "SELECT e.target_file, e.target_symbol, e.line,
3497                r.byte_start, r.byte_end, r.status, e.provenance,
3498                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3499                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3500                src_file.lang,
3501                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3502                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3503                tgt_file.lang
3504         FROM edges e
3505         JOIN refs r ON r.ref_id = e.ref_id
3506         JOIN nodes src ON src.id = e.source_node
3507         JOIN files src_file ON src_file.path = src.file_path
3508         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3509             ON tgt.id = e.target_node
3510         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3511         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3512    )?;
3513    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3514        let caller = store_node_from_row_at(row, 7)?;
3515        let target = optional_store_node_from_row_at(row, 18)?;
3516        Ok(StoreCallSite {
3517            caller,
3518            target_file: row.get(0)?,
3519            target_symbol: row.get(1)?,
3520            target,
3521            line: row.get::<_, i64>(2)?.max(0) as u32,
3522            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3523            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3524            resolved: row.get::<_, String>(5)? == "resolved",
3525            provenance: row.get(6)?,
3526        })
3527    })?;
3528    rows.collect::<std::result::Result<Vec<_>, _>>()
3529        .map_err(Into::into)
3530}
3531
3532fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3533    let mut stmt = conn.prepare(
3534        "SELECT e.target_file, e.target_symbol, e.line,
3535                r.byte_start, r.byte_end, r.status, e.provenance,
3536                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3537                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3538                tgt_file.lang
3539         FROM edges e
3540         JOIN refs r ON r.ref_id = e.ref_id
3541         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3542             ON tgt.id = e.target_node
3543         WHERE e.kind = 'call' AND e.source_node = ?1
3544         ORDER BY r.byte_start, r.line, r.ref_id",
3545    )?;
3546    let rows = stmt.query_map(params![node.node_id], |row| {
3547        let target = optional_store_node_from_row_at(row, 7)?;
3548        Ok(StoreCallSite {
3549            caller: node.clone(),
3550            target_file: row.get(0)?,
3551            target_symbol: row.get(1)?,
3552            target,
3553            line: row.get::<_, i64>(2)?.max(0) as u32,
3554            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3555            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3556            resolved: row.get::<_, String>(5)? == "resolved",
3557            provenance: row.get(6)?,
3558        })
3559    })?;
3560    rows.collect::<std::result::Result<Vec<_>, _>>()
3561        .map_err(Into::into)
3562}
3563
3564fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3565    let mut stmt = conn.prepare(
3566        "SELECT r.target_file, r.target_symbol, r.line,
3567                r.byte_start, r.byte_end, r.status, r.provenance,
3568                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3569                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3570                tgt_file.lang
3571         FROM refs r
3572         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3573             ON tgt.id = r.target_node
3574         WHERE r.caller_node = ?1
3575           AND r.kind = 'call'
3576           AND r.status <> 'unresolved'
3577           AND r.target_file = ?2
3578           AND r.target_symbol = ?3
3579           AND r.provenance = ?4
3580           AND NOT EXISTS (
3581               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
3582           )
3583         ORDER BY r.byte_start, r.line, r.ref_id",
3584    )?;
3585    let rows = stmt.query_map(
3586        params![
3587            &node.node_id,
3588            &node.file,
3589            &node.symbol,
3590            PROVENANCE_TREESITTER
3591        ],
3592        |row| {
3593            let target = optional_store_node_from_row_at(row, 7)?;
3594            Ok(StoreCallSite {
3595                caller: node.clone(),
3596                target_file: row.get(0)?,
3597                target_symbol: row.get(1)?,
3598                target,
3599                line: row.get::<_, i64>(2)?.max(0) as u32,
3600                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3601                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3602                resolved: row.get::<_, String>(5)? == "resolved",
3603                provenance: row.get(6)?,
3604            })
3605        },
3606    )?;
3607    rows.collect::<std::result::Result<Vec<_>, _>>()
3608        .map_err(Into::into)
3609}
3610
3611fn unresolved_calls_for_node(
3612    conn: &Connection,
3613    node: &StoreNode,
3614) -> Result<Vec<StoreUnresolvedCall>> {
3615    let mut stmt = conn.prepare(
3616        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
3617         FROM refs
3618         WHERE caller_node = ?1
3619           AND kind = 'call'
3620           AND status = 'unresolved'
3621           AND NOT EXISTS (
3622               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3623           )
3624         ORDER BY byte_start, line, ref_id",
3625    )?;
3626    let rows = stmt.query_map(params![node.node_id], |row| {
3627        Ok(StoreUnresolvedCall {
3628            caller: node.clone(),
3629            symbol: row.get(0)?,
3630            full_ref: row.get(1)?,
3631            line: row.get::<_, i64>(2)?.max(0) as u32,
3632            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3633            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3634        })
3635    })?;
3636    rows.collect::<std::result::Result<Vec<_>, _>>()
3637        .map_err(Into::into)
3638}
3639
3640fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
3641    let mut calls = Vec::new();
3642    calls.extend(
3643        outgoing_calls_for_node(conn, node)?
3644            .into_iter()
3645            .map(StoreForwardCall::Resolved),
3646    );
3647    calls.extend(
3648        unresolved_calls_for_node(conn, node)?
3649            .into_iter()
3650            .map(StoreForwardCall::Unresolved),
3651    );
3652    calls.sort_by(|left, right| {
3653        left.byte_start()
3654            .cmp(&right.byte_start())
3655            .then(left.line().cmp(&right.line()))
3656    });
3657    Ok(calls)
3658}
3659
3660fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
3661    let resolved_count: i64 = conn.query_row(
3662        "SELECT COUNT(*)
3663         FROM edges e
3664         JOIN refs r ON r.ref_id = e.ref_id
3665         WHERE e.kind = 'call' AND e.source_node = ?1",
3666        params![&node.node_id],
3667        |row| row.get(0),
3668    )?;
3669    let unresolved_count: i64 = conn.query_row(
3670        "SELECT COUNT(*)
3671         FROM refs
3672         WHERE caller_node = ?1
3673           AND kind = 'call'
3674           AND status = 'unresolved'
3675           AND NOT EXISTS (
3676               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3677           )",
3678        params![&node.node_id],
3679        |row| row.get(0),
3680    )?;
3681    let total = resolved_count.saturating_add(unresolved_count);
3682    Ok(usize::try_from(total).unwrap_or(usize::MAX))
3683}
3684
3685fn call_tree_inner(
3686    conn: &Connection,
3687    node: &StoreNode,
3688    max_depth: usize,
3689    current_depth: usize,
3690    visited: &mut HashSet<(String, String)>,
3691) -> Result<callgraph::CallTreeNode> {
3692    let visit_key = (node.file.clone(), node.symbol.clone());
3693    if visited.contains(&visit_key) {
3694        return Ok(callgraph::CallTreeNode {
3695            name: node.symbol.clone(),
3696            file: node.file.clone(),
3697            line: node.line,
3698            signature: node.signature.clone(),
3699            resolved: true,
3700            children: Vec::new(),
3701            depth_limited: false,
3702            truncated: 0,
3703        });
3704    }
3705    visited.insert(visit_key.clone());
3706
3707    let mut children = Vec::new();
3708    let mut depth_limited = false;
3709    let mut truncated = 0usize;
3710
3711    if current_depth < max_depth {
3712        let calls = forward_calls_for_node(conn, node)?;
3713        for call in calls {
3714            match call {
3715                StoreForwardCall::Resolved(site) => {
3716                    if let Some(target) = site.target {
3717                        let child =
3718                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
3719                        depth_limited |= child.depth_limited;
3720                        truncated += child.truncated;
3721                        children.push(child);
3722                    } else {
3723                        children.push(callgraph::CallTreeNode {
3724                            name: site.target_symbol,
3725                            file: site.target_file,
3726                            line: site.line,
3727                            signature: None,
3728                            resolved: false,
3729                            children: Vec::new(),
3730                            depth_limited: false,
3731                            truncated: 0,
3732                        });
3733                    }
3734                }
3735                StoreForwardCall::Unresolved(call) => {
3736                    children.push(callgraph::CallTreeNode {
3737                        name: call.symbol,
3738                        file: call.caller.file,
3739                        line: call.line,
3740                        signature: None,
3741                        resolved: false,
3742                        children: Vec::new(),
3743                        depth_limited: false,
3744                        truncated: 0,
3745                    });
3746                }
3747            }
3748        }
3749    } else {
3750        truncated = forward_call_count_for_node(conn, node)?;
3751        depth_limited = truncated > 0;
3752    }
3753
3754    visited.remove(&visit_key);
3755    Ok(callgraph::CallTreeNode {
3756        name: node.symbol.clone(),
3757        file: node.file.clone(),
3758        line: node.line,
3759        signature: node.signature.clone(),
3760        resolved: true,
3761        children,
3762        depth_limited,
3763        truncated,
3764    })
3765}
3766
3767fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
3768    callgraph::TraceToSymbolHop {
3769        symbol: node.symbol.clone(),
3770        file: node.file.clone(),
3771        line: node.line,
3772    }
3773}
3774
3775fn trace_to_symbol_matches_target(
3776    node: &StoreNode,
3777    to_symbol: &str,
3778    to_file: Option<&str>,
3779) -> bool {
3780    if !symbol_query_matches(&node.symbol, to_symbol) {
3781        return false;
3782    }
3783    match to_file {
3784        Some(file) => node.file == file,
3785        None => true,
3786    }
3787}
3788
3789fn symbol_query_matches(symbol: &str, query: &str) -> bool {
3790    symbol == query || unqualified_name(symbol) == query
3791}
3792
3793fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
3794    let source = std::fs::read_to_string(path).ok()?;
3795    Some(source.lines().map(|line| line.trim().to_string()).collect())
3796}
3797
3798#[doc(hidden)]
3799pub fn live_callgraph_edge_snapshot(
3800    project_root: &Path,
3801    files: &[PathBuf],
3802) -> Result<BTreeSet<StoredEdge>> {
3803    let files = normalize_file_list(project_root, files)?;
3804    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
3805    let mut file_data = Vec::new();
3806    for file in &files {
3807        let canon = canonicalize_path(file);
3808        let data = graph.build_file(&canon)?.clone();
3809        file_data.push((canon, data));
3810    }
3811
3812    let mut edges = BTreeSet::new();
3813    for (caller_file, data) in &file_data {
3814        for (caller_symbol, call_sites) in &data.calls_by_symbol {
3815            for call_site in call_sites {
3816                let resolution = graph.resolve_cross_file_edge(
3817                    &call_site.full_callee,
3818                    &call_site.callee_name,
3819                    caller_file,
3820                    &data.import_block,
3821                );
3822                let (target_file, target_symbol) = match resolution {
3823                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
3824                    EdgeResolution::Unresolved { callee_name } => {
3825                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
3826                            continue;
3827                        }
3828                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
3829                            data,
3830                            caller_file,
3831                            &callee_name,
3832                        ) else {
3833                            continue;
3834                        };
3835                        (caller_file.clone(), target_symbol)
3836                    }
3837                };
3838                if target_file == *caller_file && target_symbol == *caller_symbol {
3839                    continue;
3840                }
3841                edges.insert(StoredEdge {
3842                    source_file: relative_path(project_root, caller_file),
3843                    source_symbol: caller_symbol.clone(),
3844                    target_file: relative_path(project_root, &target_file),
3845                    target_symbol,
3846                    kind: "call".to_string(),
3847                    line: call_site.line,
3848                });
3849            }
3850        }
3851    }
3852    Ok(edges)
3853}
3854
3855fn acquire_writer_lease(
3856    callgraph_dir: &Path,
3857    project_key: &str,
3858    project_root: &Path,
3859) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
3860    crate::root_cache::WriterLease::acquire_shared(
3861        crate::root_cache::RootCacheDomain::Callgraph,
3862        callgraph_dir,
3863        project_key,
3864        project_root,
3865    )
3866    .map_err(CallGraphStoreError::from)
3867}
3868
3869fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
3870    if lease.verify()? {
3871        Ok(())
3872    } else {
3873        Err(CallGraphStoreError::Unavailable(format!(
3874            "callgraph writer lease for key {} lost epoch {}; aborting write",
3875            lease.key(),
3876            lease.epoch()
3877        )))
3878    }
3879}
3880
3881fn legacy_migration_completion_line(
3882    project_key: &str,
3883    method: &str,
3884    legacy_bytes: u64,
3885    migrated_bytes: u64,
3886) -> String {
3887    format!(
3888        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
3889    )
3890}
3891
3892fn log_legacy_migration_completion(
3893    project_key: &str,
3894    method: &str,
3895    legacy_bytes: u64,
3896    migrated_bytes: u64,
3897) {
3898    crate::slog_info!(
3899        "{}",
3900        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
3901    );
3902}
3903
3904fn try_legacy_migration_or_fallback(
3905    callgraph_dir: &Path,
3906    project_root: &Path,
3907    project_key: &str,
3908    writer_lease: Arc<crate::root_cache::WriterLease>,
3909) -> Result<Option<CallGraphStore>> {
3910    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
3911    if partitions.is_empty() {
3912        return Ok(None);
3913    }
3914
3915    for partition in &partitions {
3916        if let Some(source) = newest_superseded_legacy_generation(partition)? {
3917            if !migration_disk_floor_allows(&source, callgraph_dir)? {
3918                return open_legacy_fallback_store(
3919                    callgraph_dir,
3920                    project_root,
3921                    project_key,
3922                    &partitions,
3923                );
3924            }
3925            match publish_generation_copy_migration(
3926                callgraph_dir,
3927                project_key,
3928                &source,
3929                Arc::clone(&writer_lease),
3930            ) {
3931                Ok(published) => {
3932                    log_legacy_migration_completion(
3933                        project_key,
3934                        "generation_copy",
3935                        source.source_bytes,
3936                        published.migrated_bytes,
3937                    );
3938                    return CallGraphStore::open_generation(
3939                        callgraph_dir,
3940                        project_root.to_path_buf(),
3941                        project_key.to_string(),
3942                        published.generation,
3943                        writer_lease,
3944                    )
3945                    .map(Some);
3946                }
3947                Err(error) => {
3948                    crate::slog_warn!(
3949                        "root-keyed callgraph generation-copy migration failed from {}: {}",
3950                        source.sqlite_path.display(),
3951                        error
3952                    );
3953                    return open_legacy_fallback_store(
3954                        callgraph_dir,
3955                        project_root,
3956                        project_key,
3957                        &partitions,
3958                    );
3959                }
3960            }
3961        }
3962
3963        if let Some(source) = current_legacy_generation(partition)? {
3964            if !migration_disk_floor_allows(&source, callgraph_dir)? {
3965                return open_legacy_fallback_store(
3966                    callgraph_dir,
3967                    project_root,
3968                    project_key,
3969                    &partitions,
3970                );
3971            }
3972            match publish_backup_migration(
3973                callgraph_dir,
3974                project_key,
3975                &source,
3976                Arc::clone(&writer_lease),
3977            ) {
3978                Ok(published) => {
3979                    log_legacy_migration_completion(
3980                        project_key,
3981                        "sqlite_backup",
3982                        source.source_bytes,
3983                        published.migrated_bytes,
3984                    );
3985                    return CallGraphStore::open_generation(
3986                        callgraph_dir,
3987                        project_root.to_path_buf(),
3988                        project_key.to_string(),
3989                        published.generation,
3990                        writer_lease,
3991                    )
3992                    .map(Some);
3993                }
3994                Err(error) => {
3995                    crate::slog_warn!(
3996                        "root-keyed callgraph backup migration failed from {}: {}",
3997                        source.sqlite_path.display(),
3998                        error
3999                    );
4000                    return open_legacy_fallback_store(
4001                        callgraph_dir,
4002                        project_root,
4003                        project_key,
4004                        &partitions,
4005                    );
4006                }
4007            }
4008        }
4009    }
4010
4011    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4012}
4013
4014fn open_legacy_fallback_store(
4015    callgraph_dir: &Path,
4016    project_root: &Path,
4017    project_key: &str,
4018    partitions: &[LegacyCallgraphPartition],
4019) -> Result<Option<CallGraphStore>> {
4020    let Some(target) = first_ready_legacy_target(partitions)? else {
4021        return Ok(None);
4022    };
4023    crate::slog_warn!(
4024        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4025        target.partition.harness,
4026        target.sqlite_path.display()
4027    );
4028    let conn = open_readonly_connection(&target.sqlite_path)?;
4029    if !database_ready(&conn).unwrap_or(false) {
4030        return Ok(None);
4031    }
4032    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4033    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4034    Ok(Some(CallGraphStore::from_connection(
4035        project_root.to_path_buf(),
4036        project_key.to_string(),
4037        target.sqlite_path,
4038        callgraph_dir.to_path_buf(),
4039        true,
4040        target.generation,
4041        None,
4042        Some(read_marker),
4043        conn,
4044    )))
4045}
4046
4047fn migration_disk_floor_allows(
4048    source: &LegacyCallgraphTarget,
4049    callgraph_dir: &Path,
4050) -> Result<bool> {
4051    let available = migration_available_disk(callgraph_dir)?;
4052    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4053        source.source_bytes,
4054        available,
4055    );
4056    if decision.should_skip_copy() {
4057        crate::slog_warn!(
4058            "{}",
4059            decision.warning_message(&source.sqlite_path, callgraph_dir)
4060        );
4061        return Ok(false);
4062    }
4063    Ok(true)
4064}
4065
4066fn migration_available_disk(path: &Path) -> Result<u64> {
4067    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4068        return Ok(bytes);
4069    }
4070    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4071}
4072
4073fn legacy_callgraph_partitions(
4074    callgraph_dir: &Path,
4075    project_key: &str,
4076) -> Result<Vec<LegacyCallgraphPartition>> {
4077    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4078        return Ok(Vec::new());
4079    };
4080    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4081    let mut partitions = inventory
4082        .into_iter()
4083        .filter(|entry| {
4084            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4085                && entry.key == project_key
4086        })
4087        .map(|entry| {
4088            let dir = if entry.path.is_dir() {
4089                entry.path.clone()
4090            } else {
4091                entry
4092                    .path
4093                    .parent()
4094                    .map(Path::to_path_buf)
4095                    .unwrap_or_else(|| entry.path.clone())
4096            };
4097            LegacyCallgraphPartition {
4098                harness: entry.harness,
4099                dir,
4100                key: entry.key,
4101                bytes: entry.bytes,
4102                freshness: entry.callgraph_pointer_mtime,
4103            }
4104        })
4105        .collect::<Vec<_>>();
4106    partitions.sort_by(|left, right| {
4107        right
4108            .freshness
4109            .cmp(&left.freshness)
4110            .then_with(|| right.bytes.cmp(&left.bytes))
4111            .then_with(|| left.harness.cmp(&right.harness))
4112    });
4113    Ok(partitions)
4114}
4115
4116fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4117    let domain_dir = callgraph_dir.parent()?;
4118    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4119        return None;
4120    }
4121    domain_dir.parent().map(Path::to_path_buf)
4122}
4123
4124pub(crate) fn all_legacy_partitions_migrated_for_keys(
4125    callgraph_dir: &Path,
4126    configured_keys: &BTreeSet<String>,
4127) -> Result<bool> {
4128    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4129        return Ok(false);
4130    };
4131    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4132        .into_iter()
4133        .filter(|entry| {
4134            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4135                && configured_keys.contains(&entry.key)
4136        })
4137        .map(|entry| entry.key)
4138        .collect::<BTreeSet<_>>();
4139    if legacy_keys.is_empty() {
4140        return Ok(false);
4141    }
4142
4143    for key in legacy_keys {
4144        let migrated_dir = storage_root.join("callgraph").join(&key);
4145        let Some(generation) = read_pointer(&migrated_dir, &key) else {
4146            return Ok(false);
4147        };
4148        if !migration_generation_requires_manifest(&generation)
4149            || !migration_manifest_valid(&migrated_dir, &generation)
4150        {
4151            return Ok(false);
4152        }
4153    }
4154    Ok(true)
4155}
4156
4157fn newest_superseded_legacy_generation(
4158    partition: &LegacyCallgraphPartition,
4159) -> Result<Option<LegacyCallgraphTarget>> {
4160    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4161        return Ok(None);
4162    };
4163    let prefix = format!("{}.g", partition.key);
4164    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4165        return Ok(None);
4166    };
4167    let mut candidates = Vec::new();
4168    for entry in entries.flatten() {
4169        let name = entry.file_name().to_string_lossy().to_string();
4170        if name == current
4171            || name.contains(".tmp.")
4172            || !name.starts_with(&prefix)
4173            || !name.ends_with(".sqlite")
4174        {
4175            continue;
4176        }
4177        let path = entry.path();
4178        if !db_path_ready(&path) {
4179            continue;
4180        }
4181        let modified = entry
4182            .metadata()
4183            .and_then(|metadata| metadata.modified())
4184            .unwrap_or(SystemTime::UNIX_EPOCH);
4185        candidates.push((modified, path, name));
4186    }
4187    candidates.sort_by(|left, right| right.0.cmp(&left.0));
4188    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4189        return Ok(None);
4190    };
4191    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4192    Ok(Some(LegacyCallgraphTarget {
4193        partition: partition.clone(),
4194        sqlite_path,
4195        generation: Some(generation),
4196        source_bytes,
4197        source_blake3: String::new(),
4198    }))
4199}
4200
4201fn current_legacy_generation(
4202    partition: &LegacyCallgraphPartition,
4203) -> Result<Option<LegacyCallgraphTarget>> {
4204    let Some(target) = ready_legacy_target(partition)? else {
4205        return Ok(None);
4206    };
4207    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4208    if has_superseded {
4209        return Ok(None);
4210    }
4211    Ok(Some(target))
4212}
4213
4214fn freshest_legacy_fallback_target(
4215    callgraph_dir: &Path,
4216    project_key: &str,
4217) -> Result<Option<LegacyCallgraphTarget>> {
4218    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4219    first_ready_legacy_target(&partitions)
4220}
4221
4222fn first_ready_legacy_target(
4223    partitions: &[LegacyCallgraphPartition],
4224) -> Result<Option<LegacyCallgraphTarget>> {
4225    for partition in partitions {
4226        if let Some(target) = ready_legacy_target(partition)? {
4227            return Ok(Some(target));
4228        }
4229    }
4230    Ok(None)
4231}
4232
4233fn ready_legacy_target(
4234    partition: &LegacyCallgraphPartition,
4235) -> Result<Option<LegacyCallgraphTarget>> {
4236    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4237        let sqlite_path = partition.dir.join(&generation);
4238        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4239            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4240            return Ok(Some(LegacyCallgraphTarget {
4241                partition: partition.clone(),
4242                sqlite_path,
4243                generation: Some(generation),
4244                source_bytes,
4245                source_blake3: String::new(),
4246            }));
4247        }
4248    }
4249
4250    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4251    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4252        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4253        return Ok(Some(LegacyCallgraphTarget {
4254            partition: partition.clone(),
4255            sqlite_path,
4256            generation: None,
4257            source_bytes,
4258            source_blake3: String::new(),
4259        }));
4260    }
4261    Ok(None)
4262}
4263
4264fn publish_generation_copy_migration(
4265    callgraph_dir: &Path,
4266    project_key: &str,
4267    source: &LegacyCallgraphTarget,
4268    writer_lease: Arc<crate::root_cache::WriterLease>,
4269) -> Result<PublishedLegacyMigration> {
4270    let generation = migration_generation_file_name(project_key, "copy");
4271    let temp_path = migration_temp_path(callgraph_dir, &generation);
4272    remove_sqlite_file_set(&temp_path);
4273    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4274    fail_after_temp_copy_for_test()?;
4275
4276    let mut source = source.clone();
4277    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4278    source.source_blake3 = fingerprint.blake3;
4279    let generation = publish_migrated_generation(
4280        callgraph_dir,
4281        project_key,
4282        &generation,
4283        &temp_path,
4284        &source,
4285        fingerprint.bytes,
4286        writer_lease,
4287        "generation_copy",
4288    )?;
4289    Ok(PublishedLegacyMigration {
4290        generation,
4291        migrated_bytes: fingerprint.bytes,
4292    })
4293}
4294
4295fn publish_backup_migration(
4296    callgraph_dir: &Path,
4297    project_key: &str,
4298    source: &LegacyCallgraphTarget,
4299    writer_lease: Arc<crate::root_cache::WriterLease>,
4300) -> Result<PublishedLegacyMigration> {
4301    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4302        return Err(CallGraphStoreError::Unavailable(
4303            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4304        ));
4305    }
4306
4307    let generation = migration_generation_file_name(project_key, "backup");
4308    let temp_path = migration_temp_path(callgraph_dir, &generation);
4309    remove_sqlite_file_set(&temp_path);
4310
4311    let source_conn = open_readonly_connection(&source.sqlite_path)?;
4312    let mut destination = Connection::open(&temp_path)?;
4313    destination.busy_timeout(Duration::from_secs(5))?;
4314    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4315    let started = Instant::now();
4316    let mut retries = 0;
4317    loop {
4318        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4319            rusqlite::backup::StepResult::Done => break,
4320            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4321            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4322                retries += 1;
4323                if retries > MIGRATION_BACKUP_RETRY_BUDGET
4324                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4325                {
4326                    return Err(CallGraphStoreError::Unavailable(format!(
4327                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4328                    )));
4329                }
4330                std::thread::sleep(Duration::from_millis(20));
4331            }
4332            _ => {
4333                return Err(CallGraphStoreError::Unavailable(
4334                    "legacy callgraph backup returned an unknown step result".to_string(),
4335                ));
4336            }
4337        }
4338    }
4339    drop(backup);
4340
4341    let integrity: String =
4342        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4343    if integrity != "ok" {
4344        return Err(CallGraphStoreError::Unavailable(format!(
4345            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4346        )));
4347    }
4348    if !database_ready(&destination)? {
4349        return Err(CallGraphStoreError::Unavailable(
4350            "legacy callgraph backup produced a database without ready metadata".to_string(),
4351        ));
4352    }
4353    destination.execute_batch("PRAGMA optimize;")?;
4354    drop(destination);
4355    sync_file(&temp_path)?;
4356    fail_after_temp_copy_for_test()?;
4357
4358    let mut source = source.clone();
4359    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4360    source.source_blake3 = fingerprint.blake3;
4361    let generation = publish_migrated_generation(
4362        callgraph_dir,
4363        project_key,
4364        &generation,
4365        &temp_path,
4366        &source,
4367        fingerprint.bytes,
4368        writer_lease,
4369        "sqlite_backup",
4370    )?;
4371    Ok(PublishedLegacyMigration {
4372        generation,
4373        migrated_bytes: fingerprint.bytes,
4374    })
4375}
4376
4377fn publish_migrated_generation(
4378    callgraph_dir: &Path,
4379    project_key: &str,
4380    generation: &str,
4381    temp_path: &Path,
4382    source: &LegacyCallgraphTarget,
4383    migrated_bytes: u64,
4384    writer_lease: Arc<crate::root_cache::WriterLease>,
4385    method: &str,
4386) -> Result<String> {
4387    let gen_path = callgraph_dir.join(generation);
4388    let publication = publish_if_current(|| {
4389        verify_writer_lease(&writer_lease)?;
4390        remove_sqlite_file_set(&gen_path);
4391        rename_sqlite_file_set(temp_path, &gen_path)?;
4392        crate::fs_lock::sync_parent(&gen_path);
4393
4394        verify_writer_lease(&writer_lease)?;
4395        publish_pointer(callgraph_dir, project_key, generation)?;
4396        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
4397        Ok(generation.to_string())
4398    });
4399    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
4400        remove_sqlite_file_set(temp_path);
4401    }
4402    publication
4403}
4404
4405fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4406    if let Some(parent) = destination.parent() {
4407        std::fs::create_dir_all(parent)?;
4408    }
4409    for suffix in SQLITE_FILE_SET_SUFFIXES {
4410        let source_path = sqlite_file_set_path(source, suffix);
4411        if !source_path.is_file() {
4412            continue;
4413        }
4414        let destination_path = sqlite_file_set_path(destination, suffix);
4415        std::fs::copy(&source_path, &destination_path)?;
4416        sync_file(&destination_path)?;
4417    }
4418    Ok(())
4419}
4420
4421fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4422    for suffix in SQLITE_FILE_SET_SUFFIXES {
4423        let source_path = sqlite_file_set_path(source, suffix);
4424        if !source_path.exists() {
4425            continue;
4426        }
4427        let destination_path = sqlite_file_set_path(destination, suffix);
4428        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
4429            let _ = std::fs::remove_file(&source_path);
4430            return Err(error.into());
4431        }
4432    }
4433    Ok(())
4434}
4435
4436fn sqlite_file_set_size(path: &Path) -> Result<u64> {
4437    let mut bytes = 0_u64;
4438    for suffix in SQLITE_FILE_SET_SUFFIXES {
4439        let member = sqlite_file_set_path(path, suffix);
4440        if !member.is_file() {
4441            continue;
4442        }
4443        bytes = bytes.saturating_add(member.metadata()?.len());
4444    }
4445    Ok(bytes)
4446}
4447
4448fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
4449    let mut hasher = blake3::Hasher::new();
4450    let mut bytes = 0_u64;
4451    let mut buffer = [0_u8; 64 * 1024];
4452    for suffix in SQLITE_FILE_SET_SUFFIXES {
4453        let member = sqlite_file_set_path(path, suffix);
4454        if !member.is_file() {
4455            continue;
4456        }
4457        hasher.update(suffix.as_bytes());
4458        let mut file = std::fs::File::open(&member)?;
4459        loop {
4460            let read = file.read(&mut buffer)?;
4461            if read == 0 {
4462                break;
4463            }
4464            bytes = bytes.saturating_add(read as u64);
4465            hasher.update(&buffer[..read]);
4466        }
4467    }
4468    Ok(SourceFingerprint {
4469        bytes,
4470        blake3: hash_to_hex(hasher.finalize()),
4471    })
4472}
4473
4474fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
4475    if suffix.is_empty() {
4476        path.to_path_buf()
4477    } else {
4478        PathBuf::from(format!("{}{suffix}", path.display()))
4479    }
4480}
4481
4482fn sync_file(path: &Path) -> Result<()> {
4483    let file = std::fs::OpenOptions::new()
4484        .read(true)
4485        .write(true)
4486        .open(path)?;
4487    file.sync_all()?;
4488    Ok(())
4489}
4490
4491fn fail_after_temp_copy_for_test() -> Result<()> {
4492    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
4493        return Err(CallGraphStoreError::Unavailable(
4494            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
4495        ));
4496    }
4497    Ok(())
4498}
4499
4500fn migration_generation_file_name(project_key: &str, method: &str) -> String {
4501    format!(
4502        "{project_key}.g{}.{}{}{}.sqlite",
4503        now_nanos(),
4504        std::process::id(),
4505        MIGRATION_GENERATION_TAG,
4506        method
4507    )
4508}
4509
4510fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4511    callgraph_dir.join(format!(
4512        "{generation}.tmp.{}.{}",
4513        std::process::id(),
4514        now_nanos()
4515    ))
4516}
4517
4518fn write_migration_manifest(
4519    callgraph_dir: &Path,
4520    generation: &str,
4521    source: &LegacyCallgraphTarget,
4522    migrated_bytes: u64,
4523    method: &str,
4524) -> Result<()> {
4525    let manifest_path = migration_manifest_path(callgraph_dir, generation);
4526    let temp_path = manifest_path.with_extension(format!(
4527        "migration.json.tmp.{}.{}",
4528        std::process::id(),
4529        now_nanos()
4530    ));
4531    let manifest = serde_json::json!({
4532        "version": MIGRATION_MANIFEST_VERSION,
4533        "method": method,
4534        "target_generation": generation,
4535        "source_harness": source.partition.harness,
4536        "source_path": source.sqlite_path.display().to_string(),
4537        "source_generation": source.generation,
4538        "source_bytes": source.source_bytes,
4539        "source_blake3": source.source_blake3,
4540        "migrated_bytes": migrated_bytes,
4541    });
4542    {
4543        use std::io::Write as _;
4544        let mut file = std::fs::File::create(&temp_path)?;
4545        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
4546        file.write_all(b"\n")?;
4547        file.sync_all()?;
4548    }
4549    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
4550        let _ = std::fs::remove_file(&temp_path);
4551        return Err(error.into());
4552    }
4553    crate::fs_lock::sync_parent(&manifest_path);
4554    Ok(())
4555}
4556
4557fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4558    callgraph_dir.join(format!("{generation}.migration.json"))
4559}
4560
4561fn migration_generation_requires_manifest(generation: &str) -> bool {
4562    generation.contains(MIGRATION_GENERATION_TAG)
4563}
4564
4565fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
4566    if !migration_generation_requires_manifest(generation) {
4567        return true;
4568    }
4569    let path = migration_manifest_path(callgraph_dir, generation);
4570    let Ok(bytes) = std::fs::read(path) else {
4571        return false;
4572    };
4573    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
4574        return false;
4575    };
4576    value.get("version").and_then(serde_json::Value::as_u64)
4577        == Some(MIGRATION_MANIFEST_VERSION as u64)
4578        && value
4579            .get("target_generation")
4580            .and_then(serde_json::Value::as_str)
4581            == Some(generation)
4582        && value
4583            .get("source_bytes")
4584            .and_then(serde_json::Value::as_u64)
4585            .is_some_and(|bytes| bytes > 0)
4586        && value
4587            .get("source_blake3")
4588            .and_then(serde_json::Value::as_str)
4589            .is_some_and(|hash| hash.len() == 64)
4590}
4591
4592fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
4593    let pointer_generation = read_pointer(callgraph_dir, project_key);
4594    if let Some(generation) = pointer_generation.as_deref() {
4595        if migration_generation_requires_manifest(generation)
4596            && !migration_manifest_valid(callgraph_dir, generation)
4597        {
4598            let path = callgraph_dir.join(generation);
4599            remove_sqlite_file_set(&path);
4600            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
4601            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
4602        }
4603    }
4604
4605    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
4606        return;
4607    };
4608    for entry in entries.flatten() {
4609        let name = entry.file_name().to_string_lossy().to_string();
4610        let path = entry.path();
4611        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
4612            let _ = std::fs::remove_file(path);
4613            continue;
4614        }
4615        if name.starts_with(&format!("{project_key}.g"))
4616            && name.ends_with(".sqlite")
4617            && name.contains(MIGRATION_GENERATION_TAG)
4618            && pointer_generation.as_deref() != Some(&name)
4619            && !migration_manifest_valid(callgraph_dir, &name)
4620        {
4621            remove_sqlite_file_set(&path);
4622            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
4623        }
4624    }
4625    crate::fs_lock::sync_parent(callgraph_dir);
4626}
4627
4628fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
4629    let mut hasher = blake3::Hasher::new();
4630    hasher.update(path.to_string_lossy().as_bytes());
4631    if let Some(generation) = generation {
4632        hasher.update(generation.as_bytes());
4633    }
4634    let digest = hash_to_hex(hasher.finalize());
4635    format!("legacy-{}", &digest[..16])
4636}
4637
4638fn open_readonly_connection(path: &Path) -> Result<Connection> {
4639    let uri = sqlite_readonly_uri(path);
4640    let conn = Connection::open_with_flags(
4641        &uri,
4642        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
4643    )?;
4644    conn.busy_timeout(reader_busy_timeout())?;
4645    conn.execute_batch("PRAGMA query_only=ON;")?;
4646    Ok(conn)
4647}
4648
4649fn reader_busy_timeout() -> Duration {
4650    let jitter = (now_nanos() % 500) as u64;
4651    Duration::from_millis(250 + jitter)
4652}
4653
4654fn sqlite_readonly_uri(path: &Path) -> String {
4655    let raw = path.to_string_lossy().replace('\\', "/");
4656    let encoded = percent_encode_sqlite_uri_path(&raw);
4657    if raw.starts_with('/') {
4658        format!("file://{encoded}?mode=ro")
4659    } else if raw.as_bytes().get(1) == Some(&b':') {
4660        format!("file:///{encoded}?mode=ro")
4661    } else {
4662        format!("file:{encoded}?mode=ro")
4663    }
4664}
4665
4666fn percent_encode_sqlite_uri_path(path: &str) -> String {
4667    let mut encoded = String::with_capacity(path.len());
4668    for byte in path.bytes() {
4669        match byte {
4670            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
4671                encoded.push(byte as char)
4672            }
4673            _ => encoded.push_str(&format!("%{byte:02X}")),
4674        }
4675    }
4676    encoded
4677}
4678
4679fn configure_connection(conn: &Connection) -> Result<()> {
4680    conn.pragma_update(None, "journal_mode", "WAL")?;
4681    conn.pragma_update(None, "busy_timeout", 5_000)?;
4682    Ok(())
4683}
4684
4685fn configure_build_connection(conn: &Connection) -> Result<()> {
4686    conn.pragma_update(None, "journal_mode", "DELETE")?;
4687    conn.pragma_update(None, "busy_timeout", 5_000)?;
4688    Ok(())
4689}
4690
4691fn initialize_schema(conn: &Connection) -> Result<()> {
4692    conn.execute_batch(
4693        "CREATE TABLE IF NOT EXISTS files (
4694            path                TEXT PRIMARY KEY,
4695            content_hash        TEXT NOT NULL,
4696            mtime_ns            INTEGER NOT NULL,
4697            size                INTEGER NOT NULL,
4698            lang                TEXT NOT NULL,
4699            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
4700            is_public_api       INTEGER NOT NULL DEFAULT 0,
4701            surface_fingerprint TEXT NOT NULL,
4702            indexed_at          INTEGER NOT NULL
4703        );
4704
4705        CREATE TABLE IF NOT EXISTS nodes (
4706            id                         TEXT PRIMARY KEY,
4707            file_path                  TEXT NOT NULL,
4708            name                       TEXT NOT NULL,
4709            scoped_name                TEXT NOT NULL,
4710            kind                       TEXT NOT NULL,
4711            start_line                 INTEGER NOT NULL,
4712            start_col                  INTEGER NOT NULL,
4713            end_line                   INTEGER NOT NULL,
4714            end_col                    INTEGER NOT NULL,
4715            range_ordinal              INTEGER NOT NULL,
4716            signature                  TEXT,
4717            exported                   INTEGER NOT NULL,
4718            is_default_export          INTEGER NOT NULL,
4719            is_type_like               INTEGER NOT NULL,
4720            is_callgraph_entry_point   INTEGER NOT NULL,
4721            provenance                 TEXT NOT NULL,
4722            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
4723        );
4724        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
4725        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
4726        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
4727
4728        CREATE TABLE IF NOT EXISTS refs (
4729            ref_id          TEXT PRIMARY KEY,
4730            caller_node     TEXT,
4731            caller_file     TEXT NOT NULL,
4732            kind            TEXT NOT NULL,
4733            short_name      TEXT,
4734            full_ref        TEXT,
4735            module_path     TEXT,
4736            import_kind     TEXT,
4737            local_name      TEXT,
4738            requested_name  TEXT,
4739            namespace_alias TEXT,
4740            wildcard        INTEGER NOT NULL DEFAULT 0,
4741            line            INTEGER NOT NULL,
4742            byte_start      INTEGER NOT NULL,
4743            byte_end        INTEGER NOT NULL,
4744            status          TEXT NOT NULL,
4745            target_node     TEXT,
4746            target_file     TEXT,
4747            target_symbol   TEXT,
4748            provenance      TEXT NOT NULL
4749        );
4750        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
4751        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
4752        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
4753        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
4754        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
4755
4756        CREATE TABLE IF NOT EXISTS file_dependencies (
4757            file_path   TEXT NOT NULL,
4758            dep_file    TEXT NOT NULL,
4759            PRIMARY KEY(file_path, dep_file)
4760        );
4761        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
4762
4763        CREATE TABLE IF NOT EXISTS edges (
4764            edge_id       TEXT PRIMARY KEY,
4765            ref_id        TEXT NOT NULL,
4766            source_node   TEXT NOT NULL,
4767            target_node   TEXT,
4768            target_file   TEXT NOT NULL,
4769            target_symbol TEXT NOT NULL,
4770            kind          TEXT NOT NULL,
4771            line          INTEGER NOT NULL,
4772            provenance    TEXT NOT NULL
4773        );
4774        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
4775        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
4776        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
4777        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
4778
4779        CREATE TABLE IF NOT EXISTS dispatch_hints (
4780            id           TEXT PRIMARY KEY,
4781            method_name  TEXT NOT NULL,
4782            caller_node  TEXT NOT NULL,
4783            file         TEXT NOT NULL,
4784            line         INTEGER NOT NULL,
4785            byte_start   INTEGER NOT NULL,
4786            byte_end     INTEGER NOT NULL,
4787            provenance   TEXT NOT NULL
4788        );
4789        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
4790
4791        CREATE TABLE IF NOT EXISTS type_ref_names (
4792            name TEXT PRIMARY KEY
4793        );
4794
4795        CREATE TABLE IF NOT EXISTS backend_file_state (
4796            backend        TEXT NOT NULL,
4797            workspace_root TEXT NOT NULL,
4798            file_path      TEXT NOT NULL,
4799            content_hash   TEXT NOT NULL,
4800            status         TEXT NOT NULL,
4801            updated_at     INTEGER NOT NULL,
4802            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
4803        );
4804        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
4805
4806        CREATE TABLE IF NOT EXISTS meta (
4807            k TEXT PRIMARY KEY,
4808            v TEXT NOT NULL
4809        );",
4810    )?;
4811    insert_meta(conn)?;
4812    Ok(())
4813}
4814
4815fn insert_meta(conn: &Connection) -> Result<()> {
4816    conn.execute(
4817        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
4818        params![SCHEMA_VERSION.to_string()],
4819    )?;
4820    conn.execute(
4821        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
4822        params![schema_fingerprint()],
4823    )?;
4824    Ok(())
4825}
4826
4827fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
4828    conn.execute(
4829        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
4830        params![if ready { "1" } else { "0" }],
4831    )?;
4832    Ok(())
4833}
4834
4835fn database_ready(conn: &Connection) -> Result<bool> {
4836    let schema_version: Option<String> = conn
4837        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
4838            row.get(0)
4839        })
4840        .optional()?;
4841    let fingerprint: Option<String> = conn
4842        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
4843            row.get(0)
4844        })
4845        .optional()?;
4846    let ready: Option<String> = conn
4847        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
4848        .optional()?;
4849
4850    let expected_schema = SCHEMA_VERSION.to_string();
4851    let expected_fingerprint = schema_fingerprint();
4852    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
4853        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
4854        && ready.as_deref() == Some("1"))
4855}
4856
4857fn ensure_database_ready(conn: &Connection) -> Result<()> {
4858    if database_ready(conn)? {
4859        Ok(())
4860    } else {
4861        Err(CallGraphStoreError::Unavailable(
4862            "database is missing, stale, or mid-build".to_string(),
4863        ))
4864    }
4865}
4866
4867fn schema_fingerprint() -> String {
4868    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
4869    // edge sources, broader call extraction) even if the table SHAPE is
4870    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
4871    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
4872    let input =
4873        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
4874    hash_to_hex(blake3::hash(input.as_bytes()))
4875}
4876
4877fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
4878    tx.execute_batch(
4879        "DELETE FROM edges;
4880         DELETE FROM file_dependencies;
4881         DELETE FROM refs;
4882         DELETE FROM dispatch_hints;
4883         DELETE FROM type_ref_names;
4884         DELETE FROM backend_file_state;
4885         DELETE FROM nodes;
4886         DELETE FROM files;",
4887    )?;
4888    Ok(())
4889}
4890
4891fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
4892    tx.execute_batch(
4893        "DROP INDEX IF EXISTS idx_nodes_file;
4894         DROP INDEX IF EXISTS idx_nodes_name;
4895         DROP INDEX IF EXISTS idx_nodes_scoped;
4896         DROP INDEX IF EXISTS idx_refs_short_name;
4897         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
4898         DROP INDEX IF EXISTS idx_refs_caller_file;
4899         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
4900         DROP INDEX IF EXISTS idx_refs_target_file;
4901         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
4902         DROP INDEX IF EXISTS idx_edges_source_kind;
4903         DROP INDEX IF EXISTS idx_edges_target_kind;
4904         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
4905         DROP INDEX IF EXISTS idx_edges_ref_id;
4906         DROP INDEX IF EXISTS idx_dispatch_hints_method;
4907         DROP INDEX IF EXISTS idx_backend_file_state_file;",
4908    )?;
4909    Ok(())
4910}
4911
4912fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
4913    tx.execute_batch(
4914        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
4915         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
4916         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
4917         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
4918         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
4919         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
4920         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
4921         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
4922         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
4923         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
4924         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
4925         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
4926         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
4927         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
4928         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
4929    )?;
4930    Ok(())
4931}
4932
4933const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
4934    ("files", "path"),
4935    ("nodes", "file_path"),
4936    ("refs", "caller_file"),
4937    ("refs", "target_file"),
4938    ("file_dependencies", "file_path"),
4939    ("file_dependencies", "dep_file"),
4940    ("edges", "target_file"),
4941    ("dispatch_hints", "file"),
4942    ("backend_file_state", "file_path"),
4943];
4944
4945/// Reconcile `backend_file_state.workspace_root` when the opener's project root
4946/// differs from what is stored. The store key is the git-root commit hash, so
4947/// multiple live checkouts/clones share one on-disk generation.
4948///
4949/// Cheap in-place re-root is only safe when every previously stored root path is
4950/// gone from disk (true move/rename). If any stale root still exists, another
4951/// clone is still alive and rewriting metadata would ping-pong relative rows
4952/// between trees (possibly on different branches). We then return
4953/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
4954/// opener. That can make each clone rebuild on open when they alternate — bounded
4955/// by open frequency — but each rebuild is correct for its opener, unlike silent
4956/// cross-clone corruption.
4957fn reconcile_workspace_roots(
4958    conn: &mut Connection,
4959    project_root: &Path,
4960    allow_repair: bool,
4961) -> Result<OpenRootRepair> {
4962    let roots = stored_workspace_roots(conn)?;
4963    let current_root = project_root.display().to_string();
4964    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
4965        return Ok(OpenRootRepair::None);
4966    }
4967
4968    if let Some(sample) = sample_absolute_data_path(conn)? {
4969        return Ok(OpenRootRepair::NeedsRebuild {
4970            previous_roots: roots,
4971            current_root,
4972            reason: format!("absolute store data path row {sample}"),
4973        });
4974    }
4975
4976    for stored_root in roots.iter() {
4977        if stored_root == &current_root {
4978            continue;
4979        }
4980        if Path::new(stored_root).exists() {
4981            let reason = format!(
4982                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
4983            );
4984            return Ok(OpenRootRepair::NeedsRebuild {
4985                previous_roots: roots,
4986                current_root,
4987                reason,
4988            });
4989        }
4990    }
4991
4992    if !allow_repair {
4993        return Ok(OpenRootRepair::NeedsRebuild {
4994            previous_roots: roots,
4995            current_root,
4996            reason: "workspace root metadata requires deferred repair".to_string(),
4997        });
4998    }
4999
5000    publish_if_current(|| {
5001        let tx = conn.transaction()?;
5002        tx.execute(
5003            "UPDATE OR IGNORE backend_file_state
5004             SET workspace_root = ?1
5005             WHERE workspace_root <> ?1",
5006            params![&current_root],
5007        )?;
5008        tx.execute(
5009            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5010            params![&current_root],
5011        )?;
5012        tx.commit()?;
5013        Ok(())
5014    })?;
5015
5016    crate::slog_info!(
5017        "callgraph store re-rooted from {} to {}",
5018        roots.join(", "),
5019        current_root
5020    );
5021    Ok(OpenRootRepair::ReRooted)
5022}
5023
5024fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5025    let mut stmt = conn.prepare(
5026        "SELECT DISTINCT workspace_root
5027         FROM backend_file_state
5028         ORDER BY workspace_root",
5029    )?;
5030    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5031    rows.collect::<std::result::Result<Vec<_>, _>>()
5032        .map_err(Into::into)
5033}
5034
5035fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5036    for (table, column) in STORE_DATA_PATH_COLUMNS {
5037        let sql = format!(
5038            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5039        );
5040        let mut stmt = conn.prepare(&sql)?;
5041        let mut rows = stmt.query([])?;
5042        while let Some(row) = rows.next()? {
5043            let value: String = row.get(0)?;
5044            if stored_path_is_absolute(&value) {
5045                return Ok(Some(format!("{table}.{column}={value}")));
5046            }
5047        }
5048    }
5049    Ok(None)
5050}
5051
5052fn stored_path_is_absolute(value: &str) -> bool {
5053    if value.is_empty() {
5054        return false;
5055    }
5056    if Path::new(value).is_absolute() || value.starts_with('/') {
5057        return true;
5058    }
5059    let bytes = value.as_bytes();
5060    if bytes.len() >= 3
5061        && bytes[1] == b':'
5062        && (bytes[2] == b'/' || bytes[2] == b'\\')
5063        && bytes[0].is_ascii_alphabetic()
5064    {
5065        return true;
5066    }
5067    value.starts_with("\\\\") || value.starts_with("//")
5068}
5069
5070fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5071    if let OpenRootRepair::NeedsRebuild {
5072        previous_roots,
5073        current_root,
5074        reason,
5075    } = repair
5076    {
5077        crate::slog_info!(
5078            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5079            previous_roots.join(", "),
5080            current_root,
5081            reason
5082        );
5083    }
5084}
5085
5086/// Nanosecond clock used to make temp/generation file names unique.
5087fn now_nanos() -> u128 {
5088    SystemTime::now()
5089        .duration_since(UNIX_EPOCH)
5090        .unwrap_or(Duration::ZERO)
5091        .as_nanos()
5092}
5093
5094/// The pointer file `<dir>/<key>.current`. Its single line names the current
5095/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
5096/// can always be atomically replaced via rename even on Windows — Rust opens
5097/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
5098fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5099    callgraph_dir.join(format!("{project_key}.current"))
5100}
5101
5102/// The legacy single-file DB path used before the generation scheme. Still read
5103/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
5104/// build publishes a generation.
5105fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5106    callgraph_dir.join(format!("{project_key}.sqlite"))
5107}
5108
5109/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
5110/// cold build writes a brand-new generation file, so publishing NEVER replaces
5111/// a file another process holds open (the root Windows fix).
5112fn generation_file_name(project_key: &str) -> String {
5113    format!(
5114        "{project_key}.g{}.{}.sqlite",
5115        now_nanos(),
5116        std::process::id()
5117    )
5118}
5119
5120/// Read the pointer; returns the generation file name if present and non-empty.
5121fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5122    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5123    let name = text.trim();
5124    if name.is_empty() {
5125        None
5126    } else {
5127        Some(name.to_string())
5128    }
5129}
5130
5131/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
5132/// `ready` flag). Uses a throwaway read-only connection.
5133fn db_path_ready(path: &Path) -> bool {
5134    (|| -> Result<bool> {
5135        let conn = open_readonly_connection(path)?;
5136        database_ready(&conn)
5137    })()
5138    .unwrap_or(false)
5139}
5140
5141/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
5142/// where `generation` is `Some(name)` for a pointer-published generation or
5143/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
5144/// published (caller treats that as "needs cold build").
5145///
5146/// Handles the GC race (the pointer names a generation that was just deleted) by
5147/// re-reading the pointer and retrying a few times.
5148fn resolve_ready_target(
5149    callgraph_dir: &Path,
5150    project_key: &str,
5151) -> Option<(PathBuf, Option<String>)> {
5152    for _ in 0..5 {
5153        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5154            let gen_path = callgraph_dir.join(&generation);
5155            if gen_path.is_file() {
5156                return (migration_manifest_valid(callgraph_dir, &generation)
5157                    && db_path_ready(&gen_path))
5158                .then_some((gen_path, Some(generation)));
5159            }
5160            // Pointer names a missing generation (a GC/publish race): re-read the
5161            // pointer and retry rather than failing the reader.
5162            std::thread::sleep(Duration::from_millis(5));
5163            continue;
5164        }
5165        // No pointer: fall back to the legacy single-file DB if it is ready.
5166        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5167        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5168    }
5169    None
5170}
5171
5172/// Atomically publish `generation` as the current store by flipping the pointer
5173/// file. Writes a temp file, fsyncs, then renames over the pointer — never
5174/// replacing an open DB file, so it succeeds cross-platform.
5175fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5176    let pointer = pointer_path(callgraph_dir, project_key);
5177    let tmp = callgraph_dir.join(format!(
5178        "{project_key}.current.tmp.{}.{}",
5179        std::process::id(),
5180        now_nanos()
5181    ));
5182    {
5183        use std::io::Write as _;
5184        let mut file = std::fs::File::create(&tmp)?;
5185        file.write_all(generation.as_bytes())?;
5186        file.write_all(b"\n")?;
5187        file.sync_all()?;
5188    }
5189    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5190        let _ = std::fs::remove_file(&tmp);
5191        return Err(error.into());
5192    }
5193    crate::fs_lock::sync_parent(&pointer);
5194    Ok(())
5195}
5196
5197#[derive(Clone, Debug)]
5198struct GenerationGcCandidate {
5199    name: String,
5200    path: PathBuf,
5201    modified: SystemTime,
5202}
5203
5204/// Best-effort GC of superseded generation files. The current pointer target and
5205/// newest previous generation are always retained. Older generations are removed
5206/// when they have no protected read marker, or after the absolute retention TTL
5207/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
5208/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
5209fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5210    let temp_grace = Duration::from_secs(60);
5211    let now = SystemTime::now();
5212    let pointer_current =
5213        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5214    let gen_prefix = format!("{project_key}.g");
5215    let tmp_prefixes = [
5216        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
5217        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
5218        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
5219    ];
5220    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5221        return;
5222    };
5223    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5224    for entry in entries.flatten() {
5225        let name = entry.file_name();
5226        let name = name.to_string_lossy().to_string();
5227        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5228        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5229
5230        // Orphaned temp files from a crashed build/publish: remove once aged out.
5231        if name.contains(".tmp.") {
5232            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5233                let _ = std::fs::remove_file(entry.path());
5234            }
5235            continue;
5236        }
5237
5238        // Superseded legacy single-file DB: best-effort delete once a generation
5239        // is published (ignored if another process still holds it open).
5240        if name == format!("{project_key}.sqlite") {
5241            remove_sqlite_file_set(&entry.path());
5242            continue;
5243        }
5244
5245        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5246            gens.push(GenerationGcCandidate {
5247                name,
5248                path: entry.path(),
5249                modified: mtime,
5250            });
5251        }
5252    }
5253
5254    let mut superseded = gens
5255        .iter()
5256        .filter(|generation| generation.name != pointer_current)
5257        .collect::<Vec<_>>();
5258    superseded.sort_by(|left, right| {
5259        right
5260            .modified
5261            .cmp(&left.modified)
5262            .then_with(|| right.name.cmp(&left.name))
5263    });
5264    let previous = superseded.first().map(|generation| generation.name.clone());
5265
5266    for generation in gens {
5267        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5268        if generation.name == pointer_current
5269            || Some(generation.name.as_str()) == previous.as_deref()
5270        {
5271            continue;
5272        }
5273
5274        let age = now
5275            .duration_since(generation.modified)
5276            .unwrap_or(Duration::ZERO);
5277        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5278            continue;
5279        }
5280
5281        remove_sqlite_file_set(&generation.path);
5282        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5283        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5284            callgraph_dir,
5285            &generation.name,
5286        ));
5287    }
5288}
5289
5290fn remove_sqlite_file_set(path: &Path) {
5291    let _ = std::fs::remove_file(path);
5292    remove_sqlite_sidecars(path);
5293}
5294
5295fn remove_sqlite_sidecars(path: &Path) {
5296    let path_text = path.to_string_lossy();
5297    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5298    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5299    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5300}
5301
5302/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
5303/// the global all-cores rayon pool. The store cold-build is the heaviest
5304/// background pass (parse-dominated) and runs on a separate thread off the
5305/// single-threaded request loop; left unbounded it monopolizes every core and
5306/// starves the bridge so interactive tools time out (the same starvation the
5307/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
5308/// match the main thread, since the extract walks tree-sitter ASTs.
5309fn build_pool_size() -> usize {
5310    std::thread::available_parallelism()
5311        .map(|parallelism| parallelism.get())
5312        .unwrap_or(1)
5313        .div_ceil(2)
5314        .clamp(1, 8)
5315}
5316
5317fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
5318    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
5319        Ok(extract) => Ok(extract),
5320        Err(error) => {
5321            let abs_path =
5322                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
5323            let rel_path = relative_path(project_root, &abs_path);
5324            let freshness = cache_freshness::collect(&abs_path).ok();
5325            log::debug!(
5326                "callgraph store: skipping {} during cold build: {}",
5327                abs_path.display(),
5328                error
5329            );
5330            Err(ExtractFailure {
5331                rel_path,
5332                freshness,
5333            })
5334        }
5335    };
5336
5337    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
5338        files.par_iter().map(extract_one).collect()
5339    };
5340
5341    // Run inside a dedicated bounded pool when one builds; fall back to the
5342    // global pool only if the bounded pool can't be constructed.
5343    let results = match rayon::ThreadPoolBuilder::new()
5344        .num_threads(build_pool_size())
5345        .thread_name(|index| format!("aft-callgraph-build-{index}"))
5346        .stack_size(8 * 1024 * 1024)
5347        .build()
5348    {
5349        Ok(pool) => pool.install(run),
5350        Err(error) => {
5351            log::warn!(
5352                "callgraph store: bounded build pool unavailable ({error}); using global pool"
5353            );
5354            run()
5355        }
5356    };
5357
5358    let mut extracts = Vec::new();
5359    let mut failures = Vec::new();
5360    for result in results {
5361        match result {
5362            Ok(extract) => extracts.push(extract),
5363            Err(failure) => failures.push(failure),
5364        }
5365    }
5366    BuildExtractsResult { extracts, failures }
5367}
5368
5369fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
5370    let metadata = std::fs::metadata(path)?;
5371    let size = metadata.len();
5372    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
5373        cache_freshness::zero_hash()
5374    } else if source.len() as u64 == size {
5375        cache_freshness::hash_bytes(source.as_bytes())
5376    } else {
5377        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
5378    };
5379    Ok(FileFreshness {
5380        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
5381        size,
5382        content_hash,
5383    })
5384}
5385
5386fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
5387    let abs_path = normalize_file_path(project_root, path)?;
5388    let rel_path = relative_path(project_root, &abs_path);
5389    let source = std::fs::read_to_string(&abs_path)?;
5390    let freshness = collect_source_freshness(&abs_path, &source)?;
5391    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
5392    let lang = data.lang;
5393    if lang == LangId::Rust {
5394        extend_rust_imports_with_nested_uses(&source, &mut data);
5395    }
5396    let mut nodes = build_node_records(&rel_path, &source, &data)?;
5397    let node_by_scoped: HashMap<String, String> = nodes
5398        .iter()
5399        .map(|node| (node.scoped_name.clone(), node.id.clone()))
5400        .collect();
5401    let import_dependencies =
5402        import_dependencies(project_root, &abs_path, &data.import_block.imports);
5403    let line_index = LineIndex::new(&source);
5404    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
5405    let rust_reexports = if lang == LangId::Rust {
5406        collect_rust_pub_use_reexport_refs(
5407            project_root,
5408            &abs_path,
5409            &rel_path,
5410            &data.import_block.imports,
5411            &line_index,
5412        )
5413    } else {
5414        ReexportRefs {
5415            raw_refs: Vec::new(),
5416            surface_parts: Vec::new(),
5417        }
5418    };
5419    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
5420    let mut raw_refs = Vec::new();
5421    raw_refs.extend(build_call_refs(
5422        &rel_path,
5423        &data,
5424        &node_by_scoped,
5425        &import_dependencies,
5426    ));
5427    raw_refs.extend(build_import_refs(
5428        project_root,
5429        &abs_path,
5430        &rel_path,
5431        &data.import_block.imports,
5432        &line_index,
5433    ));
5434    let mut surface_parts = reexports.surface_parts;
5435    surface_parts.extend(rust_reexports.surface_parts);
5436    surface_parts.extend(source_less_exports.surface_parts);
5437    raw_refs.extend(reexports.raw_refs);
5438    raw_refs.extend(rust_reexports.raw_refs);
5439    raw_refs.extend(source_less_exports.raw_refs);
5440    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
5441    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
5442
5443    Ok(FileExtract {
5444        rel_path,
5445        freshness,
5446        lang,
5447        data,
5448        nodes,
5449        raw_refs,
5450        dispatch_hints,
5451        surface_fingerprint,
5452    })
5453}
5454
5455fn build_node_records(
5456    rel_path: &str,
5457    source: &str,
5458    data: &FileCallData,
5459) -> Result<Vec<NodeRecord>> {
5460    let mut records = Vec::new();
5461    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
5462    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
5463    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
5464
5465    for (scoped_name, meta) in metadata {
5466        let name = unqualified_name(scoped_name).to_string();
5467        let range = selection_range(source, scoped_name, &name, &meta.range);
5468        let range_key = (
5469            range.start_line,
5470            range.start_col,
5471            range.end_line,
5472            range.end_col,
5473        );
5474        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
5475        let range_ordinal = *ordinal;
5476        *ordinal += 1;
5477        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
5478        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
5479        let is_default_export = data
5480            .default_export_symbol
5481            .as_deref()
5482            .map(|default| default == scoped_name || default == name)
5483            .unwrap_or(false);
5484        records.push(NodeRecord {
5485            id,
5486            file_path: rel_path.to_string(),
5487            name: name.clone(),
5488            scoped_name: scoped_name.clone(),
5489            kind: symbol_kind_label(&meta.kind).to_string(),
5490            range,
5491            range_ordinal,
5492            signature: meta.signature.clone(),
5493            exported,
5494            is_default_export,
5495            is_type_like: is_type_like(&meta.kind),
5496            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
5497                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
5498        });
5499    }
5500
5501    Ok(records)
5502}
5503
5504fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
5505    if scoped_name == TOP_LEVEL_SYMBOL {
5506        return Range {
5507            start_line: 0,
5508            start_col: 0,
5509            end_line: 0,
5510            end_col: 0,
5511        };
5512    }
5513    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
5514        return fallback.clone();
5515    };
5516    let start_col = fallback.start_col as usize;
5517    let search_start = start_col.min(line.len());
5518    if let Some(offset) = line[search_start..].find(name) {
5519        let col = search_start + offset;
5520        return Range {
5521            start_line: fallback.start_line,
5522            start_col: col as u32,
5523            end_line: fallback.start_line,
5524            end_col: (col + name.len()) as u32,
5525        };
5526    }
5527    if let Some(offset) = line.find(name) {
5528        return Range {
5529            start_line: fallback.start_line,
5530            start_col: offset as u32,
5531            end_line: fallback.start_line,
5532            end_col: (offset + name.len()) as u32,
5533        };
5534    }
5535    Range {
5536        start_line: fallback.start_line,
5537        start_col: fallback.start_col,
5538        end_line: fallback.start_line,
5539        end_col: fallback.start_col.saturating_add(name.len() as u32),
5540    }
5541}
5542
5543fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
5544    if scoped_name == TOP_LEVEL_SYMBOL {
5545        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
5546    }
5547    let input = format!(
5548        "{rel_path}:{}:{}:{}:{}:{ordinal}",
5549        range.start_line, range.start_col, range.end_line, range.end_col
5550    );
5551    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
5552}
5553
5554fn build_call_refs(
5555    rel_path: &str,
5556    data: &FileCallData,
5557    node_by_scoped: &HashMap<String, String>,
5558    import_dependencies: &BTreeSet<String>,
5559) -> Vec<RawRef> {
5560    let mut refs = Vec::new();
5561    let mut ordinal = 0usize;
5562    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
5563    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
5564    for (caller_symbol, call_sites) in symbols {
5565        let caller_node = node_by_scoped.get(caller_symbol).cloned();
5566        for call_site in call_sites {
5567            ordinal += 1;
5568            let ref_id = ref_id(&[
5569                rel_path,
5570                "call",
5571                caller_symbol,
5572                &call_site.line.to_string(),
5573                &call_site.byte_start.to_string(),
5574                &call_site.byte_end.to_string(),
5575                &call_site.full_callee,
5576                &ordinal.to_string(),
5577            ]);
5578            refs.push(RawRef {
5579                ref_id,
5580                caller_node: caller_node.clone(),
5581                caller_symbol: Some(caller_symbol.clone()),
5582                caller_file: rel_path.to_string(),
5583                kind: "call".to_string(),
5584                short_name: Some(call_site.callee_name.clone()),
5585                full_ref: Some(call_site.full_callee.clone()),
5586                module_path: None,
5587                import_kind: None,
5588                local_name: Some(call_site.callee_name.clone()),
5589                requested_name: Some(call_site.callee_name.clone()),
5590                namespace_alias: namespace_alias(&call_site.full_callee),
5591                wildcard: false,
5592                line: call_site.line,
5593                byte_start: call_site.byte_start,
5594                byte_end: call_site.byte_end,
5595                dependencies: import_dependencies.clone(),
5596            });
5597        }
5598    }
5599    refs
5600}
5601
5602fn build_import_refs(
5603    project_root: &Path,
5604    abs_path: &Path,
5605    rel_path: &str,
5606    imports: &[ImportStatement],
5607    line_index: &LineIndex,
5608) -> Vec<RawRef> {
5609    let mut refs = Vec::new();
5610    for (index, import) in imports.iter().enumerate() {
5611        let import_kind = import_kind_label(import.kind).to_string();
5612        let local_name = import_local_names(import).join(",");
5613        let requested_name = import_requested_names(import).join(",");
5614        let ref_id = ref_id(&[
5615            rel_path,
5616            "import",
5617            &import.byte_range.start.to_string(),
5618            &import.byte_range.end.to_string(),
5619            &import.module_path,
5620            &index.to_string(),
5621        ]);
5622        refs.push(RawRef {
5623            ref_id,
5624            caller_node: None,
5625            caller_symbol: None,
5626            caller_file: rel_path.to_string(),
5627            kind: "import".to_string(),
5628            short_name: None,
5629            full_ref: Some(import.raw_text.clone()),
5630            module_path: Some(import.module_path.clone()),
5631            import_kind: Some(import_kind),
5632            local_name: empty_to_none(local_name),
5633            requested_name: empty_to_none(requested_name),
5634            namespace_alias: import.namespace_import.clone(),
5635            wildcard: import_is_wildcard(import),
5636            line: line_index.byte_to_line(import.byte_range.start),
5637            byte_start: import.byte_range.start,
5638            byte_end: import.byte_range.end,
5639            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
5640        });
5641    }
5642    refs
5643}
5644
5645fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
5646    let grammar = grammar_for(LangId::Rust);
5647    let mut parser = Parser::new();
5648    if parser.set_language(&grammar).is_err() {
5649        return;
5650    }
5651    let Some(tree) = parser.parse(source, None) else {
5652        return;
5653    };
5654
5655    let mut seen = data
5656        .import_block
5657        .imports
5658        .iter()
5659        .map(|import| (import.byte_range.start, import.byte_range.end))
5660        .collect::<HashSet<_>>();
5661    let mut nested_imports = Vec::new();
5662    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
5663    if nested_imports.is_empty() {
5664        return;
5665    }
5666
5667    data.import_block.imports.extend(nested_imports);
5668    data.import_block
5669        .imports
5670        .sort_by_key(|import| import.byte_range.start);
5671    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
5672}
5673
5674fn collect_rust_use_imports(
5675    source: &str,
5676    node: Node<'_>,
5677    seen: &mut HashSet<(usize, usize)>,
5678    imports: &mut Vec<ImportStatement>,
5679) {
5680    if node.kind() == "use_declaration" {
5681        let range = node.byte_range();
5682        if seen.insert((range.start, range.end)) {
5683            if let Some(import) = rust_import_from_use_node(source, node) {
5684                imports.push(import);
5685            }
5686        }
5687    }
5688
5689    let mut cursor = node.walk();
5690    if !cursor.goto_first_child() {
5691        return;
5692    }
5693    loop {
5694        collect_rust_use_imports(source, cursor.node(), seen, imports);
5695        if !cursor.goto_next_sibling() {
5696            break;
5697        }
5698    }
5699}
5700
5701fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
5702    let raw_text = source[node.byte_range()].to_string();
5703    let body = rust_use_body(&raw_text)?.to_string();
5704    let visibility = rust_use_visibility(&raw_text);
5705    let names = rust_use_list_names(&body);
5706    let group = classify_rust_import_group(&body);
5707    let byte_range = node.byte_range();
5708
5709    Some(ImportStatement {
5710        module_path: body,
5711        names: names.clone(),
5712        default_import: visibility.clone(),
5713        namespace_import: None,
5714        kind: ImportKind::Value,
5715        group,
5716        byte_range,
5717        raw_text,
5718        form: ImportForm::RustUse {
5719            visibility,
5720            named: names,
5721        },
5722    })
5723}
5724
5725fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
5726    let start = imports.iter().map(|import| import.byte_range.start).min()?;
5727    let end = imports.iter().map(|import| import.byte_range.end).max()?;
5728    Some(start..end)
5729}
5730
5731fn rust_use_visibility(raw_text: &str) -> Option<String> {
5732    let use_pos = raw_text.find("use ")?;
5733    let prefix = raw_text[..use_pos].trim();
5734    if prefix.is_empty() {
5735        None
5736    } else {
5737        Some(prefix.to_string())
5738    }
5739}
5740
5741fn rust_use_body(raw_text: &str) -> Option<&str> {
5742    let use_pos = raw_text.find("use ")?;
5743    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
5744}
5745
5746fn rust_use_list_names(body: &str) -> Vec<String> {
5747    let Some(open) = body.find("::{") else {
5748        return Vec::new();
5749    };
5750    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
5751        return Vec::new();
5752    };
5753    body[open + 3..close]
5754        .split(',')
5755        .filter_map(|spec| {
5756            let spec = spec.trim();
5757            if spec.is_empty() {
5758                None
5759            } else {
5760                Some(spec.to_string())
5761            }
5762        })
5763        .collect()
5764}
5765
5766fn classify_rust_import_group(body: &str) -> ImportGroup {
5767    let first = body
5768        .split("::")
5769        .next()
5770        .unwrap_or(body)
5771        .split_whitespace()
5772        .next()
5773        .unwrap_or(body);
5774    match first.trim() {
5775        "std" | "core" | "alloc" => ImportGroup::Stdlib,
5776        "crate" | "self" | "super" => ImportGroup::Internal,
5777        _ => ImportGroup::External,
5778    }
5779}
5780
5781#[derive(Debug, Clone)]
5782struct ReexportRefs {
5783    raw_refs: Vec<RawRef>,
5784    surface_parts: Vec<String>,
5785}
5786
5787fn collect_reexport_refs(
5788    project_root: &Path,
5789    abs_path: &Path,
5790    rel_path: &str,
5791    source: &str,
5792) -> ReexportRefs {
5793    let mut raw_refs = Vec::new();
5794    let mut surface_parts = Vec::new();
5795    let mut search_start = 0usize;
5796    let mut ordinal = 0usize;
5797    while let Some(export_offset) = source[search_start..].find("export") {
5798        let start = search_start + export_offset;
5799        let Some(statement_end_offset) = source[start..].find(';') else {
5800            break;
5801        };
5802        let end = start + statement_end_offset + 1;
5803        let statement = &source[start..end];
5804        search_start = end;
5805        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
5806            continue;
5807        }
5808        let Some(module_path) = quoted_module_path(statement) else {
5809            continue;
5810        };
5811        ordinal += 1;
5812        let wildcard = statement.contains('*');
5813        let line = source[..start]
5814            .bytes()
5815            .filter(|byte| *byte == b'\n')
5816            .count() as u32
5817            + 1;
5818        let ref_id = ref_id(&[
5819            rel_path,
5820            "reexport",
5821            &start.to_string(),
5822            &end.to_string(),
5823            &module_path,
5824            &ordinal.to_string(),
5825        ]);
5826        surface_parts.push(format!("reexport\t{statement}"));
5827        raw_refs.push(RawRef {
5828            ref_id,
5829            caller_node: None,
5830            caller_symbol: None,
5831            caller_file: rel_path.to_string(),
5832            kind: "reexport".to_string(),
5833            short_name: None,
5834            full_ref: Some(statement.to_string()),
5835            module_path: Some(module_path.clone()),
5836            import_kind: Some("reexport".to_string()),
5837            local_name: None,
5838            requested_name: None,
5839            namespace_alias: None,
5840            wildcard,
5841            line,
5842            byte_start: start,
5843            byte_end: end,
5844            dependencies: module_dependencies(project_root, abs_path, &module_path),
5845        });
5846    }
5847    ReexportRefs {
5848        raw_refs,
5849        surface_parts,
5850    }
5851}
5852
5853fn collect_rust_pub_use_reexport_refs(
5854    project_root: &Path,
5855    abs_path: &Path,
5856    rel_path: &str,
5857    imports: &[ImportStatement],
5858    line_index: &LineIndex,
5859) -> ReexportRefs {
5860    let mut raw_refs = Vec::new();
5861    let mut surface_parts = Vec::new();
5862    let mut ordinal = 0usize;
5863
5864    for import in imports {
5865        let Some(visibility) = &import.default_import else {
5866            continue;
5867        };
5868        if !visibility.starts_with("pub") {
5869            continue;
5870        }
5871        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
5872            continue;
5873        };
5874        ordinal += 1;
5875        let ref_id = ref_id(&[
5876            rel_path,
5877            "rust_reexport",
5878            &import.byte_range.start.to_string(),
5879            &import.byte_range.end.to_string(),
5880            &module_path,
5881            &ordinal.to_string(),
5882        ]);
5883        surface_parts.push(format!("reexport\t{}", import.raw_text));
5884        raw_refs.push(RawRef {
5885            ref_id,
5886            caller_node: None,
5887            caller_symbol: None,
5888            caller_file: rel_path.to_string(),
5889            kind: "reexport".to_string(),
5890            short_name: None,
5891            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
5892            module_path: Some(module_path.clone()),
5893            import_kind: Some("reexport".to_string()),
5894            local_name: None,
5895            requested_name: None,
5896            namespace_alias: None,
5897            wildcard,
5898            line: line_index.byte_to_line(import.byte_range.start),
5899            byte_start: import.byte_range.start,
5900            byte_end: import.byte_range.end,
5901            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
5902        });
5903    }
5904
5905    ReexportRefs {
5906        raw_refs,
5907        surface_parts,
5908    }
5909}
5910
5911fn rust_pub_use_reexport_parts(
5912    import: &ImportStatement,
5913) -> Option<(String, HashMap<String, String>, bool)> {
5914    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
5915    let body = body.trim();
5916    if let Some(module_path) = body.strip_suffix("::*") {
5917        return Some((module_path.trim().to_string(), HashMap::new(), true));
5918    }
5919
5920    if let Some(brace_start) = body.find("::{") {
5921        let module_path = body[..brace_start].trim().to_string();
5922        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
5923        if names.is_empty() {
5924            return None;
5925        }
5926        return Some((module_path, names, false));
5927    }
5928
5929    let (module_path, spec) = body.rsplit_once("::")?;
5930    let names = rust_reexport_names_from_specs(spec);
5931    if names.is_empty() {
5932        return None;
5933    }
5934    Some((module_path.trim().to_string(), names, false))
5935}
5936
5937fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
5938    let mut names = HashMap::new();
5939    for spec in specs.split(',') {
5940        let spec = spec.trim();
5941        if spec.is_empty() || spec == "self" {
5942            continue;
5943        }
5944        if let Some((source, local)) = spec.split_once(" as ") {
5945            let source = source.trim();
5946            let local = local.trim();
5947            if !source.is_empty() && !local.is_empty() && source != "self" {
5948                names.insert(local.to_string(), source.to_string());
5949            }
5950        } else {
5951            names.insert(spec.to_string(), spec.to_string());
5952        }
5953    }
5954    names
5955}
5956
5957fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
5958    if named.is_empty() {
5959        return fallback.to_string();
5960    }
5961    let mut specs = named
5962        .iter()
5963        .map(|(local, source)| {
5964            if local == source {
5965                source.clone()
5966            } else {
5967                format!("{source} as {local}")
5968            }
5969        })
5970        .collect::<Vec<_>>();
5971    specs.sort();
5972    format!("pub use {{{}}};", specs.join(", "))
5973}
5974
5975fn quoted_module_path(statement: &str) -> Option<String> {
5976    let quote = match (statement.find('\''), statement.find('"')) {
5977        (Some(single), Some(double)) if single < double => '\'',
5978        (Some(_), Some(_)) => '"',
5979        (Some(_), None) => '\'',
5980        (None, Some(_)) => '"',
5981        (None, None) => return None,
5982    };
5983    let start = statement.find(quote)? + 1;
5984    let end = statement[start..].find(quote)? + start;
5985    Some(statement[start..end].to_string())
5986}
5987
5988#[derive(Debug, Clone)]
5989struct SourceLessExportRefs {
5990    raw_refs: Vec<RawRef>,
5991    surface_parts: Vec<String>,
5992}
5993
5994fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
5995    let mut raw_refs = Vec::new();
5996    let mut surface_parts = Vec::new();
5997    let mut search_start = 0usize;
5998    let mut ordinal = 0usize;
5999    while let Some(export_offset) = source[search_start..].find("export") {
6000        let start = search_start + export_offset;
6001        let Some(statement_end_offset) = source[start..].find(';') else {
6002            break;
6003        };
6004        let end = start + statement_end_offset + 1;
6005        let statement = &source[start..end];
6006        search_start = end;
6007        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6008            continue;
6009        }
6010        let aliases = parse_reexport_names(statement);
6011        if aliases.is_empty() {
6012            continue;
6013        }
6014        let line = source[..start]
6015            .bytes()
6016            .filter(|byte| *byte == b'\n')
6017            .count() as u32
6018            + 1;
6019        for (exported, source_symbol) in aliases {
6020            ordinal += 1;
6021            let ref_id = ref_id(&[
6022                rel_path,
6023                "export_alias",
6024                &start.to_string(),
6025                &end.to_string(),
6026                &exported,
6027                &source_symbol,
6028                &ordinal.to_string(),
6029            ]);
6030            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6031            raw_refs.push(RawRef {
6032                ref_id,
6033                caller_node: None,
6034                caller_symbol: None,
6035                caller_file: rel_path.to_string(),
6036                kind: "export_alias".to_string(),
6037                short_name: None,
6038                full_ref: Some(statement.to_string()),
6039                module_path: None,
6040                import_kind: Some("export_alias".to_string()),
6041                local_name: Some(exported),
6042                requested_name: Some(source_symbol),
6043                namespace_alias: None,
6044                wildcard: false,
6045                line,
6046                byte_start: start,
6047                byte_end: end,
6048                dependencies: BTreeSet::new(),
6049            });
6050        }
6051    }
6052    SourceLessExportRefs {
6053        raw_refs,
6054        surface_parts,
6055    }
6056}
6057
6058fn build_dispatch_hints(
6059    rel_path: &str,
6060    data: &FileCallData,
6061    node_by_scoped: &HashMap<String, String>,
6062) -> Vec<DispatchHint> {
6063    let mut hints = Vec::new();
6064    let mut ordinal = 0usize;
6065    for (caller_symbol, call_sites) in &data.calls_by_symbol {
6066        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6067            continue;
6068        };
6069        for call_site in call_sites {
6070            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6071                continue;
6072            }
6073            ordinal += 1;
6074            hints.push(DispatchHint {
6075                id: ref_id(&[
6076                    rel_path,
6077                    "dispatch",
6078                    caller_symbol,
6079                    &call_site.line.to_string(),
6080                    &call_site.byte_start.to_string(),
6081                    &call_site.byte_end.to_string(),
6082                    &ordinal.to_string(),
6083                ]),
6084                method_name: call_site.callee_name.clone(),
6085                caller_node: caller_node.clone(),
6086                file: rel_path.to_string(),
6087                line: call_site.line,
6088                byte_start: call_site.byte_start,
6089                byte_end: call_site.byte_end,
6090            });
6091        }
6092    }
6093    hints
6094}
6095
6096fn surface_fingerprint(
6097    nodes: &mut [NodeRecord],
6098    data: &FileCallData,
6099    reexport_parts: &[String],
6100) -> String {
6101    nodes.sort_by(|left, right| {
6102        (left.file_path.as_str(), left.scoped_name.as_str())
6103            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6104    });
6105    let mut parts = Vec::new();
6106    for node in nodes.iter() {
6107        parts.push(format!(
6108            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6109            node.scoped_name,
6110            node.name,
6111            node.kind,
6112            node.exported,
6113            node.range.start_line,
6114            node.range.start_col,
6115            node.range.end_line,
6116            node.range.end_col,
6117            node.range_ordinal,
6118            node.signature.as_deref().unwrap_or("")
6119        ));
6120    }
6121    let mut exports = data.exported_symbols.clone();
6122    exports.sort();
6123    for export in exports {
6124        parts.push(format!("export\t{export}"));
6125    }
6126    if let Some(default_export) = &data.default_export_symbol {
6127        parts.push(format!("default\t{default_export}"));
6128    }
6129    let mut imports: Vec<String> = data
6130        .import_block
6131        .imports
6132        .iter()
6133        .map(|import| {
6134            format!(
6135                "import\t{}\t{:?}\t{}",
6136                import.module_path, import.form, import.raw_text
6137            )
6138        })
6139        .collect();
6140    imports.sort();
6141    parts.extend(imports);
6142    parts.extend(reexport_parts.iter().cloned());
6143    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6144}
6145
6146fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6147    if raw.kind != "call" {
6148        return Ok(ResolvedRef {
6149            dependencies: raw.dependencies.clone(),
6150            raw,
6151            status: "unresolved".to_string(),
6152            target_node: None,
6153            target_file: None,
6154            target_symbol: None,
6155            edge: None,
6156        });
6157    }
6158
6159    let caller_file = raw.caller_file.clone();
6160    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6161        CallGraphStoreError::MissingCallerData {
6162            file: caller_file.clone(),
6163        }
6164    })?;
6165    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6166    let short_name = raw.short_name.as_deref().unwrap_or_default();
6167    let mut dependencies = raw.dependencies.clone();
6168
6169    let resolved = match index.lang_for(&caller_file) {
6170        Some(LangId::Rust) => {
6171            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6172        }
6173        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6174            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6175        }
6176        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6177    };
6178
6179    let Some((status, target_file, target_symbol)) = resolved else {
6180        return Ok(ResolvedRef {
6181            raw,
6182            status: "unresolved".to_string(),
6183            target_node: None,
6184            target_file: None,
6185            target_symbol: None,
6186            dependencies,
6187            edge: None,
6188        });
6189    };
6190
6191    dependencies.insert(target_file.clone());
6192    let target_node = index.node_for_symbol(&target_file, &target_symbol);
6193    let source_node = raw.caller_node.clone();
6194    let edge = if let Some(source_node) = source_node {
6195        if target_file == caller_file
6196            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6197        {
6198            None
6199        } else {
6200            Some(EdgeRecord {
6201                edge_id: ref_id(&[&raw.ref_id, "edge"]),
6202                source_node,
6203                target_node: target_node.clone(),
6204                target_file: target_file.clone(),
6205                target_symbol: target_symbol.clone(),
6206                kind: "call".to_string(),
6207                line: raw.line,
6208            })
6209        }
6210    } else {
6211        None
6212    };
6213
6214    Ok(ResolvedRef {
6215        raw,
6216        status,
6217        target_node,
6218        target_file: Some(target_file),
6219        target_symbol: Some(target_symbol),
6220        dependencies,
6221        edge,
6222    })
6223}
6224
6225fn resolve_js_ts_target(
6226    index: &ProjectIndex<'_>,
6227    caller_file: &str,
6228    full_ref: &str,
6229    short_name: &str,
6230    caller_data: &FileCallData,
6231) -> Option<(String, String, String)> {
6232    if let Some((namespace, member)) = full_ref.split_once('.') {
6233        for import in &caller_data.import_block.imports {
6234            if import.namespace_import.as_deref() == Some(namespace) {
6235                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6236                    if let Some((file, symbol)) =
6237                        resolve_exported_symbol(index, &target_file, member, 0)
6238                    {
6239                        return Some(("resolved".to_string(), file, symbol));
6240                    }
6241                }
6242            }
6243        }
6244    }
6245
6246    for import in &caller_data.import_block.imports {
6247        for spec in &import.names {
6248            if crate::imports::specifier_local_name(spec) == short_name {
6249                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6250                    let requested = crate::imports::specifier_imported_name(spec);
6251                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6252                        .unwrap_or_else(|| (target_file, requested.to_string()));
6253                    return Some(("resolved".to_string(), file, symbol));
6254                }
6255            }
6256        }
6257
6258        if import.default_import.as_deref() == Some(short_name) {
6259            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6260                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6261                    .or_else(|| {
6262                        index
6263                            .files
6264                            .get(&target_file)
6265                            .and_then(|file| file.default_export.clone())
6266                            .map(|symbol| (target_file.clone(), symbol))
6267                    })
6268                    .unwrap_or_else(|| {
6269                        let file_name = Path::new(&target_file)
6270                            .file_name()
6271                            .and_then(|name| name.to_str())
6272                            .unwrap_or("unknown")
6273                            .to_string();
6274                        (target_file, format!("<default:{file_name}>"))
6275                    });
6276                return Some(("resolved".to_string(), file, symbol));
6277            }
6278        }
6279    }
6280
6281    for import in &caller_data.import_block.imports {
6282        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6283            if index
6284                .files
6285                .get(&target_file)
6286                .map(|file| file.exports.contains(short_name))
6287                .unwrap_or(false)
6288            {
6289                return Some(("resolved".to_string(), target_file, short_name.to_string()));
6290            }
6291        }
6292    }
6293
6294    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6295}
6296
6297fn resolve_exported_symbol(
6298    index: &ProjectIndex<'_>,
6299    file: &str,
6300    requested: &str,
6301    depth: usize,
6302) -> Option<(String, String)> {
6303    let mut visited = std::collections::HashMap::new();
6304    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
6305}
6306
6307/// Re-export graphs are frequently cyclic (barrel files re-exporting each
6308/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
6309/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
6310/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
6311/// (file, symbol) pair — but only when the earlier visit had at least as much
6312/// remaining depth budget (a shallower re-visit can reach leaves the deeper
6313/// first visit had to cut off at the cap, so plain visited-set pruning would
6314/// lose resolutions the capped walk finds).
6315fn resolve_exported_symbol_inner(
6316    index: &ProjectIndex<'_>,
6317    file: &str,
6318    requested: &str,
6319    depth: usize,
6320    visited: &mut std::collections::HashMap<(String, String), usize>,
6321) -> Option<(String, String)> {
6322    if depth > 16 {
6323        return None;
6324    }
6325    if requested != "default" {
6326        if let Some(source_symbol) = index
6327            .files
6328            .get(file)
6329            .and_then(|item| item.export_aliases.get(requested))
6330        {
6331            return Some((file.to_string(), source_symbol.clone()));
6332        }
6333        if index
6334            .files
6335            .get(file)
6336            .map(|item| item.exports.contains(requested))
6337            .unwrap_or(false)
6338        {
6339            return Some((file.to_string(), requested.to_string()));
6340        }
6341    } else if let Some(default) = index
6342        .files
6343        .get(file)
6344        .and_then(|item| item.default_export.clone())
6345    {
6346        return Some((file.to_string(), default));
6347    }
6348
6349    // Memo check sits after the local-export fast paths: the common direct
6350    // hit never allocates the key, and a hit through the memo would have
6351    // returned above anyway.
6352    match visited.entry((file.to_string(), requested.to_string())) {
6353        std::collections::hash_map::Entry::Occupied(mut seen) => {
6354            if *seen.get() <= depth {
6355                return None;
6356            }
6357            seen.insert(depth);
6358        }
6359        std::collections::hash_map::Entry::Vacant(slot) => {
6360            slot.insert(depth);
6361        }
6362    }
6363
6364    for reexport in index.reexports_for(file) {
6365        let mut next_requested = requested.to_string();
6366        let matches = if reexport.wildcard {
6367            true
6368        } else if let Some(source_name) = reexport.named.get(requested) {
6369            next_requested = source_name.clone();
6370            true
6371        } else {
6372            false
6373        };
6374        if !matches {
6375            continue;
6376        }
6377        if let Some(target_file) = &reexport.target_file {
6378            if let Some(target) = resolve_exported_symbol_inner(
6379                index,
6380                target_file,
6381                &next_requested,
6382                depth + 1,
6383                visited,
6384            ) {
6385                return Some(target);
6386            }
6387        }
6388    }
6389    None
6390}
6391
6392fn resolve_rust_target(
6393    index: &ProjectIndex<'_>,
6394    caller_file: &str,
6395    full_ref: &str,
6396    short_name: &str,
6397    caller_data: &FileCallData,
6398    raw: &RawRef,
6399) -> Option<(String, String, String)> {
6400    if full_ref.contains("::") {
6401        if let Some((target_file, target_symbol)) =
6402            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
6403        {
6404            return Some(("resolved".to_string(), target_file, target_symbol));
6405        }
6406    }
6407
6408    for import in &caller_data.import_block.imports {
6409        if let Some((target_file, target_symbol)) =
6410            rust_target_for_use(index, caller_file, import, short_name)
6411        {
6412            return Some(("resolved".to_string(), target_file, target_symbol));
6413        }
6414    }
6415
6416    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6417}
6418
6419fn rust_target_for_qualified(
6420    index: &ProjectIndex<'_>,
6421    caller_file: &str,
6422    full_ref: &str,
6423    short_name: &str,
6424    caller_data: &FileCallData,
6425    raw: &RawRef,
6426) -> Option<(String, String)> {
6427    let mut segments: Vec<&str> = full_ref.split("::").collect();
6428    if segments.len() < 2 {
6429        return None;
6430    }
6431    segments.pop();
6432    let requested_symbol = rust_target_symbol(full_ref, short_name);
6433
6434    for path in rust_module_path_candidates(&segments, caller_data, raw) {
6435        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
6436        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
6437            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
6438                return Some(rust_resolve_reexport_if_symbol_missing(
6439                    index,
6440                    target_file,
6441                    requested_symbol.clone(),
6442                ));
6443            }
6444        }
6445
6446        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
6447        if let Some(target) =
6448            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
6449        {
6450            return Some(target);
6451        }
6452        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
6453            return Some(rust_resolve_reexport_if_symbol_missing(
6454                index,
6455                target_file,
6456                requested_symbol.clone(),
6457            ));
6458        }
6459    }
6460    None
6461}
6462
6463fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
6464    full_ref
6465        .rsplit("::")
6466        .next()
6467        .filter(|name| !name.is_empty())
6468        .unwrap_or(short_name)
6469        .to_string()
6470}
6471
6472fn rust_resolve_reexport_if_symbol_missing(
6473    index: &ProjectIndex<'_>,
6474    target_file: String,
6475    target_symbol: String,
6476) -> (String, String) {
6477    if index
6478        .node_for_symbol(&target_file, &target_symbol)
6479        .is_some()
6480    {
6481        return (target_file, target_symbol);
6482    }
6483    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
6484        resolved
6485    } else {
6486        (target_file, target_symbol)
6487    }
6488}
6489
6490fn rust_module_path_candidates(
6491    segments: &[&str],
6492    caller_data: &FileCallData,
6493    raw: &RawRef,
6494) -> Vec<Vec<String>> {
6495    let mut candidates = Vec::new();
6496    if let Some(first) = segments.first().copied() {
6497        for import in &caller_data.import_block.imports {
6498            if !rust_import_is_visible_to_call(import, raw) {
6499                continue;
6500            }
6501            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
6502                continue;
6503            };
6504            if local_name == first {
6505                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
6506                rust_push_unique_path_candidate(&mut candidates, path_segments);
6507            }
6508        }
6509    }
6510    rust_push_unique_path_candidate(
6511        &mut candidates,
6512        segments
6513            .iter()
6514            .map(|segment| (*segment).to_string())
6515            .collect(),
6516    );
6517    candidates
6518}
6519
6520fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
6521    if !candidates.iter().any(|existing| existing == &candidate) {
6522        candidates.push(candidate);
6523    }
6524}
6525
6526fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
6527    import.byte_range.start <= raw.byte_start
6528}
6529
6530fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
6531    let path = import.module_path.trim().trim_end_matches(';').trim();
6532    if path.contains("::{") || path.contains('{') || path.contains('*') {
6533        return None;
6534    }
6535    let (path_without_alias, alias) = path
6536        .split_once(" as ")
6537        .map(|(left, right)| (left.trim(), Some(right.trim())))
6538        .unwrap_or((path, None));
6539    let segments = path_without_alias
6540        .split("::")
6541        .map(str::trim)
6542        .filter(|segment| !segment.is_empty())
6543        .collect::<Vec<_>>();
6544    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
6545    if local_name.chars().next().is_some_and(char::is_uppercase) {
6546        return None;
6547    }
6548    Some((
6549        local_name,
6550        segments
6551            .into_iter()
6552            .map(|segment| segment.to_string())
6553            .collect(),
6554    ))
6555}
6556
6557fn rust_inline_scoped_target(
6558    index: &ProjectIndex<'_>,
6559    caller_file: &str,
6560    module_segments: &[String],
6561    short_name: &str,
6562) -> Option<(String, String)> {
6563    let src_prefix = rust_src_prefix(caller_file);
6564    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
6565    file_paths.sort();
6566    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
6567        let caller = file_paths.remove(position);
6568        file_paths.insert(0, caller);
6569    }
6570
6571    for file_path in file_paths {
6572        if index.lang_for(&file_path) != Some(LangId::Rust)
6573            || rust_src_prefix(&file_path) != src_prefix
6574        {
6575            continue;
6576        }
6577        let file_module_segments = rust_module_segments_for_rel(&file_path);
6578        if !module_segments.starts_with(&file_module_segments) {
6579            continue;
6580        }
6581        let scoped_segments = &module_segments[file_module_segments.len()..];
6582        if scoped_segments.is_empty() {
6583            continue;
6584        }
6585        let mut scoped_symbol = scoped_segments.join("::");
6586        scoped_symbol.push_str("::");
6587        scoped_symbol.push_str(short_name);
6588        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
6589            return Some((file_path, scoped_symbol));
6590        }
6591    }
6592    None
6593}
6594
6595fn rust_target_for_use(
6596    index: &ProjectIndex<'_>,
6597    caller_file: &str,
6598    import: &ImportStatement,
6599    short_name: &str,
6600) -> Option<(String, String)> {
6601    let path = import.module_path.trim().trim_end_matches(';');
6602    if let Some(brace_start) = path.find("::{") {
6603        let prefix = &path[..brace_start];
6604        if import.names.iter().any(|name| name == short_name) {
6605            let prefix_segments: Vec<&str> = prefix.split("::").collect();
6606            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
6607            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6608            return Some((file, short_name.to_string()));
6609        }
6610        return None;
6611    }
6612
6613    let (path_without_alias, alias) = path
6614        .split_once(" as ")
6615        .map(|(left, right)| (left.trim(), Some(right.trim())))
6616        .unwrap_or((path, None));
6617    let segments: Vec<&str> = path_without_alias.split("::").collect();
6618    let imported = alias.or_else(|| segments.last().copied())?;
6619    if imported != short_name {
6620        return None;
6621    }
6622    if segments.len() < 2 {
6623        return None;
6624    }
6625    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
6626    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6627    Some((file, segments.last().unwrap_or(&short_name).to_string()))
6628}
6629
6630fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
6631    let crate_name = segments.first().copied()?;
6632    let src_prefix = index.crate_src_prefix(crate_name)?;
6633    let module_segments = segments[1..]
6634        .iter()
6635        .map(|segment| segment.to_string())
6636        .collect::<Vec<_>>();
6637    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
6638}
6639
6640/// Walk the project tree once and map every Rust crate name (package name with
6641/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
6642/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
6643/// longer re-walks the filesystem once per ref.
6644fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
6645    let mut prefixes = HashMap::new();
6646    let mut stack = vec![project_root.to_path_buf()];
6647    while let Some(dir) = stack.pop() {
6648        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
6649        if matches!(name, "target" | "node_modules" | ".git") {
6650            continue;
6651        }
6652        let manifest = dir.join("Cargo.toml");
6653        if manifest.is_file() {
6654            let crate_names = rust_manifest_crate_names(&manifest);
6655            if !crate_names.is_empty() {
6656                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
6657                for crate_name in crate_names {
6658                    prefixes
6659                        .entry(crate_name)
6660                        .or_insert_with(|| src_prefix.clone());
6661                }
6662            }
6663        }
6664        let Ok(entries) = std::fs::read_dir(&dir) else {
6665            continue;
6666        };
6667        for entry in entries.flatten() {
6668            let path = entry.path();
6669            if path.is_dir() {
6670                stack.push(path);
6671            }
6672        }
6673    }
6674    prefixes
6675}
6676
6677/// Extract the crate names a manifest defines: the normalized package name
6678/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
6679/// reachable by either spelling, matching the previous match semantics.
6680fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
6681    let Ok(source) = std::fs::read_to_string(manifest) else {
6682        return Vec::new();
6683    };
6684    let mut in_lib = false;
6685    let mut package_name = None;
6686    let mut lib_name = None;
6687    for line in source.lines() {
6688        let trimmed = line.trim();
6689        if trimmed.starts_with('[') {
6690            in_lib = trimmed == "[lib]";
6691            continue;
6692        }
6693        let Some((key, value)) = trimmed.split_once('=') else {
6694            continue;
6695        };
6696        let key = key.trim();
6697        let value = value.trim().trim_matches('"');
6698        if in_lib && key == "name" {
6699            lib_name = Some(value.to_string());
6700        } else if !in_lib && key == "name" && package_name.is_none() {
6701            package_name = Some(value.to_string());
6702        }
6703    }
6704    let mut names = Vec::new();
6705    if let Some(lib) = lib_name {
6706        names.push(lib);
6707    }
6708    if let Some(package) = package_name {
6709        let normalized = package.replace('-', "_");
6710        if !names.contains(&normalized) {
6711            names.push(normalized);
6712        }
6713    }
6714    names
6715}
6716
6717fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
6718    if segments.is_empty() {
6719        return Some(Vec::new());
6720    }
6721    let caller_segments = rust_module_segments_for_rel(caller_file);
6722    match segments[0] {
6723        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
6724        "self" => {
6725            let mut resolved = caller_segments;
6726            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6727            Some(resolved)
6728        }
6729        "super" => {
6730            let mut resolved = caller_segments;
6731            resolved.pop();
6732            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6733            Some(resolved)
6734        }
6735        _ => {
6736            let mut resolved = caller_segments;
6737            resolved.pop();
6738            resolved.extend(segments.iter().map(|item| item.to_string()));
6739            Some(resolved)
6740        }
6741    }
6742}
6743
6744fn rust_file_for_segments(
6745    index: &ProjectIndex<'_>,
6746    caller_file: &str,
6747    segments: &[String],
6748) -> Option<String> {
6749    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
6750}
6751
6752fn rust_file_for_src_prefix(
6753    index: &ProjectIndex<'_>,
6754    src_prefix: &str,
6755    segments: &[String],
6756) -> Option<String> {
6757    let candidate = if segments.is_empty() {
6758        [src_prefix, "lib.rs"].join("/")
6759    } else {
6760        format!("{}/{}.rs", src_prefix, segments.join("/"))
6761    };
6762    if index.files.contains_key(&candidate) {
6763        return Some(candidate);
6764    }
6765    if !segments.is_empty() {
6766        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
6767        if index.files.contains_key(&mod_candidate) {
6768            return Some(mod_candidate);
6769        }
6770    }
6771    None
6772}
6773
6774fn rust_src_prefix(rel_path: &str) -> String {
6775    rel_path
6776        .split_once("/src/")
6777        .map(|(prefix, _)| format!("{prefix}/src"))
6778        .unwrap_or_else(|| "src".to_string())
6779}
6780
6781fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
6782    let after_src = rel_path
6783        .split_once("/src/")
6784        .map(|(_, rest)| rest)
6785        .or_else(|| rel_path.strip_prefix("src/"))
6786        .unwrap_or(rel_path);
6787    if matches!(after_src, "lib.rs" | "main.rs") {
6788        return Vec::new();
6789    }
6790    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
6791        return prefix.split('/').map(|item| item.to_string()).collect();
6792    }
6793    after_src
6794        .strip_suffix(".rs")
6795        .unwrap_or(after_src)
6796        .split('/')
6797        .map(|item| item.to_string())
6798        .collect()
6799}
6800
6801fn resolve_local_target(
6802    _index: &ProjectIndex<'_>,
6803    caller_file: &str,
6804    full_ref: &str,
6805    short_name: &str,
6806    caller_data: &FileCallData,
6807) -> Option<(String, String, String)> {
6808    if !callgraph::is_bare_callee(full_ref, short_name) {
6809        return None;
6810    }
6811    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
6812        .ok()
6813        .map(|symbol| {
6814            (
6815                "resolved_local".to_string(),
6816                caller_file.to_string(),
6817                symbol,
6818            )
6819        })
6820}
6821
6822impl<'a> ProjectIndex<'a> {
6823    fn from_parts(
6824        project_root: &Path,
6825        files: HashMap<String, DbFileIndex>,
6826        caller_data: HashMap<String, &'a FileCallData>,
6827    ) -> Self {
6828        Self {
6829            project_root: project_root.to_path_buf(),
6830            files,
6831            caller_data,
6832            workspace_crate_prefixes: std::sync::OnceLock::new(),
6833        }
6834    }
6835
6836    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
6837        let mut files = HashMap::new();
6838        let mut caller_data = HashMap::new();
6839        for extract in extracts {
6840            let index = DbFileIndex::from_extract(project_root, extract);
6841            caller_data.insert(extract.rel_path.clone(), &extract.data);
6842            files.insert(extract.rel_path.clone(), index);
6843        }
6844        Self::from_parts(project_root, files, caller_data)
6845    }
6846
6847    fn from_db_and_callers(
6848        tx: &Transaction<'_>,
6849        project_root: &Path,
6850        caller_extracts: &'a HashMap<String, FileExtract>,
6851    ) -> Result<Self> {
6852        let mut files = load_db_file_indexes(tx, project_root)?;
6853        let mut caller_data = HashMap::new();
6854        for (rel_path, extract) in caller_extracts {
6855            files.insert(
6856                rel_path.clone(),
6857                DbFileIndex::from_extract(project_root, extract),
6858            );
6859            caller_data.insert(rel_path.clone(), &extract.data);
6860        }
6861        Ok(Self::from_parts(project_root, files, caller_data))
6862    }
6863
6864    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
6865        self.files.get(rel_path).and_then(|file| file.lang)
6866    }
6867
6868    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
6869        self.files
6870            .get(caller_file)
6871            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
6872    }
6873
6874    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
6875        self.files
6876            .get(rel_path)
6877            .map(|file| file.reexports.as_slice())
6878            .unwrap_or(&[])
6879    }
6880
6881    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
6882        self.files.get(rel_path).and_then(|file| {
6883            file.node_by_scoped
6884                .get(symbol)
6885                .cloned()
6886                .or_else(|| file.node_by_bare.get(symbol).cloned())
6887        })
6888    }
6889}
6890
6891impl DbFileIndex {
6892    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
6893        let mut node_by_scoped = HashMap::new();
6894        let mut node_by_bare = HashMap::new();
6895        for node in &extract.nodes {
6896            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
6897            node_by_bare
6898                .entry(node.name.clone())
6899                .or_insert(node.id.clone());
6900        }
6901        let mut export_aliases = HashMap::new();
6902        for raw_ref in &extract.raw_refs {
6903            if raw_ref.kind == "export_alias" {
6904                if let (Some(exported), Some(source_symbol)) =
6905                    (&raw_ref.local_name, &raw_ref.requested_name)
6906                {
6907                    export_aliases.insert(exported.clone(), source_symbol.clone());
6908                }
6909            }
6910        }
6911        let mut module_targets = HashMap::new();
6912        let mut reexports = Vec::new();
6913        for raw_ref in &extract.raw_refs {
6914            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
6915                continue;
6916            }
6917            let Some(module_path) = &raw_ref.module_path else {
6918                continue;
6919            };
6920            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
6921            module_targets
6922                .entry(module_path.clone())
6923                .or_insert_with(|| target_file.clone());
6924            if raw_ref.kind == "reexport" {
6925                reexports.push(reexport_index_from_raw(raw_ref, target_file));
6926            }
6927        }
6928        Self {
6929            lang: Some(extract.lang),
6930            exports: extract.data.exported_symbols.iter().cloned().collect(),
6931            default_export: extract.data.default_export_symbol.clone(),
6932            export_aliases,
6933            node_by_scoped,
6934            node_by_bare,
6935            module_targets,
6936            reexports,
6937        }
6938    }
6939}
6940
6941fn load_db_file_indexes(
6942    tx: &Transaction<'_>,
6943    project_root: &Path,
6944) -> Result<HashMap<String, DbFileIndex>> {
6945    let mut files = HashMap::new();
6946    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
6947    let rows = stmt.query_map([], |row| {
6948        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6949    })?;
6950    for row in rows {
6951        let (rel_path, lang) = row?;
6952        files.insert(
6953            rel_path.clone(),
6954            DbFileIndex {
6955                lang: lang_from_label(&lang),
6956                exports: HashSet::new(),
6957                default_export: None,
6958                export_aliases: HashMap::new(),
6959                node_by_scoped: HashMap::new(),
6960                node_by_bare: HashMap::new(),
6961                module_targets: HashMap::new(),
6962                reexports: Vec::new(),
6963            },
6964        );
6965    }
6966
6967    let mut node_stmt = tx.prepare(
6968        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
6969    )?;
6970    let nodes = node_stmt.query_map([], |row| {
6971        Ok((
6972            row.get::<_, String>(0)?,
6973            row.get::<_, String>(1)?,
6974            row.get::<_, String>(2)?,
6975            row.get::<_, String>(3)?,
6976            row.get::<_, i64>(4)? != 0,
6977            row.get::<_, i64>(5)? != 0,
6978        ))
6979    })?;
6980    for row in nodes {
6981        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
6982        let file = files
6983            .entry(file_path.clone())
6984            .or_insert_with(|| DbFileIndex {
6985                lang: None,
6986                exports: HashSet::new(),
6987                default_export: None,
6988                export_aliases: HashMap::new(),
6989                node_by_scoped: HashMap::new(),
6990                node_by_bare: HashMap::new(),
6991                module_targets: HashMap::new(),
6992                reexports: Vec::new(),
6993            });
6994        if exported {
6995            file.exports.insert(name.clone());
6996            file.exports.insert(scoped_name.clone());
6997        }
6998        if is_default_export {
6999            file.default_export = Some(scoped_name.clone());
7000        }
7001        file.node_by_scoped.insert(scoped_name, id.clone());
7002        file.node_by_bare.entry(name).or_insert(id);
7003    }
7004    let file_keys: HashSet<String> = files.keys().cloned().collect();
7005    // Persisted caller extracts supply import targets. Only reexports from other
7006    // files need dependency reconstruction, and their caller dependencies are
7007    // loaded once instead of issuing repeated SQLite queries per reference.
7008    let dependencies_by_file = load_file_dependencies_index(tx)?;
7009    let mut ref_stmt = tx.prepare(
7010        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7011         FROM refs WHERE kind IN ('reexport', 'export_alias')",
7012    )?;
7013    let ref_rows = ref_stmt.query_map([], |row| {
7014        Ok((
7015            row.get::<_, String>(0)?,
7016            row.get::<_, String>(1)?,
7017            row.get::<_, String>(2)?,
7018            row.get::<_, Option<String>>(3)?,
7019            row.get::<_, Option<String>>(4)?,
7020            row.get::<_, i64>(5)? != 0,
7021            row.get::<_, Option<String>>(6)?,
7022            row.get::<_, Option<String>>(7)?,
7023        ))
7024    })?;
7025    for row in ref_rows {
7026        let (
7027            ref_id,
7028            caller_file,
7029            kind,
7030            module_path,
7031            full_ref,
7032            wildcard,
7033            local_name,
7034            requested_name,
7035        ) = row?;
7036        if kind == "export_alias" {
7037            if let (Some(exported), Some(source_symbol), Some(file)) =
7038                (local_name, requested_name, files.get_mut(&caller_file))
7039            {
7040                file.export_aliases.insert(exported, source_symbol);
7041            }
7042            continue;
7043        }
7044        let Some(module_path) = module_path else {
7045            continue;
7046        };
7047        let file_deps = dependencies_by_file
7048            .get(&caller_file)
7049            .cloned()
7050            .unwrap_or_default();
7051        let deps = stored_dependencies_for_module(
7052            project_root,
7053            &caller_file,
7054            &module_path,
7055            &file_deps,
7056            &file_keys,
7057        );
7058        let target_file = deps
7059            .iter()
7060            .find(|dep| file_keys.contains(*dep))
7061            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7062        if let Some(file) = files.get_mut(&caller_file) {
7063            file.module_targets
7064                .entry(module_path.clone())
7065                .or_insert_with(|| target_file.clone());
7066            if kind == "reexport" {
7067                let raw = RawRef {
7068                    ref_id,
7069                    caller_node: None,
7070                    caller_symbol: None,
7071                    caller_file,
7072                    kind,
7073                    short_name: None,
7074                    full_ref,
7075                    module_path: Some(module_path),
7076                    import_kind: Some("reexport".to_string()),
7077                    local_name: None,
7078                    requested_name: None,
7079                    namespace_alias: None,
7080                    wildcard,
7081                    line: 0,
7082                    byte_start: 0,
7083                    byte_end: 0,
7084                    dependencies: deps,
7085                };
7086                file.reexports
7087                    .push(reexport_index_from_raw(&raw, target_file));
7088            }
7089        }
7090    }
7091
7092    Ok(files)
7093}
7094
7095fn stored_dependencies_for_module(
7096    project_root: &Path,
7097    caller_file: &str,
7098    module_path: &str,
7099    caller_dependencies: &BTreeSet<String>,
7100    indexed_files: &HashSet<String>,
7101) -> BTreeSet<String> {
7102    let caller_path = project_root.join(caller_file);
7103    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7104    if module_path.starts_with('.') {
7105        let caller_dir = caller_path.parent().unwrap_or(project_root);
7106        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7107            let normalized = if candidate.is_file() {
7108                canonicalize_path(&candidate)
7109            } else {
7110                candidate
7111            };
7112            candidates.insert(relative_path(project_root, &normalized));
7113        }
7114    }
7115    let exact = candidates
7116        .intersection(caller_dependencies)
7117        .filter(|dependency| indexed_files.contains(*dependency))
7118        .cloned()
7119        .collect::<BTreeSet<_>>();
7120    if !exact.is_empty() || module_path.starts_with('.') {
7121        return exact;
7122    }
7123
7124    let module_path = rust_module_path_without_alias_or_use_list(module_path)
7125        .trim_matches(|character| matches!(character, '\'' | '"'));
7126    let package_name = module_path
7127        .split('/')
7128        .next_back()
7129        .unwrap_or(module_path)
7130        .replace('_', "-");
7131    let matched = caller_dependencies
7132        .iter()
7133        .filter(|dependency| indexed_files.contains(*dependency))
7134        .filter(|dependency| {
7135            dependency.as_str() == module_path
7136                || dependency.ends_with(&format!("/{module_path}"))
7137                || Path::new(dependency).components().any(|component| {
7138                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7139                })
7140        })
7141        .cloned()
7142        .collect::<BTreeSet<_>>();
7143    if matched.len() == 1 {
7144        matched
7145    } else {
7146        BTreeSet::new()
7147    }
7148}
7149
7150fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7151    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7152    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7153    let rows = stmt.query_map([], |row| {
7154        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7155    })?;
7156    for row in rows {
7157        let (file_path, dependency) = row?;
7158        by_file.entry(file_path).or_default().insert(dependency);
7159    }
7160    Ok(by_file)
7161}
7162
7163struct ColdBuildInsertStatements<'stmt> {
7164    file: Statement<'stmt>,
7165    node: Statement<'stmt>,
7166    file_dependency: Statement<'stmt>,
7167    dispatch_hint: Statement<'stmt>,
7168    backend_state: Statement<'stmt>,
7169    reference: Statement<'stmt>,
7170    edge: Statement<'stmt>,
7171}
7172
7173impl<'stmt> ColdBuildInsertStatements<'stmt> {
7174    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7175        Ok(Self {
7176            file: tx.prepare(
7177                "INSERT OR REPLACE INTO files(
7178                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7179                    is_public_api, surface_fingerprint, indexed_at
7180                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7181            )?,
7182            node: tx.prepare(
7183                "INSERT OR REPLACE INTO nodes(
7184                    id, file_path, name, scoped_name, kind, start_line, start_col,
7185                    end_line, end_col, range_ordinal, signature, exported,
7186                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
7187                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7188            )?,
7189            file_dependency: tx.prepare(
7190                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7191            )?,
7192            dispatch_hint: tx.prepare(
7193                "INSERT OR REPLACE INTO dispatch_hints(
7194                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7195                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7196            )?,
7197            backend_state: tx.prepare(
7198                "INSERT OR REPLACE INTO backend_file_state(
7199                    backend, workspace_root, file_path, content_hash, status, updated_at
7200                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7201            )?,
7202            reference: tx.prepare(
7203                "INSERT OR REPLACE INTO refs(
7204                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7205                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7206                    byte_start, byte_end, status, target_node, target_file, target_symbol,
7207                    provenance
7208                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7209            )?,
7210            edge: tx.prepare(
7211                "INSERT OR REPLACE INTO edges(
7212                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7213                    kind, line, provenance
7214                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7215            )?,
7216        })
7217    }
7218}
7219
7220fn insert_file_extract_prepared(
7221    statements: &mut ColdBuildInsertStatements<'_>,
7222    workspace_root: &str,
7223    extract: &FileExtract,
7224) -> Result<()> {
7225    statements.file.execute(params![
7226        extract.rel_path,
7227        hash_to_hex(extract.freshness.content_hash),
7228        system_time_to_ns(extract.freshness.mtime),
7229        extract.freshness.size as i64,
7230        lang_label(extract.lang),
7231        extract.surface_fingerprint,
7232        unix_seconds_now(),
7233    ])?;
7234    for node in &extract.nodes {
7235        statements.node.execute(params![
7236            node.id,
7237            node.file_path,
7238            node.name,
7239            node.scoped_name,
7240            node.kind,
7241            node.range.start_line as i64,
7242            node.range.start_col as i64,
7243            node.range.end_line as i64,
7244            node.range.end_col as i64,
7245            node.range_ordinal as i64,
7246            node.signature,
7247            bool_int(node.exported),
7248            bool_int(node.is_default_export),
7249            bool_int(node.is_type_like),
7250            bool_int(node.is_callgraph_entry_point),
7251            PROVENANCE_TREESITTER,
7252        ])?;
7253    }
7254
7255    let mut dependencies = BTreeSet::new();
7256    for raw_ref in &extract.raw_refs {
7257        dependencies.extend(raw_ref.dependencies.iter().cloned());
7258    }
7259    for dep_file in &dependencies {
7260        statements
7261            .file_dependency
7262            .execute(params![extract.rel_path, dep_file])?;
7263    }
7264
7265    for hint in &extract.dispatch_hints {
7266        statements.dispatch_hint.execute(params![
7267            hint.id,
7268            hint.method_name,
7269            hint.caller_node,
7270            hint.file,
7271            hint.line as i64,
7272            hint.byte_start as i64,
7273            hint.byte_end as i64,
7274            PROVENANCE_TREESITTER,
7275        ])?;
7276    }
7277    insert_backend_state_prepared(
7278        &mut statements.backend_state,
7279        workspace_root,
7280        &extract.rel_path,
7281        Some(&extract.freshness.content_hash),
7282        "fresh",
7283    )?;
7284    Ok(())
7285}
7286
7287fn insert_backend_state_prepared(
7288    stmt: &mut Statement<'_>,
7289    workspace_root: &str,
7290    rel_path: &str,
7291    content_hash: Option<&blake3::Hash>,
7292    status: &str,
7293) -> Result<()> {
7294    let hash = content_hash
7295        .map(|hash| hash_to_hex(*hash))
7296        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
7297    stmt.execute(params![
7298        BACKEND_TREESITTER,
7299        workspace_root,
7300        rel_path,
7301        hash,
7302        status,
7303        unix_seconds_now(),
7304    ])?;
7305    Ok(())
7306}
7307
7308fn insert_resolved_ref_prepared(
7309    statements: &mut ColdBuildInsertStatements<'_>,
7310    resolved: &ResolvedRef,
7311) -> Result<()> {
7312    let raw = &resolved.raw;
7313    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7314    statements.reference.execute(params![
7315        raw.ref_id,
7316        raw.caller_node,
7317        raw.caller_file,
7318        raw.kind,
7319        raw.short_name,
7320        raw.full_ref,
7321        raw.module_path,
7322        raw.import_kind,
7323        raw.local_name,
7324        raw.requested_name,
7325        raw.namespace_alias,
7326        bool_int(raw.wildcard),
7327        raw.line as i64,
7328        raw.byte_start as i64,
7329        raw.byte_end as i64,
7330        resolved.status,
7331        resolved.target_node,
7332        resolved.target_file,
7333        resolved.target_symbol,
7334        PROVENANCE_TREESITTER,
7335    ])?;
7336    if let Some(edge) = &resolved.edge {
7337        statements.edge.execute(params![
7338            edge.edge_id,
7339            raw.ref_id,
7340            edge.source_node,
7341            edge.target_node,
7342            edge.target_file,
7343            edge.target_symbol,
7344            edge.kind,
7345            edge.line as i64,
7346            PROVENANCE_TREESITTER,
7347        ])?;
7348    }
7349    Ok(())
7350}
7351
7352fn insert_file_extract(
7353    tx: &Transaction<'_>,
7354    project_root: &Path,
7355    extract: &FileExtract,
7356) -> Result<()> {
7357    tx.execute(
7358        "INSERT OR REPLACE INTO files(
7359            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7360            is_public_api, surface_fingerprint, indexed_at
7361        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7362        params![
7363            extract.rel_path,
7364            hash_to_hex(extract.freshness.content_hash),
7365            system_time_to_ns(extract.freshness.mtime),
7366            extract.freshness.size as i64,
7367            lang_label(extract.lang),
7368            extract.surface_fingerprint,
7369            unix_seconds_now(),
7370        ],
7371    )?;
7372    for node in &extract.nodes {
7373        tx.execute(
7374            "INSERT OR REPLACE INTO nodes(
7375                id, file_path, name, scoped_name, kind, start_line, start_col,
7376                end_line, end_col, range_ordinal, signature, exported,
7377                is_default_export, is_type_like, is_callgraph_entry_point, provenance
7378            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7379            params![
7380                node.id,
7381                node.file_path,
7382                node.name,
7383                node.scoped_name,
7384                node.kind,
7385                node.range.start_line as i64,
7386                node.range.start_col as i64,
7387                node.range.end_line as i64,
7388                node.range.end_col as i64,
7389                node.range_ordinal as i64,
7390                node.signature,
7391                bool_int(node.exported),
7392                bool_int(node.is_default_export),
7393                bool_int(node.is_type_like),
7394                bool_int(node.is_callgraph_entry_point),
7395                PROVENANCE_TREESITTER,
7396            ],
7397        )?;
7398    }
7399    let mut dependencies = BTreeSet::new();
7400    for raw_ref in &extract.raw_refs {
7401        dependencies.extend(raw_ref.dependencies.iter().cloned());
7402    }
7403    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
7404
7405    for hint in &extract.dispatch_hints {
7406        tx.execute(
7407            "INSERT OR REPLACE INTO dispatch_hints(
7408                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7409            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7410            params![
7411                hint.id,
7412                hint.method_name,
7413                hint.caller_node,
7414                hint.file,
7415                hint.line as i64,
7416                hint.byte_start as i64,
7417                hint.byte_end as i64,
7418                PROVENANCE_TREESITTER,
7419            ],
7420        )?;
7421    }
7422    mark_backend_state(
7423        tx,
7424        project_root,
7425        &extract.rel_path,
7426        Some(&extract.freshness.content_hash),
7427        "fresh",
7428    )?;
7429    Ok(())
7430}
7431
7432fn insert_file_dependencies(
7433    tx: &Transaction<'_>,
7434    file_path: &str,
7435    dependencies: &BTreeSet<String>,
7436) -> Result<()> {
7437    for dep_file in dependencies {
7438        tx.execute(
7439            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7440            params![file_path, dep_file],
7441        )?;
7442    }
7443    Ok(())
7444}
7445
7446fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
7447    let raw = &resolved.raw;
7448    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7449    tx.execute(
7450        "INSERT OR REPLACE INTO refs(
7451            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7452            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7453            byte_start, byte_end, status, target_node, target_file, target_symbol,
7454            provenance
7455        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7456        params![
7457            raw.ref_id,
7458            raw.caller_node,
7459            raw.caller_file,
7460            raw.kind,
7461            raw.short_name,
7462            raw.full_ref,
7463            raw.module_path,
7464            raw.import_kind,
7465            raw.local_name,
7466            raw.requested_name,
7467            raw.namespace_alias,
7468            bool_int(raw.wildcard),
7469            raw.line as i64,
7470            raw.byte_start as i64,
7471            raw.byte_end as i64,
7472            resolved.status,
7473            resolved.target_node,
7474            resolved.target_file,
7475            resolved.target_symbol,
7476            PROVENANCE_TREESITTER,
7477        ],
7478    )?;
7479    if let Some(edge) = &resolved.edge {
7480        tx.execute(
7481            "INSERT OR REPLACE INTO edges(
7482                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7483                kind, line, provenance
7484            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7485            params![
7486                edge.edge_id,
7487                raw.ref_id,
7488                edge.source_node,
7489                edge.target_node,
7490                edge.target_file,
7491                edge.target_symbol,
7492                edge.kind,
7493                edge.line as i64,
7494                PROVENANCE_TREESITTER,
7495            ],
7496        )?;
7497    }
7498    Ok(())
7499}
7500
7501fn insert_method_dispatch_edges(
7502    tx: &Transaction<'_>,
7503    project_root: &Path,
7504    caller_files: Option<&BTreeSet<String>>,
7505) -> Result<usize> {
7506    let references = load_name_match_refs(tx, caller_files)?;
7507    if references.is_empty() {
7508        return Ok(0);
7509    }
7510
7511    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
7512    let mut source_cache: DispatchSourceCache = HashMap::new();
7513    let mut inserted = 0usize;
7514    for reference in references {
7515        let key = (reference.method_name.clone(), reference.lang.clone());
7516        let candidates = match candidates_by_name.entry(key) {
7517            Entry::Occupied(entry) => entry.into_mut(),
7518            Entry::Vacant(entry) => {
7519                let candidates =
7520                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
7521                entry.insert(candidates)
7522            }
7523        };
7524
7525        if let Some(receiver_type) =
7526            infer_receiver_type(project_root, &reference, &mut source_cache)
7527        {
7528            let Some(candidate) =
7529                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
7530            else {
7531                continue;
7532            };
7533            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
7534            inserted += 1;
7535            continue;
7536        }
7537
7538        if method_name_match_denylisted(&reference.method_name) {
7539            continue;
7540        }
7541
7542        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
7543            continue;
7544        };
7545        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
7546        inserted += 1;
7547    }
7548    Ok(inserted)
7549}
7550
7551fn insert_method_dispatch_edges_chunked(
7552    tx: &Transaction<'_>,
7553    project_root: &Path,
7554    caller_files: &BTreeSet<String>,
7555    chunk_size: usize,
7556) -> Result<usize> {
7557    if caller_files.is_empty() {
7558        return Ok(0);
7559    }
7560    if chunk_size == 0 || caller_files.len() <= chunk_size {
7561        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
7562    }
7563
7564    let mut inserted = 0usize;
7565    let mut batch = BTreeSet::new();
7566    for caller_file in caller_files {
7567        batch.insert(caller_file.clone());
7568        if batch.len() == chunk_size {
7569            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7570            batch.clear();
7571        }
7572    }
7573    if !batch.is_empty() {
7574        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7575    }
7576    Ok(inserted)
7577}
7578
7579fn insert_method_dispatch_edge(
7580    tx: &Transaction<'_>,
7581    reference: &NameMatchRef,
7582    candidate: &NameMatchCandidate,
7583    provenance: &str,
7584) -> Result<()> {
7585    tx.execute(
7586        "INSERT OR REPLACE INTO edges(
7587            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7588            kind, line, provenance
7589        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
7590        params![
7591            ref_id(&[&reference.ref_id, provenance, "edge"]),
7592            &reference.ref_id,
7593            &reference.caller_node,
7594            &candidate.node_id,
7595            &candidate.file_path,
7596            &candidate.scoped_name,
7597            reference.line as i64,
7598            provenance,
7599        ],
7600    )?;
7601    Ok(())
7602}
7603
7604fn delete_method_dispatch_edges_for_callers(
7605    tx: &Transaction<'_>,
7606    caller_files: &BTreeSet<String>,
7607) -> Result<()> {
7608    if caller_files.is_empty() {
7609        return Ok(());
7610    }
7611
7612    let mut stmt = tx.prepare(
7613        "DELETE FROM edges
7614         WHERE provenance IN (?1, ?2)
7615           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
7616    )?;
7617    for caller_file in caller_files {
7618        stmt.execute(params![
7619            PROVENANCE_NAME_MATCH,
7620            PROVENANCE_TYPE_MATCH,
7621            caller_file
7622        ])?;
7623    }
7624    Ok(())
7625}
7626
7627fn load_name_match_refs(
7628    tx: &Transaction<'_>,
7629    caller_files: Option<&BTreeSet<String>>,
7630) -> Result<Vec<NameMatchRef>> {
7631    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
7632                           n.signature, r.short_name, r.full_ref, r.line, f.lang
7633                    FROM refs r
7634                    JOIN files f ON f.path = r.caller_file
7635                    JOIN nodes n ON n.id = r.caller_node
7636                    WHERE r.kind = 'call'
7637                      AND r.status = 'unresolved'
7638                      AND r.caller_node IS NOT NULL
7639                      AND r.full_ref IS NOT NULL
7640                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
7641                      AND NOT EXISTS (
7642                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
7643                      )";
7644    let mut references = Vec::new();
7645
7646    if let Some(caller_files) = caller_files {
7647        if caller_files.is_empty() {
7648            return Ok(references);
7649        }
7650        let sql = format!(
7651            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
7652        );
7653        let mut stmt = tx.prepare(&sql)?;
7654        for caller_file in caller_files {
7655            let rows = stmt.query_map(params![caller_file], |row| {
7656                Ok((
7657                    row.get::<_, String>(0)?,
7658                    row.get::<_, Option<String>>(1)?,
7659                    row.get::<_, String>(2)?,
7660                    row.get::<_, String>(3)?,
7661                    row.get::<_, Option<String>>(4)?,
7662                    row.get::<_, Option<String>>(5)?,
7663                    row.get::<_, Option<String>>(6)?,
7664                    row.get::<_, i64>(7)?,
7665                    row.get::<_, String>(8)?,
7666                ))
7667            })?;
7668            for row in rows {
7669                let (
7670                    ref_id,
7671                    caller_node,
7672                    caller_file,
7673                    caller_symbol,
7674                    caller_signature,
7675                    short_name,
7676                    full_ref,
7677                    line,
7678                    lang,
7679                ) = row?;
7680                if let Some(reference) = name_match_ref_from_parts(
7681                    ref_id,
7682                    caller_node,
7683                    caller_file,
7684                    caller_symbol,
7685                    caller_signature,
7686                    short_name,
7687                    full_ref,
7688                    line,
7689                    lang,
7690                ) {
7691                    references.push(reference);
7692                }
7693            }
7694        }
7695        return Ok(references);
7696    }
7697
7698    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
7699    let mut stmt = tx.prepare(&sql)?;
7700    let rows = stmt.query_map([], |row| {
7701        Ok((
7702            row.get::<_, String>(0)?,
7703            row.get::<_, Option<String>>(1)?,
7704            row.get::<_, String>(2)?,
7705            row.get::<_, String>(3)?,
7706            row.get::<_, Option<String>>(4)?,
7707            row.get::<_, Option<String>>(5)?,
7708            row.get::<_, Option<String>>(6)?,
7709            row.get::<_, i64>(7)?,
7710            row.get::<_, String>(8)?,
7711        ))
7712    })?;
7713    for row in rows {
7714        let (
7715            ref_id,
7716            caller_node,
7717            caller_file,
7718            caller_symbol,
7719            caller_signature,
7720            short_name,
7721            full_ref,
7722            line,
7723            lang,
7724        ) = row?;
7725        if let Some(reference) = name_match_ref_from_parts(
7726            ref_id,
7727            caller_node,
7728            caller_file,
7729            caller_symbol,
7730            caller_signature,
7731            short_name,
7732            full_ref,
7733            line,
7734            lang,
7735        ) {
7736            references.push(reference);
7737        }
7738    }
7739    Ok(references)
7740}
7741
7742#[allow(clippy::too_many_arguments)]
7743fn name_match_ref_from_parts(
7744    ref_id: String,
7745    caller_node: Option<String>,
7746    caller_file: String,
7747    caller_symbol: String,
7748    caller_signature: Option<String>,
7749    short_name: Option<String>,
7750    full_ref: Option<String>,
7751    line: i64,
7752    lang: String,
7753) -> Option<NameMatchRef> {
7754    let caller_node = caller_node?;
7755    let full_ref = full_ref?;
7756    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
7757    let method_name = if member.is_empty() {
7758        short_name.as_deref()?.to_string()
7759    } else {
7760        member
7761    };
7762    Some(NameMatchRef {
7763        ref_id,
7764        caller_node,
7765        caller_file,
7766        caller_symbol,
7767        caller_signature,
7768        receiver,
7769        method_name,
7770        colon_dispatch,
7771        line: line.max(0) as u32,
7772        lang,
7773    })
7774}
7775
7776fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
7777    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
7778    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
7779    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
7780    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
7781        .into_iter()
7782        .flatten()
7783        .max_by_key(|(index, _, _)| *index)?;
7784    if delimiter == 0 {
7785        return None;
7786    }
7787    let member_start = delimiter + delimiter_len;
7788    if member_start >= full_ref.len() {
7789        return None;
7790    }
7791    let receiver = last_name_segment(&full_ref[..delimiter]);
7792    let member = &full_ref[member_start..];
7793    if receiver.is_empty() || member.is_empty() {
7794        return None;
7795    }
7796    Some((receiver.to_string(), member.to_string(), colon_dispatch))
7797}
7798
7799fn last_name_segment(value: &str) -> &str {
7800    value
7801        .rsplit(['.', ':', '/', '\\', '-', '>'])
7802        .find(|segment| !segment.is_empty())
7803        .unwrap_or(value)
7804}
7805
7806fn load_name_match_candidates(
7807    tx: &Transaction<'_>,
7808    method_name: &str,
7809    lang: &str,
7810) -> Result<Vec<NameMatchCandidate>> {
7811    let mut stmt = tx.prepare(
7812        "SELECT n.id, n.file_path, n.scoped_name, n.kind
7813         FROM nodes n JOIN files f ON f.path = n.file_path
7814         WHERE n.name = ?1
7815           AND f.lang = ?2
7816           AND n.kind IN ('method', 'function')
7817         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
7818    )?;
7819    let rows = stmt.query_map(params![method_name, lang], |row| {
7820        Ok(NameMatchCandidate {
7821            node_id: row.get(0)?,
7822            file_path: row.get(1)?,
7823            scoped_name: row.get(2)?,
7824            kind: row.get(3)?,
7825        })
7826    })?;
7827    rows.collect::<std::result::Result<Vec<_>, _>>()
7828        .map_err(Into::into)
7829}
7830
7831struct ParsedDispatchSource {
7832    source: String,
7833    tree: tree_sitter::Tree,
7834}
7835
7836type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
7837
7838fn infer_receiver_type(
7839    project_root: &Path,
7840    reference: &NameMatchRef,
7841    source_cache: &mut DispatchSourceCache,
7842) -> Option<String> {
7843    match reference.lang.as_str() {
7844        "rust" => infer_rust_receiver_type(reference),
7845        "java" => {
7846            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
7847        }
7848        "kotlin" => {
7849            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
7850        }
7851        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
7852        _ => None,
7853    }
7854}
7855
7856fn parse_dispatch_source(
7857    project_root: &Path,
7858    caller_file: &str,
7859    lang: LangId,
7860) -> Option<ParsedDispatchSource> {
7861    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
7862    let grammar = crate::parser::grammar_for(lang);
7863    let mut parser = tree_sitter::Parser::new();
7864    parser.set_language(&grammar).ok()?;
7865    let tree = parser.parse(&source, None)?;
7866    Some(ParsedDispatchSource { source, tree })
7867}
7868
7869fn parsed_dispatch_source<'a>(
7870    project_root: &Path,
7871    reference: &NameMatchRef,
7872    lang: LangId,
7873    source_cache: &'a mut DispatchSourceCache,
7874) -> Option<&'a ParsedDispatchSource> {
7875    let key = (reference.caller_file.clone(), reference.lang.clone());
7876    source_cache
7877        .entry(key)
7878        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
7879        .as_ref()
7880}
7881
7882fn infer_java_like_receiver_type(
7883    project_root: &Path,
7884    reference: &NameMatchRef,
7885    lang: LangId,
7886    source_cache: &mut DispatchSourceCache,
7887) -> Option<String> {
7888    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
7889        return None;
7890    }
7891
7892    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
7893    let root = parsed.tree.root_node();
7894    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
7895
7896    let callable_scope = type_node
7897        .and_then(|node| {
7898            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
7899        })
7900        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
7901
7902    if let Some(callable_scope) = callable_scope {
7903        if let Some(receiver_type) = infer_java_like_local_receiver_type(
7904            callable_scope,
7905            &parsed.source,
7906            &reference.receiver,
7907            reference.line.max(1),
7908            lang,
7909        ) {
7910            return Some(receiver_type);
7911        }
7912    }
7913
7914    type_node.and_then(|node| {
7915        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
7916    })
7917}
7918
7919fn infer_cpp_receiver_type(
7920    project_root: &Path,
7921    reference: &NameMatchRef,
7922    source_cache: &mut DispatchSourceCache,
7923) -> Option<String> {
7924    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
7925        return None;
7926    }
7927
7928    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
7929    let root = parsed.tree.root_node();
7930    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
7931    infer_cpp_receiver_type_from_scope(
7932        scope,
7933        &parsed.source,
7934        &reference.receiver,
7935        reference.line.max(1),
7936    )
7937}
7938
7939fn find_enclosing_java_like_type_node<'tree>(
7940    root: tree_sitter::Node<'tree>,
7941    source: &str,
7942    reference: &NameMatchRef,
7943    lang: LangId,
7944) -> Option<tree_sitter::Node<'tree>> {
7945    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
7946        .and_then(|name| simple_type_name(&name));
7947    let line = reference.line.max(1);
7948    let mut best = None;
7949    let mut stack = vec![root];
7950    while let Some(node) = stack.pop() {
7951        if !node_contains_line(node, line) {
7952            continue;
7953        }
7954        if is_java_like_type_kind(node.kind(), lang) {
7955            let name = declaration_name(node, source);
7956            if expected_type
7957                .as_deref()
7958                .is_none_or(|expected| name == Some(expected))
7959            {
7960                best = tighter_node(best, node);
7961            }
7962        }
7963        push_named_children(node, &mut stack);
7964    }
7965    best
7966}
7967
7968fn find_enclosing_java_like_callable_node<'tree>(
7969    root: tree_sitter::Node<'tree>,
7970    source: &str,
7971    reference: &NameMatchRef,
7972    lang: LangId,
7973) -> Option<tree_sitter::Node<'tree>> {
7974    let expected_name = reference.caller_symbol.rsplit("::").next();
7975    let line = reference.line.max(1);
7976    let mut best = None;
7977    let mut stack = vec![root];
7978    while let Some(node) = stack.pop() {
7979        if !node_contains_line(node, line) {
7980            continue;
7981        }
7982        if is_java_like_callable_kind(node.kind(), lang) {
7983            let name = declaration_name(node, source);
7984            if expected_name.is_none_or(|expected| name == Some(expected)) {
7985                best = tighter_node(best, node);
7986            }
7987        }
7988        push_named_children(node, &mut stack);
7989    }
7990    best
7991}
7992
7993fn find_enclosing_cpp_callable_node<'tree>(
7994    root: tree_sitter::Node<'tree>,
7995    _source: &str,
7996    reference: &NameMatchRef,
7997) -> Option<tree_sitter::Node<'tree>> {
7998    let line = reference.line.max(1);
7999    let mut best = None;
8000    let mut stack = vec![root];
8001    while let Some(node) = stack.pop() {
8002        if !node_contains_line(node, line) {
8003            continue;
8004        }
8005        if node.kind() == "function_definition" {
8006            best = tighter_node(best, node);
8007        }
8008        push_named_children(node, &mut stack);
8009    }
8010    best
8011}
8012
8013fn tighter_node<'tree>(
8014    current: Option<tree_sitter::Node<'tree>>,
8015    candidate: tree_sitter::Node<'tree>,
8016) -> Option<tree_sitter::Node<'tree>> {
8017    match current {
8018        Some(current)
8019            if current.start_byte() > candidate.start_byte()
8020                || (current.start_byte() == candidate.start_byte()
8021                    && current.end_byte() <= candidate.end_byte()) =>
8022        {
8023            Some(current)
8024        }
8025        _ => Some(candidate),
8026    }
8027}
8028
8029fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8030    let start = node.start_position().row as u32 + 1;
8031    let end = node.end_position().row as u32 + 1;
8032    start <= line && line <= end
8033}
8034
8035fn push_named_children<'tree>(
8036    node: tree_sitter::Node<'tree>,
8037    stack: &mut Vec<tree_sitter::Node<'tree>>,
8038) {
8039    for index in 0..node.named_child_count() {
8040        if let Some(child) = node.named_child(index as u32) {
8041            stack.push(child);
8042        }
8043    }
8044}
8045
8046fn declaration_name<'source>(
8047    node: tree_sitter::Node<'_>,
8048    source: &'source str,
8049) -> Option<&'source str> {
8050    node.child_by_field_name("name")
8051        .map(|name| node_text(name, source))
8052        .or_else(|| {
8053            first_named_child_text(
8054                node,
8055                source,
8056                &["identifier", "type_identifier", "simple_identifier"],
8057            )
8058        })
8059}
8060
8061fn first_named_child_text<'source>(
8062    node: tree_sitter::Node<'_>,
8063    source: &'source str,
8064    kinds: &[&str],
8065) -> Option<&'source str> {
8066    for index in 0..node.named_child_count() {
8067        let child = node.named_child(index as u32)?;
8068        if kinds.contains(&child.kind()) {
8069            return Some(node_text(child, source));
8070        }
8071    }
8072    None
8073}
8074
8075fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8076    &source[node.byte_range()]
8077}
8078
8079fn infer_java_like_field_receiver_type(
8080    type_node: tree_sitter::Node<'_>,
8081    source: &str,
8082    receiver: &str,
8083    lang: LangId,
8084) -> Option<String> {
8085    let mut stack = Vec::new();
8086    push_named_children(type_node, &mut stack);
8087    while let Some(node) = stack.pop() {
8088        if is_java_like_field_kind(node.kind(), lang) {
8089            if let Some(receiver_type) =
8090                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8091            {
8092                return Some(receiver_type);
8093            }
8094        }
8095        if is_java_like_type_kind(node.kind(), lang)
8096            || is_java_like_callable_kind(node.kind(), lang)
8097        {
8098            continue;
8099        }
8100        push_named_children(node, &mut stack);
8101    }
8102    None
8103}
8104
8105fn infer_java_like_local_receiver_type(
8106    callable_node: tree_sitter::Node<'_>,
8107    source: &str,
8108    receiver: &str,
8109    call_line: u32,
8110    lang: LangId,
8111) -> Option<String> {
8112    let mut best: Option<(u32, String)> = None;
8113    let mut stack = Vec::new();
8114    push_named_children(callable_node, &mut stack);
8115    while let Some(node) = stack.pop() {
8116        let start_line = node.start_position().row as u32 + 1;
8117        if start_line > call_line {
8118            continue;
8119        }
8120        if is_java_like_local_kind(node.kind(), lang) {
8121            if let Some(receiver_type) =
8122                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8123            {
8124                if best
8125                    .as_ref()
8126                    .is_none_or(|(best_line, _)| start_line >= *best_line)
8127                {
8128                    best = Some((start_line, receiver_type));
8129                }
8130            }
8131        }
8132        if is_java_like_type_kind(node.kind(), lang)
8133            || is_java_like_callable_kind(node.kind(), lang)
8134        {
8135            continue;
8136        }
8137        push_named_children(node, &mut stack);
8138    }
8139    best.map(|(_, receiver_type)| receiver_type)
8140}
8141
8142fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8143    match lang {
8144        LangId::Java => matches!(
8145            kind,
8146            "class_declaration"
8147                | "interface_declaration"
8148                | "enum_declaration"
8149                | "record_declaration"
8150                | "annotation_type_declaration"
8151        ),
8152        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8153        _ => false,
8154    }
8155}
8156
8157fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8158    match lang {
8159        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8160        LangId::Kotlin => kind == "function_declaration",
8161        _ => false,
8162    }
8163}
8164
8165fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8166    match lang {
8167        LangId::Java => kind == "field_declaration",
8168        LangId::Kotlin => kind == "property_declaration",
8169        _ => false,
8170    }
8171}
8172
8173fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8174    match lang {
8175        LangId::Java => kind == "local_variable_declaration",
8176        LangId::Kotlin => kind == "property_declaration",
8177        _ => false,
8178    }
8179}
8180
8181fn extract_java_like_declared_type(
8182    declaration: &str,
8183    receiver: &str,
8184    lang: LangId,
8185) -> Option<String> {
8186    match lang {
8187        LangId::Java => extract_java_declared_type(declaration, receiver),
8188        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8189        _ => None,
8190    }
8191}
8192
8193fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8194    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8195    let after = declaration[receiver_start + receiver.len()..].trim_start();
8196    if after
8197        .chars()
8198        .next()
8199        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8200    {
8201        return None;
8202    }
8203
8204    let before = declaration[..receiver_start].trim_end();
8205    if before.contains(',') {
8206        return None;
8207    }
8208    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8209}
8210
8211fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8212    loop {
8213        value = value.trim_start();
8214        if let Some(stripped) = strip_leading_java_annotation(value) {
8215            value = stripped;
8216            continue;
8217        }
8218        if let Some(stripped) = strip_leading_java_modifier(value) {
8219            value = stripped;
8220            continue;
8221        }
8222        return value.trim();
8223    }
8224}
8225
8226fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8227    let value = value.trim_start();
8228    let mut chars = value.char_indices();
8229    let (_, first) = chars.next()?;
8230    if first != '@' {
8231        return None;
8232    }
8233    let mut end = first.len_utf8();
8234    for (index, ch) in chars {
8235        if !(is_code_ident_char(ch) || ch == '.') {
8236            end = index;
8237            break;
8238        }
8239        end = index + ch.len_utf8();
8240    }
8241    let rest = value[end..].trim_start();
8242    if let Some(stripped) = rest.strip_prefix('(') {
8243        let mut depth = 1usize;
8244        for (index, ch) in stripped.char_indices() {
8245            match ch {
8246                '(' => depth += 1,
8247                ')' => {
8248                    depth = depth.saturating_sub(1);
8249                    if depth == 0 {
8250                        return Some(stripped[index + ch.len_utf8()..].trim_start());
8251                    }
8252                }
8253                _ => {}
8254            }
8255        }
8256        return Some("");
8257    }
8258    Some(rest)
8259}
8260
8261fn strip_leading_java_modifier(value: &str) -> Option<&str> {
8262    const MODIFIERS: &[&str] = &[
8263        "public",
8264        "protected",
8265        "private",
8266        "abstract",
8267        "static",
8268        "final",
8269        "transient",
8270        "volatile",
8271        "synchronized",
8272        "native",
8273        "strictfp",
8274    ];
8275    MODIFIERS
8276        .iter()
8277        .find_map(|modifier| strip_leading_word(value, modifier))
8278}
8279
8280fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8281    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8282    let before = &declaration[..receiver_start];
8283    if find_identifier_occurrence(before, "val").is_none()
8284        && find_identifier_occurrence(before, "var").is_none()
8285    {
8286        return None;
8287    }
8288
8289    let after = declaration[receiver_start + receiver.len()..].trim_start();
8290    if let Some(type_text) = after.strip_prefix(':') {
8291        return normalize_receiver_type_name(read_type_prefix(type_text));
8292    }
8293    after
8294        .strip_prefix('=')
8295        .and_then(infer_kotlin_constructor_type)
8296}
8297
8298fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
8299    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
8300    if rest.trim_start().starts_with('(') {
8301        normalize_receiver_type_name(head)
8302    } else {
8303        None
8304    }
8305}
8306
8307fn read_type_prefix(value: &str) -> &str {
8308    let mut angle_depth = 0usize;
8309    for (index, ch) in value.char_indices() {
8310        match ch {
8311            '<' => angle_depth += 1,
8312            '>' => angle_depth = angle_depth.saturating_sub(1),
8313            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
8314                return value[..index].trim();
8315            }
8316            _ => {}
8317        }
8318    }
8319    value.trim()
8320}
8321
8322fn infer_cpp_receiver_type_from_scope(
8323    scope: tree_sitter::Node<'_>,
8324    source: &str,
8325    receiver: &str,
8326    call_line: u32,
8327) -> Option<String> {
8328    let lines = source.lines().collect::<Vec<_>>();
8329    if lines.is_empty() {
8330        return None;
8331    }
8332    let scope_start = scope.start_position().row as usize;
8333    let call_index = (call_line as usize)
8334        .saturating_sub(1)
8335        .min(lines.len().saturating_sub(1));
8336    for index in (scope_start..=call_index).rev() {
8337        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
8338            return Some(receiver_type);
8339        }
8340    }
8341    None
8342}
8343
8344fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
8345    for receiver_start in identifier_occurrences(line, receiver) {
8346        let after = line[receiver_start + receiver.len()..].trim_start();
8347        if after
8348            .chars()
8349            .next()
8350            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
8351        {
8352            continue;
8353        }
8354        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
8355        let normalized = normalize_cpp_type_name(type_text)?;
8356        if normalized == "auto" {
8357            if let Some(rhs) = after.strip_prefix('=') {
8358                return infer_cpp_auto_receiver_type(rhs);
8359            }
8360            continue;
8361        }
8362        return Some(normalized);
8363    }
8364    None
8365}
8366
8367fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
8368    let candidate = prefix
8369        .rsplit([';', '{', '}', '('])
8370        .next()
8371        .unwrap_or(prefix)
8372        .trim();
8373    if candidate.is_empty() || candidate.ends_with(',') {
8374        None
8375    } else {
8376        Some(candidate)
8377    }
8378}
8379
8380fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
8381    let without_templates = strip_angle_groups(type_text);
8382    let mut cleaned = String::with_capacity(without_templates.len());
8383    for token in without_templates.split_whitespace() {
8384        if matches!(
8385            token,
8386            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
8387        ) {
8388            continue;
8389        }
8390        if !cleaned.is_empty() {
8391            cleaned.push(' ');
8392        }
8393        cleaned.push_str(token);
8394    }
8395    let token = cleaned
8396        .split_whitespace()
8397        .last()
8398        .unwrap_or(cleaned.trim())
8399        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
8400        .trim_matches(['*', '&']);
8401    let simple = token.rsplit("::").next().unwrap_or(token).trim();
8402    if simple.is_empty() || cpp_non_type_token(simple) {
8403        None
8404    } else {
8405        Some(simple.to_string())
8406    }
8407}
8408
8409fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
8410    let rhs = rhs.trim_start();
8411    if let Some(after_new) = rhs.strip_prefix("new ") {
8412        return infer_cpp_constructor_type(after_new);
8413    }
8414    infer_cpp_make_template_type(rhs)
8415        .or_else(|| infer_cpp_constructor_type(rhs))
8416        .or_else(|| infer_cpp_factory_type(rhs))
8417}
8418
8419fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
8420    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8421    let normalized = normalize_cpp_type_name(head)?;
8422    if !normalized
8423        .chars()
8424        .next()
8425        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8426    {
8427        return None;
8428    }
8429    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
8430        Some(normalized)
8431    } else {
8432        None
8433    }
8434}
8435
8436fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
8437    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8438    if !rest.trim_start().starts_with('(') {
8439        return None;
8440    }
8441    let base = head.split('<').next().unwrap_or(head);
8442    let base_simple = base.rsplit("::").next().unwrap_or(base);
8443    if !matches!(base_simple, "make_unique" | "make_shared") {
8444        return None;
8445    }
8446    first_angle_arg(head).and_then(normalize_cpp_type_name)
8447}
8448
8449fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
8450    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8451    if !rest.trim_start().starts_with('(') {
8452        return None;
8453    }
8454    let simple = head
8455        .split('<')
8456        .next()
8457        .unwrap_or(head)
8458        .rsplit("::")
8459        .next()
8460        .unwrap_or(head);
8461    for prefix in ["make", "create", "build"] {
8462        if let Some(suffix) = simple.strip_prefix(prefix) {
8463            if suffix
8464                .chars()
8465                .next()
8466                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8467            {
8468                return normalize_cpp_type_name(suffix);
8469            }
8470        }
8471    }
8472    None
8473}
8474
8475#[derive(Debug, Clone, Copy)]
8476enum JavaLikeInvocation {
8477    Kotlin,
8478    Cpp,
8479}
8480
8481fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
8482    let value = value.trim_start();
8483    let mut end = 0usize;
8484    for (index, ch) in value.char_indices() {
8485        let allowed_separator = match flavor {
8486            JavaLikeInvocation::Kotlin => ch == '.',
8487            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
8488        };
8489        if is_code_ident_char(ch) || allowed_separator {
8490            end = index + ch.len_utf8();
8491            continue;
8492        }
8493        break;
8494    }
8495    if end == 0 {
8496        return None;
8497    }
8498    let mut rest = &value[end..];
8499    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
8500        let skipped = skip_balanced_angle(stripped)?;
8501        let rest_start = rest.len() - rest.trim_start().len();
8502        let angle_len = 1 + skipped;
8503        end += rest_start + angle_len;
8504        rest = &value[end..];
8505    }
8506    Some((value[..end].trim(), rest))
8507}
8508
8509fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
8510    let mut depth = 1usize;
8511    for (index, ch) in value_after_open.char_indices() {
8512        match ch {
8513            '<' => depth += 1,
8514            '>' => {
8515                depth = depth.saturating_sub(1);
8516                if depth == 0 {
8517                    return Some(index + ch.len_utf8());
8518                }
8519            }
8520            _ => {}
8521        }
8522    }
8523    None
8524}
8525
8526fn first_angle_arg(value: &str) -> Option<&str> {
8527    let open = value.find('<')?;
8528    let inner_len = skip_balanced_angle(&value[open + 1..])?;
8529    let inner = &value[open + 1..open + inner_len];
8530    split_top_level_commas(inner).into_iter().next()
8531}
8532
8533fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
8534    let without_generics = strip_angle_groups(type_text);
8535    let cleaned = without_generics
8536        .replace("[]", " ")
8537        .replace("...", " ")
8538        .replace(['?', '&', '*'], " ");
8539    let token = cleaned
8540        .split_whitespace()
8541        .last()
8542        .unwrap_or(cleaned.trim())
8543        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
8544    let token = token.rsplit("::").next().unwrap_or(token);
8545    let simple = token.rsplit('.').next().unwrap_or(token).trim();
8546    if simple.is_empty()
8547        || java_like_primitive_type(simple)
8548        || !simple
8549            .chars()
8550            .next()
8551            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8552    {
8553        None
8554    } else {
8555        Some(simple.to_string())
8556    }
8557}
8558
8559fn simple_type_name(scoped_name: &str) -> Option<String> {
8560    scoped_name
8561        .rsplit("::")
8562        .find(|segment| !segment.is_empty())
8563        .and_then(normalize_receiver_type_name)
8564}
8565
8566fn strip_angle_groups(value: &str) -> String {
8567    let mut output = String::with_capacity(value.len());
8568    let mut depth = 0usize;
8569    for ch in value.chars() {
8570        match ch {
8571            '<' => {
8572                if depth == 0 {
8573                    output.push(' ');
8574                }
8575                depth += 1;
8576            }
8577            '>' => depth = depth.saturating_sub(1),
8578            _ if depth == 0 => output.push(ch),
8579            _ => {}
8580        }
8581    }
8582    output
8583}
8584
8585fn java_like_primitive_type(value: &str) -> bool {
8586    matches!(
8587        value,
8588        "boolean"
8589            | "byte"
8590            | "char"
8591            | "double"
8592            | "float"
8593            | "int"
8594            | "long"
8595            | "short"
8596            | "void"
8597            | "Boolean"
8598            | "Byte"
8599            | "Char"
8600            | "Double"
8601            | "Float"
8602            | "Int"
8603            | "Long"
8604            | "Short"
8605            | "Unit"
8606    )
8607}
8608
8609fn cpp_non_type_token(value: &str) -> bool {
8610    matches!(
8611        value,
8612        "return"
8613            | "if"
8614            | "else"
8615            | "for"
8616            | "while"
8617            | "do"
8618            | "switch"
8619            | "case"
8620            | "default"
8621            | "break"
8622            | "continue"
8623            | "goto"
8624            | "throw"
8625            | "new"
8626            | "delete"
8627            | "co_await"
8628            | "co_yield"
8629            | "co_return"
8630            | "static_cast"
8631            | "const_cast"
8632            | "dynamic_cast"
8633            | "reinterpret_cast"
8634            | "sizeof"
8635            | "alignof"
8636            | "typeid"
8637            | "and"
8638            | "or"
8639            | "not"
8640            | "xor"
8641    )
8642}
8643
8644fn receiver_is_bare_identifier(value: &str) -> bool {
8645    let mut chars = value.chars();
8646    let Some(first) = chars.next() else {
8647        return false;
8648    };
8649    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
8650}
8651
8652fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
8653    identifier_occurrences(value, needle).into_iter().next()
8654}
8655
8656fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
8657    value
8658        .match_indices(needle)
8659        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
8660        .collect()
8661}
8662
8663fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
8664    let before = value[..start].chars().next_back();
8665    let after = value[start + len..].chars().next();
8666    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
8667}
8668
8669fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
8670    let stripped = value.strip_prefix(word)?;
8671    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
8672        Some(stripped.trim_start())
8673    } else {
8674        None
8675    }
8676}
8677
8678fn is_code_ident_char(ch: char) -> bool {
8679    ch == '_' || ch.is_ascii_alphanumeric()
8680}
8681
8682fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
8683    if matches!(reference.receiver.as_str(), "self" | "Self") {
8684        return enclosing_type_from_scoped_name(&reference.caller_symbol);
8685    }
8686
8687    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
8688        return Some(reference.receiver.clone());
8689    }
8690
8691    reference
8692        .caller_signature
8693        .as_deref()
8694        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
8695}
8696
8697fn rust_receiver_looks_type_like(receiver: &str) -> bool {
8698    receiver
8699        .chars()
8700        .next()
8701        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
8702}
8703
8704fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
8705    scoped_name
8706        .rsplit_once("::")
8707        .map(|(enclosing, _)| enclosing)
8708        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
8709        .map(ToString::to_string)
8710}
8711
8712fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
8713    let params = signature_parameter_text(signature)?;
8714    for param in split_top_level_commas(params) {
8715        let Some((pattern, type_text)) = param.split_once(':') else {
8716            continue;
8717        };
8718        let Some(name) = rust_parameter_name(pattern) else {
8719            continue;
8720        };
8721        if name == receiver {
8722            return normalize_rust_receiver_type(type_text);
8723        }
8724    }
8725    None
8726}
8727
8728fn signature_parameter_text(signature: &str) -> Option<&str> {
8729    let open = signature.find('(')?;
8730    let mut depth = 0usize;
8731    for (offset, ch) in signature[open..].char_indices() {
8732        match ch {
8733            '(' => depth += 1,
8734            ')' => {
8735                depth = depth.saturating_sub(1);
8736                if depth == 0 {
8737                    return Some(&signature[open + 1..open + offset]);
8738                }
8739            }
8740            _ => {}
8741        }
8742    }
8743    None
8744}
8745
8746fn split_top_level_commas(value: &str) -> Vec<&str> {
8747    let mut parts = Vec::new();
8748    let mut start = 0usize;
8749    let mut angle_depth = 0usize;
8750    let mut paren_depth = 0usize;
8751    let mut bracket_depth = 0usize;
8752    for (index, ch) in value.char_indices() {
8753        match ch {
8754            '<' => angle_depth += 1,
8755            '>' => angle_depth = angle_depth.saturating_sub(1),
8756            '(' => paren_depth += 1,
8757            ')' => paren_depth = paren_depth.saturating_sub(1),
8758            '[' => bracket_depth += 1,
8759            ']' => bracket_depth = bracket_depth.saturating_sub(1),
8760            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
8761                let part = value[start..index].trim();
8762                if !part.is_empty() {
8763                    parts.push(part);
8764                }
8765                start = index + ch.len_utf8();
8766            }
8767            _ => {}
8768        }
8769    }
8770    let part = value[start..].trim();
8771    if !part.is_empty() {
8772        parts.push(part);
8773    }
8774    parts
8775}
8776
8777fn rust_parameter_name(pattern: &str) -> Option<&str> {
8778    let mut pattern = pattern.trim();
8779    if let Some(stripped) = pattern.strip_prefix("mut ") {
8780        pattern = stripped.trim_start();
8781    }
8782    pattern
8783        .rsplit(|ch: char| !is_rust_ident_char(ch))
8784        .find(|part| !part.is_empty())
8785}
8786
8787fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
8788    let mut ty = strip_leading_rust_type_modifiers(type_text);
8789    let owned_inner;
8790    if let Some(inner) = single_outer_generic_arg(ty) {
8791        owned_inner = inner.trim().to_string();
8792        ty = strip_leading_rust_type_modifiers(&owned_inner);
8793    }
8794    rust_base_type_ident(ty)
8795}
8796
8797fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
8798    loop {
8799        ty = ty.trim_start();
8800        if let Some(stripped) = ty.strip_prefix('&') {
8801            ty = stripped.trim_start();
8802            if let Some(stripped) = strip_leading_lifetime(ty) {
8803                ty = stripped.trim_start();
8804            }
8805            if let Some(stripped) = ty.strip_prefix("mut ") {
8806                ty = stripped.trim_start();
8807            }
8808            continue;
8809        }
8810        if let Some(stripped) = ty.strip_prefix("mut ") {
8811            ty = stripped.trim_start();
8812            continue;
8813        }
8814        if let Some(stripped) = ty.strip_prefix("dyn ") {
8815            ty = stripped.trim_start();
8816            continue;
8817        }
8818        if let Some(stripped) = ty.strip_prefix("impl ") {
8819            ty = stripped.trim_start();
8820            continue;
8821        }
8822        break ty.trim();
8823    }
8824}
8825
8826fn strip_leading_lifetime(value: &str) -> Option<&str> {
8827    let mut chars = value.char_indices();
8828    let (_, first) = chars.next()?;
8829    if first != '\'' {
8830        return None;
8831    }
8832    for (index, ch) in chars {
8833        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
8834            return Some(&value[index..]);
8835        }
8836    }
8837    Some("")
8838}
8839
8840fn single_outer_generic_arg(ty: &str) -> Option<&str> {
8841    let ty = ty.trim();
8842    let open = ty.find('<')?;
8843    let mut depth = 0usize;
8844    let mut close = None;
8845    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
8846        match ch {
8847            '<' => depth += 1,
8848            '>' => {
8849                depth = depth.saturating_sub(1);
8850                if depth == 0 {
8851                    close = Some(index);
8852                    break;
8853                }
8854            }
8855            _ => {}
8856        }
8857    }
8858    let close = close?;
8859    if !ty[close + 1..].trim().is_empty() {
8860        return None;
8861    }
8862    let inner = &ty[open + 1..close];
8863    let args = split_top_level_commas(inner);
8864    match args.as_slice() {
8865        [arg] => Some(*arg),
8866        _ => None,
8867    }
8868}
8869
8870fn rust_base_type_ident(ty: &str) -> Option<String> {
8871    let ty = ty.trim();
8872    let head = ty
8873        .split([' ', '+', '='])
8874        .find(|part| !part.is_empty())
8875        .unwrap_or(ty);
8876    let head = head.split('<').next().unwrap_or(head).trim();
8877    let ident = head
8878        .rsplit("::")
8879        .next()
8880        .unwrap_or(head)
8881        .trim_matches(|ch: char| !is_rust_ident_char(ch));
8882    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
8883        None
8884    } else {
8885        Some(ident.to_string())
8886    }
8887}
8888
8889fn is_rust_ident_char(ch: char) -> bool {
8890    ch == '_' || ch.is_ascii_alphanumeric()
8891}
8892
8893fn select_type_match_candidate(
8894    reference: &NameMatchRef,
8895    candidates: &[NameMatchCandidate],
8896    receiver_type: &str,
8897) -> Option<NameMatchCandidate> {
8898    let candidates = candidates
8899        .iter()
8900        .filter(|candidate| candidate.node_id != reference.caller_node)
8901        .filter(|candidate| {
8902            type_candidate_matches(candidate, receiver_type, &reference.method_name)
8903        })
8904        .collect::<Vec<_>>();
8905    match candidates.as_slice() {
8906        [candidate] => Some((**candidate).clone()),
8907        _ => None,
8908    }
8909}
8910
8911fn type_candidate_matches(
8912    candidate: &NameMatchCandidate,
8913    receiver_type: &str,
8914    method_name: &str,
8915) -> bool {
8916    let normalized_type = receiver_type.replace('.', "::");
8917    let suffix = format!("{normalized_type}::{method_name}");
8918    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
8919}
8920
8921fn select_name_match_candidate(
8922    reference: &NameMatchRef,
8923    candidates: &[NameMatchCandidate],
8924) -> Option<NameMatchCandidate> {
8925    let candidates = candidates
8926        .iter()
8927        .filter(|candidate| candidate.node_id != reference.caller_node)
8928        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
8929        .collect::<Vec<_>>();
8930    match candidates.as_slice() {
8931        [] => None,
8932        [candidate] => Some((**candidate).clone()),
8933        _ => select_scored_name_match_candidate(reference, &candidates),
8934    }
8935}
8936
8937fn candidate_allowed_for_reference(
8938    reference: &NameMatchRef,
8939    candidate: &NameMatchCandidate,
8940) -> bool {
8941    if !reference.colon_dispatch {
8942        return true;
8943    }
8944
8945    candidate.kind == "method"
8946        && candidate
8947            .scoped_name
8948            .split("::")
8949            .any(|segment| segment == reference.receiver)
8950}
8951
8952fn select_scored_name_match_candidate(
8953    reference: &NameMatchRef,
8954    candidates: &[&NameMatchCandidate],
8955) -> Option<NameMatchCandidate> {
8956    let receiver_words = split_camel_case(&reference.receiver);
8957    if receiver_words.is_empty() {
8958        return None;
8959    }
8960
8961    let mut best: Option<(&NameMatchCandidate, f64)> = None;
8962    let mut tied_best = false;
8963    for candidate in candidates {
8964        let candidate_words = split_camel_case(&candidate.scoped_name);
8965        let overlap = receiver_words
8966            .iter()
8967            .filter(|receiver_word| {
8968                candidate_words
8969                    .iter()
8970                    .any(|candidate_word| candidate_word == *receiver_word)
8971            })
8972            .count() as f64;
8973        let score =
8974            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
8975        match best {
8976            None => {
8977                best = Some((*candidate, score));
8978                tied_best = false;
8979            }
8980            Some((_, best_score)) if score > best_score => {
8981                best = Some((*candidate, score));
8982                tied_best = false;
8983            }
8984            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
8985                tied_best = true;
8986            }
8987            _ => {}
8988        }
8989    }
8990
8991    let (candidate, score) = best?;
8992    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
8993        Some(candidate.clone())
8994    } else {
8995        None
8996    }
8997}
8998
8999fn method_name_match_denylisted(method_name: &str) -> bool {
9000    matches!(
9001        method_name,
9002        "and_then"
9003            | "as_bytes"
9004            | "as_deref"
9005            | "as_mut"
9006            | "as_ref"
9007            | "as_str"
9008            | "borrow"
9009            | "borrow_mut"
9010            | "clear"
9011            | "clone"
9012            | "collect"
9013            | "contains"
9014            | "contains_key"
9015            | "count"
9016            | "dedup"
9017            | "default"
9018            | "drain"
9019            | "ends_with"
9020            | "entry"
9021            | "err"
9022            | "expect"
9023            | "extend"
9024            | "filter"
9025            | "filter_map"
9026            | "find"
9027            | "from"
9028            | "get"
9029            | "get_mut"
9030            | "insert"
9031            | "into"
9032            | "into_iter"
9033            | "is_empty"
9034            | "is_err"
9035            | "is_none"
9036            | "is_ok"
9037            | "is_some"
9038            | "iter"
9039            | "iter_mut"
9040            | "join"
9041            | "len"
9042            | "lock"
9043            | "map"
9044            | "map_err"
9045            | "max"
9046            | "min"
9047            | "new"
9048            | "next"
9049            | "ok"
9050            | "or_default"
9051            | "or_else"
9052            | "or_insert"
9053            | "or_insert_with"
9054            | "parse"
9055            | "pop"
9056            | "position"
9057            | "push"
9058            | "read"
9059            | "recv"
9060            | "remove"
9061            | "replace"
9062            | "retain"
9063            | "send"
9064            | "sort"
9065            | "sort_by"
9066            | "split"
9067            | "starts_with"
9068            | "sum"
9069            | "take"
9070            | "to_owned"
9071            | "to_string"
9072            | "trim"
9073            | "try_from"
9074            | "try_into"
9075            | "unwrap"
9076            | "unwrap_or"
9077            | "unwrap_or_default"
9078            | "unwrap_or_else"
9079            | "with_capacity"
9080            | "write"
9081    )
9082}
9083
9084fn split_camel_case(value: &str) -> Vec<String> {
9085    let chars = value.chars().collect::<Vec<_>>();
9086    let mut normalized = String::with_capacity(value.len() + 8);
9087    for (index, ch) in chars.iter().enumerate() {
9088        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9089        let next = chars.get(index + 1);
9090        let is_separator = ch.is_whitespace()
9091            || matches!(
9092                ch,
9093                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9094            );
9095        if is_separator {
9096            normalized.push(' ');
9097            continue;
9098        }
9099        let camel_boundary = previous.is_some_and(|prev| {
9100            (prev.is_lowercase() && ch.is_uppercase())
9101                || (prev.is_ascii_digit() && ch.is_alphabetic())
9102                || (prev.is_uppercase()
9103                    && ch.is_uppercase()
9104                    && next.is_some_and(|next| next.is_lowercase()))
9105        });
9106        if camel_boundary {
9107            normalized.push(' ');
9108        }
9109        normalized.push(*ch);
9110    }
9111
9112    normalized
9113        .split_whitespace()
9114        .filter(|word| word.len() > 1)
9115        .map(|word| word.to_ascii_lowercase())
9116        .collect()
9117}
9118
9119fn compute_path_proximity(left: &str, right: &str) -> f64 {
9120    let left_dirs = left
9121        .rsplit_once('/')
9122        .map(|(dir, _)| dir)
9123        .unwrap_or_default()
9124        .split('/')
9125        .filter(|part| !part.is_empty());
9126    let right_dirs = right
9127        .rsplit_once('/')
9128        .map(|(dir, _)| dir)
9129        .unwrap_or_default()
9130        .split('/')
9131        .filter(|part| !part.is_empty());
9132
9133    let shared = left_dirs
9134        .zip(right_dirs)
9135        .take_while(|(left, right)| left == right)
9136        .count();
9137    ((shared as f64) * 0.05).min(0.5)
9138}
9139
9140fn mark_backend_state(
9141    tx: &Transaction<'_>,
9142    project_root: &Path,
9143    rel_path: &str,
9144    content_hash: Option<&blake3::Hash>,
9145    status: &str,
9146) -> Result<()> {
9147    clear_backend_state_for_file(tx, project_root, rel_path)?;
9148    let hash = content_hash
9149        .map(|hash| hash_to_hex(*hash))
9150        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9151    tx.execute(
9152        "INSERT OR REPLACE INTO backend_file_state(
9153            backend, workspace_root, file_path, content_hash, status, updated_at
9154        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9155        params![
9156            BACKEND_TREESITTER,
9157            project_root.display().to_string(),
9158            rel_path,
9159            hash,
9160            status,
9161            unix_seconds_now(),
9162        ],
9163    )?;
9164    Ok(())
9165}
9166
9167fn clear_backend_state_for_file(
9168    tx: &Transaction<'_>,
9169    project_root: &Path,
9170    rel_path: &str,
9171) -> Result<()> {
9172    tx.execute(
9173        "DELETE FROM backend_file_state
9174         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9175        params![
9176            BACKEND_TREESITTER,
9177            project_root.display().to_string(),
9178            rel_path
9179        ],
9180    )?;
9181    Ok(())
9182}
9183
9184fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9185    tx.query_row(
9186        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9187        params![rel_path],
9188        |row| {
9189            let hash_text: String = row.get(1)?;
9190            Ok(FileRow {
9191                surface_fingerprint: row.get(0)?,
9192                freshness: FileFreshness {
9193                    content_hash: hash_from_hex(&hash_text)
9194                        .unwrap_or_else(cache_freshness::zero_hash),
9195                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9196                    size: row.get::<_, i64>(3)? as u64,
9197                },
9198            })
9199        },
9200    )
9201    .optional()
9202    .map_err(CallGraphStoreError::from)
9203}
9204
9205fn stored_node_ids_match_extract(
9206    tx: &Transaction<'_>,
9207    rel_path: &str,
9208    extract: &FileExtract,
9209) -> Result<bool> {
9210    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9211    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9212    let mut stored = BTreeSet::new();
9213    for row in rows {
9214        stored.insert(row?);
9215    }
9216    let extracted = extract
9217        .nodes
9218        .iter()
9219        .map(|node| node.id.clone())
9220        .collect::<BTreeSet<_>>();
9221    Ok(stored == extracted)
9222}
9223
9224fn update_file_fresh_metadata(
9225    tx: &Transaction<'_>,
9226    rel_path: &str,
9227    hash: &blake3::Hash,
9228    mtime: SystemTime,
9229    size: u64,
9230) -> Result<()> {
9231    tx.execute(
9232        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9233        params![
9234            rel_path,
9235            system_time_to_ns(mtime),
9236            size as i64,
9237            unix_seconds_now()
9238        ],
9239    )?;
9240    tx.execute(
9241        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9242         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9243        params![
9244            BACKEND_TREESITTER,
9245            rel_path,
9246            hash_to_hex(*hash),
9247            unix_seconds_now(),
9248        ],
9249    )?;
9250    Ok(())
9251}
9252
9253#[derive(Debug, Clone, PartialEq, Eq)]
9254struct DependentRefSelection {
9255    ref_id: String,
9256    caller_file: String,
9257}
9258
9259fn ref_ids_depending_on(
9260    tx: &Transaction<'_>,
9261    project_root: &Path,
9262    rel_path: &str,
9263) -> Result<Vec<DependentRefSelection>> {
9264    let mut stmt = tx.prepare(
9265        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
9266         FROM refs r
9267         WHERE r.caller_file IN (
9268             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
9269         )
9270            OR r.target_file = ?1
9271         ORDER BY r.ref_id",
9272    )?;
9273    let rows = stmt.query_map(params![rel_path], |row| {
9274        Ok(RefDependencyRow {
9275            ref_id: row.get(0)?,
9276            kind: row.get(1)?,
9277            caller_file: row.get(2)?,
9278            module_path: row.get(3)?,
9279            target_file: row.get(4)?,
9280        })
9281    })?;
9282    let mut ids = Vec::new();
9283    for row in rows {
9284        let row = row?;
9285        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
9286            ids.push(DependentRefSelection {
9287                ref_id: row.ref_id,
9288                caller_file: row.caller_file,
9289            });
9290        }
9291    }
9292    Ok(ids)
9293}
9294
9295fn record_dependent_refs(
9296    selected_ref_ids: &mut BTreeSet<String>,
9297    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
9298    dependent_refs: Vec<DependentRefSelection>,
9299) {
9300    for dependent_ref in dependent_refs {
9301        let DependentRefSelection {
9302            ref_id,
9303            caller_file,
9304        } = dependent_ref;
9305        selected_ref_ids.insert(ref_id.clone());
9306        selected_refs_by_caller
9307            .entry(caller_file)
9308            .or_default()
9309            .insert(ref_id);
9310    }
9311}
9312
9313#[cfg(test)]
9314fn refs_by_caller_for_ref_ids(
9315    tx: &Transaction<'_>,
9316    ref_ids: &BTreeSet<String>,
9317) -> Result<BTreeMap<String, BTreeSet<String>>> {
9318    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9319    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
9320    for ref_id in ref_ids {
9321        if let Some(caller) = stmt
9322            .query_row(params![ref_id], |row| row.get::<_, String>(0))
9323            .optional()?
9324        {
9325            by_caller.entry(caller).or_default().insert(ref_id.clone());
9326        }
9327    }
9328    Ok(by_caller)
9329}
9330
9331fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9332    tx.execute(
9333        "DELETE FROM file_dependencies WHERE file_path = ?1",
9334        params![rel_path],
9335    )?;
9336    delete_refs_for_caller(tx, rel_path)?;
9337    tx.execute(
9338        "DELETE FROM dispatch_hints WHERE file = ?1",
9339        params![rel_path],
9340    )?;
9341    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
9342    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
9343    Ok(())
9344}
9345
9346fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9347    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
9348    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9349    let mut ids = BTreeSet::new();
9350    for row in rows {
9351        ids.insert(row?);
9352    }
9353    delete_ref_ids(tx, &ids)
9354}
9355
9356fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
9357    for ref_id in ref_ids {
9358        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
9359        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
9360    }
9361    Ok(())
9362}
9363
9364fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
9365    let mut stmt = conn.prepare(
9366        "SELECT source.file_path, source.scoped_name, edges.target_file,
9367                edges.target_symbol, edges.kind, edges.line
9368         FROM edges
9369         JOIN nodes AS source ON source.id = edges.source_node
9370         ORDER BY source.file_path, source.scoped_name, edges.target_file,
9371                  edges.target_symbol, edges.kind, edges.line",
9372    )?;
9373    let rows = stmt.query_map([], |row| {
9374        Ok(StoredEdge {
9375            source_file: row.get(0)?,
9376            source_symbol: row.get(1)?,
9377            target_file: row.get(2)?,
9378            target_symbol: row.get(3)?,
9379            kind: row.get(4)?,
9380            line: row.get::<_, i64>(5)? as u32,
9381        })
9382    })?;
9383    let mut edges = BTreeSet::new();
9384    for row in rows {
9385        edges.insert(row?);
9386    }
9387    Ok(edges)
9388}
9389
9390fn module_target_from_dependencies(
9391    project_root: &Path,
9392    dependencies: &BTreeSet<String>,
9393) -> Option<String> {
9394    dependencies.iter().find_map(|dep| {
9395        let path = project_root.join(dep);
9396        if path.is_file() {
9397            Some(relative_path(project_root, &canonicalize_path(&path)))
9398        } else {
9399            None
9400        }
9401    })
9402}
9403
9404fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
9405    let mut named = HashMap::new();
9406    if let Some(full_ref) = &raw_ref.full_ref {
9407        named = parse_reexport_names(full_ref);
9408    }
9409    ReexportIndex {
9410        target_file,
9411        named,
9412        wildcard: raw_ref.wildcard,
9413    }
9414}
9415
9416fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
9417    let mut names = HashMap::new();
9418    let Some(open) = statement.find('{') else {
9419        return names;
9420    };
9421    let Some(close) = statement[open + 1..]
9422        .find('}')
9423        .map(|offset| open + 1 + offset)
9424    else {
9425        return names;
9426    };
9427    for spec in statement[open + 1..close].split(',') {
9428        let spec = spec.trim();
9429        if spec.is_empty() {
9430            continue;
9431        }
9432        if let Some((source, local)) = spec.split_once(" as ") {
9433            names.insert(local.trim().to_string(), source.trim().to_string());
9434        } else {
9435            names.insert(spec.to_string(), spec.to_string());
9436        }
9437    }
9438    names
9439}
9440
9441#[derive(Debug)]
9442struct RefDependencyRow {
9443    ref_id: String,
9444    kind: String,
9445    caller_file: String,
9446    module_path: Option<String>,
9447    target_file: Option<String>,
9448}
9449
9450fn ref_dependency_row_depends_on(
9451    project_root: &Path,
9452    row: &RefDependencyRow,
9453    rel_path: &str,
9454) -> bool {
9455    if row.target_file.as_deref() == Some(rel_path) {
9456        return true;
9457    }
9458
9459    match row.kind.as_str() {
9460        "call" => true,
9461        "import" | "reexport" => row
9462            .module_path
9463            .as_deref()
9464            .map(|module_path| {
9465                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
9466                    .contains(rel_path)
9467            })
9468            .unwrap_or(false),
9469        "export_alias" => false,
9470        _ => false,
9471    }
9472}
9473
9474fn module_dependencies_for_ref(
9475    project_root: &Path,
9476    caller_file: &str,
9477    module_path: &str,
9478) -> BTreeSet<String> {
9479    module_dependencies(project_root, &project_root.join(caller_file), module_path)
9480}
9481
9482fn import_dependencies(
9483    project_root: &Path,
9484    abs_path: &Path,
9485    imports: &[ImportStatement],
9486) -> BTreeSet<String> {
9487    let mut deps = BTreeSet::new();
9488    for import in imports {
9489        deps.extend(module_dependencies(
9490            project_root,
9491            abs_path,
9492            &import.module_path,
9493        ));
9494    }
9495    deps
9496}
9497
9498fn module_dependencies(
9499    project_root: &Path,
9500    abs_path: &Path,
9501    module_path: &str,
9502) -> BTreeSet<String> {
9503    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
9504    let caller_dir = abs_path.parent().unwrap_or(project_root);
9505    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
9506        deps.insert(relative_path(project_root, &resolved));
9507    }
9508    if module_path.starts_with('.') {
9509        let base = caller_dir.join(module_path);
9510        for candidate in relative_module_candidates(&base) {
9511            deps.insert(relative_path(project_root, &candidate));
9512        }
9513    }
9514    deps
9515}
9516
9517fn rust_module_dependencies(
9518    project_root: &Path,
9519    abs_path: &Path,
9520    module_path: &str,
9521) -> BTreeSet<String> {
9522    let mut deps = BTreeSet::new();
9523    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
9524    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
9525        return deps;
9526    };
9527    let src_prefix = rust_src_prefix(&rel_path);
9528    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
9529    if !path_segments.is_empty() {
9530        rust_push_module_dependency_candidate(
9531            project_root,
9532            &mut deps,
9533            &src_prefix,
9534            &path_segments[..path_segments.len() - 1],
9535        );
9536    }
9537    deps
9538}
9539
9540fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
9541    let path = rust_module_path_without_alias_or_use_list(module_path);
9542    let segments = path
9543        .split("::")
9544        .map(str::trim)
9545        .filter(|segment| !segment.is_empty())
9546        .collect::<Vec<_>>();
9547    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
9548        return None;
9549    }
9550    rust_resolve_segments(rel_path, &segments)
9551}
9552
9553fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
9554    let path = module_path
9555        .trim()
9556        .trim_end_matches(';')
9557        .split_once(" as ")
9558        .map(|(left, _)| left.trim())
9559        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
9560    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
9561}
9562
9563fn rust_push_module_dependency_candidate(
9564    project_root: &Path,
9565    deps: &mut BTreeSet<String>,
9566    src_prefix: &str,
9567    segments: &[String],
9568) {
9569    let candidates = if segments.is_empty() {
9570        vec![
9571            format!("{src_prefix}/lib.rs"),
9572            format!("{src_prefix}/main.rs"),
9573        ]
9574    } else {
9575        vec![
9576            format!("{}/{}.rs", src_prefix, segments.join("/")),
9577            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
9578        ]
9579    };
9580    for candidate in candidates {
9581        if project_root.join(&candidate).is_file() {
9582            deps.insert(candidate);
9583        }
9584    }
9585}
9586
9587fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
9588    let mut candidates = Vec::new();
9589    if base.extension().is_some() {
9590        candidates.push(base.to_path_buf());
9591        return candidates;
9592    }
9593    for ext in JS_TS_EXTENSIONS {
9594        candidates.push(base.with_extension(ext));
9595    }
9596    for ext in JS_TS_EXTENSIONS {
9597        candidates.push(base.join(format!("index.{ext}")));
9598    }
9599    candidates
9600}
9601
9602fn import_local_names(import: &ImportStatement) -> Vec<String> {
9603    let mut names = Vec::new();
9604    if let Some(default) = &import.default_import {
9605        names.push(default.clone());
9606    }
9607    if let Some(namespace) = &import.namespace_import {
9608        names.push(namespace.clone());
9609    }
9610    for name in &import.names {
9611        names.push(crate::imports::specifier_local_name(name).to_string());
9612    }
9613    names
9614}
9615
9616fn import_requested_names(import: &ImportStatement) -> Vec<String> {
9617    import
9618        .names
9619        .iter()
9620        .map(|name| crate::imports::specifier_imported_name(name).to_string())
9621        .collect()
9622}
9623
9624fn import_is_wildcard(import: &ImportStatement) -> bool {
9625    import.namespace_import.is_some() || import.raw_text.contains('*')
9626}
9627
9628fn namespace_alias(full_ref: &str) -> Option<String> {
9629    full_ref
9630        .split_once('.')
9631        .map(|(namespace, _)| namespace.to_string())
9632}
9633
9634fn import_kind_label(kind: ImportKind) -> &'static str {
9635    match kind {
9636        ImportKind::Value => "value",
9637        ImportKind::Type => "type",
9638        ImportKind::SideEffect => "side_effect",
9639    }
9640}
9641
9642fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
9643    match kind {
9644        SymbolKind::Function => "function",
9645        SymbolKind::Class => "class",
9646        SymbolKind::Method => "method",
9647        SymbolKind::Struct => "struct",
9648        SymbolKind::Interface => "interface",
9649        SymbolKind::Enum => "enum",
9650        SymbolKind::TypeAlias => "type_alias",
9651        SymbolKind::Variable => "variable",
9652        SymbolKind::Heading => "heading",
9653        SymbolKind::FileSummary => "file_summary",
9654    }
9655}
9656
9657fn is_type_like(kind: &SymbolKind) -> bool {
9658    matches!(
9659        kind,
9660        SymbolKind::Class
9661            | SymbolKind::Struct
9662            | SymbolKind::Interface
9663            | SymbolKind::Enum
9664            | SymbolKind::TypeAlias
9665    )
9666}
9667
9668fn lang_label(lang: LangId) -> &'static str {
9669    match lang {
9670        LangId::TypeScript => "typescript",
9671        LangId::Tsx => "tsx",
9672        LangId::JavaScript => "javascript",
9673        LangId::Python => "python",
9674        LangId::Rust => "rust",
9675        LangId::Go => "go",
9676        LangId::C => "c",
9677        LangId::Cpp => "cpp",
9678        LangId::Zig => "zig",
9679        LangId::CSharp => "csharp",
9680        LangId::Bash => "bash",
9681        LangId::Html => "html",
9682        LangId::Markdown => "markdown",
9683        LangId::Solidity => "solidity",
9684        LangId::Scss => "scss",
9685        LangId::Vue => "vue",
9686        LangId::Json => "json",
9687        LangId::Scala => "scala",
9688        LangId::Java => "java",
9689        LangId::Ruby => "ruby",
9690        LangId::Kotlin => "kotlin",
9691        LangId::Swift => "swift",
9692        LangId::Php => "php",
9693        LangId::Lua => "lua",
9694        LangId::Perl => "perl",
9695        LangId::Yaml => "yaml",
9696        LangId::Pascal => "pascal",
9697        LangId::R => "r",
9698        LangId::Groovy => "groovy",
9699        LangId::ObjC => "objc",
9700    }
9701}
9702
9703fn lang_from_label(label: &str) -> Option<LangId> {
9704    match label {
9705        "typescript" => Some(LangId::TypeScript),
9706        "tsx" => Some(LangId::Tsx),
9707        "javascript" => Some(LangId::JavaScript),
9708        "python" => Some(LangId::Python),
9709        "rust" => Some(LangId::Rust),
9710        "go" => Some(LangId::Go),
9711        "c" => Some(LangId::C),
9712        "cpp" => Some(LangId::Cpp),
9713        "zig" => Some(LangId::Zig),
9714        "csharp" => Some(LangId::CSharp),
9715        "bash" => Some(LangId::Bash),
9716        "html" => Some(LangId::Html),
9717        "markdown" => Some(LangId::Markdown),
9718        "solidity" => Some(LangId::Solidity),
9719        "scss" => Some(LangId::Scss),
9720        "vue" => Some(LangId::Vue),
9721        "json" => Some(LangId::Json),
9722        "scala" => Some(LangId::Scala),
9723        "java" => Some(LangId::Java),
9724        "ruby" => Some(LangId::Ruby),
9725        "kotlin" => Some(LangId::Kotlin),
9726        "swift" => Some(LangId::Swift),
9727        "php" => Some(LangId::Php),
9728        "lua" => Some(LangId::Lua),
9729        "perl" => Some(LangId::Perl),
9730        "yaml" => Some(LangId::Yaml),
9731        "pascal" => Some(LangId::Pascal),
9732        "r" => Some(LangId::R),
9733        "groovy" => Some(LangId::Groovy),
9734        "objc" => Some(LangId::ObjC),
9735        _ => None,
9736    }
9737}
9738
9739fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
9740    let mut normalized = if files.is_empty() {
9741        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
9742    } else {
9743        files
9744            .iter()
9745            .map(|path| normalize_file_path(project_root, path))
9746            .collect::<Result<Vec<_>>>()?
9747    };
9748    normalized.sort();
9749    normalized.dedup();
9750    Ok(normalized)
9751}
9752
9753fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
9754    let full_path = if path.is_relative() {
9755        project_root.join(path)
9756    } else {
9757        path.to_path_buf()
9758    };
9759    Ok(canonicalize_path(&full_path))
9760}
9761
9762fn canonicalize_path(path: &Path) -> PathBuf {
9763    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
9764}
9765
9766fn relative_path(project_root: &Path, path: &Path) -> String {
9767    if let Ok(stripped) = path.strip_prefix(project_root) {
9768        return stripped.to_string_lossy().replace('\\', "/");
9769    }
9770    let canon_root = canonicalize_path(project_root);
9771    let canon_path = canonicalize_path(path);
9772    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
9773        return stripped.to_string_lossy().replace('\\', "/");
9774    }
9775    canon_path.to_string_lossy().replace('\\', "/")
9776}
9777
9778fn unqualified_name(scoped: &str) -> &str {
9779    if scoped == TOP_LEVEL_SYMBOL {
9780        return scoped;
9781    }
9782    scoped
9783        .rsplit("::")
9784        .next()
9785        .unwrap_or(scoped)
9786        .rsplit('.')
9787        .next()
9788        .unwrap_or(scoped)
9789        .rsplit('#')
9790        .next()
9791        .unwrap_or(scoped)
9792}
9793
9794fn ref_id(parts: &[&str]) -> String {
9795    let joined = parts.join("\0");
9796    hash_to_hex(blake3::hash(joined.as_bytes()))
9797}
9798
9799fn hash_to_hex(hash: blake3::Hash) -> String {
9800    hash.to_hex().to_string()
9801}
9802
9803fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
9804    let bytes = hex_to_bytes(value)?;
9805    Some(blake3::Hash::from_bytes(bytes))
9806}
9807
9808fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
9809    if value.len() != 64 {
9810        return None;
9811    }
9812    let mut bytes = [0u8; 32];
9813    for (index, slot) in bytes.iter_mut().enumerate() {
9814        let start = index * 2;
9815        let end = start + 2;
9816        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
9817    }
9818    Some(bytes)
9819}
9820
9821#[derive(Debug, Clone)]
9822struct LineIndex {
9823    newline_offsets: Vec<usize>,
9824    source_len: usize,
9825}
9826
9827impl LineIndex {
9828    fn new(source: &str) -> Self {
9829        Self {
9830            newline_offsets: source
9831                .bytes()
9832                .enumerate()
9833                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
9834                .collect(),
9835            source_len: source.len(),
9836        }
9837    }
9838
9839    fn byte_to_line(&self, byte_offset: usize) -> u32 {
9840        let byte_offset = byte_offset.min(self.source_len);
9841        self.newline_offsets
9842            .partition_point(|offset| *offset < byte_offset) as u32
9843            + 1
9844    }
9845}
9846
9847fn empty_to_none(value: String) -> Option<String> {
9848    if value.is_empty() {
9849        None
9850    } else {
9851        Some(value)
9852    }
9853}
9854
9855fn bool_int(value: bool) -> i64 {
9856    if value {
9857        1
9858    } else {
9859        0
9860    }
9861}
9862
9863fn system_time_to_ns(time: SystemTime) -> i64 {
9864    time.duration_since(UNIX_EPOCH)
9865        .unwrap_or_default()
9866        .as_nanos()
9867        .min(i64::MAX as u128) as i64
9868}
9869
9870fn ns_to_system_time(value: i64) -> SystemTime {
9871    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
9872}
9873
9874fn unix_seconds_now() -> i64 {
9875    SystemTime::now()
9876        .duration_since(UNIX_EPOCH)
9877        .unwrap_or_default()
9878        .as_secs() as i64
9879}
9880
9881/// Serializes every test that drives the process-wide refresh worker
9882/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
9883/// worker down between another test's enqueue and its flush, deferring the
9884/// batch and zeroing that test's seam counts).
9885#[cfg(test)]
9886pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9887
9888#[cfg(test)]
9889mod refresh_worker_tests {
9890    use super::*;
9891    use std::fs;
9892    use tempfile::tempdir;
9893
9894    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
9895        let temp = tempdir().unwrap();
9896        let root = temp.path().join("root");
9897        let callgraph_dir = temp
9898            .path()
9899            .join("storage")
9900            .join("callgraph")
9901            .join(crate::search_index::artifact_cache_key(&root));
9902        fs::create_dir_all(&root).unwrap();
9903        let source = root.join("main.rs");
9904        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
9905        let (store, _) = CallGraphStore::cold_build_with_lease(
9906            callgraph_dir.clone(),
9907            root.clone(),
9908            std::slice::from_ref(&source),
9909        )
9910        .unwrap();
9911        drop(store);
9912        (temp, root, callgraph_dir, source)
9913    }
9914
9915    fn pending_paths() -> PendingCallGraphStorePaths {
9916        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
9917    }
9918
9919    fn wait_for_refresh_calls(root: &Path, expected: usize) {
9920        let deadline = Instant::now() + Duration::from_secs(12);
9921        while callgraph_refresh_worker_test_counts(root).0 < expected {
9922            assert!(
9923                Instant::now() < deadline,
9924                "timed out waiting for {expected} callgraph refresh worker call(s)"
9925            );
9926            std::thread::sleep(Duration::from_millis(5));
9927        }
9928    }
9929
9930    #[test]
9931    fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
9932        let _git_env = crate::test_env::hermetic_git_env_guard();
9933        let temp = tempdir().unwrap();
9934        let main = temp.path().join("main");
9935        let root = temp.path().join("worktree");
9936        fs::create_dir_all(&main).unwrap();
9937        let mut git = std::process::Command::new("git");
9938        assert!(
9939            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
9940                .status()
9941                .unwrap()
9942                .success()
9943        );
9944        let source = main.join("lib.rs");
9945        fs::write(&source, "pub fn marker() {}\n").unwrap();
9946        for args in [
9947            vec![
9948                "-C",
9949                main.to_str().unwrap(),
9950                "config",
9951                "user.email",
9952                "test@example.com",
9953            ],
9954            vec![
9955                "-C",
9956                main.to_str().unwrap(),
9957                "config",
9958                "user.name",
9959                "AFT Test",
9960            ],
9961            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
9962            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
9963        ] {
9964            let mut command = std::process::Command::new("git");
9965            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
9966                .status()
9967                .unwrap()
9968                .success());
9969        }
9970        let mut worktree = std::process::Command::new("git");
9971        assert!(crate::test_env::apply_hermetic_git_env(
9972            worktree
9973                .arg("-C")
9974                .arg(&main)
9975                .args(["worktree", "add", "--detach"])
9976                .arg(&root),
9977        )
9978        .status()
9979        .unwrap()
9980        .success());
9981
9982        let project_key = crate::search_index::artifact_cache_key(&root);
9983        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
9984        crate::root_cache::configure_artifact_access(&root, &project_key, true);
9985        let source = root.join("lib.rs");
9986        let error =
9987            CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
9988                .expect_err("borrow-only forced rebuild must remain unsatisfied");
9989
9990        assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
9991    }
9992
9993    #[test]
9994    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
9995        let _guard = REFRESH_WORKER_TEST_LOCK
9996            .lock()
9997            .unwrap_or_else(std::sync::PoisonError::into_inner);
9998        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
9999        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10000        let pending = pending_paths();
10001        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10002
10003        let lifecycle = SubcLifecycleAdmission::default();
10004        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10005        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10006        let ticket = CallgraphRefreshTicket::new(
10007            lifecycle,
10008            Arc::clone(&generation),
10009            7,
10010            publish_epoch.clone(),
10011            publish_epoch.current(),
10012        );
10013        // Supersede before the worker runs: the batch must defer, not commit.
10014        generation.store(8, std::sync::atomic::Ordering::SeqCst);
10015
10016        enqueue_callgraph_store_refresh_fenced(
10017            callgraph_dir,
10018            root.clone(),
10019            vec![source.clone()],
10020            Arc::clone(&pending),
10021            ticket,
10022        );
10023        assert!(flush_callgraph_store_refreshes_with_budget(
10024            Duration::from_secs(5)
10025        ));
10026        assert_eq!(
10027            callgraph_refresh_worker_test_counts(&root).0,
10028            0,
10029            "superseded batch must not reach refresh_files"
10030        );
10031        assert!(
10032            pending.lock().contains(&source),
10033            "superseded batch must defer its paths to the pending sink"
10034        );
10035        clear_callgraph_refresh_worker_test_seam(&root);
10036    }
10037
10038    #[test]
10039    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
10040        let _guard = REFRESH_WORKER_TEST_LOCK
10041            .lock()
10042            .unwrap_or_else(std::sync::PoisonError::into_inner);
10043        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10044        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10045        let pending = pending_paths();
10046        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10047
10048        let lifecycle = SubcLifecycleAdmission::default();
10049        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
10050        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10051        let expected_epoch = publish_epoch.current();
10052        let ticket = CallgraphRefreshTicket::new(
10053            lifecycle,
10054            generation,
10055            3,
10056            publish_epoch.clone(),
10057            expected_epoch,
10058        );
10059        // A cold build published a replacement generation after enqueue.
10060        publish_epoch.next();
10061
10062        enqueue_callgraph_store_refresh_fenced(
10063            callgraph_dir,
10064            root.clone(),
10065            vec![source.clone()],
10066            Arc::clone(&pending),
10067            ticket,
10068        );
10069        assert!(flush_callgraph_store_refreshes_with_budget(
10070            Duration::from_secs(5)
10071        ));
10072        assert_eq!(
10073            callgraph_refresh_worker_test_counts(&root).0,
10074            0,
10075            "epoch-superseded batch must not reach refresh_files"
10076        );
10077        assert!(
10078            pending.lock().contains(&source),
10079            "epoch-superseded batch must defer its paths to the pending sink"
10080        );
10081        clear_callgraph_refresh_worker_test_seam(&root);
10082    }
10083
10084    #[test]
10085    fn fenced_refresh_with_current_ticket_commits_normally() {
10086        let _guard = REFRESH_WORKER_TEST_LOCK
10087            .lock()
10088            .unwrap_or_else(std::sync::PoisonError::into_inner);
10089        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10090        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10091        let pending = pending_paths();
10092        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10093
10094        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10095
10096        let lifecycle = SubcLifecycleAdmission::default();
10097        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
10098        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10099        let ticket = CallgraphRefreshTicket::new(
10100            lifecycle,
10101            generation,
10102            5,
10103            publish_epoch.clone(),
10104            publish_epoch.current(),
10105        );
10106
10107        enqueue_callgraph_store_refresh_fenced(
10108            callgraph_dir.clone(),
10109            root.clone(),
10110            vec![source.clone()],
10111            Arc::clone(&pending),
10112            ticket,
10113        );
10114        assert!(flush_callgraph_store_refreshes_with_budget(
10115            Duration::from_secs(5)
10116        ));
10117        assert_eq!(
10118            callgraph_refresh_worker_test_counts(&root).0,
10119            1,
10120            "current ticket must run the refresh"
10121        );
10122        assert!(
10123            pending.lock().is_empty(),
10124            "committed batch must not defer paths"
10125        );
10126
10127        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10128            .unwrap()
10129            .expect("published generation must remain readable");
10130        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10131        assert_eq!(
10132            tree.children[0].name, "new_leaf",
10133            "fenced commit must actually persist the refreshed content"
10134        );
10135        clear_callgraph_refresh_worker_test_seam(&root);
10136    }
10137
10138    #[test]
10139    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
10140        let _guard = REFRESH_WORKER_TEST_LOCK
10141            .lock()
10142            .unwrap_or_else(std::sync::PoisonError::into_inner);
10143        // Generous pre-drain: the refresh worker is process-wide, so a prior
10144        // test's still-running batch (slow Windows CI) must fully settle
10145        // before this test enqueues, or its wait deadline absorbs the
10146        // leftover work. Idle workers return immediately.
10147        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10148        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10149        let pending = pending_paths();
10150        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
10151
10152        enqueue_callgraph_store_refresh(
10153            callgraph_dir.clone(),
10154            root.clone(),
10155            vec![source.clone()],
10156            Arc::clone(&pending),
10157        );
10158        wait_for_refresh_calls(&root, 1);
10159        for _ in 0..3 {
10160            enqueue_callgraph_store_refresh(
10161                callgraph_dir.clone(),
10162                root.clone(),
10163                vec![source.clone()],
10164                Arc::clone(&pending),
10165            );
10166        }
10167
10168        assert!(flush_callgraph_store_refreshes_with_budget(
10169            Duration::from_secs(2)
10170        ));
10171        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
10172        assert!(pending.lock().is_empty());
10173        clear_callgraph_refresh_worker_test_seam(&root);
10174    }
10175
10176    #[test]
10177    fn queued_refresh_opens_generation_published_after_enqueue() {
10178        let _guard = REFRESH_WORKER_TEST_LOCK
10179            .lock()
10180            .unwrap_or_else(std::sync::PoisonError::into_inner);
10181        // Generous pre-drain: the refresh worker is process-wide, so a prior
10182        // test's still-running batch (slow Windows CI) must fully settle
10183        // before this test enqueues, or its wait deadline absorbs the
10184        // leftover work. Idle workers return immediately.
10185        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10186        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10187        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
10188        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
10189        let (active_held_rx, active_release_tx) =
10190            install_refresh_worker_test_gate(active_root.clone());
10191        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
10192        enqueue_callgraph_store_refresh(
10193            active_dir,
10194            active_root.clone(),
10195            vec![active_source],
10196            pending_paths(),
10197        );
10198        active_held_rx
10199            .recv_timeout(Duration::from_secs(12))
10200            .expect("active refresh worker holds the queue");
10201
10202        fs::write(
10203            &target_source,
10204            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10205        )
10206        .unwrap();
10207        enqueue_callgraph_store_refresh(
10208            target_dir.clone(),
10209            target_root.clone(),
10210            vec![target_source.clone()],
10211            pending_paths(),
10212        );
10213        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
10214            target_dir.clone(),
10215            target_root.clone(),
10216            std::slice::from_ref(&target_source),
10217        )
10218        .unwrap();
10219        fs::write(
10220            &target_source,
10221            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10222        )
10223        .unwrap();
10224        drop(new_generation);
10225
10226        active_release_tx
10227            .send(())
10228            .expect("release active refresh worker");
10229        wait_for_refresh_calls(&target_root, 1);
10230        assert!(flush_callgraph_store_refreshes_with_budget(
10231            Duration::from_secs(12)
10232        ));
10233        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
10234            .unwrap()
10235            .expect("current callgraph generation");
10236        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10237        assert_eq!(tree.children[0].name, "worker_leaf");
10238        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
10239        clear_callgraph_refresh_worker_test_seam(&active_root);
10240        clear_callgraph_refresh_worker_test_seam(&target_root);
10241    }
10242
10243    #[test]
10244    fn refresh_failure_marks_files_stale() {
10245        let _guard = REFRESH_WORKER_TEST_LOCK
10246            .lock()
10247            .unwrap_or_else(std::sync::PoisonError::into_inner);
10248        // Generous pre-drain: the refresh worker is process-wide, so a prior
10249        // test's still-running batch (slow Windows CI) must fully settle
10250        // before this test enqueues, or its wait deadline absorbs the
10251        // leftover work. Idle workers return immediately.
10252        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10253        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10254        let pending = pending_paths();
10255        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
10256
10257        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
10258        assert!(flush_callgraph_store_refreshes_with_budget(
10259            Duration::from_secs(2)
10260        ));
10261
10262        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
10263        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
10264            .unwrap()
10265            .expect("ready callgraph store");
10266        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
10267        clear_callgraph_refresh_worker_test_seam(&root);
10268    }
10269
10270    #[test]
10271    fn bounded_shutdown_defers_unprocessed_batches() {
10272        let _guard = REFRESH_WORKER_TEST_LOCK
10273            .lock()
10274            .unwrap_or_else(std::sync::PoisonError::into_inner);
10275        // Generous pre-drain: the refresh worker is process-wide, so a prior
10276        // test's still-running batch (slow Windows CI) must fully settle
10277        // before this test enqueues, or its wait deadline absorbs the
10278        // leftover work. Idle workers return immediately.
10279        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10280        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10281        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
10282        let active_pending = pending_paths();
10283        let queued_pending = pending_paths();
10284        set_callgraph_refresh_worker_test_seam(
10285            active_root.clone(),
10286            Duration::from_millis(300),
10287            false,
10288        );
10289
10290        enqueue_callgraph_store_refresh(
10291            active_dir,
10292            active_root.clone(),
10293            vec![active_source.clone()],
10294            Arc::clone(&active_pending),
10295        );
10296        wait_for_refresh_calls(&active_root, 1);
10297        enqueue_callgraph_store_refresh(
10298            queued_dir,
10299            queued_root.clone(),
10300            vec![queued_source.clone()],
10301            Arc::clone(&queued_pending),
10302        );
10303
10304        assert!(!flush_callgraph_store_refreshes_with_budget(
10305            Duration::from_millis(20)
10306        ));
10307        assert!(active_pending.lock().contains(&active_source));
10308        assert!(queued_pending.lock().contains(&queued_source));
10309        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
10310        clear_callgraph_refresh_worker_test_seam(&active_root);
10311    }
10312}
10313
10314#[cfg(test)]
10315mod cold_build_insert_tests {
10316    use super::*;
10317    use crate::imports::ImportBlock;
10318    use std::fs;
10319    use std::path::{Path, PathBuf};
10320    use tempfile::tempdir;
10321
10322    #[test]
10323    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
10324        let dir = tempdir().unwrap();
10325        let previous_root = dir.path().join("previous-root");
10326        let current_root = dir.path().join("current-root");
10327        fs::create_dir_all(&previous_root).unwrap();
10328        fs::create_dir_all(&current_root).unwrap();
10329        fs::remove_dir(&previous_root).unwrap();
10330        let mut conn = Connection::open_in_memory().unwrap();
10331        initialize_schema(&conn).unwrap();
10332        conn.execute(
10333            "INSERT INTO backend_file_state(
10334                backend, workspace_root, file_path, content_hash, status, updated_at
10335             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
10336            params![previous_root.display().to_string()],
10337        )
10338        .unwrap();
10339
10340        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
10341
10342        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
10343        assert_eq!(
10344            stored_workspace_roots(&conn).unwrap(),
10345            vec![previous_root.display().to_string()]
10346        );
10347    }
10348
10349    #[test]
10350    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
10351        assert_eq!(
10352            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
10353            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
10354        );
10355    }
10356
10357    #[test]
10358    fn legacy_migration_completion_log_has_operator_fields() {
10359        assert_eq!(
10360            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
10361            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
10362        );
10363    }
10364
10365    fn write_generation_with_age(
10366        dir: &Path,
10367        project_key: &str,
10368        ordinal: u64,
10369        age: Duration,
10370    ) -> String {
10371        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
10372        let path = dir.join(&generation);
10373        fs::write(&path, b"sqlite placeholder").unwrap();
10374        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
10375        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
10376        generation
10377    }
10378
10379    #[test]
10380    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
10381        let dir = tempfile::tempdir().unwrap();
10382        let project_key = "project";
10383        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10384        let previous =
10385            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10386        let pinned =
10387            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10388        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10389
10390        gc_old_generations(dir.path(), project_key, &current);
10391
10392        assert!(dir.path().join(&previous).is_file());
10393        assert!(dir.path().join(&pinned).is_file());
10394
10395        drop(marker);
10396        gc_old_generations(dir.path(), project_key, &current);
10397
10398        assert!(dir.path().join(&previous).is_file());
10399        assert!(!dir.path().join(&pinned).exists());
10400    }
10401
10402    #[test]
10403    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
10404        let dir = tempfile::tempdir().unwrap();
10405        let project_key = "project";
10406        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10407        let _previous =
10408            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10409        let pinned =
10410            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10411        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10412        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
10413
10414        gc_old_generations(dir.path(), project_key, &current);
10415
10416        assert!(dir.path().join(&pinned).is_file());
10417    }
10418
10419    #[test]
10420    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
10421        let dir = tempfile::tempdir().unwrap();
10422        let project_key = "project";
10423        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
10424        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10425        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
10426        let old = write_generation_with_age(
10427            dir.path(),
10428            project_key,
10429            200,
10430            expired + Duration::from_secs(60),
10431        );
10432        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
10433
10434        gc_old_generations(dir.path(), project_key, &current);
10435
10436        assert!(dir.path().join(&current).is_file());
10437        assert!(dir.path().join(&previous).is_file());
10438        assert!(!dir.path().join(&old).exists());
10439    }
10440
10441    #[test]
10442    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
10443        let dir = tempfile::tempdir().unwrap();
10444        let project_key = "project".to_string();
10445        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
10446        let sqlite_path = dir.path().join(&generation);
10447        fs::remove_file(&sqlite_path).unwrap();
10448        let conn = Connection::open(&sqlite_path).unwrap();
10449        let store = CallGraphStore::from_connection(
10450            dir.path().to_path_buf(),
10451            project_key,
10452            sqlite_path,
10453            dir.path().to_path_buf(),
10454            false,
10455            Some(generation.clone()),
10456            None,
10457            None,
10458            conn,
10459        );
10460
10461        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
10462        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
10463
10464        drop(marker);
10465        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
10466    }
10467
10468    #[test]
10469    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
10470        let dir = tempdir().expect("temp dir");
10471        let file = dir.path().join("main.ts");
10472        fs::write(
10473            &file,
10474            r#"export function topA() {
10475  root();
10476}
10477
10478export function topB() {
10479  root();
10480}
10481
10482export function root() {
10483  leaf();
10484  missing();
10485}
10486
10487export function leaf() {}
10488"#,
10489        )
10490        .expect("write fixture");
10491
10492        let store = CallGraphStore::open(
10493            dir.path().join(".store-depth-boundary-counts"),
10494            dir.path().to_path_buf(),
10495        )
10496        .expect("open store");
10497        store
10498            .cold_build(std::slice::from_ref(&file))
10499            .expect("cold build");
10500
10501        let root = store
10502            .node_for(Path::new("main.ts"), "root")
10503            .expect("root node");
10504        let leaf = store
10505            .node_for(Path::new("main.ts"), "leaf")
10506            .expect("leaf node");
10507
10508        let (full_forward_len, full_direct_len) = {
10509            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
10510            conn.execute(
10511                "INSERT INTO edges (
10512                    edge_id, ref_id, source_node, target_node, target_file,
10513                    target_symbol, kind, line, provenance
10514                 ) VALUES (
10515                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
10516                    ?2, ?3, 'call', 98, ?4
10517                 )",
10518                rusqlite::params![
10519                    &root.node_id,
10520                    &leaf.file,
10521                    &leaf.symbol,
10522                    PROVENANCE_TREESITTER
10523                ],
10524            )
10525            .expect("insert dangling forward edge");
10526            conn.execute(
10527                "INSERT INTO edges (
10528                    edge_id, ref_id, source_node, target_node, target_file,
10529                    target_symbol, kind, line, provenance
10530                 ) VALUES (
10531                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
10532                    ?1, ?2, ?3, 'call', 99, ?4
10533                 )",
10534                rusqlite::params![
10535                    &root.node_id,
10536                    &root.file,
10537                    &root.symbol,
10538                    PROVENANCE_TREESITTER
10539                ],
10540            )
10541            .expect("insert dangling direct-caller edge");
10542
10543            let full_forward_len = forward_calls_for_node(&conn, &root)
10544                .expect("full forward calls")
10545                .len();
10546            let counted_forward_len =
10547                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
10548            assert_eq!(
10549                counted_forward_len, full_forward_len,
10550                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
10551            );
10552
10553            let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
10554                .expect("full direct callers")
10555                .len();
10556            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
10557                .expect("counted direct callers");
10558            assert_eq!(
10559                counted_direct_len, full_direct_len,
10560                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
10561            );
10562
10563            (full_forward_len, full_direct_len)
10564        };
10565
10566        assert_eq!(
10567            full_forward_len, 2,
10568            "fixture root should have one resolved and one unresolved outgoing call"
10569        );
10570        assert_eq!(
10571            full_direct_len, 2,
10572            "fixture root should have two real direct callers"
10573        );
10574
10575        let tree = store
10576            .call_tree(Path::new("main.ts"), "root", 0)
10577            .expect("call tree");
10578        assert!(tree.depth_limited);
10579        assert_eq!(tree.children.len(), 0);
10580        assert_eq!(
10581            tree.truncated, full_forward_len,
10582            "call_tree depth boundary must report the full forward-call list length"
10583        );
10584
10585        let callers = store
10586            .callers_of(Path::new("main.ts"), "leaf", 0)
10587            .expect("callers");
10588        assert!(callers.depth_limited);
10589        assert_eq!(callers.callers.len(), 1);
10590        assert_eq!(callers.callers[0].caller.symbol, "root");
10591        assert_eq!(
10592            callers.truncated, full_direct_len,
10593            "callers depth boundary must report the full direct-caller list length"
10594        );
10595    }
10596
10597    #[test]
10598    fn source_freshness_matches_cache_collect_for_same_bytes() {
10599        let dir = tempdir().expect("temp dir");
10600        let path = dir.path().join("fixture.ts");
10601        let source = "export function main() { return helper(); }\n";
10602        fs::write(&path, source).expect("write fixture");
10603
10604        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
10605        let actual =
10606            collect_source_freshness(&path, source).expect("collect freshness from source");
10607
10608        assert_eq!(actual, expected);
10609    }
10610
10611    #[test]
10612    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
10613        let root = tempfile::tempdir().unwrap();
10614        let callgraph_dir = tempfile::tempdir().unwrap();
10615        let source_dir = root.path().join("src");
10616        std::fs::create_dir_all(&source_dir).unwrap();
10617        let source = source_dir.join("lib.rs");
10618        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
10619        let files = vec![source.clone()];
10620        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
10621        let old_epoch = epoch.next();
10622        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
10623        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
10624        let old_epoch_flag = epoch.clone();
10625        let old_dir = callgraph_dir.path().to_path_buf();
10626        let old_root = root.path().to_path_buf();
10627        let old_files = files.clone();
10628        let old = std::thread::spawn(move || {
10629            set_cold_build_before_publish_observer(Some(Arc::new(move || {
10630                reached_tx.send(()).unwrap();
10631                release_rx.recv().unwrap();
10632            })));
10633            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
10634                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
10635            });
10636            set_cold_build_before_publish_observer(None);
10637            result
10638        });
10639        reached_rx
10640            .recv_timeout(Duration::from_secs(5))
10641            .expect("older build did not reach its publication barrier");
10642
10643        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
10644        let new_epoch = epoch.next();
10645        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
10646            CallGraphStore::cold_build_with_lease(
10647                callgraph_dir.path().to_path_buf(),
10648                root.path().to_path_buf(),
10649                &files,
10650            )
10651        })
10652        .expect("newer build should publish");
10653        drop(new_store);
10654
10655        release_tx.send(()).unwrap();
10656        assert!(matches!(
10657            old.join().unwrap(),
10658            Err(CallGraphStoreError::Superseded)
10659        ));
10660
10661        let current = CallGraphStore::open_readonly(
10662            callgraph_dir.path().to_path_buf(),
10663            root.path().to_path_buf(),
10664        )
10665        .unwrap()
10666        .expect("current callgraph generation");
10667        assert_eq!(
10668            current
10669                .nodes_matching("new_generation_marker")
10670                .unwrap()
10671                .len(),
10672            1
10673        );
10674        assert!(current
10675            .nodes_matching("old_generation_marker")
10676            .unwrap()
10677            .is_empty());
10678    }
10679
10680    #[test]
10681    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
10682        let dir = tempdir().expect("temp dir");
10683        let project_root = dir.path();
10684        let extract = fixture_extract(project_root);
10685        let resolved = fixture_resolved(&extract);
10686
10687        let reference = build_reference_connection(project_root, &extract, &resolved);
10688        let optimized = build_optimized_connection(project_root, &extract, &resolved);
10689
10690        for table in [
10691            "files",
10692            "nodes",
10693            "file_dependencies",
10694            "dispatch_hints",
10695            "refs",
10696            "edges",
10697        ] {
10698            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
10699            // the reference and optimized builds run sequentially and can straddle a
10700            // one-second tick under load, so it is legitimately allowed to differ.
10701            // This mirrors the existing exclusions of `backend_file_state.updated_at`
10702            // and the chunked-vs-unchunked sibling test. The check is for structural
10703            // row equivalence of the optimized bulk insert, not wall-clock equality.
10704            let excluded: &[&str] = if table == "files" {
10705                &["indexed_at"]
10706            } else {
10707                &[]
10708            };
10709            assert_eq!(
10710                table_rows_without(&reference, table, excluded),
10711                table_rows_without(&optimized, table, excluded),
10712                "table `{table}` rows must match apart from wall-clock columns"
10713            );
10714        }
10715        assert_eq!(
10716            backend_state_rows(&reference),
10717            backend_state_rows(&optimized),
10718            "backend freshness rows must match apart from updated_at"
10719        );
10720        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
10721    }
10722
10723    #[test]
10724    fn cold_build_chunked_matches_unchunked_logical_rows() {
10725        let dir = tempdir().expect("temp dir");
10726        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
10727        write_chunked_equivalence_fixture(&project_root);
10728        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
10729        assert!(
10730            files.len() > 6,
10731            "fixture should be large enough to split into multiple chunks"
10732        );
10733
10734        let unchunked = CallGraphStore::open(
10735            project_root.join(".store-unchunked"),
10736            project_root.to_path_buf(),
10737        )
10738        .expect("open unchunked store");
10739        let unchunked_stats = unchunked
10740            .cold_build_chunked(&files, 0)
10741            .expect("unchunked cold build");
10742
10743        let chunked = CallGraphStore::open(
10744            project_root.join(".store-chunked"),
10745            project_root.to_path_buf(),
10746        )
10747        .expect("open chunked store");
10748        let chunked_stats = chunked
10749            .cold_build_chunked(&files, 3)
10750            .expect("chunked cold build");
10751
10752        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
10753        assert_eq!(
10754            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
10755            chunked.edge_snapshot().expect("chunked edge snapshot"),
10756            "public edge snapshots must match"
10757        );
10758
10759        let dispatch_edges = {
10760            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
10761            conn.query_row(
10762                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
10763                [],
10764                |row| row.get::<_, i64>(0),
10765            )
10766            .expect("count dispatch edges")
10767        };
10768        assert!(
10769            dispatch_edges > 0,
10770            "fixture must exercise method-dispatch edge insertion"
10771        );
10772
10773        for table in [
10774            "edges",
10775            "refs",
10776            "nodes",
10777            "file_dependencies",
10778            "dispatch_hints",
10779        ] {
10780            assert_eq!(
10781                graph_table_rows(&unchunked, table),
10782                graph_table_rows(&chunked, table),
10783                "chunked cold build must match unchunked rows for {table}"
10784            );
10785        }
10786        assert_eq!(
10787            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
10788            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
10789            "files rows must match apart from indexed_at"
10790        );
10791        assert_eq!(
10792            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
10793            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
10794            "backend freshness rows must match apart from updated_at"
10795        );
10796
10797        let published_dir = project_root.join(".store-published");
10798        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
10799            published_dir.clone(),
10800            project_root.to_path_buf(),
10801            &files,
10802            0,
10803        )
10804        .expect("published unchunked cold build");
10805        assert!(
10806            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
10807                .expect("needs_cold_build after publish"),
10808            "published store should be ready"
10809        );
10810        drop(_published);
10811        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
10812            published_dir,
10813            project_root.to_path_buf(),
10814            &files,
10815            3,
10816        )
10817        .expect("ensure with a different chunk size");
10818        assert!(
10819            rebuild_stats.is_none(),
10820            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
10821        );
10822    }
10823
10824    // Perf A/B bench (not a gate): measures cold_build wall time at a given
10825    // chunk size against a real repo. Driven by env so the same binary can A/B
10826    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
10827    // memory work. Run:
10828    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
10829    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
10830    #[test]
10831    #[ignore]
10832    fn bench_cold_build_chunk() {
10833        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
10834        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
10835            .expect("AFT_PERF_CHUNK")
10836            .parse()
10837            .expect("AFT_PERF_CHUNK must be a non-negative integer");
10838        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
10839        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
10840        let dir = tempdir().expect("temp dir");
10841        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
10842            .expect("open store");
10843        let started = Instant::now();
10844        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
10845        let ms = started.elapsed().as_millis();
10846        println!(
10847            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
10848            stats.files, stats.nodes, stats.refs, stats.edges
10849        );
10850    }
10851
10852    #[test]
10853    fn persisted_workspace_reexport_selects_its_package_dependency() {
10854        let root = tempdir().expect("temp dir");
10855        let dependencies = BTreeSet::from([
10856            "packages/aft-bridge/src/index.ts".to_string(),
10857            "packages/opencode-plugin/src/types.ts".to_string(),
10858        ]);
10859        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
10860
10861        assert_eq!(
10862            stored_dependencies_for_module(
10863                root.path(),
10864                "packages/opencode-plugin/src/shared/bash-hints.ts",
10865                "@cortexkit/aft-bridge",
10866                &dependencies,
10867                &indexed_files,
10868            ),
10869            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
10870        );
10871    }
10872
10873    #[test]
10874    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
10875        let dir = tempdir().expect("temp dir");
10876        let project_root = dir.path();
10877        let files =
10878            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
10879        let index_path = project_root.join("src/index.ts");
10880
10881        let store = CallGraphStore::open(
10882            project_root.join(".store-incremental-barrel"),
10883            project_root.to_path_buf(),
10884        )
10885        .expect("open incremental store");
10886        store.cold_build(&files).expect("initial cold build");
10887
10888        {
10889            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10890            let tx = conn.transaction().expect("dependency transaction");
10891            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
10892                .expect("dependent refs for barrel");
10893            let selected_ref_ids = dependent_refs
10894                .iter()
10895                .map(|dependent_ref| dependent_ref.ref_id.clone())
10896                .collect::<BTreeSet<_>>();
10897            let mut threaded_ref_ids = BTreeSet::new();
10898            let mut threaded_by_caller = BTreeMap::new();
10899            record_dependent_refs(
10900                &mut threaded_ref_ids,
10901                &mut threaded_by_caller,
10902                dependent_refs,
10903            );
10904            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
10905                .expect("old per-ref caller lookup");
10906
10907            assert_eq!(threaded_ref_ids, selected_ref_ids);
10908            assert_eq!(threaded_by_caller, old_by_caller);
10909            for consumer in [
10910                "src/consumer_a.ts",
10911                "src/consumer_b.ts",
10912                "src/consumer_c.ts",
10913            ] {
10914                assert!(
10915                    threaded_by_caller.contains_key(consumer),
10916                    "barrel edit should select dependent refs from {consumer}"
10917                );
10918            }
10919        }
10920
10921        fs::write(
10922            &index_path,
10923            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
10924        )
10925        .expect("edit barrel");
10926        let stats = store
10927            .refresh_files(std::slice::from_ref(&index_path))
10928            .expect("incremental refresh");
10929        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
10930        assert!(
10931            stats.dependency_selected_refs > 0,
10932            "barrel surface edit should select dependent refs"
10933        );
10934
10935        let cold_store = CallGraphStore::open(
10936            project_root.join(".store-cold-barrel"),
10937            project_root.to_path_buf(),
10938        )
10939        .expect("open cold rebuild store");
10940        cold_store
10941            .cold_build(&files)
10942            .expect("comparison cold build");
10943
10944        for table in [
10945            "nodes",
10946            "refs",
10947            "file_dependencies",
10948            "edges",
10949            "dispatch_hints",
10950        ] {
10951            assert_eq!(
10952                graph_table_rows(&store, table),
10953                graph_table_rows(&cold_store, table),
10954                "incremental refresh {table} rows must match cold rebuild"
10955            );
10956        }
10957
10958        let consumer_path = project_root.join("src/consumer_a.ts");
10959        fs::write(
10960            &consumer_path,
10961            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
10962        )
10963        .expect("edit barrel consumer");
10964        store
10965            .refresh_files(std::slice::from_ref(&consumer_path))
10966            .expect("refresh consumer through unchanged barrel");
10967        cold_store
10968            .cold_build(&files)
10969            .expect("comparison cold rebuild after consumer refresh");
10970        for table in [
10971            "nodes",
10972            "refs",
10973            "file_dependencies",
10974            "edges",
10975            "dispatch_hints",
10976        ] {
10977            assert_eq!(
10978                graph_table_rows(&store, table),
10979                graph_table_rows(&cold_store, table),
10980                "refresh through a persisted barrel must preserve cold-build {table} rows"
10981            );
10982        }
10983    }
10984
10985    fn build_reference_connection(
10986        project_root: &Path,
10987        extract: &FileExtract,
10988        resolved: &ResolvedRef,
10989    ) -> Connection {
10990        let mut conn = Connection::open_in_memory().expect("open reference db");
10991        configure_build_connection(&conn).expect("configure reference db");
10992        initialize_schema(&conn).expect("initialize reference schema");
10993        {
10994            let tx = conn.transaction().expect("reference transaction");
10995            clear_tables(&tx).expect("reference clear");
10996            insert_meta(&tx).expect("reference meta");
10997            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
10998            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
10999            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11000                .expect("reference dispatch edges");
11001            assert_eq!(supplemental, 0);
11002            tx.commit().expect("reference commit");
11003        }
11004        conn
11005    }
11006
11007    fn build_optimized_connection(
11008        project_root: &Path,
11009        extract: &FileExtract,
11010        resolved: &ResolvedRef,
11011    ) -> Connection {
11012        let mut conn = Connection::open_in_memory().expect("open optimized db");
11013        configure_build_connection(&conn).expect("configure optimized db");
11014        initialize_schema(&conn).expect("initialize optimized schema");
11015        {
11016            let tx = conn.transaction().expect("optimized transaction");
11017            clear_tables(&tx).expect("optimized clear");
11018            insert_meta(&tx).expect("optimized meta");
11019            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
11020            {
11021                let workspace_root = project_root.display().to_string();
11022                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
11023                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
11024                    .expect("optimized file extract");
11025                insert_resolved_ref_prepared(&mut inserts, resolved)
11026                    .expect("optimized resolved ref");
11027            }
11028            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
11029            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11030                .expect("optimized dispatch edges");
11031            assert_eq!(supplemental, 0);
11032            tx.commit().expect("optimized commit");
11033        }
11034        conn
11035    }
11036
11037    fn fixture_extract(_project_root: &Path) -> FileExtract {
11038        let rel_path = "src/main.ts".to_string();
11039        let target_path = "src/helper.ts".to_string();
11040        let node = NodeRecord {
11041            id: "node-main".to_string(),
11042            file_path: rel_path.clone(),
11043            name: "main".to_string(),
11044            scoped_name: "main".to_string(),
11045            kind: "function".to_string(),
11046            range: Range {
11047                start_line: 0,
11048                start_col: 0,
11049                end_line: 0,
11050                end_col: 32,
11051            },
11052            range_ordinal: 0,
11053            signature: Some("export function main()".to_string()),
11054            exported: true,
11055            is_default_export: false,
11056            is_type_like: false,
11057            is_callgraph_entry_point: true,
11058        };
11059        let mut dependencies = BTreeSet::new();
11060        dependencies.insert(target_path.clone());
11061        let raw_ref = RawRef {
11062            ref_id: "ref-main-helper".to_string(),
11063            caller_node: Some(node.id.clone()),
11064            caller_symbol: Some(node.scoped_name.clone()),
11065            caller_file: rel_path.clone(),
11066            kind: "call".to_string(),
11067            short_name: Some("helper".to_string()),
11068            full_ref: Some("helper".to_string()),
11069            module_path: None,
11070            import_kind: None,
11071            local_name: Some("helper".to_string()),
11072            requested_name: Some("helper".to_string()),
11073            namespace_alias: None,
11074            wildcard: false,
11075            line: 1,
11076            byte_start: 24,
11077            byte_end: 32,
11078            dependencies,
11079        };
11080        FileExtract {
11081            rel_path,
11082            freshness: FileFreshness {
11083                mtime: UNIX_EPOCH + Duration::from_secs(123),
11084                size: 40,
11085                content_hash: cache_freshness::hash_bytes(b"fixture source"),
11086            },
11087            lang: LangId::TypeScript,
11088            data: FileCallData {
11089                calls_by_symbol: HashMap::new(),
11090                exported_symbols: Vec::new(),
11091                symbol_metadata: HashMap::new(),
11092                default_export_symbol: None,
11093                import_block: ImportBlock::empty(),
11094                lang: LangId::TypeScript,
11095            },
11096            nodes: vec![node.clone()],
11097            raw_refs: vec![raw_ref],
11098            dispatch_hints: vec![DispatchHint {
11099                id: "dispatch-main-helper".to_string(),
11100                method_name: "helper".to_string(),
11101                caller_node: node.id,
11102                file: "src/main.ts".to_string(),
11103                line: 1,
11104                byte_start: 24,
11105                byte_end: 32,
11106            }],
11107            surface_fingerprint: "surface".to_string(),
11108        }
11109    }
11110
11111    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
11112        let raw = extract.raw_refs[0].clone();
11113        let mut dependencies = raw.dependencies.clone();
11114        dependencies.insert("src/helper.ts".to_string());
11115        ResolvedRef {
11116            edge: Some(EdgeRecord {
11117                edge_id: "edge-main-helper".to_string(),
11118                source_node: raw.caller_node.clone().expect("caller node"),
11119                target_node: Some("node-helper".to_string()),
11120                target_file: "src/helper.ts".to_string(),
11121                target_symbol: "helper".to_string(),
11122                kind: "call".to_string(),
11123                line: raw.line,
11124            }),
11125            raw,
11126            status: "resolved".to_string(),
11127            target_node: Some("node-helper".to_string()),
11128            target_file: Some("src/helper.ts".to_string()),
11129            target_symbol: Some("helper".to_string()),
11130            dependencies,
11131        }
11132    }
11133
11134    fn write_chunked_equivalence_fixture(project_root: &Path) {
11135        let ts_dir = project_root.join("ts");
11136        fs::create_dir_all(&ts_dir).expect("create ts dir");
11137        fs::write(
11138            ts_dir.join("leaf.ts"),
11139            "export function leaf(value: number) {\n  return value + 1;\n}\n",
11140        )
11141        .expect("write ts leaf");
11142        fs::write(
11143            ts_dir.join("mid.ts"),
11144            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
11145        )
11146        .expect("write ts mid");
11147        fs::write(
11148            ts_dir.join("entry.ts"),
11149            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
11150        )
11151        .expect("write ts entry");
11152        fs::write(
11153            ts_dir.join("worker.ts"),
11154            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
11155        )
11156        .expect("write ts worker");
11157        for idx in 0..4 {
11158            fs::write(
11159                ts_dir.join(format!("extra_{idx}.ts")),
11160                format!(
11161                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
11162                ),
11163            )
11164            .expect("write ts extra");
11165        }
11166
11167        let rust_dir = project_root.join("src");
11168        let commands_dir = rust_dir.join("commands");
11169        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
11170        fs::write(
11171            rust_dir.join("context.rs"),
11172            r#"pub struct AppContext;
11173
11174impl AppContext {
11175    pub fn callgraph_store_for_ops(&self) -> usize {
11176        1
11177    }
11178}
11179"#,
11180        )
11181        .expect("write rust context");
11182        fs::write(
11183            rust_dir.join("lib.rs"),
11184            "pub mod context;\npub mod commands;\n",
11185        )
11186        .expect("write rust lib");
11187        fs::write(
11188            commands_dir.join("mod.rs"),
11189            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
11190        )
11191        .expect("write rust commands mod");
11192        for name in ["callers", "impact", "trace_to"] {
11193            fs::write(
11194                commands_dir.join(format!("{name}.rs")),
11195                format!(
11196                    r#"use crate::context::AppContext;
11197
11198pub fn handle_{name}(ctx: &AppContext) -> usize {{
11199    ctx.callgraph_store_for_ops()
11200}}
11201"#
11202                ),
11203            )
11204            .expect("write rust command");
11205        }
11206    }
11207
11208    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
11209        let src_dir = project_root.join("src");
11210        fs::create_dir_all(&src_dir).expect("create src dir");
11211
11212        let target_path = src_dir.join("target.ts");
11213        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
11214            .expect("write target");
11215
11216        let index_path = src_dir.join("index.ts");
11217        fs::write(&index_path, barrel_source).expect("write barrel");
11218
11219        let mut files = vec![target_path, index_path];
11220        for (file_name, function_name) in [
11221            ("consumer_a.ts", "consumerA"),
11222            ("consumer_b.ts", "consumerB"),
11223            ("consumer_c.ts", "consumerC"),
11224        ] {
11225            let path = src_dir.join(file_name);
11226            fs::write(
11227                &path,
11228                format!(
11229                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
11230                ),
11231            )
11232            .expect("write consumer");
11233            files.push(path);
11234        }
11235        files
11236    }
11237
11238    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
11239        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11240        table_rows(&conn, table)
11241    }
11242
11243    fn graph_table_rows_without(
11244        store: &CallGraphStore,
11245        table: &str,
11246        excluded_columns: &[&str],
11247    ) -> Vec<String> {
11248        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11249        table_rows_without(&conn, table, excluded_columns)
11250    }
11251
11252    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
11253        table_rows_without(conn, table, &[])
11254    }
11255
11256    fn table_rows_without(
11257        conn: &Connection,
11258        table: &str,
11259        excluded_columns: &[&str],
11260    ) -> Vec<String> {
11261        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
11262        let columns: Vec<String> = conn
11263            .prepare(&format!("PRAGMA table_info({table})"))
11264            .expect("prepare table_info")
11265            .query_map([], |row| row.get::<_, String>(1))
11266            .expect("query table_info")
11267            .collect::<std::result::Result<Vec<String>, _>>()
11268            .expect("collect columns")
11269            .into_iter()
11270            .filter(|column| !excluded_columns.contains(column.as_str()))
11271            .collect();
11272        let sql = format!(
11273            "SELECT {} FROM {table} ORDER BY {}",
11274            columns.join(", "),
11275            columns.join(", ")
11276        );
11277        conn.prepare(&sql)
11278            .expect("prepare table rows")
11279            .query_map([], |row| row_to_strings(row, columns.len()))
11280            .expect("query table rows")
11281            .collect::<std::result::Result<_, _>>()
11282            .expect("collect table rows")
11283    }
11284
11285    fn assert_cold_build_stats_match_except_elapsed(
11286        expected: &ColdBuildStats,
11287        actual: &ColdBuildStats,
11288    ) {
11289        assert_eq!(actual.files, expected.files, "file counts must match");
11290        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
11291        assert_eq!(actual.refs, expected.refs, "ref counts must match");
11292        assert_eq!(actual.edges, expected.edges, "edge counts must match");
11293        assert_eq!(
11294            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
11295            expected
11296                .failed_files
11297                .iter()
11298                .cloned()
11299                .collect::<BTreeSet<_>>(),
11300            "failed file sets must match"
11301        );
11302    }
11303
11304    fn backend_state_rows(conn: &Connection) -> Vec<String> {
11305        conn.prepare(
11306            "SELECT backend, workspace_root, file_path, content_hash, status
11307             FROM backend_file_state
11308             ORDER BY backend, workspace_root, file_path, content_hash, status",
11309        )
11310        .expect("prepare backend rows")
11311        .query_map([], |row| row_to_strings(row, 5))
11312        .expect("query backend rows")
11313        .collect::<std::result::Result<_, _>>()
11314        .expect("collect backend rows")
11315    }
11316
11317    fn secondary_indexes(conn: &Connection) -> Vec<String> {
11318        let mut indexes = Vec::new();
11319        for table in [
11320            "files",
11321            "nodes",
11322            "refs",
11323            "file_dependencies",
11324            "edges",
11325            "dispatch_hints",
11326            "type_ref_names",
11327            "backend_file_state",
11328            "meta",
11329        ] {
11330            let sql = format!("PRAGMA index_list({table})");
11331            let mut stmt = conn.prepare(&sql).expect("prepare index list");
11332            let rows = stmt
11333                .query_map([], |row| row.get::<_, String>(1))
11334                .expect("query index list");
11335            for name in rows {
11336                let name = name.expect("index name");
11337                if name.starts_with("idx_") {
11338                    indexes.push(format!("{table}:{name}"));
11339                }
11340            }
11341        }
11342        indexes.sort();
11343        indexes
11344    }
11345
11346    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
11347        let mut values = Vec::with_capacity(len);
11348        for index in 0..len {
11349            let value = row.get_ref(index)?;
11350            values.push(match value {
11351                rusqlite::types::ValueRef::Null => "NULL".to_string(),
11352                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
11353                rusqlite::types::ValueRef::Real(value) => value.to_string(),
11354                rusqlite::types::ValueRef::Text(value) => {
11355                    String::from_utf8_lossy(value).into_owned()
11356                }
11357                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
11358            });
11359        }
11360        Ok(values.join("\u{1f}"))
11361    }
11362}
11363
11364#[cfg(test)]
11365mod rust_resolution_tests {
11366    use super::*;
11367    use crate::inspect::job::CallgraphSnapshot;
11368    use std::fs;
11369    use tempfile::tempdir;
11370
11371    #[test]
11372    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
11373        let dir = tempdir().expect("tempdir");
11374        let root = dir.path();
11375        write_rust_manifest(root, "scoped-alias-fixture");
11376        write_file(
11377            root,
11378            "src/lib.rs",
11379            r#"pub mod finalization_contract;
11380
11381pub fn run_alias() {
11382    use crate::finalization_contract as fc;
11383    fc::check_mason_contract();
11384}
11385"#,
11386        );
11387        write_file(
11388            root,
11389            "src/finalization_contract.rs",
11390            r#"pub fn check_mason_contract() {}
11391fn planted_dead() {}
11392"#,
11393        );
11394
11395        let (store, snapshot) = cold_build_twice(root);
11396        assert_direct_caller(
11397            &store,
11398            "src/finalization_contract.rs",
11399            "check_mason_contract",
11400            "src/lib.rs",
11401            "run_alias",
11402        );
11403        assert_projected_call(
11404            root,
11405            &snapshot,
11406            "src/finalization_contract.rs",
11407            "check_mason_contract",
11408        );
11409        assert_no_projected_call(
11410            root,
11411            &snapshot,
11412            "src/finalization_contract.rs",
11413            "planted_dead",
11414        );
11415        assert!(
11416            store
11417                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
11418                .expect("planted dead callers")
11419                .is_empty(),
11420            "planted-dead guard should stay without callers"
11421        );
11422    }
11423
11424    #[test]
11425    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
11426        let dir = tempdir().expect("tempdir");
11427        let root = dir.path();
11428        write_rust_manifest(root, "inline-module-fixture");
11429        write_file(
11430            root,
11431            "src/lib.rs",
11432            r#"mod work_graph { fn operations() {} }
11433mod manifest { fn operations() {} }
11434mod audit { fn operations() {} }
11435mod dispatch { fn operations() {} }
11436mod finalization { fn operations() {} }
11437
11438pub fn run_inline_operations() {
11439    work_graph::operations();
11440    manifest::operations();
11441    audit::operations();
11442    dispatch::operations();
11443    finalization::operations();
11444}
11445
11446fn planted_dead() {}
11447"#,
11448        );
11449
11450        let (store, snapshot) = cold_build_twice(root);
11451        for module in [
11452            "work_graph",
11453            "manifest",
11454            "audit",
11455            "dispatch",
11456            "finalization",
11457        ] {
11458            assert_direct_caller(
11459                &store,
11460                "src/lib.rs",
11461                &format!("{module}::operations"),
11462                "src/lib.rs",
11463                "run_inline_operations",
11464            );
11465        }
11466        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
11467        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11468    }
11469
11470    #[test]
11471    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
11472        let dir = tempdir().expect("tempdir");
11473        let root = dir.path();
11474        fs::write(
11475            root.join("Cargo.toml"),
11476            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
11477        )
11478        .expect("write workspace manifest");
11479        write_file(
11480            root,
11481            "crates/but-action/Cargo.toml",
11482            r#"[package]
11483name = "but-action"
11484version = "0.1.0"
11485edition = "2021"
11486"#,
11487        );
11488        write_file(
11489            root,
11490            "crates/but-action/src/lib.rs",
11491            "mod action;\npub use action::{list_actions};\n",
11492        );
11493        write_file(
11494            root,
11495            "crates/but-action/src/action.rs",
11496            "pub fn list_actions() {}\nfn planted_dead() {}\n",
11497        );
11498        write_file(
11499            root,
11500            "crates/app/Cargo.toml",
11501            r#"[package]
11502name = "app"
11503version = "0.1.0"
11504edition = "2021"
11505"#,
11506        );
11507        write_file(
11508            root,
11509            "crates/app/src/lib.rs",
11510            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
11511        );
11512
11513        let (store, snapshot) = cold_build_twice(root);
11514        assert_direct_caller(
11515            &store,
11516            "crates/but-action/src/action.rs",
11517            "list_actions",
11518            "crates/app/src/lib.rs",
11519            "run_actions",
11520        );
11521        assert!(
11522            store
11523                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
11524                .expect("lib reexport callers")
11525                .is_empty(),
11526            "call should target the reexported source function, not lib.rs"
11527        );
11528        assert_projected_call(
11529            root,
11530            &snapshot,
11531            "crates/but-action/src/action.rs",
11532            "list_actions",
11533        );
11534        assert_no_projected_call(
11535            root,
11536            &snapshot,
11537            "crates/but-action/src/action.rs",
11538            "planted_dead",
11539        );
11540    }
11541
11542    #[test]
11543    fn rust_generic_self_turbofish_method_dispatch_resolves() {
11544        let dir = tempdir().expect("tempdir");
11545        let root = dir.path();
11546        write_rust_manifest(root, "generic-self-fixture");
11547        write_file(
11548            root,
11549            "src/lib.rs",
11550            r#"pub struct Matcher;
11551
11552impl Matcher {
11553    pub fn run(&self) -> bool {
11554        self.fuzzy_match_optimal::<usize>("needle")
11555    }
11556
11557    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
11558        let _ = std::marker::PhantomData::<T>;
11559        true
11560    }
11561
11562    fn planted_dead(&self) {}
11563}
11564
11565pub fn entry() -> bool {
11566    let matcher = Matcher;
11567    matcher.run()
11568}
11569"#,
11570        );
11571
11572        let (store, snapshot) = cold_build_twice(root);
11573        assert_direct_caller(
11574            &store,
11575            "src/lib.rs",
11576            "Matcher::fuzzy_match_optimal",
11577            "src/lib.rs",
11578            "Matcher::run",
11579        );
11580        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
11581        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11582    }
11583
11584    #[test]
11585    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
11586        let dir = tempdir().expect("tempdir");
11587        let root = dir.path();
11588        write_rust_manifest(root, "manifest-operations-fixture");
11589        write_file(
11590            root,
11591            "src/main.rs",
11592            r#"mod dispatch;
11593use dispatch::{manifest_operations};
11594
11595fn main() {
11596    manifest_operations();
11597}
11598"#,
11599        );
11600        write_file(
11601            root,
11602            "src/dispatch.rs",
11603            r#"mod work_graph { fn operations() {} }
11604mod manifest { fn operations() {} }
11605mod audit { fn operations() {} }
11606mod descriptor { fn operations() {} }
11607mod writer { fn operations() {} }
11608
11609pub fn manifest_operations() {
11610    manifest::operations();
11611}
11612
11613pub fn work_graph_operations() {
11614    work_graph::operations();
11615}
11616
11617pub fn audit_operations() {
11618    audit::operations();
11619}
11620
11621pub fn descriptor_operations() {
11622    descriptor::operations();
11623}
11624
11625pub fn writer_operations() {
11626    writer::operations();
11627}
11628
11629fn planted_dead() {}
11630"#,
11631        );
11632
11633        let (store, snapshot) = cold_build_twice(root);
11634        assert_direct_caller(
11635            &store,
11636            "src/dispatch.rs",
11637            "manifest_operations",
11638            "src/main.rs",
11639            "main",
11640        );
11641        assert_direct_caller(
11642            &store,
11643            "src/dispatch.rs",
11644            "manifest::operations",
11645            "src/dispatch.rs",
11646            "manifest_operations",
11647        );
11648        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
11649        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
11650        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
11651    }
11652
11653    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
11654        let files = rust_files(root);
11655        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
11656            .expect("open first store");
11657        first.cold_build(&files).expect("first cold build");
11658        let first_snapshot =
11659            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
11660
11661        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
11662            .expect("open second store");
11663        second.cold_build(&files).expect("second cold build");
11664        let second_snapshot =
11665            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
11666
11667        assert_eq!(
11668            projection_rows(&first_snapshot),
11669            projection_rows(&second_snapshot),
11670            "cold-build projection should be deterministic"
11671        );
11672        (first, first_snapshot)
11673    }
11674
11675    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
11676        let mut rows = Vec::new();
11677        for export in &snapshot.exported_symbols {
11678            rows.push(format!(
11679                "export\t{}\t{}\t{}\t{}",
11680                export.file.display(),
11681                export.symbol,
11682                export.kind,
11683                export.line
11684            ));
11685        }
11686        for call in &snapshot.outbound_calls {
11687            rows.push(format!(
11688                "call\t{}\t{}\t{}\t{}\t{}",
11689                call.caller_file.display(),
11690                call.caller_symbol,
11691                call.target,
11692                call.line,
11693                call.provenance
11694            ));
11695        }
11696        for file in &snapshot.entry_points {
11697            rows.push(format!("entry_file\t{}", file.display()));
11698        }
11699        for (file, symbols) in &snapshot.entry_point_symbols {
11700            for symbol in symbols {
11701                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
11702            }
11703        }
11704        rows.sort();
11705        rows
11706    }
11707
11708    fn assert_direct_caller(
11709        store: &CallGraphStore,
11710        target_rel: &str,
11711        target_symbol: &str,
11712        caller_rel: &str,
11713        caller_symbol: &str,
11714    ) {
11715        let callers = store
11716            .direct_callers_of(Path::new(target_rel), target_symbol)
11717            .unwrap_or_else(|error| {
11718                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
11719            });
11720        assert!(
11721            callers.iter().any(|site| {
11722                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
11723            }),
11724            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
11725        );
11726    }
11727
11728    fn assert_projected_call(
11729        root: &Path,
11730        snapshot: &CallgraphSnapshot,
11731        target_rel: &str,
11732        symbol: &str,
11733    ) {
11734        let target = projected_target(root, target_rel, symbol);
11735        assert!(
11736            snapshot.outbound_calls.iter().any(|call| {
11737                call.target == target
11738                    || call.target.starts_with(&format!(
11739                        "{target}{}",
11740                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
11741                    ))
11742            }),
11743            "expected projected call to {target}; calls: {:#?}",
11744            snapshot.outbound_calls
11745        );
11746    }
11747
11748    fn assert_no_projected_call(
11749        root: &Path,
11750        snapshot: &CallgraphSnapshot,
11751        target_rel: &str,
11752        symbol: &str,
11753    ) {
11754        let target = projected_target(root, target_rel, symbol);
11755        assert!(
11756            snapshot.outbound_calls.iter().all(|call| {
11757                call.target != target
11758                    && !call.target.starts_with(&format!(
11759                        "{target}{}",
11760                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
11761                    ))
11762            }),
11763            "did not expect projected call to {target}; calls: {:#?}",
11764            snapshot.outbound_calls
11765        );
11766    }
11767
11768    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
11769        let path = fs::canonicalize(root.join(target_rel)).expect("canonical target path");
11770        format!("{}::{symbol}", path.display())
11771    }
11772
11773    fn write_rust_manifest(root: &Path, name: &str) {
11774        write_file(
11775            root,
11776            "Cargo.toml",
11777            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
11778        );
11779    }
11780
11781    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
11782        let path = root.join(rel_path);
11783        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
11784        fs::write(&path, source).expect("write fixture file");
11785        path
11786    }
11787
11788    fn rust_files(root: &Path) -> Vec<PathBuf> {
11789        let mut files = Vec::new();
11790        collect_rust_files(root, &mut files);
11791        files.sort();
11792        files
11793    }
11794
11795    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
11796        for entry in fs::read_dir(dir).expect("read fixture dir") {
11797            let entry = entry.expect("read fixture entry");
11798            let path = entry.path();
11799            if path.is_dir() {
11800                let name = path
11801                    .file_name()
11802                    .and_then(|name| name.to_str())
11803                    .unwrap_or("");
11804                if !name.starts_with(".store") {
11805                    collect_rust_files(&path, files);
11806                }
11807            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
11808                files.push(path);
11809            }
11810        }
11811    }
11812}
11813
11814#[cfg(test)]
11815mod build_pool_tests {
11816    use super::build_pool_size;
11817
11818    #[test]
11819    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
11820        let size = build_pool_size();
11821        // Never zero, never the full core count, never above the 8 cap — this is
11822        // the starvation guard for the cold-build's all-cores tree-sitter pass.
11823        assert!(size >= 1, "pool size must be at least 1");
11824        assert!(size <= 8, "pool size must be capped at 8, got {size}");
11825
11826        let cores = std::thread::available_parallelism()
11827            .map(|p| p.get())
11828            .unwrap_or(1);
11829        let expected = cores.div_ceil(2).clamp(1, 8);
11830        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
11831    }
11832}
11833
11834#[cfg(test)]
11835mod reexport_resolution_tests {
11836    use super::*;
11837
11838    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
11839        ProjectIndex {
11840            project_root: PathBuf::from("/fixture"),
11841            files: files.into_iter().collect(),
11842            caller_data: HashMap::new(),
11843            workspace_crate_prefixes: std::sync::OnceLock::new(),
11844        }
11845    }
11846
11847    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
11848        DbFileIndex {
11849            lang: None,
11850            exports: HashSet::new(),
11851            default_export: None,
11852            export_aliases: HashMap::new(),
11853            node_by_scoped: HashMap::new(),
11854            node_by_bare: HashMap::new(),
11855            module_targets: HashMap::new(),
11856            reexports: reexport_targets
11857                .iter()
11858                .map(|target| ReexportIndex {
11859                    target_file: Some((*target).to_string()),
11860                    named: HashMap::new(),
11861                    wildcard: true,
11862                })
11863                .collect(),
11864        }
11865    }
11866
11867    /// A dense wildcard re-export cycle (barrel files re-exporting each
11868    /// other) must resolve in O(files), not O(branching^depth). Without the
11869    /// resolver's memoization, resolving a MISSING symbol through this
11870    /// 12-file complete digraph explores ~11^16 paths and this test never
11871    /// finishes: the depth cap bounds path length, not path count, and one
11872    /// such resolution can pin a worker thread at 100% CPU indefinitely.
11873    #[test]
11874    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
11875        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
11876        let files = names
11877            .iter()
11878            .map(|name| {
11879                let targets: Vec<&str> = names
11880                    .iter()
11881                    .filter(|other| *other != name)
11882                    .map(String::as_str)
11883                    .collect();
11884                (name.clone(), barrel_file(&targets))
11885            })
11886            .collect();
11887        let index = barrel_index(files);
11888
11889        assert_eq!(
11890            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
11891            None
11892        );
11893    }
11894
11895    /// Depth-dominance counterexample: the walk first reaches `shared` down a
11896    /// 16-hop chain (no budget left for its leaf), then reaches it again
11897    /// directly at depth 1. Plain visited-set pruning would skip the second
11898    /// visit and lose a resolution the capped resolver finds; the
11899    /// depth-dominance memo revisits because the second arrival is shallower.
11900    #[test]
11901    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
11902        let mut leaf = barrel_file(&[]);
11903        leaf.exports.insert("deep_symbol".to_string());
11904        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
11905        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
11906        // entry's SECOND reexport goes straight to shared.
11907        files.push((
11908            "src/entry.ts".to_string(),
11909            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
11910        ));
11911        for i in 0..15 {
11912            let next = if i == 14 {
11913                "src/shared.ts".to_string()
11914            } else {
11915                format!("src/chain{}.ts", i + 1)
11916            };
11917            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
11918        }
11919        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
11920        files.push(("src/leaf.ts".to_string(), leaf));
11921        let index = barrel_index(files);
11922
11923        assert_eq!(
11924            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
11925            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
11926            "a shallower re-visit must not be pruned by a deeper capped visit"
11927        );
11928    }
11929
11930    #[test]
11931    fn symbol_reachable_through_reexport_cycle_still_resolves() {
11932        let mut leaf = barrel_file(&[]);
11933        leaf.exports.insert("real_symbol".to_string());
11934        let index = barrel_index(vec![
11935            (
11936                "src/a.ts".to_string(),
11937                barrel_file(&["src/b.ts", "src/a.ts"]),
11938            ),
11939            (
11940                "src/b.ts".to_string(),
11941                barrel_file(&["src/a.ts", "src/leaf.ts"]),
11942            ),
11943            ("src/leaf.ts".to_string(), leaf),
11944        ]);
11945
11946        assert_eq!(
11947            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
11948            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
11949        );
11950    }
11951}
11952
11953#[cfg(test)]
11954mod method_dispatch_inference_tests {
11955    use super::*;
11956    use std::fs;
11957    use tempfile::tempdir;
11958
11959    #[test]
11960    fn java_field_receiver_type_selects_declared_class_method() {
11961        let source = r#"class EntryPoint {
11962    private UserService userService;
11963
11964    void handle() {
11965        userService.find();
11966    }
11967}
11968
11969class UserService {
11970    void find() {}
11971}
11972
11973class AuditService {
11974    void find() {}
11975}
11976"#;
11977        let dir = tempdir().expect("temp dir");
11978        let root = dir.path();
11979        write_fixture(root, "src/EntryPoint.java", source);
11980        let reference = reference(
11981            "java",
11982            "src/EntryPoint.java",
11983            "EntryPoint::handle",
11984            "userService",
11985            "find",
11986            line_of(source, "userService.find()"),
11987        );
11988        let mut cache = DispatchSourceCache::new();
11989
11990        let receiver_type =
11991            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
11992        assert_eq!(receiver_type, "UserService");
11993
11994        let candidates = vec![
11995            method_candidate("audit", "AuditService::find"),
11996            method_candidate("user", "UserService::find"),
11997        ];
11998        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
11999            .expect("type candidate");
12000        assert_eq!(selected.scoped_name, "UserService::find");
12001
12002        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
12003        assert!(
12004            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
12005        );
12006    }
12007
12008    #[test]
12009    fn kotlin_property_and_local_value_types_are_inferred() {
12010        let source = r#"class Handler {
12011    private val auditService: AuditService = AuditService()
12012
12013    fun handle() {
12014        auditService.find()
12015        val userService: UserService = UserService()
12016        userService.find()
12017        val billingService = BillingService()
12018        billingService.find()
12019    }
12020}
12021
12022class UserService { fun find() {} }
12023class AuditService { fun find() {} }
12024class BillingService { fun find() {} }
12025"#;
12026        let dir = tempdir().expect("temp dir");
12027        let root = dir.path();
12028        write_fixture(root, "src/Handler.kt", source);
12029        let mut cache = DispatchSourceCache::new();
12030
12031        let audit_ref = reference(
12032            "kotlin",
12033            "src/Handler.kt",
12034            "Handler::handle",
12035            "auditService",
12036            "find",
12037            line_of(source, "auditService.find()"),
12038        );
12039        assert_eq!(
12040            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
12041            Some("AuditService")
12042        );
12043
12044        let user_ref = reference(
12045            "kotlin",
12046            "src/Handler.kt",
12047            "Handler::handle",
12048            "userService",
12049            "find",
12050            line_of(source, "userService.find()"),
12051        );
12052        assert_eq!(
12053            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
12054            Some("UserService")
12055        );
12056
12057        let billing_ref = reference(
12058            "kotlin",
12059            "src/Handler.kt",
12060            "Handler::handle",
12061            "billingService",
12062            "find",
12063            line_of(source, "billingService.find()"),
12064        );
12065        assert_eq!(
12066            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
12067            Some("BillingService")
12068        );
12069    }
12070
12071    #[test]
12072    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
12073        let source = r#"struct Foo { void run(); };
12074struct PointerFoo { void run(); };
12075struct FactoryFoo { void run(); };
12076FactoryFoo makeFactoryFoo();
12077
12078void handle() {
12079    Foo foo;
12080    foo.run();
12081    PointerFoo* pointerFoo = nullptr;
12082    pointerFoo->run();
12083    auto factoryFoo = makeFactoryFoo();
12084    factoryFoo.run();
12085}
12086"#;
12087        let dir = tempdir().expect("temp dir");
12088        let root = dir.path();
12089        write_fixture(root, "src/fixture.cpp", source);
12090        let mut cache = DispatchSourceCache::new();
12091
12092        let foo_ref = reference(
12093            "cpp",
12094            "src/fixture.cpp",
12095            "handle",
12096            "foo",
12097            "run",
12098            line_of(source, "foo.run()"),
12099        );
12100        assert_eq!(
12101            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
12102            Some("Foo")
12103        );
12104
12105        let pointer_ref = reference(
12106            "cpp",
12107            "src/fixture.cpp",
12108            "handle",
12109            "pointerFoo",
12110            "run",
12111            line_of(source, "pointerFoo->run()"),
12112        );
12113        assert_eq!(
12114            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
12115            Some("PointerFoo")
12116        );
12117
12118        let factory_ref = reference(
12119            "cpp",
12120            "src/fixture.cpp",
12121            "handle",
12122            "factoryFoo",
12123            "run",
12124            line_of(source, "factoryFoo.run()"),
12125        );
12126        assert_eq!(
12127            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
12128            Some("FactoryFoo")
12129        );
12130    }
12131
12132    #[test]
12133    fn unknown_java_receiver_still_uses_name_match_fallback() {
12134        let source = r#"class EntryPoint {
12135    void handle() {
12136        service.runSpecial();
12137    }
12138}
12139
12140class OnlyService {
12141    void runSpecial() {}
12142}
12143"#;
12144        let dir = tempdir().expect("temp dir");
12145        let root = dir.path();
12146        write_fixture(root, "src/EntryPoint.java", source);
12147        let reference = reference(
12148            "java",
12149            "src/EntryPoint.java",
12150            "EntryPoint::handle",
12151            "service",
12152            "runSpecial",
12153            line_of(source, "service.runSpecial()"),
12154        );
12155        let mut cache = DispatchSourceCache::new();
12156
12157        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
12158        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
12159        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
12160        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
12161    }
12162
12163    fn reference(
12164        lang: &str,
12165        caller_file: &str,
12166        caller_symbol: &str,
12167        receiver: &str,
12168        method_name: &str,
12169        line: u32,
12170    ) -> NameMatchRef {
12171        NameMatchRef {
12172            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
12173            caller_node: format!("{caller_symbol}:node"),
12174            caller_file: caller_file.to_string(),
12175            caller_symbol: caller_symbol.to_string(),
12176            caller_signature: None,
12177            receiver: receiver.to_string(),
12178            method_name: method_name.to_string(),
12179            colon_dispatch: false,
12180            line,
12181            lang: lang.to_string(),
12182        }
12183    }
12184
12185    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
12186        NameMatchCandidate {
12187            node_id: node_id.to_string(),
12188            file_path: "src/targets.fixture".to_string(),
12189            scoped_name: scoped_name.to_string(),
12190            kind: "method".to_string(),
12191        }
12192    }
12193
12194    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
12195        let path = root.join(rel_path);
12196        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
12197        fs::write(path, source).expect("write fixture");
12198    }
12199
12200    fn line_of(source: &str, needle: &str) -> u32 {
12201        source
12202            .lines()
12203            .position(|line| line.contains(needle))
12204            .map(|index| index as u32 + 1)
12205            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
12206    }
12207}