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