Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43/// Marker-protected generations older than this absolute age are reclaimed even
44/// if a stale reader marker remains. Current and newest-previous generations are
45/// always retained, bounding the root-keyed callgraph store to roughly two or
46/// three large generations without adding user-visible configuration.
47const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51
52type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
53#[cfg(test)]
54type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
55// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
56// thread running the cold build, and the only caller (a test) installs and
57// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
58// across parallel tests — one test's installed observer fired during ANOTHER
59// test's `cold_build_with_lease`, asserting against the wrong build's edges
60// (flaked on Windows CI under parallel scheduling). Production never sets it.
61thread_local! {
62    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
63        const { std::cell::RefCell::new(None) };
64    #[cfg(test)]
65    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
66        const { std::cell::RefCell::new(None) };
67    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
68        const { std::cell::RefCell::new(None) };
69    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
70    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
71        const { std::cell::Cell::new(false) };
72    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
73        const { std::cell::RefCell::new(None) };
74    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
75        const { std::cell::RefCell::new(None) };
76}
77
78mod dead_code_projection;
79pub use dead_code_projection::project_dead_code_snapshot;
80
81#[doc(hidden)]
82pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
83    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
84}
85
86#[cfg(test)]
87fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
88    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
89}
90
91#[cfg(test)]
92fn notify_cold_build_before_publish_observer() {
93    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
94    if let Some(observer) = observer {
95        observer();
96    }
97}
98
99#[cfg(not(test))]
100fn notify_cold_build_before_publish_observer() {}
101
102#[doc(hidden)]
103pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
104    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
105}
106
107#[doc(hidden)]
108pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
109    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
110}
111
112#[doc(hidden)]
113pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
114    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
115}
116
117struct PublishAdmissionGuard {
118    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
119}
120
121impl Drop for PublishAdmissionGuard {
122    fn drop(&mut self) {
123        PUBLISH_ADMISSION.with(|slot| {
124            *slot.borrow_mut() = self.previous.take();
125        });
126    }
127}
128
129pub(crate) fn with_publish_epoch<R>(
130    epoch: crate::root_cache::ArtifactPublishEpoch,
131    expected: u64,
132    run: impl FnOnce() -> R,
133) -> R {
134    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
135    let _guard = PublishAdmissionGuard { previous };
136    run()
137}
138
139fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
140    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
141    match admission {
142        Some((epoch, expected)) => epoch
143            .run_if_current(expected, publish)
144            .unwrap_or(Err(CallGraphStoreError::Superseded)),
145        None => publish(),
146    }
147}
148
149struct RefreshCommitAdmissionGuard {
150    previous: Option<(
151        SubcLifecycleAdmission,
152        Arc<std::sync::atomic::AtomicU64>,
153        u64,
154    )>,
155}
156
157impl Drop for RefreshCommitAdmissionGuard {
158    fn drop(&mut self) {
159        REFRESH_COMMIT_ADMISSION.with(|slot| {
160            *slot.borrow_mut() = self.previous.take();
161        });
162    }
163}
164
165fn with_refresh_commit_admission<R>(
166    lifecycle: SubcLifecycleAdmission,
167    generation_flag: Arc<std::sync::atomic::AtomicU64>,
168    expected_generation: u64,
169    run: impl FnOnce() -> R,
170) -> R {
171    let previous = REFRESH_COMMIT_ADMISSION
172        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
173    let _guard = RefreshCommitAdmissionGuard { previous };
174    run()
175}
176
177fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
178    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
179    let commit = || {
180        publish_if_current(|| {
181            tx.commit()?;
182            Ok(())
183        })
184    };
185    match admission {
186        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
187            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
188            .unwrap_or(Err(CallGraphStoreError::Superseded)),
189        None => commit(),
190    }
191}
192
193fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
194    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
195    if let Some(observer) = observer {
196        observer(temp_path, target_path);
197    }
198}
199
200#[derive(Debug)]
201pub enum CallGraphStoreError {
202    Io(std::io::Error),
203    Sqlite(rusqlite::Error),
204    Json(serde_json::Error),
205    Aft(AftError),
206    Lock(crate::fs_lock::AcquireError),
207    MissingCallerData { file: String },
208    Unavailable(String),
209    Superseded,
210    StaleFiles(Vec<String>),
211}
212
213impl fmt::Display for CallGraphStoreError {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            Self::Io(error) => write!(formatter, "I/O error: {error}"),
217            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
218            Self::Json(error) => write!(formatter, "json error: {error}"),
219            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
220            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
221            Self::MissingCallerData { file } => {
222                write!(formatter, "missing extracted caller data for {file}")
223            }
224            Self::Unavailable(message) => {
225                write!(formatter, "callgraph store unavailable: {message}")
226            }
227            Self::Superseded => {
228                write!(formatter, "callgraph store build superseded before publish")
229            }
230            Self::StaleFiles(files) => {
231                write!(
232                    formatter,
233                    "callgraph store has stale files: {}",
234                    files.join(", ")
235                )
236            }
237        }
238    }
239}
240
241impl std::error::Error for CallGraphStoreError {}
242
243impl From<std::io::Error> for CallGraphStoreError {
244    fn from(error: std::io::Error) -> Self {
245        Self::Io(error)
246    }
247}
248
249impl From<rusqlite::Error> for CallGraphStoreError {
250    fn from(error: rusqlite::Error) -> Self {
251        Self::Sqlite(error)
252    }
253}
254
255impl From<serde_json::Error> for CallGraphStoreError {
256    fn from(error: serde_json::Error) -> Self {
257        Self::Json(error)
258    }
259}
260
261impl From<AftError> for CallGraphStoreError {
262    fn from(error: AftError) -> Self {
263        Self::Aft(error)
264    }
265}
266
267impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
268    fn from(error: crate::fs_lock::AcquireError) -> Self {
269        Self::Lock(error)
270    }
271}
272
273pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
274
275/// Config flag name gating whether the store is opened (default on). Production
276/// commands open it through `open_if_enabled` so the substrate can be disabled
277/// without code changes.
278pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
279
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub struct CallGraphStoreOptions {
282    pub enabled: bool,
283}
284
285pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
286
287/// Shared context state that lets the refresh worker observe a store installed
288/// after its batch was opened. The worker clones the installed store Arc before
289/// checking it, so no context lock guard crosses the check or enqueue call.
290#[derive(Clone)]
291pub(crate) struct CallgraphRefreshState {
292    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
293    heavy_root_work_allowed: Arc<AtomicBool>,
294}
295
296impl CallgraphRefreshState {
297    pub(crate) fn new(
298        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
299        heavy_root_work_allowed: Arc<AtomicBool>,
300    ) -> Self {
301        Self {
302            store,
303            heavy_root_work_allowed,
304        }
305    }
306
307    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
308        self.store
309            .read()
310            .unwrap_or_else(std::sync::PoisonError::into_inner)
311            .as_ref()
312            .map(Arc::clone)
313    }
314}
315
316type WorkspaceCratePrefixes = HashMap<String, String>;
317
318#[derive(Clone, Debug, Default)]
319struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
320
321const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
322
323pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
324    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
325}
326
327#[derive(Clone, Debug, Hash, PartialEq, Eq)]
328struct RefreshRoot {
329    callgraph_dir: PathBuf,
330    project_root: PathBuf,
331}
332
333#[derive(Clone)]
334pub(crate) struct CallgraphRefreshTicket {
335    lifecycle: SubcLifecycleAdmission,
336    generation_flag: Arc<std::sync::atomic::AtomicU64>,
337    expected_generation: u64,
338    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
339    expected_publish_epoch: u64,
340}
341
342impl CallgraphRefreshTicket {
343    pub(crate) fn new(
344        lifecycle: SubcLifecycleAdmission,
345        generation_flag: Arc<std::sync::atomic::AtomicU64>,
346        expected_generation: u64,
347        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
348        expected_publish_epoch: u64,
349    ) -> Self {
350        Self {
351            lifecycle,
352            generation_flag,
353            expected_generation,
354            publish_epoch,
355            expected_publish_epoch,
356        }
357    }
358
359    fn is_current(&self) -> bool {
360        self.lifecycle
361            .is_current(self.generation_flag.as_ref(), self.expected_generation)
362            && self.publish_epoch.current() == self.expected_publish_epoch
363    }
364}
365
366#[derive(Clone)]
367struct RefreshBatch {
368    root: RefreshRoot,
369    paths: BTreeSet<PathBuf>,
370    pending_sinks: Vec<PendingCallGraphStorePaths>,
371    refresh_states: Vec<CallgraphRefreshState>,
372    ticket: Option<CallgraphRefreshTicket>,
373}
374
375impl RefreshBatch {
376    fn defer(&self) {
377        for sink in &self.pending_sinks {
378            sink.lock().extend(self.paths.iter().cloned());
379        }
380    }
381
382    fn defer_after_open_failure(&self) {
383        self.defer();
384        if self
385            .ticket
386            .as_ref()
387            .is_some_and(|ticket| !ticket.is_current())
388            || !self
389                .refresh_states
390                .iter()
391                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
392        {
393            return;
394        }
395
396        let ready_store_installed = self.refresh_states.iter().any(|state| {
397            let store = state.installed_store_snapshot();
398            store.is_some_and(|store| {
399                store.project_root() == self.root.project_root
400                    && !store.is_legacy_fallback()
401                    && store.is_current()
402            })
403        });
404        if !ready_store_installed {
405            return;
406        }
407
408        // This re-check and the ready-store install's pending-sink take form a
409        // check-then-act handoff: after this defer, exactly one site observes
410        // the parked paths with a ready current store, so no polling is needed.
411        for sink in &self.pending_sinks {
412            let paths = {
413                let mut pending = sink.lock();
414                self.paths
415                    .iter()
416                    .filter(|path| pending.remove(*path))
417                    .cloned()
418                    .collect::<Vec<_>>()
419            };
420            if paths.is_empty() {
421                continue;
422            }
423            let _ = enqueue_callgraph_store_refresh_inner(
424                self.root.callgraph_dir.clone(),
425                self.root.project_root.clone(),
426                paths,
427                Arc::clone(sink),
428                self.refresh_states.clone(),
429                self.ticket.clone(),
430            );
431        }
432    }
433
434    fn merge(
435        &mut self,
436        paths: impl IntoIterator<Item = PathBuf>,
437        sink: PendingCallGraphStorePaths,
438        refresh_states: Vec<CallgraphRefreshState>,
439        ticket: Option<CallgraphRefreshTicket>,
440    ) {
441        self.paths.extend(paths);
442        if ticket.is_some() {
443            self.ticket = ticket;
444        }
445        if !self
446            .pending_sinks
447            .iter()
448            .any(|existing| Arc::ptr_eq(existing, &sink))
449        {
450            self.pending_sinks.push(sink);
451        }
452        for refresh_state in refresh_states {
453            if !self.refresh_states.iter().any(|existing| {
454                Arc::ptr_eq(&existing.store, &refresh_state.store)
455                    && Arc::ptr_eq(
456                        &existing.heavy_root_work_allowed,
457                        &refresh_state.heavy_root_work_allowed,
458                    )
459            }) {
460                self.refresh_states.push(refresh_state);
461            }
462        }
463    }
464}
465
466#[derive(Default)]
467struct RefreshQueue {
468    order: VecDeque<RefreshRoot>,
469    queued: HashMap<RefreshRoot, RefreshBatch>,
470    active: Option<RefreshBatch>,
471    shutdown_requested: bool,
472}
473
474struct RefreshWorkerShared {
475    queue: Mutex<RefreshQueue>,
476    wake: Condvar,
477}
478
479struct RefreshWorker {
480    shared: Arc<RefreshWorkerShared>,
481    thread: Mutex<Option<JoinHandle<()>>>,
482}
483
484struct RefreshWorkerWatchdog {
485    first_path: PathBuf,
486    batch_len: usize,
487    started: Instant,
488}
489
490impl RefreshWorkerWatchdog {
491    fn start(paths: &[PathBuf]) -> Self {
492        Self {
493            first_path: paths
494                .first()
495                .expect("non-empty callgraph refresh batch has a first path")
496                .clone(),
497            batch_len: paths.len(),
498            started: Instant::now(),
499        }
500    }
501}
502
503impl Drop for RefreshWorkerWatchdog {
504    fn drop(&mut self) {
505        let elapsed = self.started.elapsed();
506        if elapsed < REFRESH_WORKER_WARN_AFTER {
507            return;
508        }
509        let path = if self.batch_len == 1 {
510            self.first_path.display().to_string()
511        } else {
512            format!(
513                "{} (+{} paths)",
514                self.first_path.display(),
515                self.batch_len - 1
516            )
517        };
518        log::warn!(
519            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
520            path,
521            elapsed.as_millis()
522        );
523        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
524            log::warn!(
525                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
526                path,
527                elapsed.as_millis()
528            );
529        }
530    }
531}
532
533impl RefreshWorker {
534    fn spawn() -> Arc<Self> {
535        let shared = Arc::new(RefreshWorkerShared {
536            queue: Mutex::new(RefreshQueue::default()),
537            wake: Condvar::new(),
538        });
539        let thread_shared = Arc::clone(&shared);
540        let thread = std::thread::Builder::new()
541            .name("aft-callgraph-refresh".to_string())
542            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
543            .expect("failed to spawn callgraph refresh worker");
544        Arc::new(Self {
545            shared,
546            thread: Mutex::new(Some(thread)),
547        })
548    }
549
550    fn enqueue(
551        &self,
552        root: RefreshRoot,
553        paths: Vec<PathBuf>,
554        pending_sink: PendingCallGraphStorePaths,
555        refresh_states: Vec<CallgraphRefreshState>,
556        ticket: Option<CallgraphRefreshTicket>,
557    ) -> bool {
558        let mut queue = self
559            .shared
560            .queue
561            .lock()
562            .expect("callgraph refresh queue mutex poisoned");
563        if queue.shutdown_requested {
564            pending_sink.lock().extend(paths);
565            return false;
566        }
567        if let Some(batch) = queue.queued.get_mut(&root) {
568            batch.merge(paths, pending_sink, refresh_states, ticket);
569        } else {
570            queue.order.push_back(root.clone());
571            queue.queued.insert(
572                root.clone(),
573                RefreshBatch {
574                    root,
575                    paths: paths.into_iter().collect(),
576                    pending_sinks: vec![pending_sink],
577                    refresh_states,
578                    ticket,
579                },
580            );
581        }
582        self.shared.wake.notify_one();
583        true
584    }
585
586    fn shutdown_with_budget(&self, budget: Duration) -> bool {
587        let deadline = Instant::now() + budget;
588        let mut queue = self
589            .shared
590            .queue
591            .lock()
592            .expect("callgraph refresh queue mutex poisoned");
593        queue.shutdown_requested = true;
594        self.shared.wake.notify_one();
595        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
596            let remaining = deadline.saturating_duration_since(Instant::now());
597            let (next, _) = self
598                .shared
599                .wake
600                .wait_timeout(queue, remaining)
601                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
602            queue = next;
603        }
604        let drained = queue.active.is_none() && queue.order.is_empty();
605        if !drained {
606            if let Some(active) = queue.active.as_ref() {
607                active.defer();
608            }
609            for batch in queue.queued.values() {
610                batch.defer();
611            }
612            queue.order.clear();
613            queue.queued.clear();
614        }
615        drop(queue);
616
617        if drained {
618            if let Some(thread) = self
619                .thread
620                .lock()
621                .expect("callgraph refresh worker thread mutex poisoned")
622                .take()
623            {
624                let _ = thread.join();
625            }
626        }
627        drained
628    }
629}
630
631static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
632
633pub fn enqueue_callgraph_store_refresh(
634    callgraph_dir: PathBuf,
635    project_root: PathBuf,
636    paths: Vec<PathBuf>,
637    pending_sink: PendingCallGraphStorePaths,
638) -> bool {
639    enqueue_callgraph_store_refresh_inner(
640        callgraph_dir,
641        project_root,
642        paths,
643        pending_sink,
644        Vec::new(),
645        None,
646    )
647}
648
649#[cfg(test)]
650pub(crate) fn enqueue_callgraph_store_refresh_fenced(
651    callgraph_dir: PathBuf,
652    project_root: PathBuf,
653    paths: Vec<PathBuf>,
654    pending_sink: PendingCallGraphStorePaths,
655    ticket: CallgraphRefreshTicket,
656) -> bool {
657    enqueue_callgraph_store_refresh_inner(
658        callgraph_dir,
659        project_root,
660        paths,
661        pending_sink,
662        Vec::new(),
663        Some(ticket),
664    )
665}
666
667pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
668    callgraph_dir: PathBuf,
669    project_root: PathBuf,
670    paths: Vec<PathBuf>,
671    pending_sink: PendingCallGraphStorePaths,
672    refresh_state: CallgraphRefreshState,
673    ticket: CallgraphRefreshTicket,
674) -> bool {
675    enqueue_callgraph_store_refresh_inner(
676        callgraph_dir,
677        project_root,
678        paths,
679        pending_sink,
680        vec![refresh_state],
681        Some(ticket),
682    )
683}
684
685fn enqueue_callgraph_store_refresh_inner(
686    callgraph_dir: PathBuf,
687    project_root: PathBuf,
688    paths: Vec<PathBuf>,
689    pending_sink: PendingCallGraphStorePaths,
690    refresh_states: Vec<CallgraphRefreshState>,
691    ticket: Option<CallgraphRefreshTicket>,
692) -> bool {
693    if paths.is_empty() {
694        return true;
695    }
696    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
697    let worker = {
698        let mut worker = slot
699            .lock()
700            .expect("callgraph refresh worker mutex poisoned");
701        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
702    };
703    worker.enqueue(
704        RefreshRoot {
705            callgraph_dir,
706            project_root,
707        },
708        paths,
709        pending_sink,
710        refresh_states,
711        ticket,
712    )
713}
714
715pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
716    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
717}
718
719#[doc(hidden)]
720pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
721    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
722    let worker = slot
723        .lock()
724        .expect("callgraph refresh worker mutex poisoned")
725        .clone();
726    let Some(worker) = worker else {
727        return true;
728    };
729    let drained = worker.shutdown_with_budget(budget);
730    if drained {
731        let mut current = slot
732            .lock()
733            .expect("callgraph refresh worker mutex poisoned");
734        if current
735            .as_ref()
736            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
737        {
738            *current = None;
739        }
740    }
741    drained
742}
743
744fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
745    // The worker owns these caches so maps are shared only by refreshes for the
746    // same canonical root and disappear when the worker shuts down.
747    let mut workspace_crate_prefixes = HashMap::new();
748    loop {
749        let batch = {
750            let mut queue = shared
751                .queue
752                .lock()
753                .expect("callgraph refresh queue mutex poisoned");
754            loop {
755                if let Some(root) = queue.order.pop_front() {
756                    let batch = queue
757                        .queued
758                        .remove(&root)
759                        .expect("queued callgraph refresh root has a batch");
760                    queue.active = Some(batch.clone());
761                    break batch;
762                }
763                if queue.shutdown_requested {
764                    return;
765                }
766                queue = shared
767                    .wake
768                    .wait(queue)
769                    .expect("callgraph refresh queue mutex poisoned while waiting");
770            }
771        };
772
773        process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
774
775        let mut queue = shared
776            .queue
777            .lock()
778            .expect("callgraph refresh queue mutex poisoned");
779        queue.active = None;
780        shared.wake.notify_all();
781    }
782}
783
784fn process_callgraph_refresh_batch(
785    batch: &RefreshBatch,
786    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
787) {
788    // A manifest event is an invalidation signal, not a source file to parse.
789    // Drop the root's map even for a superseded batch: the filesystem changed,
790    // and a later configure must never inherit crate membership from before it.
791    if batch
792        .paths
793        .iter()
794        .any(|path| invalidates_workspace_crate_prefix_cache(path))
795    {
796        workspace_crate_prefixes.remove(&batch.root);
797    }
798
799    let paths = batch
800        .paths
801        .iter()
802        .filter(|path| crate::parser::detect_language(path).is_some())
803        .cloned()
804        .collect::<Vec<_>>();
805    if paths.is_empty() {
806        return;
807    }
808    note_refresh_worker_batch_for_test(&batch.root.project_root);
809    if batch
810        .ticket
811        .as_ref()
812        .is_some_and(|ticket| !ticket.is_current())
813    {
814        // Superseded before starting: park the paths so the next configure's
815        // pending replay (or unbind cleanup) decides their fate.
816        batch.defer();
817        return;
818    }
819    let workspace_crate_prefix_cache =
820        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
821    let _watchdog = RefreshWorkerWatchdog::start(&paths);
822    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
823    note_refresh_worker_call_for_test(&batch.root.project_root);
824    let opened = if test_seam.fail_open {
825        Ok(None)
826    } else {
827        CallGraphStore::open_ready(
828            batch.root.callgraph_dir.clone(),
829            batch.root.project_root.clone(),
830        )
831    };
832    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
833        // The gate is deliberately after open_ready so tests can hold a failed
834        // open between its result and the defer that parks the batch.
835        let _ = gate.held_tx.send(());
836        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
837    }
838    let store = match opened {
839        Ok(Some(store)) => store,
840        Ok(None) => {
841            batch.defer_after_open_failure();
842            return;
843        }
844        Err(error) => {
845            batch.defer_after_open_failure();
846            crate::slog_warn!(
847                "callgraph store writer open failed during refresh; deferred paths: {}",
848                error
849            );
850            return;
851        }
852    };
853    if !test_seam.delay.is_zero() {
854        std::thread::sleep(test_seam.delay);
855    }
856    if batch
857        .ticket
858        .as_ref()
859        .is_some_and(|ticket| !ticket.is_current())
860    {
861        // This is a superseded-ticket defer, not an open-failure defer: leave
862        // the paths for the replacement configure instead of self-replaying.
863        batch.defer();
864        return;
865    }
866    let refresh_result = if test_seam.fail_refresh {
867        Err(CallGraphStoreError::Unavailable(
868            "injected refresh worker failure".to_string(),
869        ))
870    } else if let Some(ticket) = &batch.ticket {
871        with_publish_epoch(
872            ticket.publish_epoch.clone(),
873            ticket.expected_publish_epoch,
874            || {
875                with_refresh_commit_admission(
876                    ticket.lifecycle.clone(),
877                    Arc::clone(&ticket.generation_flag),
878                    ticket.expected_generation,
879                    || {
880                        store
881                            .refresh_files_with_workspace_crate_prefix_cache(
882                                &paths,
883                                workspace_crate_prefix_cache.clone(),
884                            )
885                            .map(|_| ())
886                    },
887                )
888            },
889        )
890    } else {
891        store
892            .refresh_files_with_workspace_crate_prefix_cache(
893                &paths,
894                workspace_crate_prefix_cache.clone(),
895            )
896            .map(|_| ())
897    };
898    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
899        // The commit lost the fence race: a newer configure or publication
900        // owns the store now. Defer instead of stale-marking — the paths were
901        // never committed, and the replacement generation re-indexes them.
902        batch.defer();
903        return;
904    }
905    if let Err(error) = refresh_result {
906        crate::slog_warn!("callgraph store refresh failed: {}", error);
907        match store.mark_files_stale(&paths) {
908            Ok(marked) => {
909                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
910                crate::slog_warn!(
911                    "marked {} callgraph store file(s) stale after refresh failure",
912                    marked.len()
913                );
914            }
915            Err(mark_error) => crate::slog_warn!(
916                "failed to mark callgraph store files stale after refresh failure: {}",
917                mark_error
918            ),
919        }
920    } else {
921        crate::logging::note_callgraph_invalidations(paths.len());
922    }
923}
924
925fn workspace_crate_prefix_cache_for_root(
926    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
927    root: &RefreshRoot,
928) -> WorkspaceCratePrefixCache {
929    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
930        // Eviction only costs a future rebuild; it cannot make resolution stale.
931        if let Some(evicted) = caches.keys().next().cloned() {
932            caches.remove(&evicted);
933        }
934    }
935    caches.entry(root.clone()).or_default().clone()
936}
937
938#[derive(Clone, Copy, Default)]
939struct RefreshWorkerTestSeam {
940    delay: Duration,
941    fail_refresh: bool,
942    fail_open: bool,
943    refresh_calls: usize,
944    worker_calls: usize,
945    stale_marks: usize,
946}
947
948static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
949    OnceLock::new();
950
951struct RefreshWorkerTestGate {
952    held_tx: crossbeam_channel::Sender<()>,
953    release_rx: crossbeam_channel::Receiver<()>,
954}
955
956static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
957    OnceLock::new();
958
959#[doc(hidden)]
960pub fn install_callgraph_refresh_worker_test_gate(
961    project_root: PathBuf,
962) -> (
963    crossbeam_channel::Receiver<()>,
964    crossbeam_channel::Sender<()>,
965) {
966    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
967    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
968    REFRESH_WORKER_TEST_GATES
969        .get_or_init(|| Mutex::new(HashMap::new()))
970        .lock()
971        .expect("callgraph refresh test gate mutex poisoned")
972        .insert(
973            project_root,
974            RefreshWorkerTestGate {
975                held_tx,
976                release_rx,
977            },
978        );
979    (held_rx, release_tx)
980}
981
982fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
983    REFRESH_WORKER_TEST_GATES
984        .get_or_init(|| Mutex::new(HashMap::new()))
985        .lock()
986        .expect("callgraph refresh test gate mutex poisoned")
987        .remove(project_root)
988}
989
990fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
991    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
992        return RefreshWorkerTestSeam::default();
993    };
994    seams
995        .lock()
996        .expect("callgraph refresh test seam mutex poisoned")
997        .get(project_root)
998        .copied()
999        .unwrap_or_default()
1000}
1001
1002fn note_refresh_worker_batch_for_test(project_root: &Path) {
1003    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1004        if let Some(seam) = seams
1005            .lock()
1006            .expect("callgraph refresh test seam mutex poisoned")
1007            .get_mut(project_root)
1008        {
1009            seam.worker_calls += 1;
1010        }
1011    }
1012}
1013
1014fn note_refresh_worker_call_for_test(project_root: &Path) {
1015    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1016        if let Some(seam) = seams
1017            .lock()
1018            .expect("callgraph refresh test seam mutex poisoned")
1019            .get_mut(project_root)
1020        {
1021            seam.refresh_calls += 1;
1022        }
1023    }
1024}
1025
1026fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1027    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1028        if let Some(seam) = seams
1029            .lock()
1030            .expect("callgraph refresh test seam mutex poisoned")
1031            .get_mut(project_root)
1032        {
1033            seam.stale_marks += 1;
1034        }
1035    }
1036}
1037
1038#[doc(hidden)]
1039pub fn set_callgraph_refresh_worker_test_seam(
1040    project_root: PathBuf,
1041    delay: Duration,
1042    fail_refresh: bool,
1043) {
1044    REFRESH_WORKER_TEST_SEAMS
1045        .get_or_init(|| Mutex::new(HashMap::new()))
1046        .lock()
1047        .expect("callgraph refresh test seam mutex poisoned")
1048        .insert(
1049            project_root,
1050            RefreshWorkerTestSeam {
1051                delay,
1052                fail_refresh,
1053                ..RefreshWorkerTestSeam::default()
1054            },
1055        );
1056}
1057
1058#[doc(hidden)]
1059pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1060    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1061        if let Some(seam) = seams
1062            .lock()
1063            .expect("callgraph refresh test seam mutex poisoned")
1064            .get_mut(&project_root)
1065        {
1066            seam.fail_open = enabled;
1067        }
1068    }
1069}
1070
1071#[doc(hidden)]
1072pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1073    let seam = refresh_worker_test_seam(project_root);
1074    (seam.refresh_calls, seam.stale_marks)
1075}
1076
1077#[doc(hidden)]
1078pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1079    refresh_worker_test_seam(project_root).worker_calls
1080}
1081
1082#[doc(hidden)]
1083pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1084    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1085        seams
1086            .lock()
1087            .expect("callgraph refresh test seam mutex poisoned")
1088            .remove(project_root);
1089    }
1090}
1091
1092#[derive(Debug)]
1093pub struct CallGraphStore {
1094    project_root: PathBuf,
1095    project_key: String,
1096    /// The concrete on-disk DB file this store opened. With the generation
1097    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1098    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1099    sqlite_path: PathBuf,
1100    /// Root-keyed directory whose pointer controls this store. For a legacy
1101    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1102    /// newly published root-keyed generation invalidates the fallback reader.
1103    publication_dir: PathBuf,
1104    /// True only when the root-keyed read path opened data from a legacy
1105    /// harness partition. Writer-capable callers use this to schedule migration
1106    /// without making read-only/worktree callers acquire a writer lease.
1107    legacy_fallback: bool,
1108    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1109    /// or `None` when it opened the legacy single-file DB. Used to detect when
1110    /// another process has published a newer generation so this process can
1111    /// drop its connection and reopen (see `current_generation`).
1112    generation: Option<String>,
1113    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1114    read_marker: Option<crate::root_cache::ReadMarker>,
1115    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1116    // Failed validations are not cached, so a later successful build remains visible.
1117    database_ready: AtomicBool,
1118    conn: Mutex<Connection>,
1119}
1120
1121#[derive(Debug)]
1122pub struct ReadonlyCallGraphStore {
1123    inner: CallGraphStore,
1124}
1125
1126pub trait CallGraphRead {
1127    fn project_root(&self) -> &Path;
1128    fn project_key(&self) -> &str;
1129    fn sqlite_path(&self) -> &Path;
1130    fn is_current(&self) -> bool;
1131    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1132    fn indexed_file_count(&self) -> Result<usize>;
1133    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1134    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1135    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1136    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1137    fn direct_caller_counts_of(
1138        &self,
1139        targets: &[(String, String)],
1140    ) -> Result<HashMap<(String, String), usize>>;
1141    fn outgoing_calls_for_symbols(
1142        &self,
1143        sources: &[(String, String)],
1144    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1145    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1146        -> Result<StoreCallersResult>;
1147    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1148    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1149    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1150    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1151    fn call_tree(
1152        &self,
1153        file_rel: &Path,
1154        symbol: &str,
1155        depth: usize,
1156    ) -> Result<callgraph::CallTreeNode>;
1157    fn trace_to(
1158        &self,
1159        file_rel: &Path,
1160        symbol: &str,
1161        max_depth: usize,
1162    ) -> Result<callgraph::TraceToResult>;
1163    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1164    fn trace_to_symbol(
1165        &self,
1166        file_rel: &Path,
1167        symbol: &str,
1168        to_symbol: &str,
1169        to_file: Option<&Path>,
1170        max_depth: usize,
1171    ) -> Result<callgraph::TraceToSymbolResult>;
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175enum OpenRootRepair {
1176    None,
1177    ReRooted,
1178    NeedsRebuild {
1179        previous_roots: Vec<String>,
1180        current_root: String,
1181        reason: String,
1182    },
1183}
1184
1185struct OpenedStore {
1186    store: CallGraphStore,
1187    root_repair: OpenRootRepair,
1188}
1189
1190#[derive(Clone, Debug)]
1191struct LegacyCallgraphPartition {
1192    harness: String,
1193    dir: PathBuf,
1194    key: String,
1195    bytes: u64,
1196    freshness: Option<SystemTime>,
1197}
1198
1199#[derive(Clone, Debug)]
1200struct LegacyCallgraphTarget {
1201    partition: LegacyCallgraphPartition,
1202    sqlite_path: PathBuf,
1203    generation: Option<String>,
1204    source_bytes: u64,
1205    source_blake3: String,
1206}
1207
1208#[derive(Clone, Debug)]
1209struct SourceFingerprint {
1210    bytes: u64,
1211    blake3: String,
1212}
1213
1214#[derive(Clone, Debug)]
1215struct PublishedLegacyMigration {
1216    generation: String,
1217    migrated_bytes: u64,
1218}
1219
1220#[derive(Debug, Clone)]
1221pub struct ColdBuildStats {
1222    pub files: usize,
1223    pub nodes: usize,
1224    pub refs: usize,
1225    pub edges: usize,
1226    pub failed_files: Vec<String>,
1227    pub elapsed_ms: u128,
1228}
1229
1230#[derive(Debug, Clone)]
1231pub struct IncrementalStats {
1232    pub changed_files: Vec<String>,
1233    pub surface_changed: Vec<String>,
1234    pub deleted_files: Vec<String>,
1235    pub dependency_selected_refs: usize,
1236    pub refreshed_own_files: usize,
1237}
1238
1239/// Phase timings for the copy-based incremental refresh benchmark.
1240#[doc(hidden)]
1241#[derive(Debug, Clone, Default, PartialEq, Eq)]
1242pub struct RefreshFilesProfile {
1243    pub parse: Duration,
1244    pub dependency_selection: Duration,
1245    pub row_deletes: Duration,
1246    pub row_inserts: Duration,
1247    pub dependent_parse: Duration,
1248    pub index_load: Duration,
1249    pub ref_resolution: Duration,
1250    pub method_dispatch: Duration,
1251    pub commit: Duration,
1252    pub total: Duration,
1253}
1254
1255impl RefreshFilesProfile {
1256    pub fn report(&self) -> String {
1257        format!(
1258            "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",
1259            self.parse.as_millis(),
1260            self.dependency_selection.as_millis(),
1261            self.row_deletes.as_millis(),
1262            self.row_inserts.as_millis(),
1263            self.dependent_parse.as_millis(),
1264            self.index_load.as_millis(),
1265            self.ref_resolution.as_millis(),
1266            self.method_dispatch.as_millis(),
1267            self.commit.as_millis(),
1268            self.total.as_millis(),
1269        )
1270    }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1274pub struct StoredEdge {
1275    pub source_file: String,
1276    pub source_symbol: String,
1277    pub target_file: String,
1278    pub target_symbol: String,
1279    pub kind: String,
1280    pub line: u32,
1281}
1282
1283#[derive(Debug, Clone, PartialEq, Eq)]
1284pub struct StoreNode {
1285    node_id: String,
1286    pub file: String,
1287    pub symbol: String,
1288    pub name: String,
1289    pub kind: String,
1290    pub line: u32,
1291    pub end_line: u32,
1292    pub signature: Option<String>,
1293    pub exported: bool,
1294    pub is_entry_point: bool,
1295    pub lang: LangId,
1296}
1297
1298#[cfg(test)]
1299impl StoreNode {
1300    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1301        Self {
1302            node_id: format!("{file}:{symbol}"),
1303            file: file.to_string(),
1304            symbol: symbol.to_string(),
1305            name: symbol.to_string(),
1306            kind: "function".to_string(),
1307            line: 1,
1308            end_line: 1,
1309            signature: None,
1310            exported: is_entry_point,
1311            is_entry_point,
1312            lang: LangId::TypeScript,
1313        }
1314    }
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Eq)]
1318pub struct StoreCallSite {
1319    pub caller: StoreNode,
1320    pub target_file: String,
1321    pub target_symbol: String,
1322    pub target: Option<StoreNode>,
1323    pub line: u32,
1324    pub byte_start: usize,
1325    pub byte_end: usize,
1326    pub resolved: bool,
1327    pub provenance: String,
1328}
1329
1330impl StoreCallSite {
1331    pub fn approximate(&self) -> bool {
1332        self.provenance == PROVENANCE_NAME_MATCH
1333    }
1334
1335    pub fn resolved_by(&self) -> &str {
1336        &self.provenance
1337    }
1338
1339    pub fn supplemental_resolution(&self) -> Option<&str> {
1340        match self.provenance.as_str() {
1341            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1342            _ => None,
1343        }
1344    }
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1348pub struct StoreUnresolvedCall {
1349    pub caller: StoreNode,
1350    pub symbol: String,
1351    pub full_ref: Option<String>,
1352    pub line: u32,
1353    pub byte_start: usize,
1354    pub byte_end: usize,
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1358pub struct StoreCallersResult {
1359    pub target: StoreNode,
1360    pub callers: Vec<StoreCallSite>,
1361    pub scanned_files: usize,
1362    pub depth_limited: bool,
1363    pub truncated: usize,
1364}
1365
1366#[derive(Debug, Clone, PartialEq, Eq)]
1367pub struct StoreImpactCaller {
1368    pub site: StoreCallSite,
1369    pub signature: Option<String>,
1370    pub is_entry_point: bool,
1371    pub call_expression: Option<String>,
1372    pub parameters: Vec<String>,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq)]
1376pub struct StoreImpactResult {
1377    pub target: StoreNode,
1378    pub parameters: Vec<String>,
1379    pub callers: Vec<StoreImpactCaller>,
1380    pub depth_limited: bool,
1381    pub truncated: usize,
1382}
1383
1384#[derive(Debug, Clone)]
1385struct ExtractFailure {
1386    rel_path: String,
1387    freshness: Option<FileFreshness>,
1388}
1389
1390#[derive(Debug, Clone)]
1391struct BuildExtractsResult {
1392    extracts: Vec<FileExtract>,
1393    failures: Vec<ExtractFailure>,
1394}
1395
1396#[derive(Debug, Clone)]
1397enum StoreForwardCall {
1398    Resolved(StoreCallSite),
1399    Unresolved(StoreUnresolvedCall),
1400}
1401
1402impl StoreForwardCall {
1403    fn byte_start(&self) -> usize {
1404        match self {
1405            Self::Resolved(site) => site.byte_start,
1406            Self::Unresolved(call) => call.byte_start,
1407        }
1408    }
1409
1410    fn line(&self) -> u32 {
1411        match self {
1412            Self::Resolved(site) => site.line,
1413            Self::Unresolved(call) => call.line,
1414        }
1415    }
1416}
1417
1418#[derive(Debug, Clone)]
1419struct FileExtract {
1420    rel_path: String,
1421    freshness: FileFreshness,
1422    lang: LangId,
1423    data: FileCallData,
1424    nodes: Vec<NodeRecord>,
1425    raw_refs: Vec<RawRef>,
1426    dispatch_hints: Vec<DispatchHint>,
1427    surface_fingerprint: String,
1428}
1429
1430#[derive(Debug, Clone)]
1431struct NodeRecord {
1432    id: String,
1433    file_path: String,
1434    name: String,
1435    scoped_name: String,
1436    kind: String,
1437    range: Range,
1438    range_ordinal: u32,
1439    signature: Option<String>,
1440    exported: bool,
1441    is_default_export: bool,
1442    is_type_like: bool,
1443    is_callgraph_entry_point: bool,
1444}
1445
1446#[derive(Debug, Clone)]
1447struct RawRef {
1448    ref_id: String,
1449    caller_node: Option<String>,
1450    caller_symbol: Option<String>,
1451    caller_file: String,
1452    kind: String,
1453    short_name: Option<String>,
1454    full_ref: Option<String>,
1455    module_path: Option<String>,
1456    import_kind: Option<String>,
1457    local_name: Option<String>,
1458    requested_name: Option<String>,
1459    namespace_alias: Option<String>,
1460    wildcard: bool,
1461    line: u32,
1462    byte_start: usize,
1463    byte_end: usize,
1464    dependencies: BTreeSet<String>,
1465}
1466
1467#[derive(Debug, Clone)]
1468struct ResolvedRef {
1469    raw: RawRef,
1470    status: String,
1471    target_node: Option<String>,
1472    target_file: Option<String>,
1473    target_symbol: Option<String>,
1474    dependencies: BTreeSet<String>,
1475    edge: Option<EdgeRecord>,
1476}
1477
1478#[derive(Debug, Clone)]
1479struct EdgeRecord {
1480    edge_id: String,
1481    source_node: String,
1482    target_node: Option<String>,
1483    target_file: String,
1484    target_symbol: String,
1485    kind: String,
1486    line: u32,
1487}
1488
1489#[derive(Debug, Clone)]
1490struct DispatchHint {
1491    id: String,
1492    method_name: String,
1493    caller_node: String,
1494    file: String,
1495    line: u32,
1496    byte_start: usize,
1497    byte_end: usize,
1498}
1499
1500#[derive(Debug, Clone)]
1501struct NameMatchRef {
1502    ref_id: String,
1503    caller_node: String,
1504    caller_file: String,
1505    caller_symbol: String,
1506    caller_signature: Option<String>,
1507    receiver: String,
1508    method_name: String,
1509    colon_dispatch: bool,
1510    line: u32,
1511    lang: String,
1512}
1513
1514#[derive(Debug, Clone)]
1515struct NameMatchCandidate {
1516    node_id: String,
1517    file_path: String,
1518    scoped_name: String,
1519    kind: String,
1520}
1521
1522#[derive(Debug, Clone)]
1523struct FileRow {
1524    surface_fingerprint: String,
1525    freshness: FileFreshness,
1526}
1527
1528#[derive(Debug, Clone)]
1529struct DbFileIndex {
1530    lang: Option<LangId>,
1531    exports: HashSet<String>,
1532    default_export: Option<String>,
1533    export_aliases: HashMap<String, String>,
1534    node_by_scoped: HashMap<String, String>,
1535    node_by_bare: HashMap<String, String>,
1536    module_targets: HashMap<String, Option<String>>,
1537    reexports: Vec<ReexportIndex>,
1538}
1539
1540#[derive(Debug, Clone)]
1541struct ReexportIndex {
1542    target_file: Option<String>,
1543    named: HashMap<String, String>,
1544    wildcard: bool,
1545}
1546
1547#[derive(Debug, Clone)]
1548struct ProjectIndex<'a> {
1549    project_root: PathBuf,
1550    files: HashMap<String, DbFileIndex>,
1551    caller_data: HashMap<String, &'a FileCallData>,
1552    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1553    /// watcher events replace the cache before another batch can resolve refs.
1554    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1555    /// its own workspace mapping.
1556    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1557}
1558
1559impl ProjectIndex<'_> {
1560    /// Resolve a crate name to its `src` prefix, building the workspace map on
1561    /// first use. Refresh-worker indexes for the same root share this cache.
1562    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1563        self.workspace_crate_prefixes
1564            .0
1565            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1566            .get(crate_name)
1567            .cloned()
1568    }
1569}
1570
1571impl CallGraphStore {
1572    pub fn open_if_enabled(
1573        options: CallGraphStoreOptions,
1574        callgraph_dir: PathBuf,
1575        project_root: PathBuf,
1576    ) -> Result<Option<Self>> {
1577        if !options.enabled {
1578            return Ok(None);
1579        }
1580        Self::open(callgraph_dir, project_root).map(Some)
1581    }
1582
1583    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1584        let project_key = crate::search_index::artifact_cache_key(&project_root);
1585        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1586        else {
1587            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1588                Some(store) => Ok(store.into_inner()),
1589                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1590            };
1591        };
1592        std::fs::create_dir_all(&callgraph_dir)?;
1593        // Resolve the current generation via the pointer (falling back to the
1594        // legacy single-file DB). If nothing is published yet, open the legacy
1595        // path so a brand-new store still gets a writable DB + schema.
1596        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1597            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1598        let OpenedStore { store, root_repair } = Self::open_at_path(
1599            project_root.clone(),
1600            project_key,
1601            sqlite_path,
1602            generation,
1603            true,
1604            Some(Arc::clone(&writer_lease)),
1605            None,
1606        )?;
1607        match root_repair {
1608            OpenRootRepair::NeedsRebuild { .. } => {
1609                log_root_repair_rebuild(&root_repair);
1610                drop(store);
1611                drop(writer_lease);
1612                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1613                let (store, _stats) =
1614                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1615                Ok(store)
1616            }
1617            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1618        }
1619    }
1620
1621    pub fn open_readonly(
1622        callgraph_dir: PathBuf,
1623        project_root: PathBuf,
1624    ) -> Result<Option<ReadonlyCallGraphStore>> {
1625        let project_key = crate::search_index::artifact_cache_key(&project_root);
1626        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1627        {
1628            let conn = open_readonly_connection(&sqlite_path)?;
1629            if !database_ready(&conn).unwrap_or(false) {
1630                return Ok(None);
1631            }
1632            let marker_label = generation.as_deref().unwrap_or("legacy");
1633            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1634            return Ok(Some(ReadonlyCallGraphStore::from_inner(
1635                Self::from_connection(
1636                    project_root,
1637                    project_key,
1638                    sqlite_path,
1639                    callgraph_dir,
1640                    false,
1641                    generation,
1642                    None,
1643                    Some(read_marker),
1644                    conn,
1645                ),
1646            )));
1647        }
1648
1649        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1650            return Ok(None);
1651        };
1652        crate::slog_warn!(
1653            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1654            target.partition.harness,
1655            target.sqlite_path.display()
1656        );
1657        let conn = open_readonly_connection(&target.sqlite_path)?;
1658        if !database_ready(&conn).unwrap_or(false) {
1659            return Ok(None);
1660        }
1661        let marker_label =
1662            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1663        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1664        Ok(Some(ReadonlyCallGraphStore::from_inner(
1665            Self::from_connection(
1666                project_root,
1667                project_key,
1668                target.sqlite_path,
1669                callgraph_dir,
1670                true,
1671                target.generation,
1672                None,
1673                Some(read_marker),
1674                conn,
1675            ),
1676        )))
1677    }
1678
1679    /// Open the currently-published ready store with write access so moved-root
1680    /// metadata can be repaired before projection readers consume it. Unlike
1681    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
1682    /// ready generation exists, it returns `Ok(None)` instead of creating an
1683    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
1684    pub fn open_ready_repairing(
1685        callgraph_dir: PathBuf,
1686        project_root: PathBuf,
1687    ) -> Result<Option<Self>> {
1688        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1689    }
1690
1691    /// Open a ready store for bounded maintenance work without repairing root
1692    /// metadata or starting a cold rebuild. A store that needs either action is
1693    /// reported as unavailable so a background build can own that work.
1694    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1695        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1696    }
1697
1698    pub fn open_ready_no_rebuild(
1699        callgraph_dir: PathBuf,
1700        project_root: PathBuf,
1701    ) -> Result<Option<Self>> {
1702        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1703    }
1704
1705    fn open_ready_with_rebuild_policy(
1706        callgraph_dir: PathBuf,
1707        project_root: PathBuf,
1708        allow_cold_build: bool,
1709        allow_root_repair: bool,
1710        allow_borrow_only: bool,
1711    ) -> Result<Option<Self>> {
1712        let project_key = crate::search_index::artifact_cache_key(&project_root);
1713        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1714        else {
1715            if !allow_borrow_only {
1716                return Ok(None);
1717            }
1718            return Self::open_readonly(callgraph_dir, project_root)
1719                .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1720        };
1721        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1722        else {
1723            return Ok(None);
1724        };
1725        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1726            project_root.clone(),
1727            project_key,
1728            sqlite_path,
1729            generation,
1730            true,
1731            Some(Arc::clone(&writer_lease)),
1732            None,
1733            allow_root_repair,
1734        )?;
1735        match root_repair {
1736            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1737                log_root_repair_rebuild(&root_repair);
1738                drop(store);
1739                drop(writer_lease);
1740                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1741                let (store, _stats) =
1742                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1743                Ok(Some(store))
1744            }
1745            OpenRootRepair::NeedsRebuild { .. } => {
1746                crate::slog_info!(
1747                    "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1748                );
1749                Ok(None)
1750            }
1751            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1752        }
1753    }
1754
1755    pub fn cold_build_with_lease(
1756        callgraph_dir: PathBuf,
1757        project_root: PathBuf,
1758        files: &[PathBuf],
1759    ) -> Result<(Self, ColdBuildStats)> {
1760        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1761    }
1762
1763    pub fn cold_build_with_lease_chunked(
1764        callgraph_dir: PathBuf,
1765        project_root: PathBuf,
1766        files: &[PathBuf],
1767        chunk_size: usize,
1768    ) -> Result<(Self, ColdBuildStats)> {
1769        Self::cold_build_with_lease_chunked_inner(
1770            callgraph_dir,
1771            project_root,
1772            files,
1773            chunk_size,
1774            false,
1775        )
1776    }
1777
1778    pub(crate) fn force_cold_build_with_lease_chunked(
1779        callgraph_dir: PathBuf,
1780        project_root: PathBuf,
1781        files: &[PathBuf],
1782        chunk_size: usize,
1783    ) -> Result<(Self, ColdBuildStats)> {
1784        Self::cold_build_with_lease_chunked_inner(
1785            callgraph_dir,
1786            project_root,
1787            files,
1788            chunk_size,
1789            true,
1790        )
1791    }
1792
1793    fn cold_build_with_lease_chunked_inner(
1794        callgraph_dir: PathBuf,
1795        project_root: PathBuf,
1796        files: &[PathBuf],
1797        chunk_size: usize,
1798        require_new_publication: bool,
1799    ) -> Result<(Self, ColdBuildStats)> {
1800        let project_key = crate::search_index::artifact_cache_key(&project_root);
1801        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1802        else {
1803            if require_new_publication {
1804                return Err(CallGraphStoreError::Unavailable(
1805                    "forced rebuild could not acquire the writer lease".to_string(),
1806                ));
1807            }
1808            let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1809                Some(store) => store.into_inner(),
1810                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1811            };
1812            return Ok((
1813                store,
1814                ColdBuildStats {
1815                    files: 0,
1816                    nodes: 0,
1817                    refs: 0,
1818                    edges: 0,
1819                    failed_files: Vec::new(),
1820                    elapsed_ms: 0,
1821                },
1822            ));
1823        };
1824        std::fs::create_dir_all(&callgraph_dir)?;
1825        let (stats, generation) = Self::cold_build_publish_locked(
1826            &callgraph_dir,
1827            &project_root,
1828            &project_key,
1829            files,
1830            chunk_size,
1831            Arc::clone(&writer_lease),
1832        )?;
1833        let store = Self::open_generation(
1834            &callgraph_dir,
1835            project_root,
1836            project_key,
1837            generation,
1838            writer_lease,
1839        )?;
1840        Ok((store, stats))
1841    }
1842
1843    pub fn ensure_built_with_lease(
1844        callgraph_dir: PathBuf,
1845        project_root: PathBuf,
1846        files: &[PathBuf],
1847    ) -> Result<(Self, Option<ColdBuildStats>)> {
1848        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1849    }
1850
1851    pub fn ensure_built_with_lease_chunked(
1852        callgraph_dir: PathBuf,
1853        project_root: PathBuf,
1854        files: &[PathBuf],
1855        chunk_size: usize,
1856    ) -> Result<(Self, Option<ColdBuildStats>)> {
1857        let project_key = crate::search_index::artifact_cache_key(&project_root);
1858        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1859        else {
1860            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1861                Some(store) => Ok((store.into_inner(), None)),
1862                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1863                    .map(|store| (store, None)),
1864            };
1865        };
1866        std::fs::create_dir_all(&callgraph_dir)?;
1867        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1868        // Another process may have published a ready generation while we waited
1869        // for the lock — open it instead of rebuilding. If that generation is
1870        // from this same project at an older filesystem root, repair the root
1871        // metadata in-place while still holding the build lease. If data rows
1872        // contain absolute paths, publish a fresh generation under this lease
1873        // rather than recursively reacquiring the same lock.
1874        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1875        {
1876            let OpenedStore { store, root_repair } = Self::open_at_path(
1877                project_root.clone(),
1878                project_key.clone(),
1879                sqlite_path,
1880                generation,
1881                true,
1882                Some(Arc::clone(&writer_lease)),
1883                None,
1884            )?;
1885            match root_repair {
1886                OpenRootRepair::NeedsRebuild { .. } => {
1887                    log_root_repair_rebuild(&root_repair);
1888                    drop(store);
1889                    let (stats, generation) = Self::cold_build_publish_locked(
1890                        &callgraph_dir,
1891                        &project_root,
1892                        &project_key,
1893                        files,
1894                        chunk_size,
1895                        Arc::clone(&writer_lease),
1896                    )?;
1897                    let store = Self::open_generation(
1898                        &callgraph_dir,
1899                        project_root,
1900                        project_key,
1901                        generation,
1902                        writer_lease,
1903                    )?;
1904                    return Ok((store, Some(stats)));
1905                }
1906                OpenRootRepair::None | OpenRootRepair::ReRooted => {
1907                    return Ok((store, None));
1908                }
1909            }
1910        }
1911        if let Some(store) = try_legacy_migration_or_fallback(
1912            &callgraph_dir,
1913            &project_root,
1914            &project_key,
1915            Arc::clone(&writer_lease),
1916        )? {
1917            return Ok((store, None));
1918        }
1919        let (stats, generation) = Self::cold_build_publish_locked(
1920            &callgraph_dir,
1921            &project_root,
1922            &project_key,
1923            files,
1924            chunk_size,
1925            Arc::clone(&writer_lease),
1926        )?;
1927        let store = Self::open_generation(
1928            &callgraph_dir,
1929            project_root,
1930            project_key,
1931            generation,
1932            writer_lease,
1933        )?;
1934        Ok((store, Some(stats)))
1935    }
1936
1937    /// Migrate a legacy harness-partition store without falling through to a
1938    /// cold build. This is used after a query has already opened a read-only
1939    /// fallback: the caller runs it on the same limited background lane as cold
1940    /// builds while queries continue using that fallback. Public so crash/retry
1941    /// tests can drive the migration synchronously on a thread where the
1942    /// thread-local failure seams apply.
1943    pub fn migrate_legacy_with_lease(
1944        callgraph_dir: PathBuf,
1945        project_root: PathBuf,
1946    ) -> Result<Option<Self>> {
1947        let project_key = crate::search_index::artifact_cache_key(&project_root);
1948        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1949        else {
1950            return Ok(None);
1951        };
1952        std::fs::create_dir_all(&callgraph_dir)?;
1953        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1954
1955        // Another writer may have completed the migration while this worker was
1956        // waiting for the lease. Adopt its root-keyed generation rather than
1957        // copying the legacy source a second time.
1958        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1959        {
1960            let OpenedStore { store, root_repair } = Self::open_at_path(
1961                project_root,
1962                project_key,
1963                sqlite_path,
1964                generation,
1965                true,
1966                Some(writer_lease),
1967                None,
1968            )?;
1969            return match root_repair {
1970                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1971                OpenRootRepair::NeedsRebuild { reason, .. } => {
1972                    Err(CallGraphStoreError::Unavailable(format!(
1973                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1974                    )))
1975                }
1976            };
1977        }
1978
1979        let store = try_legacy_migration_or_fallback(
1980            &callgraph_dir,
1981            &project_root,
1982            &project_key,
1983            writer_lease,
1984        )?;
1985        // A disk-floor or backup-budget failure returns a readable legacy store.
1986        // Keep the already-resident fallback instead of sending this duplicate
1987        // reader through the background-install channel.
1988        Ok(store.filter(|store| !store.is_legacy_fallback()))
1989    }
1990
1991    /// Build a fresh DB and publish it as a new generation, then atomically flip
1992    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
1993    /// succeeds even when other processes hold an older generation open (the
1994    /// multi-TUI Windows case). The builder owns the temp + generation files
1995    /// exclusively (unique pid+nanos names), so it can rename/replace them
1996    /// freely; only the tiny pointer is shared, and only Rust std touches it.
1997    ///
1998    /// Returns the published generation file name so callers open exactly the
1999    /// generation they built (avoiding a race where a concurrent build's flip
2000    /// would otherwise reopen a different generation).
2001    fn cold_build_publish_locked(
2002        callgraph_dir: &Path,
2003        project_root: &Path,
2004        project_key: &str,
2005        files: &[PathBuf],
2006        chunk_size: usize,
2007        writer_lease: Arc<crate::root_cache::WriterLease>,
2008    ) -> Result<(ColdBuildStats, String)> {
2009        let generation = generation_file_name(project_key);
2010        let gen_path = callgraph_dir.join(&generation);
2011        let temp_path = callgraph_dir.join(format!(
2012            "{generation}.tmp.{}.{}",
2013            std::process::id(),
2014            now_nanos()
2015        ));
2016        remove_sqlite_file_set(&temp_path);
2017
2018        let stats = {
2019            let temp_store = Self::open_at_path(
2020                project_root.to_path_buf(),
2021                project_key.to_string(),
2022                temp_path.clone(),
2023                None,
2024                false,
2025                Some(Arc::clone(&writer_lease)),
2026                None,
2027            )?
2028            .store;
2029            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2030            temp_store.prepare_for_atomic_swap()?;
2031            stats
2032        };
2033
2034        notify_cold_build_before_publish_observer();
2035        let publication = publish_if_current(|| {
2036            verify_writer_lease(&writer_lease)?;
2037            // Move the finished build to its final generation path. This target is
2038            // brand-new and owned by us, so the rename never hits an open file.
2039            remove_sqlite_file_set(&gen_path);
2040            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2041            crate::fs_lock::sync_parent(&gen_path);
2042            remove_sqlite_sidecars(&gen_path);
2043
2044            notify_cold_build_swap_observer(&temp_path, &gen_path);
2045
2046            // Atomically publish the new generation, then best-effort GC old ones.
2047            verify_writer_lease(&writer_lease)?;
2048            publish_pointer(callgraph_dir, project_key, &generation)?;
2049            gc_old_generations(callgraph_dir, project_key, &generation);
2050            // Store-wide orphan sweep on the same cadence: reclaims aged build
2051            // temps for roots that no longer build here, which the per-root GC
2052            // above never reaches.
2053            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2054            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2055                let inspect_root =
2056                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2057                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2058                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2059            }
2060            Ok(())
2061        });
2062        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2063            remove_sqlite_file_set(&temp_path);
2064        }
2065        publication?;
2066        Ok((stats, generation))
2067    }
2068
2069    /// Open a specific just-published generation (read-write, WAL) so a builder
2070    /// returns a store pinned to exactly what it built.
2071    fn open_generation(
2072        callgraph_dir: &Path,
2073        project_root: PathBuf,
2074        project_key: String,
2075        generation: String,
2076        writer_lease: Arc<crate::root_cache::WriterLease>,
2077    ) -> Result<Self> {
2078        let gen_path = callgraph_dir.join(&generation);
2079        Ok(Self::open_at_path(
2080            project_root,
2081            project_key,
2082            gen_path,
2083            Some(generation),
2084            true,
2085            Some(writer_lease),
2086            None,
2087        )?
2088        .store)
2089    }
2090
2091    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2092        let project_key = crate::search_index::artifact_cache_key(project_root);
2093        // A cold build is needed unless a ready generation (or ready legacy DB)
2094        // is currently published.
2095        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2096    }
2097
2098    fn open_at_path(
2099        project_root: PathBuf,
2100        project_key: String,
2101        sqlite_path: PathBuf,
2102        generation: Option<String>,
2103        use_wal: bool,
2104        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2105        read_marker: Option<crate::root_cache::ReadMarker>,
2106    ) -> Result<OpenedStore> {
2107        Self::open_at_path_with_root_repair(
2108            project_root,
2109            project_key,
2110            sqlite_path,
2111            generation,
2112            use_wal,
2113            writer_lease,
2114            read_marker,
2115            true,
2116        )
2117    }
2118
2119    fn open_at_path_with_root_repair(
2120        project_root: PathBuf,
2121        project_key: String,
2122        sqlite_path: PathBuf,
2123        generation: Option<String>,
2124        use_wal: bool,
2125        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2126        read_marker: Option<crate::root_cache::ReadMarker>,
2127        allow_root_repair: bool,
2128    ) -> Result<OpenedStore> {
2129        if let Some(lease) = writer_lease.as_ref() {
2130            verify_writer_lease(lease)?;
2131        }
2132        if let Some(parent) = sqlite_path.parent() {
2133            std::fs::create_dir_all(parent)?;
2134        }
2135        let mut conn = Connection::open(&sqlite_path)?;
2136        if use_wal {
2137            configure_connection(&conn)?;
2138        } else {
2139            configure_build_connection(&conn)?;
2140        }
2141        if let Some(lease) = writer_lease.as_ref() {
2142            verify_writer_lease(lease)?;
2143        }
2144        initialize_schema(&conn)?;
2145        if let Some(lease) = writer_lease.as_ref() {
2146            verify_writer_lease(lease)?;
2147        }
2148        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2149        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2150            (Some(marker), _, _) => Some(marker),
2151            (None, Some(label), Some(cache_dir)) => {
2152                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2153            }
2154            (None, _, _) => None,
2155        };
2156        let publication_dir = sqlite_path
2157            .parent()
2158            .map(Path::to_path_buf)
2159            .unwrap_or_default();
2160        let store = Self::from_connection(
2161            project_root,
2162            project_key,
2163            sqlite_path,
2164            publication_dir,
2165            false,
2166            generation,
2167            writer_lease,
2168            read_marker,
2169            conn,
2170        );
2171        Ok(OpenedStore { store, root_repair })
2172    }
2173
2174    fn borrow_only_empty(
2175        callgraph_dir: PathBuf,
2176        project_root: PathBuf,
2177        project_key: String,
2178    ) -> Result<Self> {
2179        let conn = Connection::open_in_memory()?;
2180        initialize_schema(&conn)?;
2181        conn.pragma_update(None, "query_only", true)?;
2182        Ok(Self::from_connection(
2183            project_root,
2184            project_key.clone(),
2185            callgraph_dir.join(format!("{project_key}.borrow-only")),
2186            callgraph_dir,
2187            false,
2188            None,
2189            None,
2190            None,
2191            conn,
2192        ))
2193    }
2194
2195    fn prepare_for_atomic_swap(&self) -> Result<()> {
2196        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2197        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2198        Ok(())
2199    }
2200
2201    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2202        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2203            self.sqlite_path
2204                .parent()
2205                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2206        });
2207        if protected_reader {
2208            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2209        } else {
2210            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2211        }
2212    }
2213
2214    fn from_connection(
2215        project_root: PathBuf,
2216        project_key: String,
2217        sqlite_path: PathBuf,
2218        publication_dir: PathBuf,
2219        legacy_fallback: bool,
2220        generation: Option<String>,
2221        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2222        read_marker: Option<crate::root_cache::ReadMarker>,
2223        conn: Connection,
2224    ) -> Self {
2225        Self {
2226            project_root,
2227            project_key,
2228            sqlite_path,
2229            publication_dir,
2230            legacy_fallback,
2231            generation,
2232            writer_lease,
2233            read_marker,
2234            database_ready: AtomicBool::new(false),
2235            conn: Mutex::new(conn),
2236        }
2237    }
2238
2239    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2240        if self.database_ready.load(AtomicOrdering::Acquire) {
2241            return Ok(());
2242        }
2243        ensure_database_ready(conn)?;
2244        self.database_ready.store(true, AtomicOrdering::Release);
2245        Ok(())
2246    }
2247
2248    pub fn project_root(&self) -> &Path {
2249        &self.project_root
2250    }
2251
2252    pub fn project_key(&self) -> &str {
2253        &self.project_key
2254    }
2255
2256    pub fn sqlite_path(&self) -> &Path {
2257        &self.sqlite_path
2258    }
2259
2260    /// Whether this store is reading from a legacy harness partition because
2261    /// the root-keyed store has not published a generation yet.
2262    pub fn is_legacy_fallback(&self) -> bool {
2263        self.legacy_fallback
2264    }
2265
2266    pub(crate) fn is_legacy_migration(&self) -> bool {
2267        self.generation.as_deref().is_some_and(|generation| {
2268            migration_generation_requires_manifest(generation)
2269                && migration_manifest_valid(&self.publication_dir, generation)
2270        })
2271    }
2272
2273    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2274        self.writer_lease.as_ref().map(|lease| lease.epoch())
2275    }
2276
2277    fn verify_writer_lease(&self) -> Result<()> {
2278        let Some(lease) = self.writer_lease.as_ref() else {
2279            return Err(CallGraphStoreError::Unavailable(
2280                "callgraph store opened read-only; write API is unavailable".to_string(),
2281            ));
2282        };
2283        verify_writer_lease(lease)
2284    }
2285
2286    fn refresh_read_marker(&self) -> Result<()> {
2287        if let Some(marker) = self.read_marker.as_ref() {
2288            marker.touch_if_due()?;
2289        }
2290        Ok(())
2291    }
2292
2293    /// True if this store still reflects the currently-published generation.
2294    /// Cheap (one small pointer-file read). When false, another process (or a
2295    /// local cold rebuild) has published a newer generation and the holder
2296    /// should drop this store and reopen via the pointer to converge. A missing
2297    /// pointer keeps the current store (legacy DB still valid, or transient).
2298    pub fn is_current(&self) -> bool {
2299        let _ = self.refresh_read_marker();
2300        match (
2301            read_pointer(&self.publication_dir, &self.project_key),
2302            &self.generation,
2303        ) {
2304            // Even when both generations happen to have the same filename, the
2305            // root-keyed pointer names a different directory from the fallback.
2306            (Some(_), _) if self.legacy_fallback => false,
2307            (Some(published), Some(opened)) => &published == opened,
2308            // A generation now supersedes the legacy single-file DB we opened.
2309            (Some(_), None) => false,
2310            // No pointer: keep serving (legacy DB, or an anomalous pointer
2311            // removal where our open generation file is still valid).
2312            (None, _) => true,
2313        }
2314    }
2315
2316    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2317        self.cold_build_chunked(files, 0)
2318    }
2319
2320    pub fn cold_build_chunked(
2321        &self,
2322        files: &[PathBuf],
2323        chunk_size: usize,
2324    ) -> Result<ColdBuildStats> {
2325        let started = Instant::now();
2326        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2327        macro_rules! phase {
2328            ($label:expr, $t:expr) => {
2329                if bench {
2330                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2331                    let _ = std::io::Write::flush(&mut std::io::stderr());
2332                }
2333            };
2334        }
2335        let files = normalize_file_list(&self.project_root, files)?;
2336
2337        if chunk_size == 0 {
2338            let t = Instant::now();
2339            let build = build_extracts_parallel(&self.project_root, &files);
2340            phase!("extract_parallel", t);
2341            let extracts = build.extracts;
2342            let failures = build.failures;
2343            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2344
2345            let t = Instant::now();
2346            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2347            phase!("build_index", t);
2348            let t = Instant::now();
2349            let mut resolved_refs = Vec::new();
2350            for extract in &extracts {
2351                for raw_ref in &extract.raw_refs {
2352                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2353                }
2354            }
2355            phase!("resolve_refs", t);
2356            let ref_count = resolved_refs.len();
2357            let edge_count = resolved_refs
2358                .iter()
2359                .filter(|item| item.edge.is_some())
2360                .count();
2361
2362            let t = Instant::now();
2363            self.verify_writer_lease()?;
2364            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2365            let tx = conn.transaction()?;
2366            clear_tables(&tx)?;
2367            insert_meta(&tx)?;
2368            drop_cold_build_secondary_indexes(&tx)?;
2369            {
2370                let workspace_root = self.project_root.display().to_string();
2371                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2372                for extract in &extracts {
2373                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2374                }
2375                for failure in &failures {
2376                    insert_backend_state_prepared(
2377                        &mut inserts.backend_state,
2378                        &workspace_root,
2379                        &failure.rel_path,
2380                        failure
2381                            .freshness
2382                            .as_ref()
2383                            .map(|freshness| &freshness.content_hash),
2384                        "stale",
2385                    )?;
2386                }
2387                for resolved in &resolved_refs {
2388                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2389                }
2390            }
2391            create_cold_build_secondary_indexes(&tx)?;
2392            let supplemental_edge_count =
2393                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2394            set_meta_ready(&tx, true)?;
2395            tx.commit()?;
2396            phase!("sqlite_insert", t);
2397
2398            let elapsed_ms = started.elapsed().as_millis();
2399            crate::slog_info!(
2400                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2401                extracts.len(),
2402                node_count,
2403                ref_count,
2404                edge_count + supplemental_edge_count,
2405                elapsed_ms
2406            );
2407            return Ok(ColdBuildStats {
2408                files: extracts.len(),
2409                nodes: node_count,
2410                refs: ref_count,
2411                edges: edge_count + supplemental_edge_count,
2412                failed_files: failures
2413                    .into_iter()
2414                    .map(|failure| failure.rel_path)
2415                    .collect(),
2416                elapsed_ms,
2417            });
2418        }
2419
2420        // Chunked implementation: parse and resolve in batches to reduce peak
2421        // memory during cold build without changing the persisted graph.
2422        let t = Instant::now();
2423        self.verify_writer_lease()?;
2424        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2425        let tx = conn.transaction()?;
2426        clear_tables(&tx)?;
2427        insert_meta(&tx)?;
2428        drop_cold_build_secondary_indexes(&tx)?;
2429
2430        let mut all_raw_refs = Vec::new();
2431        let mut failures = Vec::new();
2432        let mut node_count = 0;
2433        let mut files_parsed = 0;
2434
2435        let mut persistent_call_data = Vec::new();
2436        let mut file_to_call_data_index = HashMap::new();
2437        let mut files_index = HashMap::new();
2438
2439        let workspace_root = self.project_root.display().to_string();
2440
2441        {
2442            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2443            for chunk in files.chunks(chunk_size) {
2444                let build = build_extracts_parallel(&self.project_root, chunk);
2445                failures.extend(build.failures.clone());
2446
2447                for extract in build.extracts {
2448                    files_parsed += 1;
2449                    node_count += extract.nodes.len();
2450                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2451
2452                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2453                    files_index.insert(extract.rel_path.clone(), db_file_index);
2454
2455                    persistent_call_data.push(extract.data);
2456                    let idx = persistent_call_data.len() - 1;
2457                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2458
2459                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2460                }
2461                for failure in &build.failures {
2462                    insert_backend_state_prepared(
2463                        &mut inserts.backend_state,
2464                        &workspace_root,
2465                        &failure.rel_path,
2466                        failure
2467                            .freshness
2468                            .as_ref()
2469                            .map(|freshness| &freshness.content_hash),
2470                        "stale",
2471                    )?;
2472                }
2473            }
2474        }
2475
2476        let mut caller_data = HashMap::new();
2477        for (rel_path, idx) in &file_to_call_data_index {
2478            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2479        }
2480        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2481        let index = ProjectIndex::from_parts(
2482            &self.project_root,
2483            files_index,
2484            caller_data,
2485            WorkspaceCratePrefixCache::default(),
2486        );
2487
2488        let mut resolved_refs = Vec::new();
2489        for (_, raw_refs) in all_raw_refs {
2490            for raw_ref in raw_refs {
2491                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2492            }
2493        }
2494
2495        let ref_count = resolved_refs.len();
2496        let edge_count = resolved_refs
2497            .iter()
2498            .filter(|item| item.edge.is_some())
2499            .count();
2500
2501        {
2502            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2503            for resolved in &resolved_refs {
2504                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2505            }
2506        }
2507        create_cold_build_secondary_indexes(&tx)?;
2508        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2509            &tx,
2510            &self.project_root,
2511            &indexed_caller_files,
2512            chunk_size,
2513        )?;
2514        set_meta_ready(&tx, true)?;
2515        tx.commit()?;
2516        phase!("sqlite_insert", t);
2517
2518        let elapsed_ms = started.elapsed().as_millis();
2519        crate::slog_info!(
2520            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2521            files_parsed,
2522            node_count,
2523            ref_count,
2524            edge_count + supplemental_edge_count,
2525            elapsed_ms
2526        );
2527        Ok(ColdBuildStats {
2528            files: files_parsed,
2529            nodes: node_count,
2530            refs: ref_count,
2531            edges: edge_count + supplemental_edge_count,
2532            failed_files: failures
2533                .into_iter()
2534                .map(|failure| failure.rel_path)
2535                .collect(),
2536            elapsed_ms,
2537        })
2538    }
2539
2540    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2541        self.refresh_files_with_workspace_crate_prefix_cache(
2542            changed_files,
2543            WorkspaceCratePrefixCache::default(),
2544        )
2545    }
2546
2547    fn refresh_files_with_workspace_crate_prefix_cache(
2548        &self,
2549        changed_files: &[PathBuf],
2550        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2551    ) -> Result<IncrementalStats> {
2552        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2553            changed_files,
2554            workspace_crate_prefixes,
2555        )?;
2556        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2557            eprintln!("refresh_files phases: {}", profile.report());
2558        }
2559        Ok(stats)
2560    }
2561
2562    /// Run an incremental refresh and return phase timings for an offline store copy.
2563    #[doc(hidden)]
2564    pub fn refresh_files_profiled(
2565        &self,
2566        changed_files: &[PathBuf],
2567    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2568        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2569            changed_files,
2570            WorkspaceCratePrefixCache::default(),
2571        )
2572    }
2573
2574    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2575        &self,
2576        changed_files: &[PathBuf],
2577        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2578    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2579        let total_started = Instant::now();
2580        let mut profile = RefreshFilesProfile::default();
2581        self.verify_writer_lease()?;
2582        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2583        let tx = conn.transaction()?;
2584        ensure_database_ready(&tx)?;
2585        let mut changed = Vec::new();
2586        let mut surface_changed = BTreeSet::new();
2587        let mut deleted = BTreeSet::new();
2588        let mut own_refresh = BTreeSet::new();
2589        let mut selected_ref_ids = BTreeSet::new();
2590        let mut selected_refs_by_caller = BTreeMap::new();
2591        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2592
2593        for input in changed_files {
2594            let abs_path = normalize_file_path(&self.project_root, input)?;
2595            let rel_path = relative_path(&self.project_root, &abs_path);
2596            changed.push(rel_path.clone());
2597            let old_row = load_file_row(&tx, &rel_path)?;
2598            if !abs_path.exists() {
2599                if old_row.is_some() {
2600                    surface_changed.insert(rel_path.clone());
2601                    deleted.insert(rel_path.clone());
2602                    let started = Instant::now();
2603                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2604                    profile.dependency_selection += started.elapsed();
2605                    record_dependent_refs(
2606                        &mut selected_ref_ids,
2607                        &mut selected_refs_by_caller,
2608                        dependent_refs,
2609                    );
2610                    let started = Instant::now();
2611                    delete_file_rows(&tx, &rel_path)?;
2612                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2613                    profile.row_deletes += started.elapsed();
2614                }
2615                continue;
2616            }
2617
2618            if let Some(row) = &old_row {
2619                match cache_freshness::verify_file(&abs_path, &row.freshness) {
2620                    FreshnessVerdict::HotFresh => continue,
2621                    FreshnessVerdict::ContentFresh {
2622                        new_mtime,
2623                        new_size,
2624                    } => {
2625                        update_file_fresh_metadata(
2626                            &tx,
2627                            &rel_path,
2628                            &row.freshness.content_hash,
2629                            new_mtime,
2630                            new_size,
2631                        )?;
2632                        continue;
2633                    }
2634                    FreshnessVerdict::Deleted => {
2635                        surface_changed.insert(rel_path.clone());
2636                        deleted.insert(rel_path.clone());
2637                        let started = Instant::now();
2638                        let dependent_refs =
2639                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2640                        profile.dependency_selection += started.elapsed();
2641                        record_dependent_refs(
2642                            &mut selected_ref_ids,
2643                            &mut selected_refs_by_caller,
2644                            dependent_refs,
2645                        );
2646                        let started = Instant::now();
2647                        delete_file_rows(&tx, &rel_path)?;
2648                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2649                        profile.row_deletes += started.elapsed();
2650                        continue;
2651                    }
2652                    FreshnessVerdict::Stale => {}
2653                }
2654            }
2655
2656            let started = Instant::now();
2657            let extract = build_file_extract(&self.project_root, &abs_path)?;
2658            profile.parse += started.elapsed();
2659            let surface_is_changed = old_row
2660                .as_ref()
2661                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2662                .unwrap_or(true);
2663            if surface_is_changed {
2664                surface_changed.insert(rel_path.clone());
2665                let started = Instant::now();
2666                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2667                profile.dependency_selection += started.elapsed();
2668                record_dependent_refs(
2669                    &mut selected_ref_ids,
2670                    &mut selected_refs_by_caller,
2671                    dependent_refs,
2672                );
2673            }
2674            own_refresh.insert(rel_path.clone());
2675            let started = Instant::now();
2676            delete_file_rows(&tx, &rel_path)?;
2677            profile.row_deletes += started.elapsed();
2678            let started = Instant::now();
2679            insert_file_extract(&tx, &self.project_root, &extract)?;
2680            profile.row_inserts += started.elapsed();
2681            changed_extracts.insert(rel_path, extract);
2682        }
2683
2684        let dependency_selected_refs = selected_ref_ids.len();
2685        let mut touched_callers: BTreeSet<String> =
2686            selected_refs_by_caller.keys().cloned().collect();
2687        touched_callers.extend(own_refresh.iter().cloned());
2688
2689        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2690        for rel_path in &touched_callers {
2691            if deleted.contains(rel_path) {
2692                continue;
2693            }
2694            if let Some(extract) = changed_extracts.get(rel_path) {
2695                caller_extracts.insert(rel_path.clone(), extract.clone());
2696                continue;
2697            }
2698            let abs_path = self.project_root.join(rel_path);
2699            if abs_path.exists() {
2700                let started = Instant::now();
2701                let extract = build_file_extract(&self.project_root, &abs_path)?;
2702                profile.dependent_parse += started.elapsed();
2703                caller_extracts.insert(rel_path.clone(), extract);
2704            }
2705        }
2706
2707        let dependency_callers = touched_callers
2708            .iter()
2709            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2710            .cloned()
2711            .collect::<Vec<_>>();
2712        for rel_path in dependency_callers {
2713            let Some(extract) = caller_extracts.get(&rel_path) else {
2714                continue;
2715            };
2716            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2717                continue;
2718            }
2719
2720            own_refresh.insert(rel_path.clone());
2721            let started = Instant::now();
2722            delete_file_rows(&tx, &rel_path)?;
2723            profile.row_deletes += started.elapsed();
2724            let started = Instant::now();
2725            insert_file_extract(&tx, &self.project_root, extract)?;
2726            profile.row_inserts += started.elapsed();
2727        }
2728
2729        let started = Instant::now();
2730        let index = ProjectIndex::from_db_and_callers(
2731            &tx,
2732            &self.project_root,
2733            &caller_extracts,
2734            workspace_crate_prefixes,
2735        )?;
2736        profile.index_load += started.elapsed();
2737        let started = Instant::now();
2738        for rel_path in &touched_callers {
2739            if deleted.contains(rel_path) {
2740                continue;
2741            }
2742            let Some(extract) = caller_extracts.get(rel_path) else {
2743                continue;
2744            };
2745            if own_refresh.contains(rel_path) {
2746                delete_refs_for_caller(&tx, rel_path)?;
2747                for raw_ref in &extract.raw_refs {
2748                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2749                    insert_resolved_ref(&tx, &resolved)?;
2750                }
2751                continue;
2752            }
2753
2754            let selected_for_caller = selected_refs_by_caller
2755                .get(rel_path)
2756                .cloned()
2757                .unwrap_or_default();
2758            delete_ref_ids(&tx, &selected_for_caller)?;
2759            for raw_ref in &extract.raw_refs {
2760                if selected_for_caller.contains(&raw_ref.ref_id) {
2761                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2762                    insert_resolved_ref(&tx, &resolved)?;
2763                }
2764            }
2765        }
2766        profile.ref_resolution += started.elapsed();
2767
2768        let started = Instant::now();
2769        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2770        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2771        profile.method_dispatch += started.elapsed();
2772
2773        let started = Instant::now();
2774        commit_incremental_if_current(tx)?;
2775        profile.commit += started.elapsed();
2776        profile.total = total_started.elapsed();
2777        Ok((
2778            IncrementalStats {
2779                changed_files: changed,
2780                surface_changed: surface_changed.into_iter().collect(),
2781                deleted_files: deleted.into_iter().collect(),
2782                dependency_selected_refs,
2783                refreshed_own_files: own_refresh.len(),
2784            },
2785            profile,
2786        ))
2787    }
2788
2789    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2790        self.cold_build(current_files)
2791    }
2792
2793    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2794        self.verify_writer_lease()?;
2795        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2796        let tx = conn.transaction()?;
2797        let mut marked = Vec::new();
2798        for path in files {
2799            let abs_path = normalize_file_path(&self.project_root, path)?;
2800            let rel_path = relative_path(&self.project_root, &abs_path);
2801            let freshness = cache_freshness::collect(&abs_path).ok();
2802            mark_backend_state(
2803                &tx,
2804                &self.project_root,
2805                &rel_path,
2806                freshness.as_ref().map(|freshness| &freshness.content_hash),
2807                "stale",
2808            )?;
2809            marked.push(rel_path);
2810        }
2811        tx.commit()?;
2812        marked.sort();
2813        marked.dedup();
2814        Ok(marked)
2815    }
2816
2817    pub fn stale_files(&self) -> Result<Vec<String>> {
2818        self.refresh_read_marker()?;
2819        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2820        let mut stmt = conn.prepare(
2821            "SELECT DISTINCT file_path FROM backend_file_state
2822             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2823             ORDER BY file_path",
2824        )?;
2825        let rows = stmt.query_map(
2826            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2827            |row| row.get::<_, String>(0),
2828        )?;
2829        rows.collect::<std::result::Result<Vec<_>, _>>()
2830            .map_err(Into::into)
2831    }
2832
2833    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2834        self.refresh_read_marker()?;
2835        let rel_path = relative_path(
2836            &self.project_root,
2837            &normalize_file_path(&self.project_root, file)?,
2838        );
2839        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2840        conn.query_row(
2841            "SELECT status FROM backend_file_state
2842             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2843             ORDER BY updated_at DESC LIMIT 1",
2844            params![
2845                BACKEND_TREESITTER,
2846                self.project_root.display().to_string(),
2847                rel_path
2848            ],
2849            |row| row.get(0),
2850        )
2851        .optional()
2852        .map_err(Into::into)
2853    }
2854
2855    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2856        self.refresh_read_marker()?;
2857        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2858        self.ensure_ready(&conn)?;
2859        edge_snapshot_with_conn(&conn)
2860    }
2861
2862    pub fn indexed_file_count(&self) -> Result<usize> {
2863        self.refresh_read_marker()?;
2864        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2865        self.ensure_ready(&conn)?;
2866        indexed_file_count(&conn)
2867    }
2868
2869    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2870        self.refresh_read_marker()?;
2871        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2872        let rel_path = relative_path(&self.project_root, &abs_path);
2873        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2874        self.ensure_ready(&conn)?;
2875        resolve_node_for_rel(&conn, &rel_path, symbol)
2876    }
2877
2878    /// Return all positional nodes matching a legacy symbol query in a file.
2879    ///
2880    /// Consumers that need legacy compatibility can collapse these by
2881    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
2882    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2883        self.refresh_read_marker()?;
2884        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2885        let rel_path = relative_path(&self.project_root, &abs_path);
2886        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2887        self.ensure_ready(&conn)?;
2888        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2889    }
2890
2891    /// Return all positional nodes matching a symbol query anywhere in the store.
2892    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2893        self.refresh_read_marker()?;
2894        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2895        self.ensure_ready(&conn)?;
2896        nodes_matching_symbol(&conn, symbol)
2897    }
2898
2899    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
2900    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2901        self.refresh_read_marker()?;
2902        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2903        let rel_path = relative_path(&self.project_root, &abs_path);
2904        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2905        self.ensure_ready(&conn)?;
2906        direct_callers_for_tuple(&conn, &rel_path, symbol)
2907    }
2908
2909    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
2910    pub fn direct_caller_counts_of(
2911        &self,
2912        targets: &[(String, String)],
2913    ) -> Result<HashMap<(String, String), usize>> {
2914        if targets.is_empty() {
2915            return Ok(HashMap::new());
2916        }
2917        self.refresh_read_marker()?;
2918        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2919        self.ensure_ready(&conn)?;
2920        direct_caller_counts_for_tuples(&conn, targets)
2921    }
2922
2923    pub fn callers_of(
2924        &self,
2925        file_rel: &Path,
2926        symbol: &str,
2927        depth: usize,
2928    ) -> Result<StoreCallersResult> {
2929        let target = self.node_for(file_rel, symbol)?;
2930        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2931        self.ensure_ready(&conn)?;
2932        let effective_depth = depth.max(1);
2933        let mut visited = HashSet::new();
2934        let mut callers = Vec::new();
2935        let mut depth_limited = false;
2936        let mut truncated = 0usize;
2937        collect_callers_recursive(
2938            &conn,
2939            &target.file,
2940            &target.symbol,
2941            effective_depth,
2942            0,
2943            &mut visited,
2944            &mut callers,
2945            &mut depth_limited,
2946            &mut truncated,
2947        )?;
2948        Ok(StoreCallersResult {
2949            target,
2950            callers,
2951            scanned_files: indexed_file_count(&conn)?,
2952            depth_limited,
2953            truncated,
2954        })
2955    }
2956
2957    pub fn impact_of(
2958        &self,
2959        file_rel: &Path,
2960        symbol: &str,
2961        depth: usize,
2962    ) -> Result<StoreImpactResult> {
2963        let callers = self.callers_of(file_rel, symbol, depth)?;
2964        let target_parameters = callers
2965            .target
2966            .signature
2967            .as_deref()
2968            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2969            .unwrap_or_default();
2970        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2971        for site in &callers.callers {
2972            source_lines_by_file
2973                .entry(site.caller.file.clone())
2974                .or_insert_with(|| {
2975                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2976                });
2977        }
2978        let enriched = callers
2979            .callers
2980            .iter()
2981            .map(|site| StoreImpactCaller {
2982                site: site.clone(),
2983                signature: site.caller.signature.clone(),
2984                is_entry_point: site.caller.is_entry_point,
2985                call_expression: source_lines_by_file
2986                    .get(&site.caller.file)
2987                    .and_then(|lines| lines.as_ref())
2988                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2989                    .cloned(),
2990                parameters: site
2991                    .caller
2992                    .signature
2993                    .as_deref()
2994                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2995                    .unwrap_or_default(),
2996            })
2997            .collect();
2998        Ok(StoreImpactResult {
2999            target: callers.target,
3000            parameters: target_parameters,
3001            callers: enriched,
3002            depth_limited: callers.depth_limited,
3003            truncated: callers.truncated,
3004        })
3005    }
3006
3007    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3008        self.refresh_read_marker()?;
3009        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3010        self.ensure_ready(&conn)?;
3011        outgoing_calls_for_node(&conn, node)
3012    }
3013
3014    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3015    pub fn outgoing_calls_for_symbols(
3016        &self,
3017        sources: &[(String, String)],
3018    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3019        if sources.is_empty() {
3020            return Ok(HashMap::new());
3021        }
3022        self.refresh_read_marker()?;
3023        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3024        self.ensure_ready(&conn)?;
3025        outgoing_calls_for_symbol_tuples(&conn, sources)
3026    }
3027
3028    /// Return resolved direct self-call refs suppressed from the general edge table.
3029    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3030        self.refresh_read_marker()?;
3031        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3032        self.ensure_ready(&conn)?;
3033        resolved_self_calls_for_node(&conn, node)
3034    }
3035
3036    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3037        self.refresh_read_marker()?;
3038        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3039        self.ensure_ready(&conn)?;
3040        unresolved_calls_for_node(&conn, node)
3041    }
3042
3043    pub fn call_tree(
3044        &self,
3045        file_rel: &Path,
3046        symbol: &str,
3047        max_depth: usize,
3048    ) -> Result<callgraph::CallTreeNode> {
3049        let node = self.node_for(file_rel, symbol)?;
3050        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3051        self.ensure_ready(&conn)?;
3052        let mut visited = HashSet::new();
3053        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3054    }
3055
3056    pub fn trace_to(
3057        &self,
3058        file_rel: &Path,
3059        symbol: &str,
3060        max_depth: usize,
3061    ) -> Result<callgraph::TraceToResult> {
3062        let target = self.node_for(file_rel, symbol)?;
3063        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3064        self.ensure_ready(&conn)?;
3065        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3066
3067        #[derive(Clone)]
3068        struct PathElem {
3069            node: StoreNode,
3070        }
3071
3072        let initial = vec![PathElem {
3073            node: target.clone(),
3074        }];
3075        let mut complete_paths = Vec::new();
3076        if target.is_entry_point {
3077            complete_paths.push(initial.clone());
3078        }
3079
3080        let mut queue = vec![(initial, 0usize)];
3081        let mut max_depth_reached = false;
3082        let mut truncated_paths = 0usize;
3083
3084        while let Some((path, depth)) = queue.pop() {
3085            if depth >= effective_max {
3086                max_depth_reached = true;
3087                continue;
3088            }
3089            let Some(current) = path.last() else {
3090                continue;
3091            };
3092            let callers =
3093                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3094            if callers.is_empty() {
3095                if path.len() > 1 {
3096                    truncated_paths += 1;
3097                }
3098                continue;
3099            }
3100
3101            let mut has_new_path = false;
3102            for site in callers {
3103                if path.iter().any(|elem| {
3104                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3105                }) {
3106                    continue;
3107                }
3108                has_new_path = true;
3109                let mut new_path = path.clone();
3110                new_path.push(PathElem {
3111                    node: site.caller.clone(),
3112                });
3113                if site.caller.is_entry_point {
3114                    complete_paths.push(new_path.clone());
3115                }
3116                queue.push((new_path, depth + 1));
3117            }
3118            if !has_new_path && path.len() > 1 {
3119                truncated_paths += 1;
3120            }
3121        }
3122
3123        let mut paths: Vec<callgraph::TracePath> = complete_paths
3124            .into_iter()
3125            .map(|mut elems| {
3126                elems.reverse();
3127                let hops = elems
3128                    .iter()
3129                    .enumerate()
3130                    .map(|(index, elem)| callgraph::TraceHop {
3131                        symbol: elem.node.symbol.clone(),
3132                        file: elem.node.file.clone(),
3133                        line: elem.node.line,
3134                        signature: elem.node.signature.clone(),
3135                        is_entry_point: index == 0 && elem.node.is_entry_point,
3136                    })
3137                    .collect();
3138                callgraph::TracePath { hops }
3139            })
3140            .collect();
3141        paths.sort_by(|left, right| {
3142            let left_entry = left
3143                .hops
3144                .first()
3145                .map(|hop| hop.symbol.as_str())
3146                .unwrap_or("");
3147            let right_entry = right
3148                .hops
3149                .first()
3150                .map(|hop| hop.symbol.as_str())
3151                .unwrap_or("");
3152            left_entry
3153                .cmp(right_entry)
3154                .then(left.hops.len().cmp(&right.hops.len()))
3155        });
3156        let entry_points_found = paths
3157            .iter()
3158            .filter_map(|path| path.hops.first())
3159            .filter(|hop| hop.is_entry_point)
3160            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3161            .collect::<HashSet<_>>()
3162            .len();
3163
3164        Ok(callgraph::TraceToResult {
3165            target_symbol: target.symbol,
3166            target_file: target.file,
3167            total_paths: paths.len(),
3168            paths,
3169            entry_points_found,
3170            max_depth_reached,
3171            truncated_paths,
3172        })
3173    }
3174
3175    pub fn trace_to_symbol_candidates(
3176        &self,
3177        to_symbol: &str,
3178    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3179        self.refresh_read_marker()?;
3180        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3181        self.ensure_ready(&conn)?;
3182        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3183        for node in nodes_matching_symbol(&conn, to_symbol)? {
3184            candidates_by_file
3185                .entry(node.file)
3186                .and_modify(|line| *line = (*line).min(node.line))
3187                .or_insert(node.line);
3188        }
3189        let mut candidates: Vec<_> = candidates_by_file
3190            .into_iter()
3191            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3192            .collect();
3193        candidates
3194            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3195        Ok(candidates)
3196    }
3197
3198    pub fn trace_to_symbol(
3199        &self,
3200        file_rel: &Path,
3201        symbol: &str,
3202        to_symbol: &str,
3203        to_file: Option<&Path>,
3204        max_depth: usize,
3205    ) -> Result<callgraph::TraceToSymbolResult> {
3206        let origin = self.node_for(file_rel, symbol)?;
3207        let target_file = to_file
3208            .map(|path| normalize_file_path(&self.project_root, path))
3209            .transpose()?
3210            .map(|path| relative_path(&self.project_root, &path));
3211        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3212        self.ensure_ready(&conn)?;
3213        let effective_max = if max_depth == 0 {
3214            10
3215        } else {
3216            max_depth.min(16)
3217        };
3218
3219        let start_hop = trace_to_symbol_hop(&origin);
3220        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3221            return Ok(callgraph::TraceToSymbolResult {
3222                path: Some(vec![start_hop]),
3223                complete: true,
3224                reason: None,
3225            });
3226        }
3227
3228        let mut queue = VecDeque::new();
3229        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3230        let mut visited = HashSet::new();
3231        visited.insert((origin.file.clone(), origin.symbol.clone()));
3232        let mut max_depth_exhausted = false;
3233
3234        while let Some((current, path, depth)) = queue.pop_front() {
3235            let callees = outgoing_calls_for_node(&conn, &current)?
3236                .into_iter()
3237                .filter_map(|site| site.target)
3238                .collect::<Vec<_>>();
3239
3240            if depth >= effective_max {
3241                if callees
3242                    .iter()
3243                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3244                {
3245                    max_depth_exhausted = true;
3246                }
3247                continue;
3248            }
3249
3250            for callee in callees {
3251                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3252                    continue;
3253                }
3254                let mut next_path = path.clone();
3255                next_path.push(trace_to_symbol_hop(&callee));
3256                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3257                    return Ok(callgraph::TraceToSymbolResult {
3258                        path: Some(next_path),
3259                        complete: true,
3260                        reason: None,
3261                    });
3262                }
3263                queue.push_back((callee, next_path, depth + 1));
3264            }
3265        }
3266
3267        if max_depth_exhausted {
3268            Ok(callgraph::TraceToSymbolResult {
3269                path: None,
3270                complete: false,
3271                reason: Some("max_depth_exhausted".to_string()),
3272            })
3273        } else {
3274            Ok(callgraph::TraceToSymbolResult {
3275                path: None,
3276                complete: true,
3277                reason: Some("no_path_found".to_string()),
3278            })
3279        }
3280    }
3281}
3282
3283impl ReadonlyCallGraphStore {
3284    fn from_inner(inner: CallGraphStore) -> Self {
3285        Self { inner }
3286    }
3287
3288    fn into_inner(self) -> CallGraphStore {
3289        self.inner
3290    }
3291
3292    pub fn project_root(&self) -> &Path {
3293        self.inner.project_root()
3294    }
3295
3296    pub fn project_key(&self) -> &str {
3297        self.inner.project_key()
3298    }
3299
3300    pub fn sqlite_path(&self) -> &Path {
3301        self.inner.sqlite_path()
3302    }
3303
3304    /// Report the open generation handle. SQLite-owned allocations are measured
3305    /// once by the process-wide SQLite allocator counters.
3306    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3307        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3308    }
3309
3310    /// Whether this reader is temporarily serving a legacy harness partition.
3311    pub fn is_legacy_fallback(&self) -> bool {
3312        self.inner.is_legacy_fallback()
3313    }
3314
3315    pub fn is_current(&self) -> bool {
3316        self.inner.is_current()
3317    }
3318
3319    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3320        self.inner.edge_snapshot()
3321    }
3322
3323    pub fn indexed_file_count(&self) -> Result<usize> {
3324        self.inner.indexed_file_count()
3325    }
3326
3327    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3328        self.inner.node_for(file_rel, symbol)
3329    }
3330
3331    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3332        self.inner.nodes_for(file_rel, symbol)
3333    }
3334
3335    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3336        self.inner.nodes_matching(symbol)
3337    }
3338
3339    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3340        self.inner.direct_callers_of(file_rel, symbol)
3341    }
3342
3343    pub fn direct_caller_counts_of(
3344        &self,
3345        targets: &[(String, String)],
3346    ) -> Result<HashMap<(String, String), usize>> {
3347        self.inner.direct_caller_counts_of(targets)
3348    }
3349
3350    pub fn callers_of(
3351        &self,
3352        file_rel: &Path,
3353        symbol: &str,
3354        depth: usize,
3355    ) -> Result<StoreCallersResult> {
3356        self.inner.callers_of(file_rel, symbol, depth)
3357    }
3358
3359    pub fn impact_of(
3360        &self,
3361        file_rel: &Path,
3362        symbol: &str,
3363        depth: usize,
3364    ) -> Result<StoreImpactResult> {
3365        self.inner.impact_of(file_rel, symbol, depth)
3366    }
3367
3368    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3369        self.inner.outgoing_calls_of(node)
3370    }
3371
3372    pub fn outgoing_calls_for_symbols(
3373        &self,
3374        sources: &[(String, String)],
3375    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3376        self.inner.outgoing_calls_for_symbols(sources)
3377    }
3378
3379    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3380        self.inner.resolved_self_calls_of(node)
3381    }
3382
3383    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3384        self.inner.unresolved_calls_of(node)
3385    }
3386
3387    pub fn call_tree(
3388        &self,
3389        file_rel: &Path,
3390        symbol: &str,
3391        depth: usize,
3392    ) -> Result<callgraph::CallTreeNode> {
3393        self.inner.call_tree(file_rel, symbol, depth)
3394    }
3395
3396    pub fn trace_to(
3397        &self,
3398        file_rel: &Path,
3399        symbol: &str,
3400        max_depth: usize,
3401    ) -> Result<callgraph::TraceToResult> {
3402        self.inner.trace_to(file_rel, symbol, max_depth)
3403    }
3404
3405    pub fn trace_to_symbol_candidates(
3406        &self,
3407        to_symbol: &str,
3408    ) -> Result<Vec<TraceToSymbolCandidate>> {
3409        self.inner.trace_to_symbol_candidates(to_symbol)
3410    }
3411
3412    pub fn trace_to_symbol(
3413        &self,
3414        file_rel: &Path,
3415        symbol: &str,
3416        to_symbol: &str,
3417        to_file: Option<&Path>,
3418        max_depth: usize,
3419    ) -> Result<callgraph::TraceToSymbolResult> {
3420        self.inner
3421            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3422    }
3423}
3424
3425impl CallGraphRead for CallGraphStore {
3426    fn project_root(&self) -> &Path {
3427        CallGraphStore::project_root(self)
3428    }
3429    fn project_key(&self) -> &str {
3430        CallGraphStore::project_key(self)
3431    }
3432    fn sqlite_path(&self) -> &Path {
3433        CallGraphStore::sqlite_path(self)
3434    }
3435    fn is_current(&self) -> bool {
3436        CallGraphStore::is_current(self)
3437    }
3438    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3439        CallGraphStore::edge_snapshot(self)
3440    }
3441    fn indexed_file_count(&self) -> Result<usize> {
3442        CallGraphStore::indexed_file_count(self)
3443    }
3444    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3445        CallGraphStore::node_for(self, file_rel, symbol)
3446    }
3447    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3448        CallGraphStore::nodes_for(self, file_rel, symbol)
3449    }
3450    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3451        CallGraphStore::nodes_matching(self, symbol)
3452    }
3453    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3454        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3455    }
3456    fn direct_caller_counts_of(
3457        &self,
3458        targets: &[(String, String)],
3459    ) -> Result<HashMap<(String, String), usize>> {
3460        CallGraphStore::direct_caller_counts_of(self, targets)
3461    }
3462    fn callers_of(
3463        &self,
3464        file_rel: &Path,
3465        symbol: &str,
3466        depth: usize,
3467    ) -> Result<StoreCallersResult> {
3468        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3469    }
3470    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3471        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3472    }
3473    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3474        CallGraphStore::outgoing_calls_of(self, node)
3475    }
3476    fn outgoing_calls_for_symbols(
3477        &self,
3478        sources: &[(String, String)],
3479    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3480        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3481    }
3482    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3483        CallGraphStore::resolved_self_calls_of(self, node)
3484    }
3485    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3486        CallGraphStore::unresolved_calls_of(self, node)
3487    }
3488    fn call_tree(
3489        &self,
3490        file_rel: &Path,
3491        symbol: &str,
3492        depth: usize,
3493    ) -> Result<callgraph::CallTreeNode> {
3494        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3495    }
3496    fn trace_to(
3497        &self,
3498        file_rel: &Path,
3499        symbol: &str,
3500        max_depth: usize,
3501    ) -> Result<callgraph::TraceToResult> {
3502        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3503    }
3504    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3505        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3506    }
3507    fn trace_to_symbol(
3508        &self,
3509        file_rel: &Path,
3510        symbol: &str,
3511        to_symbol: &str,
3512        to_file: Option<&Path>,
3513        max_depth: usize,
3514    ) -> Result<callgraph::TraceToSymbolResult> {
3515        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3516    }
3517}
3518
3519impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3520    fn project_root(&self) -> &Path {
3521        (**self).project_root()
3522    }
3523    fn project_key(&self) -> &str {
3524        (**self).project_key()
3525    }
3526    fn sqlite_path(&self) -> &Path {
3527        (**self).sqlite_path()
3528    }
3529    fn is_current(&self) -> bool {
3530        (**self).is_current()
3531    }
3532    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3533        (**self).edge_snapshot()
3534    }
3535    fn indexed_file_count(&self) -> Result<usize> {
3536        (**self).indexed_file_count()
3537    }
3538    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3539        (**self).node_for(file_rel, symbol)
3540    }
3541    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3542        (**self).nodes_for(file_rel, symbol)
3543    }
3544    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3545        (**self).nodes_matching(symbol)
3546    }
3547    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3548        (**self).direct_callers_of(file_rel, symbol)
3549    }
3550    fn direct_caller_counts_of(
3551        &self,
3552        targets: &[(String, String)],
3553    ) -> Result<HashMap<(String, String), usize>> {
3554        (**self).direct_caller_counts_of(targets)
3555    }
3556    fn callers_of(
3557        &self,
3558        file_rel: &Path,
3559        symbol: &str,
3560        depth: usize,
3561    ) -> Result<StoreCallersResult> {
3562        (**self).callers_of(file_rel, symbol, depth)
3563    }
3564    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3565        (**self).impact_of(file_rel, symbol, depth)
3566    }
3567    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3568        (**self).outgoing_calls_of(node)
3569    }
3570    fn outgoing_calls_for_symbols(
3571        &self,
3572        sources: &[(String, String)],
3573    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3574        (**self).outgoing_calls_for_symbols(sources)
3575    }
3576    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3577        (**self).resolved_self_calls_of(node)
3578    }
3579    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3580        (**self).unresolved_calls_of(node)
3581    }
3582    fn call_tree(
3583        &self,
3584        file_rel: &Path,
3585        symbol: &str,
3586        depth: usize,
3587    ) -> Result<callgraph::CallTreeNode> {
3588        (**self).call_tree(file_rel, symbol, depth)
3589    }
3590    fn trace_to(
3591        &self,
3592        file_rel: &Path,
3593        symbol: &str,
3594        max_depth: usize,
3595    ) -> Result<callgraph::TraceToResult> {
3596        (**self).trace_to(file_rel, symbol, max_depth)
3597    }
3598    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3599        (**self).trace_to_symbol_candidates(to_symbol)
3600    }
3601    fn trace_to_symbol(
3602        &self,
3603        file_rel: &Path,
3604        symbol: &str,
3605        to_symbol: &str,
3606        to_file: Option<&Path>,
3607        max_depth: usize,
3608    ) -> Result<callgraph::TraceToSymbolResult> {
3609        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3610    }
3611}
3612
3613impl CallGraphRead for ReadonlyCallGraphStore {
3614    fn project_root(&self) -> &Path {
3615        self.project_root()
3616    }
3617    fn project_key(&self) -> &str {
3618        self.project_key()
3619    }
3620    fn sqlite_path(&self) -> &Path {
3621        self.sqlite_path()
3622    }
3623    fn is_current(&self) -> bool {
3624        self.is_current()
3625    }
3626    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3627        self.edge_snapshot()
3628    }
3629    fn indexed_file_count(&self) -> Result<usize> {
3630        self.indexed_file_count()
3631    }
3632    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3633        self.node_for(file_rel, symbol)
3634    }
3635    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3636        self.nodes_for(file_rel, symbol)
3637    }
3638    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3639        self.nodes_matching(symbol)
3640    }
3641    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3642        self.direct_callers_of(file_rel, symbol)
3643    }
3644    fn direct_caller_counts_of(
3645        &self,
3646        targets: &[(String, String)],
3647    ) -> Result<HashMap<(String, String), usize>> {
3648        self.direct_caller_counts_of(targets)
3649    }
3650    fn callers_of(
3651        &self,
3652        file_rel: &Path,
3653        symbol: &str,
3654        depth: usize,
3655    ) -> Result<StoreCallersResult> {
3656        self.callers_of(file_rel, symbol, depth)
3657    }
3658    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3659        self.impact_of(file_rel, symbol, depth)
3660    }
3661    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3662        self.outgoing_calls_of(node)
3663    }
3664    fn outgoing_calls_for_symbols(
3665        &self,
3666        sources: &[(String, String)],
3667    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3668        self.outgoing_calls_for_symbols(sources)
3669    }
3670    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3671        self.resolved_self_calls_of(node)
3672    }
3673    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3674        self.unresolved_calls_of(node)
3675    }
3676    fn call_tree(
3677        &self,
3678        file_rel: &Path,
3679        symbol: &str,
3680        depth: usize,
3681    ) -> Result<callgraph::CallTreeNode> {
3682        self.call_tree(file_rel, symbol, depth)
3683    }
3684    fn trace_to(
3685        &self,
3686        file_rel: &Path,
3687        symbol: &str,
3688        max_depth: usize,
3689    ) -> Result<callgraph::TraceToResult> {
3690        self.trace_to(file_rel, symbol, max_depth)
3691    }
3692    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3693        self.trace_to_symbol_candidates(to_symbol)
3694    }
3695    fn trace_to_symbol(
3696        &self,
3697        file_rel: &Path,
3698        symbol: &str,
3699        to_symbol: &str,
3700        to_file: Option<&Path>,
3701        max_depth: usize,
3702    ) -> Result<callgraph::TraceToSymbolResult> {
3703        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3704    }
3705}
3706
3707fn indexed_file_count(conn: &Connection) -> Result<usize> {
3708    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3709    Ok(count.max(0) as usize)
3710}
3711
3712fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3713    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3714    match candidates.as_slice() {
3715        [candidate] => Ok(candidate.clone()),
3716        [] => Err(AftError::SymbolNotFound {
3717            name: symbol.to_string(),
3718            file: rel_path.to_string(),
3719        }
3720        .into()),
3721        _ => Err(AftError::AmbiguousSymbol {
3722            name: symbol.to_string(),
3723            candidates: candidates
3724                .iter()
3725                .map(|candidate| candidate.symbol.clone())
3726                .collect(),
3727        }
3728        .into()),
3729    }
3730}
3731
3732fn nodes_for_file_matching_symbol(
3733    conn: &Connection,
3734    rel_path: &str,
3735    symbol: &str,
3736) -> Result<Vec<StoreNode>> {
3737    let qualified_query = symbol.contains("::");
3738    let sql = if qualified_query {
3739        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3740                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3741         FROM nodes n JOIN files f ON f.path = n.file_path
3742         WHERE n.file_path = ?1 AND n.scoped_name = ?2
3743         ORDER BY n.scoped_name, n.start_line, n.start_col"
3744    } else {
3745        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3746                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3747         FROM nodes n JOIN files f ON f.path = n.file_path
3748         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3749         ORDER BY n.scoped_name, n.start_line, n.start_col"
3750    };
3751    let mut stmt = conn.prepare(sql)?;
3752    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3753    rows.collect::<std::result::Result<Vec<_>, _>>()
3754        .map_err(Into::into)
3755}
3756
3757fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3758    let qualified_query = symbol.contains("::");
3759    let sql = if qualified_query {
3760        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3761                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3762         FROM nodes n JOIN files f ON f.path = n.file_path
3763         WHERE n.scoped_name = ?1
3764         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3765    } else {
3766        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3767                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3768         FROM nodes n JOIN files f ON f.path = n.file_path
3769         WHERE n.scoped_name = ?1 OR n.name = ?1
3770         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3771    };
3772    let mut stmt = conn.prepare(sql)?;
3773    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3774    rows.collect::<std::result::Result<Vec<_>, _>>()
3775        .map_err(Into::into)
3776}
3777
3778fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3779    store_node_from_row_at(row, 0)
3780}
3781
3782fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3783    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3784    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3785    let lang_label_value: String = row.get(offset + 10)?;
3786    Ok(StoreNode {
3787        node_id: row.get(offset)?,
3788        file: row.get(offset + 1)?,
3789        symbol: row.get(offset + 2)?,
3790        name: row.get(offset + 3)?,
3791        kind: row.get(offset + 4)?,
3792        line: start_line.saturating_add(1),
3793        end_line: end_line.saturating_add(1),
3794        signature: row.get(offset + 7)?,
3795        exported: row.get::<_, i64>(offset + 8)? != 0,
3796        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3797        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3798    })
3799}
3800
3801fn optional_store_node_from_row_at(
3802    row: &rusqlite::Row<'_>,
3803    offset: usize,
3804) -> rusqlite::Result<Option<StoreNode>> {
3805    if row.get::<_, Option<String>>(offset)?.is_some() {
3806        store_node_from_row_at(row, offset).map(Some)
3807    } else {
3808        Ok(None)
3809    }
3810}
3811
3812#[allow(clippy::too_many_arguments)]
3813fn collect_callers_recursive(
3814    conn: &Connection,
3815    file: &str,
3816    symbol: &str,
3817    max_depth: usize,
3818    current_depth: usize,
3819    visited: &mut HashSet<(String, String)>,
3820    result: &mut Vec<StoreCallSite>,
3821    depth_limited: &mut bool,
3822    truncated: &mut usize,
3823) -> Result<()> {
3824    if current_depth >= max_depth {
3825        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3826        if omitted > 0 {
3827            *depth_limited = true;
3828            *truncated += omitted;
3829        }
3830        return Ok(());
3831    }
3832
3833    if !visited.insert((file.to_string(), symbol.to_string())) {
3834        return Ok(());
3835    }
3836
3837    let sites = direct_callers_for_tuple(conn, file, symbol)?;
3838    for site in sites {
3839        result.push(site.clone());
3840        if current_depth + 1 < max_depth {
3841            collect_callers_recursive(
3842                conn,
3843                &site.caller.file,
3844                &site.caller.symbol,
3845                max_depth,
3846                current_depth + 1,
3847                visited,
3848                result,
3849                depth_limited,
3850                truncated,
3851            )?;
3852        } else {
3853            let omitted =
3854                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3855            if omitted > 0 {
3856                *depth_limited = true;
3857                *truncated += omitted;
3858            }
3859        }
3860    }
3861    Ok(())
3862}
3863
3864// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
3865const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
3866
3867fn direct_caller_counts_for_tuples(
3868    conn: &Connection,
3869    targets: &[(String, String)],
3870) -> Result<HashMap<(String, String), usize>> {
3871    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
3872    let mut counts = unique_targets
3873        .iter()
3874        .cloned()
3875        .map(|target| (target, 0usize))
3876        .collect::<HashMap<_, _>>();
3877
3878    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
3879    for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
3880        let requested_values = (0..chunk.len())
3881            .map(|_| "(?, ?)")
3882            .collect::<Vec<_>>()
3883            .join(", ");
3884        let sql = format!(
3885            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
3886             deduped AS (
3887                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
3888                 FROM requested requested
3889                 JOIN edges e
3890                   ON e.target_file = requested.target_file
3891                  AND e.target_symbol = requested.target_symbol
3892                  AND e.kind = 'call'
3893                 JOIN refs r ON r.ref_id = e.ref_id
3894                 JOIN nodes src ON src.id = e.source_node
3895                 JOIN files src_file ON src_file.path = src.file_path
3896                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
3897             )
3898             SELECT target_file, target_symbol, COUNT(*)
3899             FROM deduped
3900             GROUP BY target_file, target_symbol"
3901        );
3902        let bindings = chunk
3903            .iter()
3904            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3905        let mut stmt = conn.prepare(&sql)?;
3906        let rows = stmt.query_map(params_from_iter(bindings), |row| {
3907            Ok((
3908                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3909                row.get::<_, i64>(2)?,
3910            ))
3911        })?;
3912        for row in rows {
3913            let (target, count) = row?;
3914            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
3915        }
3916    }
3917
3918    Ok(counts)
3919}
3920
3921fn direct_caller_count_for_tuple(
3922    conn: &Connection,
3923    target_file: &str,
3924    target_symbol: &str,
3925) -> Result<usize> {
3926    let count: i64 = conn.query_row(
3927        "SELECT COUNT(*)
3928         FROM edges e
3929         JOIN refs r ON r.ref_id = e.ref_id
3930         JOIN nodes src ON src.id = e.source_node
3931         JOIN files src_file ON src_file.path = src.file_path
3932         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3933        params![target_file, target_symbol],
3934        |row| row.get(0),
3935    )?;
3936    Ok(usize::try_from(count).unwrap_or(usize::MAX))
3937}
3938
3939fn direct_callers_for_tuple(
3940    conn: &Connection,
3941    target_file: &str,
3942    target_symbol: &str,
3943) -> Result<Vec<StoreCallSite>> {
3944    let mut stmt = conn.prepare(
3945        "SELECT e.target_file, e.target_symbol, e.line,
3946                r.byte_start, r.byte_end, r.status, e.provenance,
3947                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3948                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3949                src_file.lang,
3950                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3951                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3952                tgt_file.lang
3953         FROM edges e
3954         JOIN refs r ON r.ref_id = e.ref_id
3955         JOIN nodes src ON src.id = e.source_node
3956         JOIN files src_file ON src_file.path = src.file_path
3957         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3958             ON tgt.id = e.target_node
3959         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3960         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3961    )?;
3962    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3963        let caller = store_node_from_row_at(row, 7)?;
3964        let target = optional_store_node_from_row_at(row, 18)?;
3965        Ok(StoreCallSite {
3966            caller,
3967            target_file: row.get(0)?,
3968            target_symbol: row.get(1)?,
3969            target,
3970            line: row.get::<_, i64>(2)?.max(0) as u32,
3971            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3972            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3973            resolved: row.get::<_, String>(5)? == "resolved",
3974            provenance: row.get(6)?,
3975        })
3976    })?;
3977    rows.collect::<std::result::Result<Vec<_>, _>>()
3978        .map_err(Into::into)
3979}
3980
3981// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
3982const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
3983// Outgoing-edge batches bind one source node per parameter.
3984const OUTGOING_NODE_BATCH_SIZE: usize = 999;
3985
3986fn outgoing_calls_for_symbol_tuples(
3987    conn: &Connection,
3988    sources: &[(String, String)],
3989) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3990    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
3991    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
3992    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
3993    let source_nodes = unique_sources
3994        .iter()
3995        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
3996        .cloned()
3997        .collect::<Vec<_>>();
3998    let source_nodes_by_id = source_nodes
3999        .iter()
4000        .cloned()
4001        .map(|node| (node.node_id.clone(), node))
4002        .collect::<HashMap<_, _>>();
4003    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4004
4005    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4006        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4007        let sql = format!(
4008            "SELECT e.source_node,
4009                    e.target_file, e.target_symbol, e.line,
4010                    r.byte_start, r.byte_end, r.status, e.provenance,
4011                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4012                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4013                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4014                    tgt_file.lang
4015             FROM edges e
4016             JOIN refs r ON r.ref_id = e.ref_id
4017             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4018             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4019             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4020             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4021        );
4022        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4023        let mut stmt = conn.prepare(&sql)?;
4024        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4025            let source_node_id = row.get::<_, String>(0)?;
4026            let caller = source_nodes_by_id
4027                .get(&source_node_id)
4028                .expect("batched outgoing row belongs to a requested source node")
4029                .clone();
4030            let target = optional_store_node_from_row_at(row, 8)?;
4031            Ok((
4032                source_node_id,
4033                StoreCallSite {
4034                    caller,
4035                    target_file: row.get(1)?,
4036                    target_symbol: row.get(2)?,
4037                    target,
4038                    line: row.get::<_, i64>(3)?.max(0) as u32,
4039                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4040                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4041                    resolved: row.get::<_, String>(6)? == "resolved",
4042                    provenance: row.get(7)?,
4043                },
4044            ))
4045        })?;
4046        for row in rows {
4047            let (source_node_id, call) = row?;
4048            calls_by_node.entry(source_node_id).or_default().push(call);
4049        }
4050    }
4051
4052    let mut calls_by_source = HashMap::new();
4053    for source in &unique_sources {
4054        let mut calls = Vec::new();
4055        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4056            for node in nodes {
4057                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4058                    calls.extend(node_calls);
4059                }
4060            }
4061        }
4062        calls_by_source.insert(source.clone(), calls);
4063    }
4064
4065    // Resolve each logical target once for the whole frontier. Keeping this separate
4066    // preserves positional-symbol representatives without a correlated lookup per edge.
4067    let target_tuples = calls_by_source
4068        .values()
4069        .flatten()
4070        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4071        .collect::<Vec<_>>();
4072    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4073    for calls in calls_by_source.values_mut() {
4074        for call in calls {
4075            if let Some(target) = target_nodes
4076                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4077                .and_then(|nodes| nodes.first())
4078            {
4079                call.target = Some(target.clone());
4080            }
4081        }
4082    }
4083
4084    Ok(calls_by_source)
4085}
4086
4087fn nodes_for_symbol_tuples(
4088    conn: &Connection,
4089    symbols: &[(String, String)],
4090) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4091    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4092    let mut nodes_by_symbol = unique_symbols
4093        .iter()
4094        .cloned()
4095        .map(|symbol| (symbol, Vec::new()))
4096        .collect::<HashMap<_, _>>();
4097    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4098
4099    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4100        let requested_values = (0..chunk.len())
4101            .map(|_| "(?, ?)")
4102            .collect::<Vec<_>>()
4103            .join(", ");
4104        let sql = format!(
4105            "WITH requested(file, symbol) AS (VALUES {requested_values})
4106             SELECT requested.file, requested.symbol,
4107                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4108                    node.start_line, node.end_line, node.signature, node.exported,
4109                    node.is_callgraph_entry_point, node_file.lang
4110             FROM requested
4111             JOIN nodes node INDEXED BY idx_nodes_file
4112               ON node.file_path = requested.file
4113              AND node.scoped_name = requested.symbol
4114             JOIN files node_file ON node_file.path = node.file_path
4115             ORDER BY requested.file, requested.symbol,
4116                      node.scoped_name, node.start_line, node.end_line,
4117                      node.start_col, node.range_ordinal"
4118        );
4119        let bindings = chunk
4120            .iter()
4121            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4122        let mut stmt = conn.prepare(&sql)?;
4123        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4124            Ok((
4125                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4126                store_node_from_row_at(row, 2)?,
4127            ))
4128        })?;
4129        for row in rows {
4130            let (symbol, node) = row?;
4131            nodes_by_symbol.entry(symbol).or_default().push(node);
4132        }
4133    }
4134
4135    Ok(nodes_by_symbol)
4136}
4137
4138fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4139    let mut stmt = conn.prepare(
4140        "SELECT e.target_file, e.target_symbol, e.line,
4141                r.byte_start, r.byte_end, r.status, e.provenance,
4142                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4143                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4144                tgt_file.lang
4145         FROM edges e
4146         JOIN refs r ON r.ref_id = e.ref_id
4147         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4148             ON tgt.id = e.target_node
4149         WHERE e.kind = 'call' AND e.source_node = ?1
4150         ORDER BY r.byte_start, r.line, r.ref_id",
4151    )?;
4152    let rows = stmt.query_map(params![node.node_id], |row| {
4153        let target = optional_store_node_from_row_at(row, 7)?;
4154        Ok(StoreCallSite {
4155            caller: node.clone(),
4156            target_file: row.get(0)?,
4157            target_symbol: row.get(1)?,
4158            target,
4159            line: row.get::<_, i64>(2)?.max(0) as u32,
4160            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4161            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4162            resolved: row.get::<_, String>(5)? == "resolved",
4163            provenance: row.get(6)?,
4164        })
4165    })?;
4166    rows.collect::<std::result::Result<Vec<_>, _>>()
4167        .map_err(Into::into)
4168}
4169
4170fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4171    let mut stmt = conn.prepare(
4172        "SELECT r.target_file, r.target_symbol, r.line,
4173                r.byte_start, r.byte_end, r.status, r.provenance,
4174                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4175                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4176                tgt_file.lang
4177         FROM refs r
4178         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4179             ON tgt.id = r.target_node
4180         WHERE r.caller_node = ?1
4181           AND r.kind = 'call'
4182           AND r.status <> 'unresolved'
4183           AND r.target_file = ?2
4184           AND r.target_symbol = ?3
4185           AND r.provenance = ?4
4186           AND NOT EXISTS (
4187               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4188           )
4189         ORDER BY r.byte_start, r.line, r.ref_id",
4190    )?;
4191    let rows = stmt.query_map(
4192        params![
4193            &node.node_id,
4194            &node.file,
4195            &node.symbol,
4196            PROVENANCE_TREESITTER
4197        ],
4198        |row| {
4199            let target = optional_store_node_from_row_at(row, 7)?;
4200            Ok(StoreCallSite {
4201                caller: node.clone(),
4202                target_file: row.get(0)?,
4203                target_symbol: row.get(1)?,
4204                target,
4205                line: row.get::<_, i64>(2)?.max(0) as u32,
4206                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4207                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4208                resolved: row.get::<_, String>(5)? == "resolved",
4209                provenance: row.get(6)?,
4210            })
4211        },
4212    )?;
4213    rows.collect::<std::result::Result<Vec<_>, _>>()
4214        .map_err(Into::into)
4215}
4216
4217fn unresolved_calls_for_node(
4218    conn: &Connection,
4219    node: &StoreNode,
4220) -> Result<Vec<StoreUnresolvedCall>> {
4221    let mut stmt = conn.prepare(
4222        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4223         FROM refs
4224         WHERE caller_node = ?1
4225           AND kind = 'call'
4226           AND status = 'unresolved'
4227           AND NOT EXISTS (
4228               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4229           )
4230         ORDER BY byte_start, line, ref_id",
4231    )?;
4232    let rows = stmt.query_map(params![node.node_id], |row| {
4233        Ok(StoreUnresolvedCall {
4234            caller: node.clone(),
4235            symbol: row.get(0)?,
4236            full_ref: row.get(1)?,
4237            line: row.get::<_, i64>(2)?.max(0) as u32,
4238            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4239            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4240        })
4241    })?;
4242    rows.collect::<std::result::Result<Vec<_>, _>>()
4243        .map_err(Into::into)
4244}
4245
4246fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4247    let mut calls = Vec::new();
4248    calls.extend(
4249        outgoing_calls_for_node(conn, node)?
4250            .into_iter()
4251            .map(StoreForwardCall::Resolved),
4252    );
4253    calls.extend(
4254        unresolved_calls_for_node(conn, node)?
4255            .into_iter()
4256            .map(StoreForwardCall::Unresolved),
4257    );
4258    calls.sort_by(|left, right| {
4259        left.byte_start()
4260            .cmp(&right.byte_start())
4261            .then(left.line().cmp(&right.line()))
4262    });
4263    Ok(calls)
4264}
4265
4266fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4267    let resolved_count: i64 = conn.query_row(
4268        "SELECT COUNT(*)
4269         FROM edges e
4270         JOIN refs r ON r.ref_id = e.ref_id
4271         WHERE e.kind = 'call' AND e.source_node = ?1",
4272        params![&node.node_id],
4273        |row| row.get(0),
4274    )?;
4275    let unresolved_count: i64 = conn.query_row(
4276        "SELECT COUNT(*)
4277         FROM refs
4278         WHERE caller_node = ?1
4279           AND kind = 'call'
4280           AND status = 'unresolved'
4281           AND NOT EXISTS (
4282               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4283           )",
4284        params![&node.node_id],
4285        |row| row.get(0),
4286    )?;
4287    let total = resolved_count.saturating_add(unresolved_count);
4288    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4289}
4290
4291fn call_tree_inner(
4292    conn: &Connection,
4293    node: &StoreNode,
4294    max_depth: usize,
4295    current_depth: usize,
4296    visited: &mut HashSet<(String, String)>,
4297) -> Result<callgraph::CallTreeNode> {
4298    let visit_key = (node.file.clone(), node.symbol.clone());
4299    if visited.contains(&visit_key) {
4300        return Ok(callgraph::CallTreeNode {
4301            name: node.symbol.clone(),
4302            file: node.file.clone(),
4303            line: node.line,
4304            signature: node.signature.clone(),
4305            resolved: true,
4306            children: Vec::new(),
4307            depth_limited: false,
4308            truncated: 0,
4309        });
4310    }
4311    visited.insert(visit_key.clone());
4312
4313    let mut children = Vec::new();
4314    let mut depth_limited = false;
4315    let mut truncated = 0usize;
4316
4317    if current_depth < max_depth {
4318        let calls = forward_calls_for_node(conn, node)?;
4319        for call in calls {
4320            match call {
4321                StoreForwardCall::Resolved(site) => {
4322                    if let Some(target) = site.target {
4323                        let child =
4324                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4325                        depth_limited |= child.depth_limited;
4326                        truncated += child.truncated;
4327                        children.push(child);
4328                    } else {
4329                        children.push(callgraph::CallTreeNode {
4330                            name: site.target_symbol,
4331                            file: site.target_file,
4332                            line: site.line,
4333                            signature: None,
4334                            resolved: false,
4335                            children: Vec::new(),
4336                            depth_limited: false,
4337                            truncated: 0,
4338                        });
4339                    }
4340                }
4341                StoreForwardCall::Unresolved(call) => {
4342                    children.push(callgraph::CallTreeNode {
4343                        name: call.symbol,
4344                        file: call.caller.file,
4345                        line: call.line,
4346                        signature: None,
4347                        resolved: false,
4348                        children: Vec::new(),
4349                        depth_limited: false,
4350                        truncated: 0,
4351                    });
4352                }
4353            }
4354        }
4355    } else {
4356        truncated = forward_call_count_for_node(conn, node)?;
4357        depth_limited = truncated > 0;
4358    }
4359
4360    visited.remove(&visit_key);
4361    Ok(callgraph::CallTreeNode {
4362        name: node.symbol.clone(),
4363        file: node.file.clone(),
4364        line: node.line,
4365        signature: node.signature.clone(),
4366        resolved: true,
4367        children,
4368        depth_limited,
4369        truncated,
4370    })
4371}
4372
4373fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4374    callgraph::TraceToSymbolHop {
4375        symbol: node.symbol.clone(),
4376        file: node.file.clone(),
4377        line: node.line,
4378    }
4379}
4380
4381fn trace_to_symbol_matches_target(
4382    node: &StoreNode,
4383    to_symbol: &str,
4384    to_file: Option<&str>,
4385) -> bool {
4386    if !symbol_query_matches(&node.symbol, to_symbol) {
4387        return false;
4388    }
4389    match to_file {
4390        Some(file) => node.file == file,
4391        None => true,
4392    }
4393}
4394
4395fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4396    symbol == query || unqualified_name(symbol) == query
4397}
4398
4399fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4400    let source = std::fs::read_to_string(path).ok()?;
4401    Some(source.lines().map(|line| line.trim().to_string()).collect())
4402}
4403
4404#[doc(hidden)]
4405pub fn live_callgraph_edge_snapshot(
4406    project_root: &Path,
4407    files: &[PathBuf],
4408) -> Result<BTreeSet<StoredEdge>> {
4409    let files = normalize_file_list(project_root, files)?;
4410    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4411    let mut file_data = Vec::new();
4412    for file in &files {
4413        let canon = canonicalize_path(file);
4414        let data = graph.build_file(&canon)?.clone();
4415        file_data.push((canon, data));
4416    }
4417
4418    let mut edges = BTreeSet::new();
4419    for (caller_file, data) in &file_data {
4420        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4421            for call_site in call_sites {
4422                let resolution = graph.resolve_cross_file_edge(
4423                    &call_site.full_callee,
4424                    &call_site.callee_name,
4425                    caller_file,
4426                    &data.import_block,
4427                );
4428                let (target_file, target_symbol) = match resolution {
4429                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4430                    EdgeResolution::Unresolved { callee_name } => {
4431                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4432                            continue;
4433                        }
4434                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4435                            data,
4436                            caller_file,
4437                            &callee_name,
4438                        ) else {
4439                            continue;
4440                        };
4441                        (caller_file.clone(), target_symbol)
4442                    }
4443                };
4444                if target_file == *caller_file && target_symbol == *caller_symbol {
4445                    continue;
4446                }
4447                edges.insert(StoredEdge {
4448                    source_file: relative_path(project_root, caller_file),
4449                    source_symbol: caller_symbol.clone(),
4450                    target_file: relative_path(project_root, &target_file),
4451                    target_symbol,
4452                    kind: "call".to_string(),
4453                    line: call_site.line,
4454                });
4455            }
4456        }
4457    }
4458    Ok(edges)
4459}
4460
4461fn acquire_writer_lease(
4462    callgraph_dir: &Path,
4463    project_key: &str,
4464    project_root: &Path,
4465) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4466    crate::root_cache::WriterLease::acquire_shared(
4467        crate::root_cache::RootCacheDomain::Callgraph,
4468        callgraph_dir,
4469        project_key,
4470        project_root,
4471    )
4472    .map_err(CallGraphStoreError::from)
4473}
4474
4475fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4476    if lease.verify()? {
4477        Ok(())
4478    } else {
4479        Err(CallGraphStoreError::Unavailable(format!(
4480            "callgraph writer lease for key {} lost epoch {}; aborting write",
4481            lease.key(),
4482            lease.epoch()
4483        )))
4484    }
4485}
4486
4487fn legacy_migration_completion_line(
4488    project_key: &str,
4489    method: &str,
4490    legacy_bytes: u64,
4491    migrated_bytes: u64,
4492) -> String {
4493    format!(
4494        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4495    )
4496}
4497
4498fn log_legacy_migration_completion(
4499    project_key: &str,
4500    method: &str,
4501    legacy_bytes: u64,
4502    migrated_bytes: u64,
4503) {
4504    crate::slog_info!(
4505        "{}",
4506        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4507    );
4508}
4509
4510fn try_legacy_migration_or_fallback(
4511    callgraph_dir: &Path,
4512    project_root: &Path,
4513    project_key: &str,
4514    writer_lease: Arc<crate::root_cache::WriterLease>,
4515) -> Result<Option<CallGraphStore>> {
4516    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4517    if partitions.is_empty() {
4518        return Ok(None);
4519    }
4520
4521    for partition in &partitions {
4522        if let Some(source) = newest_superseded_legacy_generation(partition)? {
4523            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4524                return open_legacy_fallback_store(
4525                    callgraph_dir,
4526                    project_root,
4527                    project_key,
4528                    &partitions,
4529                );
4530            }
4531            match publish_generation_copy_migration(
4532                callgraph_dir,
4533                project_key,
4534                &source,
4535                Arc::clone(&writer_lease),
4536            ) {
4537                Ok(published) => {
4538                    log_legacy_migration_completion(
4539                        project_key,
4540                        "generation_copy",
4541                        source.source_bytes,
4542                        published.migrated_bytes,
4543                    );
4544                    return CallGraphStore::open_generation(
4545                        callgraph_dir,
4546                        project_root.to_path_buf(),
4547                        project_key.to_string(),
4548                        published.generation,
4549                        writer_lease,
4550                    )
4551                    .map(Some);
4552                }
4553                Err(error) => {
4554                    crate::slog_warn!(
4555                        "root-keyed callgraph generation-copy migration failed from {}: {}",
4556                        source.sqlite_path.display(),
4557                        error
4558                    );
4559                    return open_legacy_fallback_store(
4560                        callgraph_dir,
4561                        project_root,
4562                        project_key,
4563                        &partitions,
4564                    );
4565                }
4566            }
4567        }
4568
4569        if let Some(source) = current_legacy_generation(partition)? {
4570            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4571                return open_legacy_fallback_store(
4572                    callgraph_dir,
4573                    project_root,
4574                    project_key,
4575                    &partitions,
4576                );
4577            }
4578            match publish_backup_migration(
4579                callgraph_dir,
4580                project_key,
4581                &source,
4582                Arc::clone(&writer_lease),
4583            ) {
4584                Ok(published) => {
4585                    log_legacy_migration_completion(
4586                        project_key,
4587                        "sqlite_backup",
4588                        source.source_bytes,
4589                        published.migrated_bytes,
4590                    );
4591                    return CallGraphStore::open_generation(
4592                        callgraph_dir,
4593                        project_root.to_path_buf(),
4594                        project_key.to_string(),
4595                        published.generation,
4596                        writer_lease,
4597                    )
4598                    .map(Some);
4599                }
4600                Err(error) => {
4601                    crate::slog_warn!(
4602                        "root-keyed callgraph backup migration failed from {}: {}",
4603                        source.sqlite_path.display(),
4604                        error
4605                    );
4606                    return open_legacy_fallback_store(
4607                        callgraph_dir,
4608                        project_root,
4609                        project_key,
4610                        &partitions,
4611                    );
4612                }
4613            }
4614        }
4615    }
4616
4617    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4618}
4619
4620fn open_legacy_fallback_store(
4621    callgraph_dir: &Path,
4622    project_root: &Path,
4623    project_key: &str,
4624    partitions: &[LegacyCallgraphPartition],
4625) -> Result<Option<CallGraphStore>> {
4626    let Some(target) = first_ready_legacy_target(partitions)? else {
4627        return Ok(None);
4628    };
4629    crate::slog_warn!(
4630        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4631        target.partition.harness,
4632        target.sqlite_path.display()
4633    );
4634    let conn = open_readonly_connection(&target.sqlite_path)?;
4635    if !database_ready(&conn).unwrap_or(false) {
4636        return Ok(None);
4637    }
4638    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4639    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4640    Ok(Some(CallGraphStore::from_connection(
4641        project_root.to_path_buf(),
4642        project_key.to_string(),
4643        target.sqlite_path,
4644        callgraph_dir.to_path_buf(),
4645        true,
4646        target.generation,
4647        None,
4648        Some(read_marker),
4649        conn,
4650    )))
4651}
4652
4653fn migration_disk_floor_allows(
4654    source: &LegacyCallgraphTarget,
4655    callgraph_dir: &Path,
4656) -> Result<bool> {
4657    let available = migration_available_disk(callgraph_dir)?;
4658    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4659        source.source_bytes,
4660        available,
4661    );
4662    if decision.should_skip_copy() {
4663        crate::slog_warn!(
4664            "{}",
4665            decision.warning_message(&source.sqlite_path, callgraph_dir)
4666        );
4667        return Ok(false);
4668    }
4669    Ok(true)
4670}
4671
4672fn migration_available_disk(path: &Path) -> Result<u64> {
4673    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4674        return Ok(bytes);
4675    }
4676    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4677}
4678
4679fn legacy_callgraph_partitions(
4680    callgraph_dir: &Path,
4681    project_key: &str,
4682) -> Result<Vec<LegacyCallgraphPartition>> {
4683    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4684        return Ok(Vec::new());
4685    };
4686    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4687    let mut partitions = inventory
4688        .into_iter()
4689        .filter(|entry| {
4690            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4691                && entry.key == project_key
4692        })
4693        .map(|entry| {
4694            let dir = if entry.path.is_dir() {
4695                entry.path.clone()
4696            } else {
4697                entry
4698                    .path
4699                    .parent()
4700                    .map(Path::to_path_buf)
4701                    .unwrap_or_else(|| entry.path.clone())
4702            };
4703            LegacyCallgraphPartition {
4704                harness: entry.harness,
4705                dir,
4706                key: entry.key,
4707                bytes: entry.bytes,
4708                freshness: entry.callgraph_pointer_mtime,
4709            }
4710        })
4711        .collect::<Vec<_>>();
4712    partitions.sort_by(|left, right| {
4713        right
4714            .freshness
4715            .cmp(&left.freshness)
4716            .then_with(|| right.bytes.cmp(&left.bytes))
4717            .then_with(|| left.harness.cmp(&right.harness))
4718    });
4719    Ok(partitions)
4720}
4721
4722fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4723    let domain_dir = callgraph_dir.parent()?;
4724    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4725        return None;
4726    }
4727    domain_dir.parent().map(Path::to_path_buf)
4728}
4729
4730pub(crate) fn all_legacy_partitions_migrated_for_keys(
4731    callgraph_dir: &Path,
4732    configured_keys: &BTreeSet<String>,
4733) -> Result<bool> {
4734    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4735        return Ok(false);
4736    };
4737    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4738        .into_iter()
4739        .filter(|entry| {
4740            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4741                && configured_keys.contains(&entry.key)
4742        })
4743        .map(|entry| entry.key)
4744        .collect::<BTreeSet<_>>();
4745    if legacy_keys.is_empty() {
4746        return Ok(false);
4747    }
4748
4749    for key in legacy_keys {
4750        let migrated_dir = storage_root.join("callgraph").join(&key);
4751        let Some(generation) = read_pointer(&migrated_dir, &key) else {
4752            return Ok(false);
4753        };
4754        if !migration_generation_requires_manifest(&generation)
4755            || !migration_manifest_valid(&migrated_dir, &generation)
4756        {
4757            return Ok(false);
4758        }
4759    }
4760    Ok(true)
4761}
4762
4763fn newest_superseded_legacy_generation(
4764    partition: &LegacyCallgraphPartition,
4765) -> Result<Option<LegacyCallgraphTarget>> {
4766    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4767        return Ok(None);
4768    };
4769    let prefix = format!("{}.g", partition.key);
4770    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4771        return Ok(None);
4772    };
4773    let mut candidates = Vec::new();
4774    for entry in entries.flatten() {
4775        let name = entry.file_name().to_string_lossy().to_string();
4776        if name == current
4777            || name.contains(".tmp.")
4778            || !name.starts_with(&prefix)
4779            || !name.ends_with(".sqlite")
4780        {
4781            continue;
4782        }
4783        let path = entry.path();
4784        if !db_path_ready(&path) {
4785            continue;
4786        }
4787        let modified = entry
4788            .metadata()
4789            .and_then(|metadata| metadata.modified())
4790            .unwrap_or(SystemTime::UNIX_EPOCH);
4791        candidates.push((modified, path, name));
4792    }
4793    candidates.sort_by(|left, right| right.0.cmp(&left.0));
4794    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4795        return Ok(None);
4796    };
4797    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4798    Ok(Some(LegacyCallgraphTarget {
4799        partition: partition.clone(),
4800        sqlite_path,
4801        generation: Some(generation),
4802        source_bytes,
4803        source_blake3: String::new(),
4804    }))
4805}
4806
4807fn current_legacy_generation(
4808    partition: &LegacyCallgraphPartition,
4809) -> Result<Option<LegacyCallgraphTarget>> {
4810    let Some(target) = ready_legacy_target(partition)? else {
4811        return Ok(None);
4812    };
4813    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4814    if has_superseded {
4815        return Ok(None);
4816    }
4817    Ok(Some(target))
4818}
4819
4820fn freshest_legacy_fallback_target(
4821    callgraph_dir: &Path,
4822    project_key: &str,
4823) -> Result<Option<LegacyCallgraphTarget>> {
4824    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4825    first_ready_legacy_target(&partitions)
4826}
4827
4828fn first_ready_legacy_target(
4829    partitions: &[LegacyCallgraphPartition],
4830) -> Result<Option<LegacyCallgraphTarget>> {
4831    for partition in partitions {
4832        if let Some(target) = ready_legacy_target(partition)? {
4833            return Ok(Some(target));
4834        }
4835    }
4836    Ok(None)
4837}
4838
4839fn ready_legacy_target(
4840    partition: &LegacyCallgraphPartition,
4841) -> Result<Option<LegacyCallgraphTarget>> {
4842    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4843        let sqlite_path = partition.dir.join(&generation);
4844        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4845            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4846            return Ok(Some(LegacyCallgraphTarget {
4847                partition: partition.clone(),
4848                sqlite_path,
4849                generation: Some(generation),
4850                source_bytes,
4851                source_blake3: String::new(),
4852            }));
4853        }
4854    }
4855
4856    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4857    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4858        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4859        return Ok(Some(LegacyCallgraphTarget {
4860            partition: partition.clone(),
4861            sqlite_path,
4862            generation: None,
4863            source_bytes,
4864            source_blake3: String::new(),
4865        }));
4866    }
4867    Ok(None)
4868}
4869
4870fn publish_generation_copy_migration(
4871    callgraph_dir: &Path,
4872    project_key: &str,
4873    source: &LegacyCallgraphTarget,
4874    writer_lease: Arc<crate::root_cache::WriterLease>,
4875) -> Result<PublishedLegacyMigration> {
4876    let generation = migration_generation_file_name(project_key, "copy");
4877    let temp_path = migration_temp_path(callgraph_dir, &generation);
4878    remove_sqlite_file_set(&temp_path);
4879    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4880    fail_after_temp_copy_for_test()?;
4881
4882    let mut source = source.clone();
4883    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4884    source.source_blake3 = fingerprint.blake3;
4885    let generation = publish_migrated_generation(
4886        callgraph_dir,
4887        project_key,
4888        &generation,
4889        &temp_path,
4890        &source,
4891        fingerprint.bytes,
4892        writer_lease,
4893        "generation_copy",
4894    )?;
4895    Ok(PublishedLegacyMigration {
4896        generation,
4897        migrated_bytes: fingerprint.bytes,
4898    })
4899}
4900
4901fn publish_backup_migration(
4902    callgraph_dir: &Path,
4903    project_key: &str,
4904    source: &LegacyCallgraphTarget,
4905    writer_lease: Arc<crate::root_cache::WriterLease>,
4906) -> Result<PublishedLegacyMigration> {
4907    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4908        return Err(CallGraphStoreError::Unavailable(
4909            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4910        ));
4911    }
4912
4913    let generation = migration_generation_file_name(project_key, "backup");
4914    let temp_path = migration_temp_path(callgraph_dir, &generation);
4915    remove_sqlite_file_set(&temp_path);
4916
4917    let source_conn = open_readonly_connection(&source.sqlite_path)?;
4918    let mut destination = Connection::open(&temp_path)?;
4919    destination.busy_timeout(Duration::from_secs(5))?;
4920    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4921    let started = Instant::now();
4922    let mut retries = 0;
4923    loop {
4924        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4925            rusqlite::backup::StepResult::Done => break,
4926            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4927            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4928                retries += 1;
4929                if retries > MIGRATION_BACKUP_RETRY_BUDGET
4930                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4931                {
4932                    return Err(CallGraphStoreError::Unavailable(format!(
4933                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4934                    )));
4935                }
4936                std::thread::sleep(Duration::from_millis(20));
4937            }
4938            _ => {
4939                return Err(CallGraphStoreError::Unavailable(
4940                    "legacy callgraph backup returned an unknown step result".to_string(),
4941                ));
4942            }
4943        }
4944    }
4945    drop(backup);
4946
4947    let integrity: String =
4948        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4949    if integrity != "ok" {
4950        return Err(CallGraphStoreError::Unavailable(format!(
4951            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4952        )));
4953    }
4954    if !database_ready(&destination)? {
4955        return Err(CallGraphStoreError::Unavailable(
4956            "legacy callgraph backup produced a database without ready metadata".to_string(),
4957        ));
4958    }
4959    destination.execute_batch("PRAGMA optimize;")?;
4960    drop(destination);
4961    sync_file(&temp_path)?;
4962    fail_after_temp_copy_for_test()?;
4963
4964    let mut source = source.clone();
4965    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4966    source.source_blake3 = fingerprint.blake3;
4967    let generation = publish_migrated_generation(
4968        callgraph_dir,
4969        project_key,
4970        &generation,
4971        &temp_path,
4972        &source,
4973        fingerprint.bytes,
4974        writer_lease,
4975        "sqlite_backup",
4976    )?;
4977    Ok(PublishedLegacyMigration {
4978        generation,
4979        migrated_bytes: fingerprint.bytes,
4980    })
4981}
4982
4983fn publish_migrated_generation(
4984    callgraph_dir: &Path,
4985    project_key: &str,
4986    generation: &str,
4987    temp_path: &Path,
4988    source: &LegacyCallgraphTarget,
4989    migrated_bytes: u64,
4990    writer_lease: Arc<crate::root_cache::WriterLease>,
4991    method: &str,
4992) -> Result<String> {
4993    let gen_path = callgraph_dir.join(generation);
4994    let publication = publish_if_current(|| {
4995        verify_writer_lease(&writer_lease)?;
4996        remove_sqlite_file_set(&gen_path);
4997        rename_sqlite_file_set(temp_path, &gen_path)?;
4998        crate::fs_lock::sync_parent(&gen_path);
4999
5000        verify_writer_lease(&writer_lease)?;
5001        publish_pointer(callgraph_dir, project_key, generation)?;
5002        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5003        Ok(generation.to_string())
5004    });
5005    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5006        remove_sqlite_file_set(temp_path);
5007    }
5008    publication
5009}
5010
5011fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5012    if let Some(parent) = destination.parent() {
5013        std::fs::create_dir_all(parent)?;
5014    }
5015    for suffix in SQLITE_FILE_SET_SUFFIXES {
5016        let source_path = sqlite_file_set_path(source, suffix);
5017        if !source_path.is_file() {
5018            continue;
5019        }
5020        let destination_path = sqlite_file_set_path(destination, suffix);
5021        std::fs::copy(&source_path, &destination_path)?;
5022        sync_file(&destination_path)?;
5023    }
5024    Ok(())
5025}
5026
5027fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5028    for suffix in SQLITE_FILE_SET_SUFFIXES {
5029        let source_path = sqlite_file_set_path(source, suffix);
5030        if !source_path.exists() {
5031            continue;
5032        }
5033        let destination_path = sqlite_file_set_path(destination, suffix);
5034        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5035            let _ = std::fs::remove_file(&source_path);
5036            return Err(error.into());
5037        }
5038    }
5039    Ok(())
5040}
5041
5042fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5043    let mut bytes = 0_u64;
5044    for suffix in SQLITE_FILE_SET_SUFFIXES {
5045        let member = sqlite_file_set_path(path, suffix);
5046        if !member.is_file() {
5047            continue;
5048        }
5049        bytes = bytes.saturating_add(member.metadata()?.len());
5050    }
5051    Ok(bytes)
5052}
5053
5054fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5055    let mut hasher = blake3::Hasher::new();
5056    let mut bytes = 0_u64;
5057    let mut buffer = [0_u8; 64 * 1024];
5058    for suffix in SQLITE_FILE_SET_SUFFIXES {
5059        let member = sqlite_file_set_path(path, suffix);
5060        if !member.is_file() {
5061            continue;
5062        }
5063        hasher.update(suffix.as_bytes());
5064        let mut file = std::fs::File::open(&member)?;
5065        loop {
5066            let read = file.read(&mut buffer)?;
5067            if read == 0 {
5068                break;
5069            }
5070            bytes = bytes.saturating_add(read as u64);
5071            hasher.update(&buffer[..read]);
5072        }
5073    }
5074    Ok(SourceFingerprint {
5075        bytes,
5076        blake3: hash_to_hex(hasher.finalize()),
5077    })
5078}
5079
5080fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5081    if suffix.is_empty() {
5082        path.to_path_buf()
5083    } else {
5084        PathBuf::from(format!("{}{suffix}", path.display()))
5085    }
5086}
5087
5088fn sync_file(path: &Path) -> Result<()> {
5089    let file = std::fs::OpenOptions::new()
5090        .read(true)
5091        .write(true)
5092        .open(path)?;
5093    file.sync_all()?;
5094    Ok(())
5095}
5096
5097fn fail_after_temp_copy_for_test() -> Result<()> {
5098    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5099        return Err(CallGraphStoreError::Unavailable(
5100            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5101        ));
5102    }
5103    Ok(())
5104}
5105
5106fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5107    format!(
5108        "{project_key}.g{}.{}{}{}.sqlite",
5109        now_nanos(),
5110        std::process::id(),
5111        MIGRATION_GENERATION_TAG,
5112        method
5113    )
5114}
5115
5116fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5117    callgraph_dir.join(format!(
5118        "{generation}.tmp.{}.{}",
5119        std::process::id(),
5120        now_nanos()
5121    ))
5122}
5123
5124fn write_migration_manifest(
5125    callgraph_dir: &Path,
5126    generation: &str,
5127    source: &LegacyCallgraphTarget,
5128    migrated_bytes: u64,
5129    method: &str,
5130) -> Result<()> {
5131    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5132    let temp_path = manifest_path.with_extension(format!(
5133        "migration.json.tmp.{}.{}",
5134        std::process::id(),
5135        now_nanos()
5136    ));
5137    let manifest = serde_json::json!({
5138        "version": MIGRATION_MANIFEST_VERSION,
5139        "method": method,
5140        "target_generation": generation,
5141        "source_harness": source.partition.harness,
5142        "source_path": source.sqlite_path.display().to_string(),
5143        "source_generation": source.generation,
5144        "source_bytes": source.source_bytes,
5145        "source_blake3": source.source_blake3,
5146        "migrated_bytes": migrated_bytes,
5147    });
5148    {
5149        use std::io::Write as _;
5150        let mut file = std::fs::File::create(&temp_path)?;
5151        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5152        file.write_all(b"\n")?;
5153        file.sync_all()?;
5154    }
5155    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5156        let _ = std::fs::remove_file(&temp_path);
5157        return Err(error.into());
5158    }
5159    crate::fs_lock::sync_parent(&manifest_path);
5160    Ok(())
5161}
5162
5163fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5164    callgraph_dir.join(format!("{generation}.migration.json"))
5165}
5166
5167fn migration_generation_requires_manifest(generation: &str) -> bool {
5168    generation.contains(MIGRATION_GENERATION_TAG)
5169}
5170
5171fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5172    if !migration_generation_requires_manifest(generation) {
5173        return true;
5174    }
5175    let path = migration_manifest_path(callgraph_dir, generation);
5176    let Ok(bytes) = std::fs::read(path) else {
5177        return false;
5178    };
5179    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5180        return false;
5181    };
5182    value.get("version").and_then(serde_json::Value::as_u64)
5183        == Some(MIGRATION_MANIFEST_VERSION as u64)
5184        && value
5185            .get("target_generation")
5186            .and_then(serde_json::Value::as_str)
5187            == Some(generation)
5188        && value
5189            .get("source_bytes")
5190            .and_then(serde_json::Value::as_u64)
5191            .is_some_and(|bytes| bytes > 0)
5192        && value
5193            .get("source_blake3")
5194            .and_then(serde_json::Value::as_str)
5195            .is_some_and(|hash| hash.len() == 64)
5196}
5197
5198fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5199    let pointer_generation = read_pointer(callgraph_dir, project_key);
5200    if let Some(generation) = pointer_generation.as_deref() {
5201        if migration_generation_requires_manifest(generation)
5202            && !migration_manifest_valid(callgraph_dir, generation)
5203        {
5204            let path = callgraph_dir.join(generation);
5205            remove_sqlite_file_set(&path);
5206            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5207            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5208        }
5209    }
5210
5211    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5212        return;
5213    };
5214    for entry in entries.flatten() {
5215        let name = entry.file_name().to_string_lossy().to_string();
5216        let path = entry.path();
5217        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5218            let _ = std::fs::remove_file(path);
5219            continue;
5220        }
5221        if name.starts_with(&format!("{project_key}.g"))
5222            && name.ends_with(".sqlite")
5223            && name.contains(MIGRATION_GENERATION_TAG)
5224            && pointer_generation.as_deref() != Some(&name)
5225            && !migration_manifest_valid(callgraph_dir, &name)
5226        {
5227            remove_sqlite_file_set(&path);
5228            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5229        }
5230    }
5231    crate::fs_lock::sync_parent(callgraph_dir);
5232}
5233
5234fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5235    let mut hasher = blake3::Hasher::new();
5236    hasher.update(path.to_string_lossy().as_bytes());
5237    if let Some(generation) = generation {
5238        hasher.update(generation.as_bytes());
5239    }
5240    let digest = hash_to_hex(hasher.finalize());
5241    format!("legacy-{}", &digest[..16])
5242}
5243
5244fn open_readonly_connection(path: &Path) -> Result<Connection> {
5245    let uri = sqlite_readonly_uri(path);
5246    let conn = Connection::open_with_flags(
5247        &uri,
5248        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5249    )?;
5250    conn.busy_timeout(reader_busy_timeout())?;
5251    conn.execute_batch("PRAGMA query_only=ON;")?;
5252    Ok(conn)
5253}
5254
5255fn reader_busy_timeout() -> Duration {
5256    let jitter = (now_nanos() % 500) as u64;
5257    Duration::from_millis(250 + jitter)
5258}
5259
5260fn sqlite_readonly_uri(path: &Path) -> String {
5261    let raw = path.to_string_lossy().replace('\\', "/");
5262    let encoded = percent_encode_sqlite_uri_path(&raw);
5263    if raw.starts_with('/') {
5264        format!("file://{encoded}?mode=ro")
5265    } else if raw.as_bytes().get(1) == Some(&b':') {
5266        format!("file:///{encoded}?mode=ro")
5267    } else {
5268        format!("file:{encoded}?mode=ro")
5269    }
5270}
5271
5272fn percent_encode_sqlite_uri_path(path: &str) -> String {
5273    let mut encoded = String::with_capacity(path.len());
5274    for byte in path.bytes() {
5275        match byte {
5276            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5277                encoded.push(byte as char)
5278            }
5279            _ => encoded.push_str(&format!("%{byte:02X}")),
5280        }
5281    }
5282    encoded
5283}
5284
5285fn configure_connection(conn: &Connection) -> Result<()> {
5286    conn.pragma_update(None, "journal_mode", "WAL")?;
5287    conn.pragma_update(None, "busy_timeout", 5_000)?;
5288    Ok(())
5289}
5290
5291fn configure_build_connection(conn: &Connection) -> Result<()> {
5292    conn.pragma_update(None, "journal_mode", "DELETE")?;
5293    conn.pragma_update(None, "busy_timeout", 5_000)?;
5294    Ok(())
5295}
5296
5297fn initialize_schema(conn: &Connection) -> Result<()> {
5298    conn.execute_batch(
5299        "CREATE TABLE IF NOT EXISTS files (
5300            path                TEXT PRIMARY KEY,
5301            content_hash        TEXT NOT NULL,
5302            mtime_ns            INTEGER NOT NULL,
5303            size                INTEGER NOT NULL,
5304            lang                TEXT NOT NULL,
5305            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5306            is_public_api       INTEGER NOT NULL DEFAULT 0,
5307            surface_fingerprint TEXT NOT NULL,
5308            indexed_at          INTEGER NOT NULL
5309        );
5310
5311        CREATE TABLE IF NOT EXISTS nodes (
5312            id                         TEXT PRIMARY KEY,
5313            file_path                  TEXT NOT NULL,
5314            name                       TEXT NOT NULL,
5315            scoped_name                TEXT NOT NULL,
5316            kind                       TEXT NOT NULL,
5317            start_line                 INTEGER NOT NULL,
5318            start_col                  INTEGER NOT NULL,
5319            end_line                   INTEGER NOT NULL,
5320            end_col                    INTEGER NOT NULL,
5321            range_ordinal              INTEGER NOT NULL,
5322            signature                  TEXT,
5323            exported                   INTEGER NOT NULL,
5324            is_default_export          INTEGER NOT NULL,
5325            is_type_like               INTEGER NOT NULL,
5326            is_callgraph_entry_point   INTEGER NOT NULL,
5327            provenance                 TEXT NOT NULL,
5328            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5329        );
5330        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5331        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5332        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5333
5334        CREATE TABLE IF NOT EXISTS refs (
5335            ref_id          TEXT PRIMARY KEY,
5336            caller_node     TEXT,
5337            caller_file     TEXT NOT NULL,
5338            kind            TEXT NOT NULL,
5339            short_name      TEXT,
5340            full_ref        TEXT,
5341            module_path     TEXT,
5342            import_kind     TEXT,
5343            local_name      TEXT,
5344            requested_name  TEXT,
5345            namespace_alias TEXT,
5346            wildcard        INTEGER NOT NULL DEFAULT 0,
5347            line            INTEGER NOT NULL,
5348            byte_start      INTEGER NOT NULL,
5349            byte_end        INTEGER NOT NULL,
5350            status          TEXT NOT NULL,
5351            target_node     TEXT,
5352            target_file     TEXT,
5353            target_symbol   TEXT,
5354            provenance      TEXT NOT NULL
5355        );
5356        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5357        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5358        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5359        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5360        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5361
5362        CREATE TABLE IF NOT EXISTS file_dependencies (
5363            file_path   TEXT NOT NULL,
5364            dep_file    TEXT NOT NULL,
5365            PRIMARY KEY(file_path, dep_file)
5366        );
5367        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5368
5369        CREATE TABLE IF NOT EXISTS edges (
5370            edge_id       TEXT PRIMARY KEY,
5371            ref_id        TEXT NOT NULL,
5372            source_node   TEXT NOT NULL,
5373            target_node   TEXT,
5374            target_file   TEXT NOT NULL,
5375            target_symbol TEXT NOT NULL,
5376            kind          TEXT NOT NULL,
5377            line          INTEGER NOT NULL,
5378            provenance    TEXT NOT NULL
5379        );
5380        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5381        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5382        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5383        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5384
5385        CREATE TABLE IF NOT EXISTS dispatch_hints (
5386            id           TEXT PRIMARY KEY,
5387            method_name  TEXT NOT NULL,
5388            caller_node  TEXT NOT NULL,
5389            file         TEXT NOT NULL,
5390            line         INTEGER NOT NULL,
5391            byte_start   INTEGER NOT NULL,
5392            byte_end     INTEGER NOT NULL,
5393            provenance   TEXT NOT NULL
5394        );
5395        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5396
5397        CREATE TABLE IF NOT EXISTS type_ref_names (
5398            name TEXT PRIMARY KEY
5399        );
5400
5401        CREATE TABLE IF NOT EXISTS backend_file_state (
5402            backend        TEXT NOT NULL,
5403            workspace_root TEXT NOT NULL,
5404            file_path      TEXT NOT NULL,
5405            content_hash   TEXT NOT NULL,
5406            status         TEXT NOT NULL,
5407            updated_at     INTEGER NOT NULL,
5408            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5409        );
5410        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5411
5412        CREATE TABLE IF NOT EXISTS meta (
5413            k TEXT PRIMARY KEY,
5414            v TEXT NOT NULL
5415        );",
5416    )?;
5417    insert_meta(conn)?;
5418    Ok(())
5419}
5420
5421fn insert_meta(conn: &Connection) -> Result<()> {
5422    conn.execute(
5423        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5424        params![SCHEMA_VERSION.to_string()],
5425    )?;
5426    conn.execute(
5427        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5428        params![schema_fingerprint()],
5429    )?;
5430    Ok(())
5431}
5432
5433fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5434    conn.execute(
5435        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5436        params![if ready { "1" } else { "0" }],
5437    )?;
5438    Ok(())
5439}
5440
5441fn database_ready(conn: &Connection) -> Result<bool> {
5442    let schema_version: Option<String> = conn
5443        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5444            row.get(0)
5445        })
5446        .optional()?;
5447    let fingerprint: Option<String> = conn
5448        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5449            row.get(0)
5450        })
5451        .optional()?;
5452    let ready: Option<String> = conn
5453        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5454        .optional()?;
5455
5456    let expected_schema = SCHEMA_VERSION.to_string();
5457    let expected_fingerprint = schema_fingerprint();
5458    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
5459        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
5460        && ready.as_deref() == Some("1"))
5461}
5462
5463fn ensure_database_ready(conn: &Connection) -> Result<()> {
5464    if database_ready(conn)? {
5465        Ok(())
5466    } else {
5467        Err(CallGraphStoreError::Unavailable(
5468            "database is missing, stale, or mid-build".to_string(),
5469        ))
5470    }
5471}
5472
5473fn schema_fingerprint() -> String {
5474    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
5475    // edge sources, broader call extraction) even if the table SHAPE is
5476    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
5477    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
5478    let input =
5479        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
5480    hash_to_hex(blake3::hash(input.as_bytes()))
5481}
5482
5483fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
5484    tx.execute_batch(
5485        "DELETE FROM edges;
5486         DELETE FROM file_dependencies;
5487         DELETE FROM refs;
5488         DELETE FROM dispatch_hints;
5489         DELETE FROM type_ref_names;
5490         DELETE FROM backend_file_state;
5491         DELETE FROM nodes;
5492         DELETE FROM files;",
5493    )?;
5494    Ok(())
5495}
5496
5497fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5498    tx.execute_batch(
5499        "DROP INDEX IF EXISTS idx_nodes_file;
5500         DROP INDEX IF EXISTS idx_nodes_name;
5501         DROP INDEX IF EXISTS idx_nodes_scoped;
5502         DROP INDEX IF EXISTS idx_refs_short_name;
5503         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
5504         DROP INDEX IF EXISTS idx_refs_caller_file;
5505         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
5506         DROP INDEX IF EXISTS idx_refs_target_file;
5507         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
5508         DROP INDEX IF EXISTS idx_edges_source_kind;
5509         DROP INDEX IF EXISTS idx_edges_target_kind;
5510         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
5511         DROP INDEX IF EXISTS idx_edges_ref_id;
5512         DROP INDEX IF EXISTS idx_dispatch_hints_method;
5513         DROP INDEX IF EXISTS idx_backend_file_state_file;",
5514    )?;
5515    Ok(())
5516}
5517
5518fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5519    tx.execute_batch(
5520        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5521         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5522         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5523         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5524         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5525         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5526         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5527         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5528         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5529         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5530         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5531         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5532         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5533         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5534         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
5535    )?;
5536    Ok(())
5537}
5538
5539const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
5540    ("files", "path"),
5541    ("nodes", "file_path"),
5542    ("refs", "caller_file"),
5543    ("refs", "target_file"),
5544    ("file_dependencies", "file_path"),
5545    ("file_dependencies", "dep_file"),
5546    ("edges", "target_file"),
5547    ("dispatch_hints", "file"),
5548    ("backend_file_state", "file_path"),
5549];
5550
5551/// Reconcile `backend_file_state.workspace_root` when the opener's project root
5552/// differs from what is stored. The store key is the git-root commit hash, so
5553/// multiple live checkouts/clones share one on-disk generation.
5554///
5555/// Cheap in-place re-root is only safe when every previously stored root path is
5556/// gone from disk (true move/rename). If any stale root still exists, another
5557/// clone is still alive and rewriting metadata would ping-pong relative rows
5558/// between trees (possibly on different branches). We then return
5559/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
5560/// opener. That can make each clone rebuild on open when they alternate — bounded
5561/// by open frequency — but each rebuild is correct for its opener, unlike silent
5562/// cross-clone corruption.
5563fn reconcile_workspace_roots(
5564    conn: &mut Connection,
5565    project_root: &Path,
5566    allow_repair: bool,
5567) -> Result<OpenRootRepair> {
5568    let roots = stored_workspace_roots(conn)?;
5569    let current_root = project_root.display().to_string();
5570    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
5571        return Ok(OpenRootRepair::None);
5572    }
5573
5574    if let Some(sample) = sample_absolute_data_path(conn)? {
5575        return Ok(OpenRootRepair::NeedsRebuild {
5576            previous_roots: roots,
5577            current_root,
5578            reason: format!("absolute store data path row {sample}"),
5579        });
5580    }
5581
5582    for stored_root in roots.iter() {
5583        if stored_root == &current_root {
5584            continue;
5585        }
5586        if Path::new(stored_root).exists() {
5587            let reason = format!(
5588                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
5589            );
5590            return Ok(OpenRootRepair::NeedsRebuild {
5591                previous_roots: roots,
5592                current_root,
5593                reason,
5594            });
5595        }
5596    }
5597
5598    if !allow_repair {
5599        return Ok(OpenRootRepair::NeedsRebuild {
5600            previous_roots: roots,
5601            current_root,
5602            reason: "workspace root metadata requires deferred repair".to_string(),
5603        });
5604    }
5605
5606    publish_if_current(|| {
5607        let tx = conn.transaction()?;
5608        tx.execute(
5609            "UPDATE OR IGNORE backend_file_state
5610             SET workspace_root = ?1
5611             WHERE workspace_root <> ?1",
5612            params![&current_root],
5613        )?;
5614        tx.execute(
5615            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5616            params![&current_root],
5617        )?;
5618        tx.commit()?;
5619        Ok(())
5620    })?;
5621
5622    crate::slog_info!(
5623        "callgraph store re-rooted from {} to {}",
5624        roots.join(", "),
5625        current_root
5626    );
5627    Ok(OpenRootRepair::ReRooted)
5628}
5629
5630fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5631    let mut stmt = conn.prepare(
5632        "SELECT DISTINCT workspace_root
5633         FROM backend_file_state
5634         ORDER BY workspace_root",
5635    )?;
5636    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5637    rows.collect::<std::result::Result<Vec<_>, _>>()
5638        .map_err(Into::into)
5639}
5640
5641fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5642    for (table, column) in STORE_DATA_PATH_COLUMNS {
5643        let sql = format!(
5644            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5645        );
5646        let mut stmt = conn.prepare(&sql)?;
5647        let mut rows = stmt.query([])?;
5648        while let Some(row) = rows.next()? {
5649            let value: String = row.get(0)?;
5650            if stored_path_is_absolute(&value) {
5651                return Ok(Some(format!("{table}.{column}={value}")));
5652            }
5653        }
5654    }
5655    Ok(None)
5656}
5657
5658fn stored_path_is_absolute(value: &str) -> bool {
5659    if value.is_empty() {
5660        return false;
5661    }
5662    if Path::new(value).is_absolute() || value.starts_with('/') {
5663        return true;
5664    }
5665    let bytes = value.as_bytes();
5666    if bytes.len() >= 3
5667        && bytes[1] == b':'
5668        && (bytes[2] == b'/' || bytes[2] == b'\\')
5669        && bytes[0].is_ascii_alphabetic()
5670    {
5671        return true;
5672    }
5673    value.starts_with("\\\\") || value.starts_with("//")
5674}
5675
5676fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5677    if let OpenRootRepair::NeedsRebuild {
5678        previous_roots,
5679        current_root,
5680        reason,
5681    } = repair
5682    {
5683        crate::slog_info!(
5684            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5685            previous_roots.join(", "),
5686            current_root,
5687            reason
5688        );
5689    }
5690}
5691
5692/// Nanosecond clock used to make temp/generation file names unique.
5693fn now_nanos() -> u128 {
5694    SystemTime::now()
5695        .duration_since(UNIX_EPOCH)
5696        .unwrap_or(Duration::ZERO)
5697        .as_nanos()
5698}
5699
5700/// The pointer file `<dir>/<key>.current`. Its single line names the current
5701/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
5702/// can always be atomically replaced via rename even on Windows — Rust opens
5703/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
5704fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5705    callgraph_dir.join(format!("{project_key}.current"))
5706}
5707
5708/// The legacy single-file DB path used before the generation scheme. Still read
5709/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
5710/// build publishes a generation.
5711fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5712    callgraph_dir.join(format!("{project_key}.sqlite"))
5713}
5714
5715/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
5716/// cold build writes a brand-new generation file, so publishing NEVER replaces
5717/// a file another process holds open (the root Windows fix).
5718fn generation_file_name(project_key: &str) -> String {
5719    format!(
5720        "{project_key}.g{}.{}.sqlite",
5721        now_nanos(),
5722        std::process::id()
5723    )
5724}
5725
5726/// Read the pointer; returns the generation file name if present and non-empty.
5727fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5728    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5729    let name = text.trim();
5730    if name.is_empty() {
5731        None
5732    } else {
5733        Some(name.to_string())
5734    }
5735}
5736
5737/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
5738/// `ready` flag). Uses a throwaway read-only connection.
5739fn db_path_ready(path: &Path) -> bool {
5740    (|| -> Result<bool> {
5741        let conn = open_readonly_connection(path)?;
5742        database_ready(&conn)
5743    })()
5744    .unwrap_or(false)
5745}
5746
5747/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
5748/// where `generation` is `Some(name)` for a pointer-published generation or
5749/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
5750/// published (caller treats that as "needs cold build").
5751///
5752/// Handles the GC race (the pointer names a generation that was just deleted) by
5753/// re-reading the pointer and retrying a few times.
5754fn resolve_ready_target(
5755    callgraph_dir: &Path,
5756    project_key: &str,
5757) -> Option<(PathBuf, Option<String>)> {
5758    for _ in 0..5 {
5759        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5760            let gen_path = callgraph_dir.join(&generation);
5761            if gen_path.is_file() {
5762                return (migration_manifest_valid(callgraph_dir, &generation)
5763                    && db_path_ready(&gen_path))
5764                .then_some((gen_path, Some(generation)));
5765            }
5766            // Pointer names a missing generation (a GC/publish race): re-read the
5767            // pointer and retry rather than failing the reader.
5768            std::thread::sleep(Duration::from_millis(5));
5769            continue;
5770        }
5771        // No pointer: fall back to the legacy single-file DB if it is ready.
5772        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5773        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5774    }
5775    None
5776}
5777
5778/// Atomically publish `generation` as the current store by flipping the pointer
5779/// file. Writes a temp file, fsyncs, then renames over the pointer — never
5780/// replacing an open DB file, so it succeeds cross-platform.
5781fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5782    let pointer = pointer_path(callgraph_dir, project_key);
5783    let tmp = callgraph_dir.join(format!(
5784        "{project_key}.current.tmp.{}.{}",
5785        std::process::id(),
5786        now_nanos()
5787    ));
5788    {
5789        use std::io::Write as _;
5790        let mut file = std::fs::File::create(&tmp)?;
5791        file.write_all(generation.as_bytes())?;
5792        file.write_all(b"\n")?;
5793        file.sync_all()?;
5794    }
5795    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5796        let _ = std::fs::remove_file(&tmp);
5797        return Err(error.into());
5798    }
5799    crate::fs_lock::sync_parent(&pointer);
5800    Ok(())
5801}
5802
5803#[derive(Clone, Debug)]
5804struct GenerationGcCandidate {
5805    name: String,
5806    path: PathBuf,
5807    modified: SystemTime,
5808}
5809
5810/// Best-effort GC of superseded generation files. The current pointer target and
5811/// newest previous generation are always retained. Older generations are removed
5812/// when they have no protected read marker, or after the absolute retention TTL
5813/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
5814/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
5815fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5816    let temp_grace = Duration::from_secs(60);
5817    let now = SystemTime::now();
5818    let pointer_current =
5819        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5820    let gen_prefix = format!("{project_key}.g");
5821    let tmp_prefixes = [
5822        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
5823        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
5824        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
5825    ];
5826    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5827        return;
5828    };
5829    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5830    for entry in entries.flatten() {
5831        let name = entry.file_name();
5832        let name = name.to_string_lossy().to_string();
5833        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5834        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5835
5836        // Orphaned temp files from a crashed build/publish: remove once aged out.
5837        if name.contains(".tmp.") {
5838            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5839                let _ = std::fs::remove_file(entry.path());
5840            }
5841            continue;
5842        }
5843
5844        // Superseded legacy single-file DB: best-effort delete once a generation
5845        // is published (ignored if another process still holds it open).
5846        if name == format!("{project_key}.sqlite") {
5847            remove_sqlite_file_set(&entry.path());
5848            continue;
5849        }
5850
5851        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5852            gens.push(GenerationGcCandidate {
5853                name,
5854                path: entry.path(),
5855                modified: mtime,
5856            });
5857        }
5858    }
5859
5860    let mut superseded = gens
5861        .iter()
5862        .filter(|generation| generation.name != pointer_current)
5863        .collect::<Vec<_>>();
5864    superseded.sort_by(|left, right| {
5865        right
5866            .modified
5867            .cmp(&left.modified)
5868            .then_with(|| right.name.cmp(&left.name))
5869    });
5870    let previous = superseded.first().map(|generation| generation.name.clone());
5871
5872    for generation in gens {
5873        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5874        if generation.name == pointer_current
5875            || Some(generation.name.as_str()) == previous.as_deref()
5876        {
5877            continue;
5878        }
5879
5880        let age = now
5881            .duration_since(generation.modified)
5882            .unwrap_or(Duration::ZERO);
5883        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5884            continue;
5885        }
5886
5887        remove_sqlite_file_set(&generation.path);
5888        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5889        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5890            callgraph_dir,
5891            &generation.name,
5892        ));
5893    }
5894}
5895
5896fn remove_sqlite_file_set(path: &Path) {
5897    let _ = std::fs::remove_file(path);
5898    remove_sqlite_sidecars(path);
5899}
5900
5901fn remove_sqlite_sidecars(path: &Path) {
5902    let path_text = path.to_string_lossy();
5903    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5904    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5905    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5906}
5907
5908/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
5909///
5910/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
5911/// place on success; a build that dies (process kill, crash, host restart) leaves
5912/// the temporary behind. The largest observed cold build finishes well under a
5913/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
5914/// never rename. A live build's temporary is minutes old at most.
5915///
5916/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
5917/// liveness check read false-positive on exactly the oldest files — the ones most
5918/// worth deleting: in production an orphan's embedded pid had been recycled to an
5919/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
5920/// cannot lie that way, so it is the honest orphan predicate.
5921const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
5922
5923/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
5924/// same cadence as [`gc_old_generations`] (after a generation is published) but,
5925/// unlike it, is not scoped to the building root: it covers every directory in the
5926/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
5927///
5928/// That last case is the production hole this fixes. The per-root cleanup in
5929/// [`gc_old_generations`] only fires when a root actually builds, so when activity
5930/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
5931/// store's orphans become permanent — gigabytes accumulated in a legacy store
5932/// whose roots no longer built there, while the active store stayed clean. A
5933/// sibling root that still builds triggers this pass and cleans both layouts.
5934fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
5935    sweep_orphaned_build_temps(callgraph_dir);
5936    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5937        return;
5938    };
5939    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
5940
5941    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
5942    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
5943        for entry in entries.flatten() {
5944            if entry.path().is_dir() {
5945                sweep_orphaned_build_temps(&entry.path());
5946            }
5947        }
5948    }
5949
5950    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
5951    if let Ok(entries) = std::fs::read_dir(&storage_root) {
5952        for entry in entries.flatten() {
5953            let legacy_dir = entry.path().join(domain);
5954            if legacy_dir.is_dir() {
5955                sweep_orphaned_build_temps(&legacy_dir);
5956            }
5957        }
5958    }
5959}
5960
5961/// Sweep one callgraph directory, removing build temporaries older than
5962/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
5963fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
5964    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
5965}
5966
5967/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
5968/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
5969fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
5970    let now = SystemTime::now();
5971    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5972        return;
5973    };
5974    let mut removed_any = false;
5975    for entry in entries.flatten() {
5976        let name = entry.file_name().to_string_lossy().to_string();
5977        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
5978        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
5979        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
5980        // without that substring — a completed `.sqlite` generation, a pointer, a
5981        // read-marker dir — is left alone: those belong to generation GC.
5982        if !name.contains(".sqlite.tmp.") {
5983            continue;
5984        }
5985        let mtime = entry
5986            .metadata()
5987            .and_then(|meta| meta.modified())
5988            .unwrap_or(now);
5989        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
5990            continue;
5991        }
5992        // Deletion races a concurrent build finishing: that build renames the temp
5993        // into place, so the file is gone by the time we unlink. The 24h age makes
5994        // this overlap practically impossible, but treat a missing file as success
5995        // (the rename won) rather than an error, and never touch a path that does
5996        // not match the temporary shape above.
5997        match std::fs::remove_file(entry.path()) {
5998            Ok(()) => removed_any = true,
5999            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6000            Err(_) => {}
6001        }
6002    }
6003    if removed_any {
6004        crate::fs_lock::sync_parent(callgraph_dir);
6005    }
6006}
6007
6008/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6009/// the global all-cores rayon pool. The store cold-build is the heaviest
6010/// background pass (parse-dominated) and runs on a separate thread off the
6011/// single-threaded request loop; left unbounded it monopolizes every core and
6012/// starves the bridge so interactive tools time out (the same starvation the
6013/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6014/// match the main thread, since the extract walks tree-sitter ASTs.
6015fn build_pool_size() -> usize {
6016    std::thread::available_parallelism()
6017        .map(|parallelism| parallelism.get())
6018        .unwrap_or(1)
6019        .div_ceil(2)
6020        .clamp(1, 8)
6021}
6022
6023fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6024    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6025        Ok(extract) => Ok(extract),
6026        Err(error) => {
6027            let abs_path =
6028                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6029            let rel_path = relative_path(project_root, &abs_path);
6030            let freshness = cache_freshness::collect(&abs_path).ok();
6031            log::debug!(
6032                "callgraph store: skipping {} during cold build: {}",
6033                abs_path.display(),
6034                error
6035            );
6036            Err(ExtractFailure {
6037                rel_path,
6038                freshness,
6039            })
6040        }
6041    };
6042
6043    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6044        files.par_iter().map(extract_one).collect()
6045    };
6046
6047    // Run inside a dedicated bounded pool when one builds; fall back to the
6048    // global pool only if the bounded pool can't be constructed.
6049    let results = match rayon::ThreadPoolBuilder::new()
6050        .num_threads(build_pool_size())
6051        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6052        .stack_size(8 * 1024 * 1024)
6053        .build()
6054    {
6055        Ok(pool) => pool.install(run),
6056        Err(error) => {
6057            log::warn!(
6058                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6059            );
6060            run()
6061        }
6062    };
6063
6064    let mut extracts = Vec::new();
6065    let mut failures = Vec::new();
6066    for result in results {
6067        match result {
6068            Ok(extract) => extracts.push(extract),
6069            Err(failure) => failures.push(failure),
6070        }
6071    }
6072    BuildExtractsResult { extracts, failures }
6073}
6074
6075fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6076    let metadata = std::fs::metadata(path)?;
6077    let size = metadata.len();
6078    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6079        cache_freshness::zero_hash()
6080    } else if source.len() as u64 == size {
6081        cache_freshness::hash_bytes(source.as_bytes())
6082    } else {
6083        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6084    };
6085    Ok(FileFreshness {
6086        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6087        size,
6088        content_hash,
6089    })
6090}
6091
6092fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6093    let abs_path = normalize_file_path(project_root, path)?;
6094    let rel_path = relative_path(project_root, &abs_path);
6095    let source = std::fs::read_to_string(&abs_path)?;
6096    let freshness = collect_source_freshness(&abs_path, &source)?;
6097    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6098    let lang = data.lang;
6099    if lang == LangId::Rust {
6100        extend_rust_imports_with_nested_uses(&source, &mut data);
6101    }
6102    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6103    let node_by_scoped: HashMap<String, String> = nodes
6104        .iter()
6105        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6106        .collect();
6107    let import_dependencies =
6108        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6109    let line_index = LineIndex::new(&source);
6110    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6111    let rust_reexports = if lang == LangId::Rust {
6112        collect_rust_pub_use_reexport_refs(
6113            project_root,
6114            &abs_path,
6115            &rel_path,
6116            &data.import_block.imports,
6117            &line_index,
6118        )
6119    } else {
6120        ReexportRefs {
6121            raw_refs: Vec::new(),
6122            surface_parts: Vec::new(),
6123        }
6124    };
6125    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6126    let mut raw_refs = Vec::new();
6127    raw_refs.extend(build_call_refs(
6128        &rel_path,
6129        &data,
6130        &node_by_scoped,
6131        &import_dependencies,
6132    ));
6133    raw_refs.extend(build_import_refs(
6134        project_root,
6135        &abs_path,
6136        &rel_path,
6137        &data.import_block.imports,
6138        &line_index,
6139    ));
6140    let mut surface_parts = reexports.surface_parts;
6141    surface_parts.extend(rust_reexports.surface_parts);
6142    surface_parts.extend(source_less_exports.surface_parts);
6143    raw_refs.extend(reexports.raw_refs);
6144    raw_refs.extend(rust_reexports.raw_refs);
6145    raw_refs.extend(source_less_exports.raw_refs);
6146    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6147    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6148
6149    Ok(FileExtract {
6150        rel_path,
6151        freshness,
6152        lang,
6153        data,
6154        nodes,
6155        raw_refs,
6156        dispatch_hints,
6157        surface_fingerprint,
6158    })
6159}
6160
6161fn build_node_records(
6162    rel_path: &str,
6163    source: &str,
6164    data: &FileCallData,
6165) -> Result<Vec<NodeRecord>> {
6166    let mut records = Vec::new();
6167    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6168    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6169    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6170
6171    for (scoped_name, meta) in metadata {
6172        let name = unqualified_name(scoped_name).to_string();
6173        let range = selection_range(source, scoped_name, &name, &meta.range);
6174        let range_key = (
6175            range.start_line,
6176            range.start_col,
6177            range.end_line,
6178            range.end_col,
6179        );
6180        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6181        let range_ordinal = *ordinal;
6182        *ordinal += 1;
6183        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6184        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6185        let is_default_export = data
6186            .default_export_symbol
6187            .as_deref()
6188            .map(|default| default == scoped_name || default == name)
6189            .unwrap_or(false);
6190        records.push(NodeRecord {
6191            id,
6192            file_path: rel_path.to_string(),
6193            name: name.clone(),
6194            scoped_name: scoped_name.clone(),
6195            kind: symbol_kind_label(&meta.kind).to_string(),
6196            range,
6197            range_ordinal,
6198            signature: meta.signature.clone(),
6199            exported,
6200            is_default_export,
6201            is_type_like: is_type_like(&meta.kind),
6202            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6203                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6204        });
6205    }
6206
6207    Ok(records)
6208}
6209
6210fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6211    if scoped_name == TOP_LEVEL_SYMBOL {
6212        return Range {
6213            start_line: 0,
6214            start_col: 0,
6215            end_line: 0,
6216            end_col: 0,
6217        };
6218    }
6219    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6220        return fallback.clone();
6221    };
6222    let start_col = fallback.start_col as usize;
6223    let search_start = start_col.min(line.len());
6224    if let Some(offset) = line[search_start..].find(name) {
6225        let col = search_start + offset;
6226        return Range {
6227            start_line: fallback.start_line,
6228            start_col: col as u32,
6229            end_line: fallback.start_line,
6230            end_col: (col + name.len()) as u32,
6231        };
6232    }
6233    if let Some(offset) = line.find(name) {
6234        return Range {
6235            start_line: fallback.start_line,
6236            start_col: offset as u32,
6237            end_line: fallback.start_line,
6238            end_col: (offset + name.len()) as u32,
6239        };
6240    }
6241    Range {
6242        start_line: fallback.start_line,
6243        start_col: fallback.start_col,
6244        end_line: fallback.start_line,
6245        end_col: fallback.start_col.saturating_add(name.len() as u32),
6246    }
6247}
6248
6249fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6250    if scoped_name == TOP_LEVEL_SYMBOL {
6251        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6252    }
6253    let input = format!(
6254        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6255        range.start_line, range.start_col, range.end_line, range.end_col
6256    );
6257    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6258}
6259
6260fn build_call_refs(
6261    rel_path: &str,
6262    data: &FileCallData,
6263    node_by_scoped: &HashMap<String, String>,
6264    import_dependencies: &BTreeSet<String>,
6265) -> Vec<RawRef> {
6266    let mut refs = Vec::new();
6267    let mut ordinal = 0usize;
6268    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6269    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6270    for (caller_symbol, call_sites) in symbols {
6271        let caller_node = node_by_scoped.get(caller_symbol).cloned();
6272        for call_site in call_sites {
6273            ordinal += 1;
6274            let ref_id = ref_id(&[
6275                rel_path,
6276                "call",
6277                caller_symbol,
6278                &call_site.line.to_string(),
6279                &call_site.byte_start.to_string(),
6280                &call_site.byte_end.to_string(),
6281                &call_site.full_callee,
6282                &ordinal.to_string(),
6283            ]);
6284            refs.push(RawRef {
6285                ref_id,
6286                caller_node: caller_node.clone(),
6287                caller_symbol: Some(caller_symbol.clone()),
6288                caller_file: rel_path.to_string(),
6289                kind: "call".to_string(),
6290                short_name: Some(call_site.callee_name.clone()),
6291                full_ref: Some(call_site.full_callee.clone()),
6292                module_path: None,
6293                import_kind: None,
6294                local_name: Some(call_site.callee_name.clone()),
6295                requested_name: Some(call_site.callee_name.clone()),
6296                namespace_alias: namespace_alias(&call_site.full_callee),
6297                wildcard: false,
6298                line: call_site.line,
6299                byte_start: call_site.byte_start,
6300                byte_end: call_site.byte_end,
6301                dependencies: import_dependencies.clone(),
6302            });
6303        }
6304    }
6305    refs
6306}
6307
6308fn build_import_refs(
6309    project_root: &Path,
6310    abs_path: &Path,
6311    rel_path: &str,
6312    imports: &[ImportStatement],
6313    line_index: &LineIndex,
6314) -> Vec<RawRef> {
6315    let mut refs = Vec::new();
6316    for (index, import) in imports.iter().enumerate() {
6317        let import_kind = import_kind_label(import.kind).to_string();
6318        let local_name = import_local_names(import).join(",");
6319        let requested_name = import_requested_names(import).join(",");
6320        let ref_id = ref_id(&[
6321            rel_path,
6322            "import",
6323            &import.byte_range.start.to_string(),
6324            &import.byte_range.end.to_string(),
6325            &import.module_path,
6326            &index.to_string(),
6327        ]);
6328        refs.push(RawRef {
6329            ref_id,
6330            caller_node: None,
6331            caller_symbol: None,
6332            caller_file: rel_path.to_string(),
6333            kind: "import".to_string(),
6334            short_name: None,
6335            full_ref: Some(import.raw_text.clone()),
6336            module_path: Some(import.module_path.clone()),
6337            import_kind: Some(import_kind),
6338            local_name: empty_to_none(local_name),
6339            requested_name: empty_to_none(requested_name),
6340            namespace_alias: import.namespace_import.clone(),
6341            wildcard: import_is_wildcard(import),
6342            line: line_index.byte_to_line(import.byte_range.start),
6343            byte_start: import.byte_range.start,
6344            byte_end: import.byte_range.end,
6345            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6346        });
6347    }
6348    refs
6349}
6350
6351fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6352    let grammar = grammar_for(LangId::Rust);
6353    let mut parser = Parser::new();
6354    if parser.set_language(&grammar).is_err() {
6355        return;
6356    }
6357    let Some(tree) = parser.parse(source, None) else {
6358        return;
6359    };
6360
6361    let mut seen = data
6362        .import_block
6363        .imports
6364        .iter()
6365        .map(|import| (import.byte_range.start, import.byte_range.end))
6366        .collect::<HashSet<_>>();
6367    let mut nested_imports = Vec::new();
6368    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6369    if nested_imports.is_empty() {
6370        return;
6371    }
6372
6373    data.import_block.imports.extend(nested_imports);
6374    data.import_block
6375        .imports
6376        .sort_by_key(|import| import.byte_range.start);
6377    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6378}
6379
6380fn collect_rust_use_imports(
6381    source: &str,
6382    node: Node<'_>,
6383    seen: &mut HashSet<(usize, usize)>,
6384    imports: &mut Vec<ImportStatement>,
6385) {
6386    if node.kind() == "use_declaration" {
6387        let range = node.byte_range();
6388        if seen.insert((range.start, range.end)) {
6389            if let Some(import) = rust_import_from_use_node(source, node) {
6390                imports.push(import);
6391            }
6392        }
6393    }
6394
6395    let mut cursor = node.walk();
6396    if !cursor.goto_first_child() {
6397        return;
6398    }
6399    loop {
6400        collect_rust_use_imports(source, cursor.node(), seen, imports);
6401        if !cursor.goto_next_sibling() {
6402            break;
6403        }
6404    }
6405}
6406
6407fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6408    let raw_text = source[node.byte_range()].to_string();
6409    let body = rust_use_body(&raw_text)?.to_string();
6410    let visibility = rust_use_visibility(&raw_text);
6411    let names = rust_use_list_names(&body);
6412    let group = classify_rust_import_group(&body);
6413    let byte_range = node.byte_range();
6414
6415    Some(ImportStatement {
6416        module_path: body,
6417        names: names.clone(),
6418        default_import: visibility.clone(),
6419        namespace_import: None,
6420        kind: ImportKind::Value,
6421        group,
6422        byte_range,
6423        raw_text,
6424        form: ImportForm::RustUse {
6425            visibility,
6426            named: names,
6427        },
6428    })
6429}
6430
6431fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6432    let start = imports.iter().map(|import| import.byte_range.start).min()?;
6433    let end = imports.iter().map(|import| import.byte_range.end).max()?;
6434    Some(start..end)
6435}
6436
6437fn rust_use_visibility(raw_text: &str) -> Option<String> {
6438    let use_pos = raw_text.find("use ")?;
6439    let prefix = raw_text[..use_pos].trim();
6440    if prefix.is_empty() {
6441        None
6442    } else {
6443        Some(prefix.to_string())
6444    }
6445}
6446
6447fn rust_use_body(raw_text: &str) -> Option<&str> {
6448    let use_pos = raw_text.find("use ")?;
6449    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6450}
6451
6452fn rust_use_list_names(body: &str) -> Vec<String> {
6453    let Some(open) = body.find("::{") else {
6454        return Vec::new();
6455    };
6456    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6457        return Vec::new();
6458    };
6459    body[open + 3..close]
6460        .split(',')
6461        .filter_map(|spec| {
6462            let spec = spec.trim();
6463            if spec.is_empty() {
6464                None
6465            } else {
6466                Some(spec.to_string())
6467            }
6468        })
6469        .collect()
6470}
6471
6472fn classify_rust_import_group(body: &str) -> ImportGroup {
6473    let first = body
6474        .split("::")
6475        .next()
6476        .unwrap_or(body)
6477        .split_whitespace()
6478        .next()
6479        .unwrap_or(body);
6480    match first.trim() {
6481        "std" | "core" | "alloc" => ImportGroup::Stdlib,
6482        "crate" | "self" | "super" => ImportGroup::Internal,
6483        _ => ImportGroup::External,
6484    }
6485}
6486
6487#[derive(Debug, Clone)]
6488struct ReexportRefs {
6489    raw_refs: Vec<RawRef>,
6490    surface_parts: Vec<String>,
6491}
6492
6493fn collect_reexport_refs(
6494    project_root: &Path,
6495    abs_path: &Path,
6496    rel_path: &str,
6497    source: &str,
6498) -> ReexportRefs {
6499    let mut raw_refs = Vec::new();
6500    let mut surface_parts = Vec::new();
6501    let mut search_start = 0usize;
6502    let mut ordinal = 0usize;
6503    while let Some(export_offset) = source[search_start..].find("export") {
6504        let start = search_start + export_offset;
6505        let Some(statement_end_offset) = source[start..].find(';') else {
6506            break;
6507        };
6508        let end = start + statement_end_offset + 1;
6509        let statement = &source[start..end];
6510        search_start = end;
6511        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
6512            continue;
6513        }
6514        let Some(module_path) = quoted_module_path(statement) else {
6515            continue;
6516        };
6517        ordinal += 1;
6518        let wildcard = statement.contains('*');
6519        let line = source[..start]
6520            .bytes()
6521            .filter(|byte| *byte == b'\n')
6522            .count() as u32
6523            + 1;
6524        let ref_id = ref_id(&[
6525            rel_path,
6526            "reexport",
6527            &start.to_string(),
6528            &end.to_string(),
6529            &module_path,
6530            &ordinal.to_string(),
6531        ]);
6532        surface_parts.push(format!("reexport\t{statement}"));
6533        raw_refs.push(RawRef {
6534            ref_id,
6535            caller_node: None,
6536            caller_symbol: None,
6537            caller_file: rel_path.to_string(),
6538            kind: "reexport".to_string(),
6539            short_name: None,
6540            full_ref: Some(statement.to_string()),
6541            module_path: Some(module_path.clone()),
6542            import_kind: Some("reexport".to_string()),
6543            local_name: None,
6544            requested_name: None,
6545            namespace_alias: None,
6546            wildcard,
6547            line,
6548            byte_start: start,
6549            byte_end: end,
6550            dependencies: module_dependencies(project_root, abs_path, &module_path),
6551        });
6552    }
6553    ReexportRefs {
6554        raw_refs,
6555        surface_parts,
6556    }
6557}
6558
6559fn collect_rust_pub_use_reexport_refs(
6560    project_root: &Path,
6561    abs_path: &Path,
6562    rel_path: &str,
6563    imports: &[ImportStatement],
6564    line_index: &LineIndex,
6565) -> ReexportRefs {
6566    let mut raw_refs = Vec::new();
6567    let mut surface_parts = Vec::new();
6568    let mut ordinal = 0usize;
6569
6570    for import in imports {
6571        let Some(visibility) = &import.default_import else {
6572            continue;
6573        };
6574        if !visibility.starts_with("pub") {
6575            continue;
6576        }
6577        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
6578            continue;
6579        };
6580        ordinal += 1;
6581        let ref_id = ref_id(&[
6582            rel_path,
6583            "rust_reexport",
6584            &import.byte_range.start.to_string(),
6585            &import.byte_range.end.to_string(),
6586            &module_path,
6587            &ordinal.to_string(),
6588        ]);
6589        surface_parts.push(format!("reexport\t{}", import.raw_text));
6590        raw_refs.push(RawRef {
6591            ref_id,
6592            caller_node: None,
6593            caller_symbol: None,
6594            caller_file: rel_path.to_string(),
6595            kind: "reexport".to_string(),
6596            short_name: None,
6597            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
6598            module_path: Some(module_path.clone()),
6599            import_kind: Some("reexport".to_string()),
6600            local_name: None,
6601            requested_name: None,
6602            namespace_alias: None,
6603            wildcard,
6604            line: line_index.byte_to_line(import.byte_range.start),
6605            byte_start: import.byte_range.start,
6606            byte_end: import.byte_range.end,
6607            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
6608        });
6609    }
6610
6611    ReexportRefs {
6612        raw_refs,
6613        surface_parts,
6614    }
6615}
6616
6617fn rust_pub_use_reexport_parts(
6618    import: &ImportStatement,
6619) -> Option<(String, HashMap<String, String>, bool)> {
6620    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
6621    let body = body.trim();
6622    if let Some(module_path) = body.strip_suffix("::*") {
6623        return Some((module_path.trim().to_string(), HashMap::new(), true));
6624    }
6625
6626    if let Some(brace_start) = body.find("::{") {
6627        let module_path = body[..brace_start].trim().to_string();
6628        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
6629        if names.is_empty() {
6630            return None;
6631        }
6632        return Some((module_path, names, false));
6633    }
6634
6635    let (module_path, spec) = body.rsplit_once("::")?;
6636    let names = rust_reexport_names_from_specs(spec);
6637    if names.is_empty() {
6638        return None;
6639    }
6640    Some((module_path.trim().to_string(), names, false))
6641}
6642
6643fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
6644    let mut names = HashMap::new();
6645    for spec in specs.split(',') {
6646        let spec = spec.trim();
6647        if spec.is_empty() || spec == "self" {
6648            continue;
6649        }
6650        if let Some((source, local)) = spec.split_once(" as ") {
6651            let source = source.trim();
6652            let local = local.trim();
6653            if !source.is_empty() && !local.is_empty() && source != "self" {
6654                names.insert(local.to_string(), source.to_string());
6655            }
6656        } else {
6657            names.insert(spec.to_string(), spec.to_string());
6658        }
6659    }
6660    names
6661}
6662
6663fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
6664    if named.is_empty() {
6665        return fallback.to_string();
6666    }
6667    let mut specs = named
6668        .iter()
6669        .map(|(local, source)| {
6670            if local == source {
6671                source.clone()
6672            } else {
6673                format!("{source} as {local}")
6674            }
6675        })
6676        .collect::<Vec<_>>();
6677    specs.sort();
6678    format!("pub use {{{}}};", specs.join(", "))
6679}
6680
6681fn quoted_module_path(statement: &str) -> Option<String> {
6682    let quote = match (statement.find('\''), statement.find('"')) {
6683        (Some(single), Some(double)) if single < double => '\'',
6684        (Some(_), Some(_)) => '"',
6685        (Some(_), None) => '\'',
6686        (None, Some(_)) => '"',
6687        (None, None) => return None,
6688    };
6689    let start = statement.find(quote)? + 1;
6690    let end = statement[start..].find(quote)? + start;
6691    Some(statement[start..end].to_string())
6692}
6693
6694#[derive(Debug, Clone)]
6695struct SourceLessExportRefs {
6696    raw_refs: Vec<RawRef>,
6697    surface_parts: Vec<String>,
6698}
6699
6700fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
6701    let mut raw_refs = Vec::new();
6702    let mut surface_parts = Vec::new();
6703    let mut search_start = 0usize;
6704    let mut ordinal = 0usize;
6705    while let Some(export_offset) = source[search_start..].find("export") {
6706        let start = search_start + export_offset;
6707        let Some(statement_end_offset) = source[start..].find(';') else {
6708            break;
6709        };
6710        let end = start + statement_end_offset + 1;
6711        let statement = &source[start..end];
6712        search_start = end;
6713        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6714            continue;
6715        }
6716        let aliases = parse_reexport_names(statement);
6717        if aliases.is_empty() {
6718            continue;
6719        }
6720        let line = source[..start]
6721            .bytes()
6722            .filter(|byte| *byte == b'\n')
6723            .count() as u32
6724            + 1;
6725        for (exported, source_symbol) in aliases {
6726            ordinal += 1;
6727            let ref_id = ref_id(&[
6728                rel_path,
6729                "export_alias",
6730                &start.to_string(),
6731                &end.to_string(),
6732                &exported,
6733                &source_symbol,
6734                &ordinal.to_string(),
6735            ]);
6736            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6737            raw_refs.push(RawRef {
6738                ref_id,
6739                caller_node: None,
6740                caller_symbol: None,
6741                caller_file: rel_path.to_string(),
6742                kind: "export_alias".to_string(),
6743                short_name: None,
6744                full_ref: Some(statement.to_string()),
6745                module_path: None,
6746                import_kind: Some("export_alias".to_string()),
6747                local_name: Some(exported),
6748                requested_name: Some(source_symbol),
6749                namespace_alias: None,
6750                wildcard: false,
6751                line,
6752                byte_start: start,
6753                byte_end: end,
6754                dependencies: BTreeSet::new(),
6755            });
6756        }
6757    }
6758    SourceLessExportRefs {
6759        raw_refs,
6760        surface_parts,
6761    }
6762}
6763
6764fn build_dispatch_hints(
6765    rel_path: &str,
6766    data: &FileCallData,
6767    node_by_scoped: &HashMap<String, String>,
6768) -> Vec<DispatchHint> {
6769    let mut hints = Vec::new();
6770    let mut ordinal = 0usize;
6771    for (caller_symbol, call_sites) in &data.calls_by_symbol {
6772        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6773            continue;
6774        };
6775        for call_site in call_sites {
6776            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6777                continue;
6778            }
6779            ordinal += 1;
6780            hints.push(DispatchHint {
6781                id: ref_id(&[
6782                    rel_path,
6783                    "dispatch",
6784                    caller_symbol,
6785                    &call_site.line.to_string(),
6786                    &call_site.byte_start.to_string(),
6787                    &call_site.byte_end.to_string(),
6788                    &ordinal.to_string(),
6789                ]),
6790                method_name: call_site.callee_name.clone(),
6791                caller_node: caller_node.clone(),
6792                file: rel_path.to_string(),
6793                line: call_site.line,
6794                byte_start: call_site.byte_start,
6795                byte_end: call_site.byte_end,
6796            });
6797        }
6798    }
6799    hints
6800}
6801
6802fn surface_fingerprint(
6803    nodes: &mut [NodeRecord],
6804    data: &FileCallData,
6805    reexport_parts: &[String],
6806) -> String {
6807    nodes.sort_by(|left, right| {
6808        (left.file_path.as_str(), left.scoped_name.as_str())
6809            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6810    });
6811    let mut parts = Vec::new();
6812    for node in nodes.iter() {
6813        parts.push(format!(
6814            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6815            node.scoped_name,
6816            node.name,
6817            node.kind,
6818            node.exported,
6819            node.range.start_line,
6820            node.range.start_col,
6821            node.range.end_line,
6822            node.range.end_col,
6823            node.range_ordinal,
6824            node.signature.as_deref().unwrap_or("")
6825        ));
6826    }
6827    let mut exports = data.exported_symbols.clone();
6828    exports.sort();
6829    for export in exports {
6830        parts.push(format!("export\t{export}"));
6831    }
6832    if let Some(default_export) = &data.default_export_symbol {
6833        parts.push(format!("default\t{default_export}"));
6834    }
6835    let mut imports: Vec<String> = data
6836        .import_block
6837        .imports
6838        .iter()
6839        .map(|import| {
6840            format!(
6841                "import\t{}\t{:?}\t{}",
6842                import.module_path, import.form, import.raw_text
6843            )
6844        })
6845        .collect();
6846    imports.sort();
6847    parts.extend(imports);
6848    parts.extend(reexport_parts.iter().cloned());
6849    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6850}
6851
6852fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6853    if raw.kind != "call" {
6854        return Ok(ResolvedRef {
6855            dependencies: raw.dependencies.clone(),
6856            raw,
6857            status: "unresolved".to_string(),
6858            target_node: None,
6859            target_file: None,
6860            target_symbol: None,
6861            edge: None,
6862        });
6863    }
6864
6865    let caller_file = raw.caller_file.clone();
6866    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6867        CallGraphStoreError::MissingCallerData {
6868            file: caller_file.clone(),
6869        }
6870    })?;
6871    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6872    let short_name = raw.short_name.as_deref().unwrap_or_default();
6873    let mut dependencies = raw.dependencies.clone();
6874
6875    let resolved = match index.lang_for(&caller_file) {
6876        Some(LangId::Rust) => {
6877            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6878        }
6879        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6880            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6881        }
6882        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6883    };
6884
6885    let Some((status, target_file, target_symbol)) = resolved else {
6886        return Ok(ResolvedRef {
6887            raw,
6888            status: "unresolved".to_string(),
6889            target_node: None,
6890            target_file: None,
6891            target_symbol: None,
6892            dependencies,
6893            edge: None,
6894        });
6895    };
6896
6897    dependencies.insert(target_file.clone());
6898    let target_node = index.node_for_symbol(&target_file, &target_symbol);
6899    let source_node = raw.caller_node.clone();
6900    let edge = if let Some(source_node) = source_node {
6901        if target_file == caller_file
6902            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6903        {
6904            None
6905        } else {
6906            Some(EdgeRecord {
6907                edge_id: ref_id(&[&raw.ref_id, "edge"]),
6908                source_node,
6909                target_node: target_node.clone(),
6910                target_file: target_file.clone(),
6911                target_symbol: target_symbol.clone(),
6912                kind: "call".to_string(),
6913                line: raw.line,
6914            })
6915        }
6916    } else {
6917        None
6918    };
6919
6920    Ok(ResolvedRef {
6921        raw,
6922        status,
6923        target_node,
6924        target_file: Some(target_file),
6925        target_symbol: Some(target_symbol),
6926        dependencies,
6927        edge,
6928    })
6929}
6930
6931fn resolve_js_ts_target(
6932    index: &ProjectIndex<'_>,
6933    caller_file: &str,
6934    full_ref: &str,
6935    short_name: &str,
6936    caller_data: &FileCallData,
6937) -> Option<(String, String, String)> {
6938    if let Some((namespace, member)) = full_ref.split_once('.') {
6939        for import in &caller_data.import_block.imports {
6940            if import.namespace_import.as_deref() == Some(namespace) {
6941                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6942                    if let Some((file, symbol)) =
6943                        resolve_exported_symbol(index, &target_file, member, 0)
6944                    {
6945                        return Some(("resolved".to_string(), file, symbol));
6946                    }
6947                }
6948            }
6949        }
6950    }
6951
6952    for import in &caller_data.import_block.imports {
6953        for spec in &import.names {
6954            if crate::imports::specifier_local_name(spec) == short_name {
6955                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6956                    let requested = crate::imports::specifier_imported_name(spec);
6957                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6958                        .unwrap_or_else(|| (target_file, requested.to_string()));
6959                    return Some(("resolved".to_string(), file, symbol));
6960                }
6961            }
6962        }
6963
6964        if import.default_import.as_deref() == Some(short_name) {
6965            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6966                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6967                    .or_else(|| {
6968                        index
6969                            .files
6970                            .get(&target_file)
6971                            .and_then(|file| file.default_export.clone())
6972                            .map(|symbol| (target_file.clone(), symbol))
6973                    })
6974                    .unwrap_or_else(|| {
6975                        let file_name = Path::new(&target_file)
6976                            .file_name()
6977                            .and_then(|name| name.to_str())
6978                            .unwrap_or("unknown")
6979                            .to_string();
6980                        (target_file, format!("<default:{file_name}>"))
6981                    });
6982                return Some(("resolved".to_string(), file, symbol));
6983            }
6984        }
6985    }
6986
6987    for import in &caller_data.import_block.imports {
6988        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6989            if index
6990                .files
6991                .get(&target_file)
6992                .map(|file| file.exports.contains(short_name))
6993                .unwrap_or(false)
6994            {
6995                return Some(("resolved".to_string(), target_file, short_name.to_string()));
6996            }
6997        }
6998    }
6999
7000    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7001}
7002
7003fn resolve_exported_symbol(
7004    index: &ProjectIndex<'_>,
7005    file: &str,
7006    requested: &str,
7007    depth: usize,
7008) -> Option<(String, String)> {
7009    let mut visited = std::collections::HashMap::new();
7010    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7011}
7012
7013/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7014/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7015/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7016/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7017/// (file, symbol) pair — but only when the earlier visit had at least as much
7018/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7019/// first visit had to cut off at the cap, so plain visited-set pruning would
7020/// lose resolutions the capped walk finds).
7021fn resolve_exported_symbol_inner(
7022    index: &ProjectIndex<'_>,
7023    file: &str,
7024    requested: &str,
7025    depth: usize,
7026    visited: &mut std::collections::HashMap<(String, String), usize>,
7027) -> Option<(String, String)> {
7028    if depth > 16 {
7029        return None;
7030    }
7031    if requested != "default" {
7032        if let Some(source_symbol) = index
7033            .files
7034            .get(file)
7035            .and_then(|item| item.export_aliases.get(requested))
7036        {
7037            return Some((file.to_string(), source_symbol.clone()));
7038        }
7039        if index
7040            .files
7041            .get(file)
7042            .map(|item| item.exports.contains(requested))
7043            .unwrap_or(false)
7044        {
7045            return Some((file.to_string(), requested.to_string()));
7046        }
7047    } else if let Some(default) = index
7048        .files
7049        .get(file)
7050        .and_then(|item| item.default_export.clone())
7051    {
7052        return Some((file.to_string(), default));
7053    }
7054
7055    // Memo check sits after the local-export fast paths: the common direct
7056    // hit never allocates the key, and a hit through the memo would have
7057    // returned above anyway.
7058    match visited.entry((file.to_string(), requested.to_string())) {
7059        std::collections::hash_map::Entry::Occupied(mut seen) => {
7060            if *seen.get() <= depth {
7061                return None;
7062            }
7063            seen.insert(depth);
7064        }
7065        std::collections::hash_map::Entry::Vacant(slot) => {
7066            slot.insert(depth);
7067        }
7068    }
7069
7070    for reexport in index.reexports_for(file) {
7071        let mut next_requested = requested.to_string();
7072        let matches = if reexport.wildcard {
7073            true
7074        } else if let Some(source_name) = reexport.named.get(requested) {
7075            next_requested = source_name.clone();
7076            true
7077        } else {
7078            false
7079        };
7080        if !matches {
7081            continue;
7082        }
7083        if let Some(target_file) = &reexport.target_file {
7084            if let Some(target) = resolve_exported_symbol_inner(
7085                index,
7086                target_file,
7087                &next_requested,
7088                depth + 1,
7089                visited,
7090            ) {
7091                return Some(target);
7092            }
7093        }
7094    }
7095    None
7096}
7097
7098fn resolve_rust_target(
7099    index: &ProjectIndex<'_>,
7100    caller_file: &str,
7101    full_ref: &str,
7102    short_name: &str,
7103    caller_data: &FileCallData,
7104    raw: &RawRef,
7105) -> Option<(String, String, String)> {
7106    if full_ref.contains("::") {
7107        if let Some((target_file, target_symbol)) =
7108            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7109        {
7110            return Some(("resolved".to_string(), target_file, target_symbol));
7111        }
7112    }
7113
7114    for import in &caller_data.import_block.imports {
7115        if let Some((target_file, target_symbol)) =
7116            rust_target_for_use(index, caller_file, import, short_name)
7117        {
7118            return Some(("resolved".to_string(), target_file, target_symbol));
7119        }
7120    }
7121
7122    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7123}
7124
7125fn rust_target_for_qualified(
7126    index: &ProjectIndex<'_>,
7127    caller_file: &str,
7128    full_ref: &str,
7129    short_name: &str,
7130    caller_data: &FileCallData,
7131    raw: &RawRef,
7132) -> Option<(String, String)> {
7133    let mut segments: Vec<&str> = full_ref.split("::").collect();
7134    if segments.len() < 2 {
7135        return None;
7136    }
7137    segments.pop();
7138    let requested_symbol = rust_target_symbol(full_ref, short_name);
7139
7140    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7141        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7142        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7143            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7144                return Some(rust_resolve_reexport_if_symbol_missing(
7145                    index,
7146                    target_file,
7147                    requested_symbol.clone(),
7148                ));
7149            }
7150        }
7151
7152        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7153        if let Some(target) =
7154            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7155        {
7156            return Some(target);
7157        }
7158        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7159            return Some(rust_resolve_reexport_if_symbol_missing(
7160                index,
7161                target_file,
7162                requested_symbol.clone(),
7163            ));
7164        }
7165    }
7166    None
7167}
7168
7169fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7170    full_ref
7171        .rsplit("::")
7172        .next()
7173        .filter(|name| !name.is_empty())
7174        .unwrap_or(short_name)
7175        .to_string()
7176}
7177
7178fn rust_resolve_reexport_if_symbol_missing(
7179    index: &ProjectIndex<'_>,
7180    target_file: String,
7181    target_symbol: String,
7182) -> (String, String) {
7183    if index
7184        .node_for_symbol(&target_file, &target_symbol)
7185        .is_some()
7186    {
7187        return (target_file, target_symbol);
7188    }
7189    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7190        resolved
7191    } else {
7192        (target_file, target_symbol)
7193    }
7194}
7195
7196fn rust_module_path_candidates(
7197    segments: &[&str],
7198    caller_data: &FileCallData,
7199    raw: &RawRef,
7200) -> Vec<Vec<String>> {
7201    let mut candidates = Vec::new();
7202    if let Some(first) = segments.first().copied() {
7203        for import in &caller_data.import_block.imports {
7204            if !rust_import_is_visible_to_call(import, raw) {
7205                continue;
7206            }
7207            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7208                continue;
7209            };
7210            if local_name == first {
7211                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7212                rust_push_unique_path_candidate(&mut candidates, path_segments);
7213            }
7214        }
7215    }
7216    rust_push_unique_path_candidate(
7217        &mut candidates,
7218        segments
7219            .iter()
7220            .map(|segment| (*segment).to_string())
7221            .collect(),
7222    );
7223    candidates
7224}
7225
7226fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7227    if !candidates.iter().any(|existing| existing == &candidate) {
7228        candidates.push(candidate);
7229    }
7230}
7231
7232fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7233    import.byte_range.start <= raw.byte_start
7234}
7235
7236fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7237    let path = import.module_path.trim().trim_end_matches(';').trim();
7238    if path.contains("::{") || path.contains('{') || path.contains('*') {
7239        return None;
7240    }
7241    let (path_without_alias, alias) = path
7242        .split_once(" as ")
7243        .map(|(left, right)| (left.trim(), Some(right.trim())))
7244        .unwrap_or((path, None));
7245    let segments = path_without_alias
7246        .split("::")
7247        .map(str::trim)
7248        .filter(|segment| !segment.is_empty())
7249        .collect::<Vec<_>>();
7250    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7251    if local_name.chars().next().is_some_and(char::is_uppercase) {
7252        return None;
7253    }
7254    Some((
7255        local_name,
7256        segments
7257            .into_iter()
7258            .map(|segment| segment.to_string())
7259            .collect(),
7260    ))
7261}
7262
7263fn rust_inline_scoped_target(
7264    index: &ProjectIndex<'_>,
7265    caller_file: &str,
7266    module_segments: &[String],
7267    short_name: &str,
7268) -> Option<(String, String)> {
7269    let src_prefix = rust_src_prefix(caller_file);
7270    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7271    file_paths.sort();
7272    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7273        let caller = file_paths.remove(position);
7274        file_paths.insert(0, caller);
7275    }
7276
7277    for file_path in file_paths {
7278        if index.lang_for(&file_path) != Some(LangId::Rust)
7279            || rust_src_prefix(&file_path) != src_prefix
7280        {
7281            continue;
7282        }
7283        let file_module_segments = rust_module_segments_for_rel(&file_path);
7284        if !module_segments.starts_with(&file_module_segments) {
7285            continue;
7286        }
7287        let scoped_segments = &module_segments[file_module_segments.len()..];
7288        if scoped_segments.is_empty() {
7289            continue;
7290        }
7291        let mut scoped_symbol = scoped_segments.join("::");
7292        scoped_symbol.push_str("::");
7293        scoped_symbol.push_str(short_name);
7294        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7295            return Some((file_path, scoped_symbol));
7296        }
7297    }
7298    None
7299}
7300
7301fn rust_target_for_use(
7302    index: &ProjectIndex<'_>,
7303    caller_file: &str,
7304    import: &ImportStatement,
7305    short_name: &str,
7306) -> Option<(String, String)> {
7307    let path = import.module_path.trim().trim_end_matches(';');
7308    if let Some(brace_start) = path.find("::{") {
7309        let prefix = &path[..brace_start];
7310        if import.names.iter().any(|name| name == short_name) {
7311            let prefix_segments: Vec<&str> = prefix.split("::").collect();
7312            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7313            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7314            return Some((file, short_name.to_string()));
7315        }
7316        return None;
7317    }
7318
7319    let (path_without_alias, alias) = path
7320        .split_once(" as ")
7321        .map(|(left, right)| (left.trim(), Some(right.trim())))
7322        .unwrap_or((path, None));
7323    let segments: Vec<&str> = path_without_alias.split("::").collect();
7324    let imported = alias.or_else(|| segments.last().copied())?;
7325    if imported != short_name {
7326        return None;
7327    }
7328    if segments.len() < 2 {
7329        return None;
7330    }
7331    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7332    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7333    Some((file, segments.last().unwrap_or(&short_name).to_string()))
7334}
7335
7336fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7337    let crate_name = segments.first().copied()?;
7338    let src_prefix = index.crate_src_prefix(crate_name)?;
7339    let module_segments = segments[1..]
7340        .iter()
7341        .map(|segment| segment.to_string())
7342        .collect::<Vec<_>>();
7343    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7344}
7345
7346#[cfg(test)]
7347static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7348    OnceLock::new();
7349
7350#[cfg(test)]
7351fn note_workspace_crate_prefix_build(project_root: &Path) {
7352    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7353        .get_or_init(|| Mutex::new(HashMap::new()))
7354        .lock()
7355        .expect("workspace crate prefix build counts mutex poisoned");
7356    *counts.entry(project_root.to_path_buf()).or_default() += 1;
7357}
7358
7359#[cfg(not(test))]
7360fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7361
7362#[cfg(test)]
7363fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7364    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7365        .get_or_init(|| Mutex::new(HashMap::new()))
7366        .lock()
7367        .expect("workspace crate prefix build counts mutex poisoned")
7368        .remove(project_root);
7369}
7370
7371#[cfg(test)]
7372fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7373    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7374        .get_or_init(|| Mutex::new(HashMap::new()))
7375        .lock()
7376        .expect("workspace crate prefix build counts mutex poisoned")
7377        .get(project_root)
7378        .copied()
7379        .unwrap_or(0)
7380}
7381
7382/// Walk the project tree once and map every Rust crate name (package name with
7383/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
7384/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
7385/// longer re-walks the filesystem once per ref.
7386fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7387    note_workspace_crate_prefix_build(project_root);
7388    let mut prefixes = HashMap::new();
7389    let mut stack = vec![project_root.to_path_buf()];
7390    while let Some(dir) = stack.pop() {
7391        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7392        if matches!(name, "target" | "node_modules" | ".git") {
7393            continue;
7394        }
7395        let manifest = dir.join("Cargo.toml");
7396        if manifest.is_file() {
7397            let crate_names = rust_manifest_crate_names(&manifest);
7398            if !crate_names.is_empty() {
7399                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7400                for crate_name in crate_names {
7401                    prefixes
7402                        .entry(crate_name)
7403                        .or_insert_with(|| src_prefix.clone());
7404                }
7405            }
7406        }
7407        let Ok(entries) = std::fs::read_dir(&dir) else {
7408            continue;
7409        };
7410        for entry in entries.flatten() {
7411            let path = entry.path();
7412            if path.is_dir() {
7413                stack.push(path);
7414            }
7415        }
7416    }
7417    prefixes
7418}
7419
7420/// Extract the crate names a manifest defines: the normalized package name
7421/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
7422/// reachable by either spelling, matching the previous match semantics.
7423fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7424    let Ok(source) = std::fs::read_to_string(manifest) else {
7425        return Vec::new();
7426    };
7427    let mut in_lib = false;
7428    let mut package_name = None;
7429    let mut lib_name = None;
7430    for line in source.lines() {
7431        let trimmed = line.trim();
7432        if trimmed.starts_with('[') {
7433            in_lib = trimmed == "[lib]";
7434            continue;
7435        }
7436        let Some((key, value)) = trimmed.split_once('=') else {
7437            continue;
7438        };
7439        let key = key.trim();
7440        let value = value.trim().trim_matches('"');
7441        if in_lib && key == "name" {
7442            lib_name = Some(value.to_string());
7443        } else if !in_lib && key == "name" && package_name.is_none() {
7444            package_name = Some(value.to_string());
7445        }
7446    }
7447    let mut names = Vec::new();
7448    if let Some(lib) = lib_name {
7449        names.push(lib);
7450    }
7451    if let Some(package) = package_name {
7452        let normalized = package.replace('-', "_");
7453        if !names.contains(&normalized) {
7454            names.push(normalized);
7455        }
7456    }
7457    names
7458}
7459
7460fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
7461    if segments.is_empty() {
7462        return Some(Vec::new());
7463    }
7464    let caller_segments = rust_module_segments_for_rel(caller_file);
7465    match segments[0] {
7466        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
7467        "self" => {
7468            let mut resolved = caller_segments;
7469            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7470            Some(resolved)
7471        }
7472        "super" => {
7473            let mut resolved = caller_segments;
7474            resolved.pop();
7475            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7476            Some(resolved)
7477        }
7478        _ => {
7479            let mut resolved = caller_segments;
7480            resolved.pop();
7481            resolved.extend(segments.iter().map(|item| item.to_string()));
7482            Some(resolved)
7483        }
7484    }
7485}
7486
7487fn rust_file_for_segments(
7488    index: &ProjectIndex<'_>,
7489    caller_file: &str,
7490    segments: &[String],
7491) -> Option<String> {
7492    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
7493}
7494
7495fn rust_file_for_src_prefix(
7496    index: &ProjectIndex<'_>,
7497    src_prefix: &str,
7498    segments: &[String],
7499) -> Option<String> {
7500    let candidate = if segments.is_empty() {
7501        [src_prefix, "lib.rs"].join("/")
7502    } else {
7503        format!("{}/{}.rs", src_prefix, segments.join("/"))
7504    };
7505    if index.files.contains_key(&candidate) {
7506        return Some(candidate);
7507    }
7508    if !segments.is_empty() {
7509        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
7510        if index.files.contains_key(&mod_candidate) {
7511            return Some(mod_candidate);
7512        }
7513    }
7514    None
7515}
7516
7517fn rust_src_prefix(rel_path: &str) -> String {
7518    rel_path
7519        .split_once("/src/")
7520        .map(|(prefix, _)| format!("{prefix}/src"))
7521        .unwrap_or_else(|| "src".to_string())
7522}
7523
7524fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
7525    let after_src = rel_path
7526        .split_once("/src/")
7527        .map(|(_, rest)| rest)
7528        .or_else(|| rel_path.strip_prefix("src/"))
7529        .unwrap_or(rel_path);
7530    if matches!(after_src, "lib.rs" | "main.rs") {
7531        return Vec::new();
7532    }
7533    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
7534        return prefix.split('/').map(|item| item.to_string()).collect();
7535    }
7536    after_src
7537        .strip_suffix(".rs")
7538        .unwrap_or(after_src)
7539        .split('/')
7540        .map(|item| item.to_string())
7541        .collect()
7542}
7543
7544fn resolve_local_target(
7545    _index: &ProjectIndex<'_>,
7546    caller_file: &str,
7547    full_ref: &str,
7548    short_name: &str,
7549    caller_data: &FileCallData,
7550) -> Option<(String, String, String)> {
7551    if !callgraph::is_bare_callee(full_ref, short_name) {
7552        return None;
7553    }
7554    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
7555        .ok()
7556        .map(|symbol| {
7557            (
7558                "resolved_local".to_string(),
7559                caller_file.to_string(),
7560                symbol,
7561            )
7562        })
7563}
7564
7565impl<'a> ProjectIndex<'a> {
7566    fn from_parts(
7567        project_root: &Path,
7568        files: HashMap<String, DbFileIndex>,
7569        caller_data: HashMap<String, &'a FileCallData>,
7570        workspace_crate_prefixes: WorkspaceCratePrefixCache,
7571    ) -> Self {
7572        Self {
7573            project_root: project_root.to_path_buf(),
7574            files,
7575            caller_data,
7576            workspace_crate_prefixes,
7577        }
7578    }
7579
7580    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
7581        let mut files = HashMap::new();
7582        let mut caller_data = HashMap::new();
7583        for extract in extracts {
7584            let index = DbFileIndex::from_extract(project_root, extract);
7585            caller_data.insert(extract.rel_path.clone(), &extract.data);
7586            files.insert(extract.rel_path.clone(), index);
7587        }
7588        Self::from_parts(
7589            project_root,
7590            files,
7591            caller_data,
7592            WorkspaceCratePrefixCache::default(),
7593        )
7594    }
7595
7596    fn from_db_and_callers(
7597        tx: &Transaction<'_>,
7598        project_root: &Path,
7599        caller_extracts: &'a HashMap<String, FileExtract>,
7600        workspace_crate_prefixes: WorkspaceCratePrefixCache,
7601    ) -> Result<Self> {
7602        let mut files = load_db_file_indexes(tx, project_root)?;
7603        let mut caller_data = HashMap::new();
7604        for (rel_path, extract) in caller_extracts {
7605            files.insert(
7606                rel_path.clone(),
7607                DbFileIndex::from_extract(project_root, extract),
7608            );
7609            caller_data.insert(rel_path.clone(), &extract.data);
7610        }
7611        Ok(Self::from_parts(
7612            project_root,
7613            files,
7614            caller_data,
7615            workspace_crate_prefixes,
7616        ))
7617    }
7618
7619    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
7620        self.files.get(rel_path).and_then(|file| file.lang)
7621    }
7622
7623    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
7624        self.files
7625            .get(caller_file)
7626            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
7627    }
7628
7629    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
7630        self.files
7631            .get(rel_path)
7632            .map(|file| file.reexports.as_slice())
7633            .unwrap_or(&[])
7634    }
7635
7636    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
7637        self.files.get(rel_path).and_then(|file| {
7638            file.node_by_scoped
7639                .get(symbol)
7640                .cloned()
7641                .or_else(|| file.node_by_bare.get(symbol).cloned())
7642        })
7643    }
7644}
7645
7646impl DbFileIndex {
7647    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
7648        let mut node_by_scoped = HashMap::new();
7649        let mut node_by_bare = HashMap::new();
7650        for node in &extract.nodes {
7651            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
7652            node_by_bare
7653                .entry(node.name.clone())
7654                .or_insert(node.id.clone());
7655        }
7656        let mut export_aliases = HashMap::new();
7657        for raw_ref in &extract.raw_refs {
7658            if raw_ref.kind == "export_alias" {
7659                if let (Some(exported), Some(source_symbol)) =
7660                    (&raw_ref.local_name, &raw_ref.requested_name)
7661                {
7662                    export_aliases.insert(exported.clone(), source_symbol.clone());
7663                }
7664            }
7665        }
7666        let mut module_targets = HashMap::new();
7667        let mut reexports = Vec::new();
7668        for raw_ref in &extract.raw_refs {
7669            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
7670                continue;
7671            }
7672            let Some(module_path) = &raw_ref.module_path else {
7673                continue;
7674            };
7675            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
7676            module_targets
7677                .entry(module_path.clone())
7678                .or_insert_with(|| target_file.clone());
7679            if raw_ref.kind == "reexport" {
7680                reexports.push(reexport_index_from_raw(raw_ref, target_file));
7681            }
7682        }
7683        Self {
7684            lang: Some(extract.lang),
7685            exports: extract.data.exported_symbols.iter().cloned().collect(),
7686            default_export: extract.data.default_export_symbol.clone(),
7687            export_aliases,
7688            node_by_scoped,
7689            node_by_bare,
7690            module_targets,
7691            reexports,
7692        }
7693    }
7694}
7695
7696fn load_db_file_indexes(
7697    tx: &Transaction<'_>,
7698    project_root: &Path,
7699) -> Result<HashMap<String, DbFileIndex>> {
7700    let mut files = HashMap::new();
7701    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
7702    let rows = stmt.query_map([], |row| {
7703        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7704    })?;
7705    for row in rows {
7706        let (rel_path, lang) = row?;
7707        files.insert(
7708            rel_path.clone(),
7709            DbFileIndex {
7710                lang: lang_from_label(&lang),
7711                exports: HashSet::new(),
7712                default_export: None,
7713                export_aliases: HashMap::new(),
7714                node_by_scoped: HashMap::new(),
7715                node_by_bare: HashMap::new(),
7716                module_targets: HashMap::new(),
7717                reexports: Vec::new(),
7718            },
7719        );
7720    }
7721
7722    let mut node_stmt = tx.prepare(
7723        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
7724    )?;
7725    let nodes = node_stmt.query_map([], |row| {
7726        Ok((
7727            row.get::<_, String>(0)?,
7728            row.get::<_, String>(1)?,
7729            row.get::<_, String>(2)?,
7730            row.get::<_, String>(3)?,
7731            row.get::<_, i64>(4)? != 0,
7732            row.get::<_, i64>(5)? != 0,
7733        ))
7734    })?;
7735    for row in nodes {
7736        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
7737        let file = files
7738            .entry(file_path.clone())
7739            .or_insert_with(|| DbFileIndex {
7740                lang: None,
7741                exports: HashSet::new(),
7742                default_export: None,
7743                export_aliases: HashMap::new(),
7744                node_by_scoped: HashMap::new(),
7745                node_by_bare: HashMap::new(),
7746                module_targets: HashMap::new(),
7747                reexports: Vec::new(),
7748            });
7749        if exported {
7750            file.exports.insert(name.clone());
7751            file.exports.insert(scoped_name.clone());
7752        }
7753        if is_default_export {
7754            file.default_export = Some(scoped_name.clone());
7755        }
7756        file.node_by_scoped.insert(scoped_name, id.clone());
7757        file.node_by_bare.entry(name).or_insert(id);
7758    }
7759    let file_keys: HashSet<String> = files.keys().cloned().collect();
7760    // Persisted caller extracts supply import targets. Only reexports from other
7761    // files need dependency reconstruction, and their caller dependencies are
7762    // loaded once instead of issuing repeated SQLite queries per reference.
7763    let dependencies_by_file = load_file_dependencies_index(tx)?;
7764    let mut ref_stmt = tx.prepare(
7765        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7766         FROM refs WHERE kind IN ('reexport', 'export_alias')",
7767    )?;
7768    let ref_rows = ref_stmt.query_map([], |row| {
7769        Ok((
7770            row.get::<_, String>(0)?,
7771            row.get::<_, String>(1)?,
7772            row.get::<_, String>(2)?,
7773            row.get::<_, Option<String>>(3)?,
7774            row.get::<_, Option<String>>(4)?,
7775            row.get::<_, i64>(5)? != 0,
7776            row.get::<_, Option<String>>(6)?,
7777            row.get::<_, Option<String>>(7)?,
7778        ))
7779    })?;
7780    for row in ref_rows {
7781        let (
7782            ref_id,
7783            caller_file,
7784            kind,
7785            module_path,
7786            full_ref,
7787            wildcard,
7788            local_name,
7789            requested_name,
7790        ) = row?;
7791        if kind == "export_alias" {
7792            if let (Some(exported), Some(source_symbol), Some(file)) =
7793                (local_name, requested_name, files.get_mut(&caller_file))
7794            {
7795                file.export_aliases.insert(exported, source_symbol);
7796            }
7797            continue;
7798        }
7799        let Some(module_path) = module_path else {
7800            continue;
7801        };
7802        let file_deps = dependencies_by_file
7803            .get(&caller_file)
7804            .cloned()
7805            .unwrap_or_default();
7806        let deps = stored_dependencies_for_module(
7807            project_root,
7808            &caller_file,
7809            &module_path,
7810            &file_deps,
7811            &file_keys,
7812        );
7813        let target_file = deps
7814            .iter()
7815            .find(|dep| file_keys.contains(*dep))
7816            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7817        if let Some(file) = files.get_mut(&caller_file) {
7818            file.module_targets
7819                .entry(module_path.clone())
7820                .or_insert_with(|| target_file.clone());
7821            if kind == "reexport" {
7822                let raw = RawRef {
7823                    ref_id,
7824                    caller_node: None,
7825                    caller_symbol: None,
7826                    caller_file,
7827                    kind,
7828                    short_name: None,
7829                    full_ref,
7830                    module_path: Some(module_path),
7831                    import_kind: Some("reexport".to_string()),
7832                    local_name: None,
7833                    requested_name: None,
7834                    namespace_alias: None,
7835                    wildcard,
7836                    line: 0,
7837                    byte_start: 0,
7838                    byte_end: 0,
7839                    dependencies: deps,
7840                };
7841                file.reexports
7842                    .push(reexport_index_from_raw(&raw, target_file));
7843            }
7844        }
7845    }
7846
7847    Ok(files)
7848}
7849
7850fn stored_dependencies_for_module(
7851    project_root: &Path,
7852    caller_file: &str,
7853    module_path: &str,
7854    caller_dependencies: &BTreeSet<String>,
7855    indexed_files: &HashSet<String>,
7856) -> BTreeSet<String> {
7857    let caller_path = project_root.join(caller_file);
7858    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7859    if module_path.starts_with('.') {
7860        let caller_dir = caller_path.parent().unwrap_or(project_root);
7861        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7862            let normalized = if candidate.is_file() {
7863                canonicalize_path(&candidate)
7864            } else {
7865                candidate
7866            };
7867            candidates.insert(relative_path(project_root, &normalized));
7868        }
7869    }
7870    let exact = candidates
7871        .intersection(caller_dependencies)
7872        .filter(|dependency| indexed_files.contains(*dependency))
7873        .cloned()
7874        .collect::<BTreeSet<_>>();
7875    if !exact.is_empty() || module_path.starts_with('.') {
7876        return exact;
7877    }
7878
7879    let module_path = rust_module_path_without_alias_or_use_list(module_path)
7880        .trim_matches(|character| matches!(character, '\'' | '"'));
7881    let package_name = module_path
7882        .split('/')
7883        .next_back()
7884        .unwrap_or(module_path)
7885        .replace('_', "-");
7886    let matched = caller_dependencies
7887        .iter()
7888        .filter(|dependency| indexed_files.contains(*dependency))
7889        .filter(|dependency| {
7890            dependency.as_str() == module_path
7891                || dependency.ends_with(&format!("/{module_path}"))
7892                || Path::new(dependency).components().any(|component| {
7893                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7894                })
7895        })
7896        .cloned()
7897        .collect::<BTreeSet<_>>();
7898    if matched.len() == 1 {
7899        matched
7900    } else {
7901        BTreeSet::new()
7902    }
7903}
7904
7905fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7906    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7907    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7908    let rows = stmt.query_map([], |row| {
7909        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7910    })?;
7911    for row in rows {
7912        let (file_path, dependency) = row?;
7913        by_file.entry(file_path).or_default().insert(dependency);
7914    }
7915    Ok(by_file)
7916}
7917
7918struct ColdBuildInsertStatements<'stmt> {
7919    file: Statement<'stmt>,
7920    node: Statement<'stmt>,
7921    file_dependency: Statement<'stmt>,
7922    dispatch_hint: Statement<'stmt>,
7923    backend_state: Statement<'stmt>,
7924    reference: Statement<'stmt>,
7925    edge: Statement<'stmt>,
7926}
7927
7928impl<'stmt> ColdBuildInsertStatements<'stmt> {
7929    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7930        Ok(Self {
7931            file: tx.prepare(
7932                "INSERT OR REPLACE INTO files(
7933                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7934                    is_public_api, surface_fingerprint, indexed_at
7935                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7936            )?,
7937            node: tx.prepare(
7938                "INSERT OR REPLACE INTO nodes(
7939                    id, file_path, name, scoped_name, kind, start_line, start_col,
7940                    end_line, end_col, range_ordinal, signature, exported,
7941                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
7942                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7943            )?,
7944            file_dependency: tx.prepare(
7945                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7946            )?,
7947            dispatch_hint: tx.prepare(
7948                "INSERT OR REPLACE INTO dispatch_hints(
7949                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7950                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7951            )?,
7952            backend_state: tx.prepare(
7953                "INSERT OR REPLACE INTO backend_file_state(
7954                    backend, workspace_root, file_path, content_hash, status, updated_at
7955                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7956            )?,
7957            reference: tx.prepare(
7958                "INSERT OR REPLACE INTO refs(
7959                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7960                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7961                    byte_start, byte_end, status, target_node, target_file, target_symbol,
7962                    provenance
7963                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7964            )?,
7965            edge: tx.prepare(
7966                "INSERT OR REPLACE INTO edges(
7967                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7968                    kind, line, provenance
7969                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7970            )?,
7971        })
7972    }
7973}
7974
7975fn insert_file_extract_prepared(
7976    statements: &mut ColdBuildInsertStatements<'_>,
7977    workspace_root: &str,
7978    extract: &FileExtract,
7979) -> Result<()> {
7980    statements.file.execute(params![
7981        extract.rel_path,
7982        hash_to_hex(extract.freshness.content_hash),
7983        system_time_to_ns(extract.freshness.mtime),
7984        extract.freshness.size as i64,
7985        lang_label(extract.lang),
7986        extract.surface_fingerprint,
7987        unix_seconds_now(),
7988    ])?;
7989    for node in &extract.nodes {
7990        statements.node.execute(params![
7991            node.id,
7992            node.file_path,
7993            node.name,
7994            node.scoped_name,
7995            node.kind,
7996            node.range.start_line as i64,
7997            node.range.start_col as i64,
7998            node.range.end_line as i64,
7999            node.range.end_col as i64,
8000            node.range_ordinal as i64,
8001            node.signature,
8002            bool_int(node.exported),
8003            bool_int(node.is_default_export),
8004            bool_int(node.is_type_like),
8005            bool_int(node.is_callgraph_entry_point),
8006            PROVENANCE_TREESITTER,
8007        ])?;
8008    }
8009
8010    let mut dependencies = BTreeSet::new();
8011    for raw_ref in &extract.raw_refs {
8012        dependencies.extend(raw_ref.dependencies.iter().cloned());
8013    }
8014    for dep_file in &dependencies {
8015        statements
8016            .file_dependency
8017            .execute(params![extract.rel_path, dep_file])?;
8018    }
8019
8020    for hint in &extract.dispatch_hints {
8021        statements.dispatch_hint.execute(params![
8022            hint.id,
8023            hint.method_name,
8024            hint.caller_node,
8025            hint.file,
8026            hint.line as i64,
8027            hint.byte_start as i64,
8028            hint.byte_end as i64,
8029            PROVENANCE_TREESITTER,
8030        ])?;
8031    }
8032    insert_backend_state_prepared(
8033        &mut statements.backend_state,
8034        workspace_root,
8035        &extract.rel_path,
8036        Some(&extract.freshness.content_hash),
8037        "fresh",
8038    )?;
8039    Ok(())
8040}
8041
8042fn insert_backend_state_prepared(
8043    stmt: &mut Statement<'_>,
8044    workspace_root: &str,
8045    rel_path: &str,
8046    content_hash: Option<&blake3::Hash>,
8047    status: &str,
8048) -> Result<()> {
8049    let hash = content_hash
8050        .map(|hash| hash_to_hex(*hash))
8051        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8052    stmt.execute(params![
8053        BACKEND_TREESITTER,
8054        workspace_root,
8055        rel_path,
8056        hash,
8057        status,
8058        unix_seconds_now(),
8059    ])?;
8060    Ok(())
8061}
8062
8063fn insert_resolved_ref_prepared(
8064    statements: &mut ColdBuildInsertStatements<'_>,
8065    resolved: &ResolvedRef,
8066) -> Result<()> {
8067    let raw = &resolved.raw;
8068    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8069    statements.reference.execute(params![
8070        raw.ref_id,
8071        raw.caller_node,
8072        raw.caller_file,
8073        raw.kind,
8074        raw.short_name,
8075        raw.full_ref,
8076        raw.module_path,
8077        raw.import_kind,
8078        raw.local_name,
8079        raw.requested_name,
8080        raw.namespace_alias,
8081        bool_int(raw.wildcard),
8082        raw.line as i64,
8083        raw.byte_start as i64,
8084        raw.byte_end as i64,
8085        resolved.status,
8086        resolved.target_node,
8087        resolved.target_file,
8088        resolved.target_symbol,
8089        PROVENANCE_TREESITTER,
8090    ])?;
8091    if let Some(edge) = &resolved.edge {
8092        statements.edge.execute(params![
8093            edge.edge_id,
8094            raw.ref_id,
8095            edge.source_node,
8096            edge.target_node,
8097            edge.target_file,
8098            edge.target_symbol,
8099            edge.kind,
8100            edge.line as i64,
8101            PROVENANCE_TREESITTER,
8102        ])?;
8103    }
8104    Ok(())
8105}
8106
8107fn insert_file_extract(
8108    tx: &Transaction<'_>,
8109    project_root: &Path,
8110    extract: &FileExtract,
8111) -> Result<()> {
8112    tx.execute(
8113        "INSERT OR REPLACE INTO files(
8114            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8115            is_public_api, surface_fingerprint, indexed_at
8116        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8117        params![
8118            extract.rel_path,
8119            hash_to_hex(extract.freshness.content_hash),
8120            system_time_to_ns(extract.freshness.mtime),
8121            extract.freshness.size as i64,
8122            lang_label(extract.lang),
8123            extract.surface_fingerprint,
8124            unix_seconds_now(),
8125        ],
8126    )?;
8127    for node in &extract.nodes {
8128        tx.execute(
8129            "INSERT OR REPLACE INTO nodes(
8130                id, file_path, name, scoped_name, kind, start_line, start_col,
8131                end_line, end_col, range_ordinal, signature, exported,
8132                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8133            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8134            params![
8135                node.id,
8136                node.file_path,
8137                node.name,
8138                node.scoped_name,
8139                node.kind,
8140                node.range.start_line as i64,
8141                node.range.start_col as i64,
8142                node.range.end_line as i64,
8143                node.range.end_col as i64,
8144                node.range_ordinal as i64,
8145                node.signature,
8146                bool_int(node.exported),
8147                bool_int(node.is_default_export),
8148                bool_int(node.is_type_like),
8149                bool_int(node.is_callgraph_entry_point),
8150                PROVENANCE_TREESITTER,
8151            ],
8152        )?;
8153    }
8154    let mut dependencies = BTreeSet::new();
8155    for raw_ref in &extract.raw_refs {
8156        dependencies.extend(raw_ref.dependencies.iter().cloned());
8157    }
8158    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8159
8160    for hint in &extract.dispatch_hints {
8161        tx.execute(
8162            "INSERT OR REPLACE INTO dispatch_hints(
8163                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8164            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8165            params![
8166                hint.id,
8167                hint.method_name,
8168                hint.caller_node,
8169                hint.file,
8170                hint.line as i64,
8171                hint.byte_start as i64,
8172                hint.byte_end as i64,
8173                PROVENANCE_TREESITTER,
8174            ],
8175        )?;
8176    }
8177    mark_backend_state(
8178        tx,
8179        project_root,
8180        &extract.rel_path,
8181        Some(&extract.freshness.content_hash),
8182        "fresh",
8183    )?;
8184    Ok(())
8185}
8186
8187fn insert_file_dependencies(
8188    tx: &Transaction<'_>,
8189    file_path: &str,
8190    dependencies: &BTreeSet<String>,
8191) -> Result<()> {
8192    for dep_file in dependencies {
8193        tx.execute(
8194            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8195            params![file_path, dep_file],
8196        )?;
8197    }
8198    Ok(())
8199}
8200
8201fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8202    let raw = &resolved.raw;
8203    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8204    tx.execute(
8205        "INSERT OR REPLACE INTO refs(
8206            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8207            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8208            byte_start, byte_end, status, target_node, target_file, target_symbol,
8209            provenance
8210        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8211        params![
8212            raw.ref_id,
8213            raw.caller_node,
8214            raw.caller_file,
8215            raw.kind,
8216            raw.short_name,
8217            raw.full_ref,
8218            raw.module_path,
8219            raw.import_kind,
8220            raw.local_name,
8221            raw.requested_name,
8222            raw.namespace_alias,
8223            bool_int(raw.wildcard),
8224            raw.line as i64,
8225            raw.byte_start as i64,
8226            raw.byte_end as i64,
8227            resolved.status,
8228            resolved.target_node,
8229            resolved.target_file,
8230            resolved.target_symbol,
8231            PROVENANCE_TREESITTER,
8232        ],
8233    )?;
8234    if let Some(edge) = &resolved.edge {
8235        tx.execute(
8236            "INSERT OR REPLACE INTO edges(
8237                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8238                kind, line, provenance
8239            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8240            params![
8241                edge.edge_id,
8242                raw.ref_id,
8243                edge.source_node,
8244                edge.target_node,
8245                edge.target_file,
8246                edge.target_symbol,
8247                edge.kind,
8248                edge.line as i64,
8249                PROVENANCE_TREESITTER,
8250            ],
8251        )?;
8252    }
8253    Ok(())
8254}
8255
8256fn insert_method_dispatch_edges(
8257    tx: &Transaction<'_>,
8258    project_root: &Path,
8259    caller_files: Option<&BTreeSet<String>>,
8260) -> Result<usize> {
8261    let references = load_name_match_refs(tx, caller_files)?;
8262    if references.is_empty() {
8263        return Ok(0);
8264    }
8265
8266    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8267    let mut source_cache: DispatchSourceCache = HashMap::new();
8268    let mut inserted = 0usize;
8269    for reference in references {
8270        let key = (reference.method_name.clone(), reference.lang.clone());
8271        let candidates = match candidates_by_name.entry(key) {
8272            Entry::Occupied(entry) => entry.into_mut(),
8273            Entry::Vacant(entry) => {
8274                let candidates =
8275                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8276                entry.insert(candidates)
8277            }
8278        };
8279
8280        if let Some(receiver_type) =
8281            infer_receiver_type(project_root, &reference, &mut source_cache)
8282        {
8283            let Some(candidate) =
8284                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8285            else {
8286                continue;
8287            };
8288            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8289            inserted += 1;
8290            continue;
8291        }
8292
8293        if method_name_match_denylisted(&reference.method_name) {
8294            continue;
8295        }
8296
8297        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8298            continue;
8299        };
8300        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8301        inserted += 1;
8302    }
8303    Ok(inserted)
8304}
8305
8306fn insert_method_dispatch_edges_chunked(
8307    tx: &Transaction<'_>,
8308    project_root: &Path,
8309    caller_files: &BTreeSet<String>,
8310    chunk_size: usize,
8311) -> Result<usize> {
8312    if caller_files.is_empty() {
8313        return Ok(0);
8314    }
8315    if chunk_size == 0 || caller_files.len() <= chunk_size {
8316        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8317    }
8318
8319    let mut inserted = 0usize;
8320    let mut batch = BTreeSet::new();
8321    for caller_file in caller_files {
8322        batch.insert(caller_file.clone());
8323        if batch.len() == chunk_size {
8324            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8325            batch.clear();
8326        }
8327    }
8328    if !batch.is_empty() {
8329        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8330    }
8331    Ok(inserted)
8332}
8333
8334fn insert_method_dispatch_edge(
8335    tx: &Transaction<'_>,
8336    reference: &NameMatchRef,
8337    candidate: &NameMatchCandidate,
8338    provenance: &str,
8339) -> Result<()> {
8340    tx.execute(
8341        "INSERT OR REPLACE INTO edges(
8342            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8343            kind, line, provenance
8344        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8345        params![
8346            ref_id(&[&reference.ref_id, provenance, "edge"]),
8347            &reference.ref_id,
8348            &reference.caller_node,
8349            &candidate.node_id,
8350            &candidate.file_path,
8351            &candidate.scoped_name,
8352            reference.line as i64,
8353            provenance,
8354        ],
8355    )?;
8356    Ok(())
8357}
8358
8359fn delete_method_dispatch_edges_for_callers(
8360    tx: &Transaction<'_>,
8361    caller_files: &BTreeSet<String>,
8362) -> Result<()> {
8363    if caller_files.is_empty() {
8364        return Ok(());
8365    }
8366
8367    let mut stmt = tx.prepare(
8368        "DELETE FROM edges
8369         WHERE provenance IN (?1, ?2)
8370           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8371    )?;
8372    for caller_file in caller_files {
8373        stmt.execute(params![
8374            PROVENANCE_NAME_MATCH,
8375            PROVENANCE_TYPE_MATCH,
8376            caller_file
8377        ])?;
8378    }
8379    Ok(())
8380}
8381
8382fn load_name_match_refs(
8383    tx: &Transaction<'_>,
8384    caller_files: Option<&BTreeSet<String>>,
8385) -> Result<Vec<NameMatchRef>> {
8386    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8387                           n.signature, r.short_name, r.full_ref, r.line, f.lang
8388                    FROM refs r
8389                    JOIN files f ON f.path = r.caller_file
8390                    JOIN nodes n ON n.id = r.caller_node
8391                    WHERE r.kind = 'call'
8392                      AND r.status = 'unresolved'
8393                      AND r.caller_node IS NOT NULL
8394                      AND r.full_ref IS NOT NULL
8395                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8396                      AND NOT EXISTS (
8397                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8398                      )";
8399    let mut references = Vec::new();
8400
8401    if let Some(caller_files) = caller_files {
8402        if caller_files.is_empty() {
8403            return Ok(references);
8404        }
8405        let sql = format!(
8406            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8407        );
8408        let mut stmt = tx.prepare(&sql)?;
8409        for caller_file in caller_files {
8410            let rows = stmt.query_map(params![caller_file], |row| {
8411                Ok((
8412                    row.get::<_, String>(0)?,
8413                    row.get::<_, Option<String>>(1)?,
8414                    row.get::<_, String>(2)?,
8415                    row.get::<_, String>(3)?,
8416                    row.get::<_, Option<String>>(4)?,
8417                    row.get::<_, Option<String>>(5)?,
8418                    row.get::<_, Option<String>>(6)?,
8419                    row.get::<_, i64>(7)?,
8420                    row.get::<_, String>(8)?,
8421                ))
8422            })?;
8423            for row in rows {
8424                let (
8425                    ref_id,
8426                    caller_node,
8427                    caller_file,
8428                    caller_symbol,
8429                    caller_signature,
8430                    short_name,
8431                    full_ref,
8432                    line,
8433                    lang,
8434                ) = row?;
8435                if let Some(reference) = name_match_ref_from_parts(
8436                    ref_id,
8437                    caller_node,
8438                    caller_file,
8439                    caller_symbol,
8440                    caller_signature,
8441                    short_name,
8442                    full_ref,
8443                    line,
8444                    lang,
8445                ) {
8446                    references.push(reference);
8447                }
8448            }
8449        }
8450        return Ok(references);
8451    }
8452
8453    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
8454    let mut stmt = tx.prepare(&sql)?;
8455    let rows = stmt.query_map([], |row| {
8456        Ok((
8457            row.get::<_, String>(0)?,
8458            row.get::<_, Option<String>>(1)?,
8459            row.get::<_, String>(2)?,
8460            row.get::<_, String>(3)?,
8461            row.get::<_, Option<String>>(4)?,
8462            row.get::<_, Option<String>>(5)?,
8463            row.get::<_, Option<String>>(6)?,
8464            row.get::<_, i64>(7)?,
8465            row.get::<_, String>(8)?,
8466        ))
8467    })?;
8468    for row in rows {
8469        let (
8470            ref_id,
8471            caller_node,
8472            caller_file,
8473            caller_symbol,
8474            caller_signature,
8475            short_name,
8476            full_ref,
8477            line,
8478            lang,
8479        ) = row?;
8480        if let Some(reference) = name_match_ref_from_parts(
8481            ref_id,
8482            caller_node,
8483            caller_file,
8484            caller_symbol,
8485            caller_signature,
8486            short_name,
8487            full_ref,
8488            line,
8489            lang,
8490        ) {
8491            references.push(reference);
8492        }
8493    }
8494    Ok(references)
8495}
8496
8497#[allow(clippy::too_many_arguments)]
8498fn name_match_ref_from_parts(
8499    ref_id: String,
8500    caller_node: Option<String>,
8501    caller_file: String,
8502    caller_symbol: String,
8503    caller_signature: Option<String>,
8504    short_name: Option<String>,
8505    full_ref: Option<String>,
8506    line: i64,
8507    lang: String,
8508) -> Option<NameMatchRef> {
8509    let caller_node = caller_node?;
8510    let full_ref = full_ref?;
8511    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
8512    let method_name = if member.is_empty() {
8513        short_name.as_deref()?.to_string()
8514    } else {
8515        member
8516    };
8517    Some(NameMatchRef {
8518        ref_id,
8519        caller_node,
8520        caller_file,
8521        caller_symbol,
8522        caller_signature,
8523        receiver,
8524        method_name,
8525        colon_dispatch,
8526        line: line.max(0) as u32,
8527        lang,
8528    })
8529}
8530
8531fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
8532    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
8533    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
8534    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
8535    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
8536        .into_iter()
8537        .flatten()
8538        .max_by_key(|(index, _, _)| *index)?;
8539    if delimiter == 0 {
8540        return None;
8541    }
8542    let member_start = delimiter + delimiter_len;
8543    if member_start >= full_ref.len() {
8544        return None;
8545    }
8546    let receiver = last_name_segment(&full_ref[..delimiter]);
8547    let member = &full_ref[member_start..];
8548    if receiver.is_empty() || member.is_empty() {
8549        return None;
8550    }
8551    Some((receiver.to_string(), member.to_string(), colon_dispatch))
8552}
8553
8554fn last_name_segment(value: &str) -> &str {
8555    value
8556        .rsplit(['.', ':', '/', '\\', '-', '>'])
8557        .find(|segment| !segment.is_empty())
8558        .unwrap_or(value)
8559}
8560
8561fn load_name_match_candidates(
8562    tx: &Transaction<'_>,
8563    method_name: &str,
8564    lang: &str,
8565) -> Result<Vec<NameMatchCandidate>> {
8566    let mut stmt = tx.prepare(
8567        "SELECT n.id, n.file_path, n.scoped_name, n.kind
8568         FROM nodes n JOIN files f ON f.path = n.file_path
8569         WHERE n.name = ?1
8570           AND f.lang = ?2
8571           AND n.kind IN ('method', 'function')
8572         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
8573    )?;
8574    let rows = stmt.query_map(params![method_name, lang], |row| {
8575        Ok(NameMatchCandidate {
8576            node_id: row.get(0)?,
8577            file_path: row.get(1)?,
8578            scoped_name: row.get(2)?,
8579            kind: row.get(3)?,
8580        })
8581    })?;
8582    rows.collect::<std::result::Result<Vec<_>, _>>()
8583        .map_err(Into::into)
8584}
8585
8586struct ParsedDispatchSource {
8587    source: String,
8588    tree: tree_sitter::Tree,
8589}
8590
8591type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
8592
8593fn infer_receiver_type(
8594    project_root: &Path,
8595    reference: &NameMatchRef,
8596    source_cache: &mut DispatchSourceCache,
8597) -> Option<String> {
8598    match reference.lang.as_str() {
8599        "rust" => infer_rust_receiver_type(reference),
8600        "java" => {
8601            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
8602        }
8603        "kotlin" => {
8604            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
8605        }
8606        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
8607        _ => None,
8608    }
8609}
8610
8611fn parse_dispatch_source(
8612    project_root: &Path,
8613    caller_file: &str,
8614    lang: LangId,
8615) -> Option<ParsedDispatchSource> {
8616    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
8617    let grammar = crate::parser::grammar_for(lang);
8618    let mut parser = tree_sitter::Parser::new();
8619    parser.set_language(&grammar).ok()?;
8620    let tree = parser.parse(&source, None)?;
8621    Some(ParsedDispatchSource { source, tree })
8622}
8623
8624fn parsed_dispatch_source<'a>(
8625    project_root: &Path,
8626    reference: &NameMatchRef,
8627    lang: LangId,
8628    source_cache: &'a mut DispatchSourceCache,
8629) -> Option<&'a ParsedDispatchSource> {
8630    let key = (reference.caller_file.clone(), reference.lang.clone());
8631    source_cache
8632        .entry(key)
8633        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
8634        .as_ref()
8635}
8636
8637fn infer_java_like_receiver_type(
8638    project_root: &Path,
8639    reference: &NameMatchRef,
8640    lang: LangId,
8641    source_cache: &mut DispatchSourceCache,
8642) -> Option<String> {
8643    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8644        return None;
8645    }
8646
8647    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
8648    let root = parsed.tree.root_node();
8649    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
8650
8651    let callable_scope = type_node
8652        .and_then(|node| {
8653            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
8654        })
8655        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
8656
8657    if let Some(callable_scope) = callable_scope {
8658        if let Some(receiver_type) = infer_java_like_local_receiver_type(
8659            callable_scope,
8660            &parsed.source,
8661            &reference.receiver,
8662            reference.line.max(1),
8663            lang,
8664        ) {
8665            return Some(receiver_type);
8666        }
8667    }
8668
8669    type_node.and_then(|node| {
8670        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
8671    })
8672}
8673
8674fn infer_cpp_receiver_type(
8675    project_root: &Path,
8676    reference: &NameMatchRef,
8677    source_cache: &mut DispatchSourceCache,
8678) -> Option<String> {
8679    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8680        return None;
8681    }
8682
8683    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
8684    let root = parsed.tree.root_node();
8685    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
8686    infer_cpp_receiver_type_from_scope(
8687        scope,
8688        &parsed.source,
8689        &reference.receiver,
8690        reference.line.max(1),
8691    )
8692}
8693
8694fn find_enclosing_java_like_type_node<'tree>(
8695    root: tree_sitter::Node<'tree>,
8696    source: &str,
8697    reference: &NameMatchRef,
8698    lang: LangId,
8699) -> Option<tree_sitter::Node<'tree>> {
8700    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
8701        .and_then(|name| simple_type_name(&name));
8702    let line = reference.line.max(1);
8703    let mut best = None;
8704    let mut stack = vec![root];
8705    while let Some(node) = stack.pop() {
8706        if !node_contains_line(node, line) {
8707            continue;
8708        }
8709        if is_java_like_type_kind(node.kind(), lang) {
8710            let name = declaration_name(node, source);
8711            if expected_type
8712                .as_deref()
8713                .is_none_or(|expected| name == Some(expected))
8714            {
8715                best = tighter_node(best, node);
8716            }
8717        }
8718        push_named_children(node, &mut stack);
8719    }
8720    best
8721}
8722
8723fn find_enclosing_java_like_callable_node<'tree>(
8724    root: tree_sitter::Node<'tree>,
8725    source: &str,
8726    reference: &NameMatchRef,
8727    lang: LangId,
8728) -> Option<tree_sitter::Node<'tree>> {
8729    let expected_name = reference.caller_symbol.rsplit("::").next();
8730    let line = reference.line.max(1);
8731    let mut best = None;
8732    let mut stack = vec![root];
8733    while let Some(node) = stack.pop() {
8734        if !node_contains_line(node, line) {
8735            continue;
8736        }
8737        if is_java_like_callable_kind(node.kind(), lang) {
8738            let name = declaration_name(node, source);
8739            if expected_name.is_none_or(|expected| name == Some(expected)) {
8740                best = tighter_node(best, node);
8741            }
8742        }
8743        push_named_children(node, &mut stack);
8744    }
8745    best
8746}
8747
8748fn find_enclosing_cpp_callable_node<'tree>(
8749    root: tree_sitter::Node<'tree>,
8750    _source: &str,
8751    reference: &NameMatchRef,
8752) -> Option<tree_sitter::Node<'tree>> {
8753    let line = reference.line.max(1);
8754    let mut best = None;
8755    let mut stack = vec![root];
8756    while let Some(node) = stack.pop() {
8757        if !node_contains_line(node, line) {
8758            continue;
8759        }
8760        if node.kind() == "function_definition" {
8761            best = tighter_node(best, node);
8762        }
8763        push_named_children(node, &mut stack);
8764    }
8765    best
8766}
8767
8768fn tighter_node<'tree>(
8769    current: Option<tree_sitter::Node<'tree>>,
8770    candidate: tree_sitter::Node<'tree>,
8771) -> Option<tree_sitter::Node<'tree>> {
8772    match current {
8773        Some(current)
8774            if current.start_byte() > candidate.start_byte()
8775                || (current.start_byte() == candidate.start_byte()
8776                    && current.end_byte() <= candidate.end_byte()) =>
8777        {
8778            Some(current)
8779        }
8780        _ => Some(candidate),
8781    }
8782}
8783
8784fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8785    let start = node.start_position().row as u32 + 1;
8786    let end = node.end_position().row as u32 + 1;
8787    start <= line && line <= end
8788}
8789
8790fn push_named_children<'tree>(
8791    node: tree_sitter::Node<'tree>,
8792    stack: &mut Vec<tree_sitter::Node<'tree>>,
8793) {
8794    for index in 0..node.named_child_count() {
8795        if let Some(child) = node.named_child(index as u32) {
8796            stack.push(child);
8797        }
8798    }
8799}
8800
8801fn declaration_name<'source>(
8802    node: tree_sitter::Node<'_>,
8803    source: &'source str,
8804) -> Option<&'source str> {
8805    node.child_by_field_name("name")
8806        .map(|name| node_text(name, source))
8807        .or_else(|| {
8808            first_named_child_text(
8809                node,
8810                source,
8811                &["identifier", "type_identifier", "simple_identifier"],
8812            )
8813        })
8814}
8815
8816fn first_named_child_text<'source>(
8817    node: tree_sitter::Node<'_>,
8818    source: &'source str,
8819    kinds: &[&str],
8820) -> Option<&'source str> {
8821    for index in 0..node.named_child_count() {
8822        let child = node.named_child(index as u32)?;
8823        if kinds.contains(&child.kind()) {
8824            return Some(node_text(child, source));
8825        }
8826    }
8827    None
8828}
8829
8830fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8831    &source[node.byte_range()]
8832}
8833
8834fn infer_java_like_field_receiver_type(
8835    type_node: tree_sitter::Node<'_>,
8836    source: &str,
8837    receiver: &str,
8838    lang: LangId,
8839) -> Option<String> {
8840    let mut stack = Vec::new();
8841    push_named_children(type_node, &mut stack);
8842    while let Some(node) = stack.pop() {
8843        if is_java_like_field_kind(node.kind(), lang) {
8844            if let Some(receiver_type) =
8845                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8846            {
8847                return Some(receiver_type);
8848            }
8849        }
8850        if is_java_like_type_kind(node.kind(), lang)
8851            || is_java_like_callable_kind(node.kind(), lang)
8852        {
8853            continue;
8854        }
8855        push_named_children(node, &mut stack);
8856    }
8857    None
8858}
8859
8860fn infer_java_like_local_receiver_type(
8861    callable_node: tree_sitter::Node<'_>,
8862    source: &str,
8863    receiver: &str,
8864    call_line: u32,
8865    lang: LangId,
8866) -> Option<String> {
8867    let mut best: Option<(u32, String)> = None;
8868    let mut stack = Vec::new();
8869    push_named_children(callable_node, &mut stack);
8870    while let Some(node) = stack.pop() {
8871        let start_line = node.start_position().row as u32 + 1;
8872        if start_line > call_line {
8873            continue;
8874        }
8875        if is_java_like_local_kind(node.kind(), lang) {
8876            if let Some(receiver_type) =
8877                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8878            {
8879                if best
8880                    .as_ref()
8881                    .is_none_or(|(best_line, _)| start_line >= *best_line)
8882                {
8883                    best = Some((start_line, receiver_type));
8884                }
8885            }
8886        }
8887        if is_java_like_type_kind(node.kind(), lang)
8888            || is_java_like_callable_kind(node.kind(), lang)
8889        {
8890            continue;
8891        }
8892        push_named_children(node, &mut stack);
8893    }
8894    best.map(|(_, receiver_type)| receiver_type)
8895}
8896
8897fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8898    match lang {
8899        LangId::Java => matches!(
8900            kind,
8901            "class_declaration"
8902                | "interface_declaration"
8903                | "enum_declaration"
8904                | "record_declaration"
8905                | "annotation_type_declaration"
8906        ),
8907        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8908        _ => false,
8909    }
8910}
8911
8912fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8913    match lang {
8914        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8915        LangId::Kotlin => kind == "function_declaration",
8916        _ => false,
8917    }
8918}
8919
8920fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8921    match lang {
8922        LangId::Java => kind == "field_declaration",
8923        LangId::Kotlin => kind == "property_declaration",
8924        _ => false,
8925    }
8926}
8927
8928fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8929    match lang {
8930        LangId::Java => kind == "local_variable_declaration",
8931        LangId::Kotlin => kind == "property_declaration",
8932        _ => false,
8933    }
8934}
8935
8936fn extract_java_like_declared_type(
8937    declaration: &str,
8938    receiver: &str,
8939    lang: LangId,
8940) -> Option<String> {
8941    match lang {
8942        LangId::Java => extract_java_declared_type(declaration, receiver),
8943        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8944        _ => None,
8945    }
8946}
8947
8948fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8949    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8950    let after = declaration[receiver_start + receiver.len()..].trim_start();
8951    if after
8952        .chars()
8953        .next()
8954        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8955    {
8956        return None;
8957    }
8958
8959    let before = declaration[..receiver_start].trim_end();
8960    if before.contains(',') {
8961        return None;
8962    }
8963    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8964}
8965
8966fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8967    loop {
8968        value = value.trim_start();
8969        if let Some(stripped) = strip_leading_java_annotation(value) {
8970            value = stripped;
8971            continue;
8972        }
8973        if let Some(stripped) = strip_leading_java_modifier(value) {
8974            value = stripped;
8975            continue;
8976        }
8977        return value.trim();
8978    }
8979}
8980
8981fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8982    let value = value.trim_start();
8983    let mut chars = value.char_indices();
8984    let (_, first) = chars.next()?;
8985    if first != '@' {
8986        return None;
8987    }
8988    let mut end = first.len_utf8();
8989    for (index, ch) in chars {
8990        if !(is_code_ident_char(ch) || ch == '.') {
8991            end = index;
8992            break;
8993        }
8994        end = index + ch.len_utf8();
8995    }
8996    let rest = value[end..].trim_start();
8997    if let Some(stripped) = rest.strip_prefix('(') {
8998        let mut depth = 1usize;
8999        for (index, ch) in stripped.char_indices() {
9000            match ch {
9001                '(' => depth += 1,
9002                ')' => {
9003                    depth = depth.saturating_sub(1);
9004                    if depth == 0 {
9005                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9006                    }
9007                }
9008                _ => {}
9009            }
9010        }
9011        return Some("");
9012    }
9013    Some(rest)
9014}
9015
9016fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9017    const MODIFIERS: &[&str] = &[
9018        "public",
9019        "protected",
9020        "private",
9021        "abstract",
9022        "static",
9023        "final",
9024        "transient",
9025        "volatile",
9026        "synchronized",
9027        "native",
9028        "strictfp",
9029    ];
9030    MODIFIERS
9031        .iter()
9032        .find_map(|modifier| strip_leading_word(value, modifier))
9033}
9034
9035fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9036    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9037    let before = &declaration[..receiver_start];
9038    if find_identifier_occurrence(before, "val").is_none()
9039        && find_identifier_occurrence(before, "var").is_none()
9040    {
9041        return None;
9042    }
9043
9044    let after = declaration[receiver_start + receiver.len()..].trim_start();
9045    if let Some(type_text) = after.strip_prefix(':') {
9046        return normalize_receiver_type_name(read_type_prefix(type_text));
9047    }
9048    after
9049        .strip_prefix('=')
9050        .and_then(infer_kotlin_constructor_type)
9051}
9052
9053fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9054    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9055    if rest.trim_start().starts_with('(') {
9056        normalize_receiver_type_name(head)
9057    } else {
9058        None
9059    }
9060}
9061
9062fn read_type_prefix(value: &str) -> &str {
9063    let mut angle_depth = 0usize;
9064    for (index, ch) in value.char_indices() {
9065        match ch {
9066            '<' => angle_depth += 1,
9067            '>' => angle_depth = angle_depth.saturating_sub(1),
9068            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9069                return value[..index].trim();
9070            }
9071            _ => {}
9072        }
9073    }
9074    value.trim()
9075}
9076
9077fn infer_cpp_receiver_type_from_scope(
9078    scope: tree_sitter::Node<'_>,
9079    source: &str,
9080    receiver: &str,
9081    call_line: u32,
9082) -> Option<String> {
9083    let lines = source.lines().collect::<Vec<_>>();
9084    if lines.is_empty() {
9085        return None;
9086    }
9087    let scope_start = scope.start_position().row as usize;
9088    let call_index = (call_line as usize)
9089        .saturating_sub(1)
9090        .min(lines.len().saturating_sub(1));
9091    for index in (scope_start..=call_index).rev() {
9092        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9093            return Some(receiver_type);
9094        }
9095    }
9096    None
9097}
9098
9099fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9100    for receiver_start in identifier_occurrences(line, receiver) {
9101        let after = line[receiver_start + receiver.len()..].trim_start();
9102        if after
9103            .chars()
9104            .next()
9105            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9106        {
9107            continue;
9108        }
9109        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9110        let normalized = normalize_cpp_type_name(type_text)?;
9111        if normalized == "auto" {
9112            if let Some(rhs) = after.strip_prefix('=') {
9113                return infer_cpp_auto_receiver_type(rhs);
9114            }
9115            continue;
9116        }
9117        return Some(normalized);
9118    }
9119    None
9120}
9121
9122fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9123    let candidate = prefix
9124        .rsplit([';', '{', '}', '('])
9125        .next()
9126        .unwrap_or(prefix)
9127        .trim();
9128    if candidate.is_empty() || candidate.ends_with(',') {
9129        None
9130    } else {
9131        Some(candidate)
9132    }
9133}
9134
9135fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9136    let without_templates = strip_angle_groups(type_text);
9137    let mut cleaned = String::with_capacity(without_templates.len());
9138    for token in without_templates.split_whitespace() {
9139        if matches!(
9140            token,
9141            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9142        ) {
9143            continue;
9144        }
9145        if !cleaned.is_empty() {
9146            cleaned.push(' ');
9147        }
9148        cleaned.push_str(token);
9149    }
9150    let token = cleaned
9151        .split_whitespace()
9152        .last()
9153        .unwrap_or(cleaned.trim())
9154        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9155        .trim_matches(['*', '&']);
9156    let simple = token.rsplit("::").next().unwrap_or(token).trim();
9157    if simple.is_empty() || cpp_non_type_token(simple) {
9158        None
9159    } else {
9160        Some(simple.to_string())
9161    }
9162}
9163
9164fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9165    let rhs = rhs.trim_start();
9166    if let Some(after_new) = rhs.strip_prefix("new ") {
9167        return infer_cpp_constructor_type(after_new);
9168    }
9169    infer_cpp_make_template_type(rhs)
9170        .or_else(|| infer_cpp_constructor_type(rhs))
9171        .or_else(|| infer_cpp_factory_type(rhs))
9172}
9173
9174fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9175    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9176    let normalized = normalize_cpp_type_name(head)?;
9177    if !normalized
9178        .chars()
9179        .next()
9180        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9181    {
9182        return None;
9183    }
9184    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9185        Some(normalized)
9186    } else {
9187        None
9188    }
9189}
9190
9191fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9192    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9193    if !rest.trim_start().starts_with('(') {
9194        return None;
9195    }
9196    let base = head.split('<').next().unwrap_or(head);
9197    let base_simple = base.rsplit("::").next().unwrap_or(base);
9198    if !matches!(base_simple, "make_unique" | "make_shared") {
9199        return None;
9200    }
9201    first_angle_arg(head).and_then(normalize_cpp_type_name)
9202}
9203
9204fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9205    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9206    if !rest.trim_start().starts_with('(') {
9207        return None;
9208    }
9209    let simple = head
9210        .split('<')
9211        .next()
9212        .unwrap_or(head)
9213        .rsplit("::")
9214        .next()
9215        .unwrap_or(head);
9216    for prefix in ["make", "create", "build"] {
9217        if let Some(suffix) = simple.strip_prefix(prefix) {
9218            if suffix
9219                .chars()
9220                .next()
9221                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9222            {
9223                return normalize_cpp_type_name(suffix);
9224            }
9225        }
9226    }
9227    None
9228}
9229
9230#[derive(Debug, Clone, Copy)]
9231enum JavaLikeInvocation {
9232    Kotlin,
9233    Cpp,
9234}
9235
9236fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9237    let value = value.trim_start();
9238    let mut end = 0usize;
9239    for (index, ch) in value.char_indices() {
9240        let allowed_separator = match flavor {
9241            JavaLikeInvocation::Kotlin => ch == '.',
9242            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9243        };
9244        if is_code_ident_char(ch) || allowed_separator {
9245            end = index + ch.len_utf8();
9246            continue;
9247        }
9248        break;
9249    }
9250    if end == 0 {
9251        return None;
9252    }
9253    let mut rest = &value[end..];
9254    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9255        let skipped = skip_balanced_angle(stripped)?;
9256        let rest_start = rest.len() - rest.trim_start().len();
9257        let angle_len = 1 + skipped;
9258        end += rest_start + angle_len;
9259        rest = &value[end..];
9260    }
9261    Some((value[..end].trim(), rest))
9262}
9263
9264fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9265    let mut depth = 1usize;
9266    for (index, ch) in value_after_open.char_indices() {
9267        match ch {
9268            '<' => depth += 1,
9269            '>' => {
9270                depth = depth.saturating_sub(1);
9271                if depth == 0 {
9272                    return Some(index + ch.len_utf8());
9273                }
9274            }
9275            _ => {}
9276        }
9277    }
9278    None
9279}
9280
9281fn first_angle_arg(value: &str) -> Option<&str> {
9282    let open = value.find('<')?;
9283    let inner_len = skip_balanced_angle(&value[open + 1..])?;
9284    let inner = &value[open + 1..open + inner_len];
9285    split_top_level_commas(inner).into_iter().next()
9286}
9287
9288fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9289    let without_generics = strip_angle_groups(type_text);
9290    let cleaned = without_generics
9291        .replace("[]", " ")
9292        .replace("...", " ")
9293        .replace(['?', '&', '*'], " ");
9294    let token = cleaned
9295        .split_whitespace()
9296        .last()
9297        .unwrap_or(cleaned.trim())
9298        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9299    let token = token.rsplit("::").next().unwrap_or(token);
9300    let simple = token.rsplit('.').next().unwrap_or(token).trim();
9301    if simple.is_empty()
9302        || java_like_primitive_type(simple)
9303        || !simple
9304            .chars()
9305            .next()
9306            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9307    {
9308        None
9309    } else {
9310        Some(simple.to_string())
9311    }
9312}
9313
9314fn simple_type_name(scoped_name: &str) -> Option<String> {
9315    scoped_name
9316        .rsplit("::")
9317        .find(|segment| !segment.is_empty())
9318        .and_then(normalize_receiver_type_name)
9319}
9320
9321fn strip_angle_groups(value: &str) -> String {
9322    let mut output = String::with_capacity(value.len());
9323    let mut depth = 0usize;
9324    for ch in value.chars() {
9325        match ch {
9326            '<' => {
9327                if depth == 0 {
9328                    output.push(' ');
9329                }
9330                depth += 1;
9331            }
9332            '>' => depth = depth.saturating_sub(1),
9333            _ if depth == 0 => output.push(ch),
9334            _ => {}
9335        }
9336    }
9337    output
9338}
9339
9340fn java_like_primitive_type(value: &str) -> bool {
9341    matches!(
9342        value,
9343        "boolean"
9344            | "byte"
9345            | "char"
9346            | "double"
9347            | "float"
9348            | "int"
9349            | "long"
9350            | "short"
9351            | "void"
9352            | "Boolean"
9353            | "Byte"
9354            | "Char"
9355            | "Double"
9356            | "Float"
9357            | "Int"
9358            | "Long"
9359            | "Short"
9360            | "Unit"
9361    )
9362}
9363
9364fn cpp_non_type_token(value: &str) -> bool {
9365    matches!(
9366        value,
9367        "return"
9368            | "if"
9369            | "else"
9370            | "for"
9371            | "while"
9372            | "do"
9373            | "switch"
9374            | "case"
9375            | "default"
9376            | "break"
9377            | "continue"
9378            | "goto"
9379            | "throw"
9380            | "new"
9381            | "delete"
9382            | "co_await"
9383            | "co_yield"
9384            | "co_return"
9385            | "static_cast"
9386            | "const_cast"
9387            | "dynamic_cast"
9388            | "reinterpret_cast"
9389            | "sizeof"
9390            | "alignof"
9391            | "typeid"
9392            | "and"
9393            | "or"
9394            | "not"
9395            | "xor"
9396    )
9397}
9398
9399fn receiver_is_bare_identifier(value: &str) -> bool {
9400    let mut chars = value.chars();
9401    let Some(first) = chars.next() else {
9402        return false;
9403    };
9404    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
9405}
9406
9407fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
9408    identifier_occurrences(value, needle).into_iter().next()
9409}
9410
9411fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
9412    value
9413        .match_indices(needle)
9414        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
9415        .collect()
9416}
9417
9418fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
9419    let before = value[..start].chars().next_back();
9420    let after = value[start + len..].chars().next();
9421    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
9422}
9423
9424fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
9425    let stripped = value.strip_prefix(word)?;
9426    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
9427        Some(stripped.trim_start())
9428    } else {
9429        None
9430    }
9431}
9432
9433fn is_code_ident_char(ch: char) -> bool {
9434    ch == '_' || ch.is_ascii_alphanumeric()
9435}
9436
9437fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
9438    if matches!(reference.receiver.as_str(), "self" | "Self") {
9439        return enclosing_type_from_scoped_name(&reference.caller_symbol);
9440    }
9441
9442    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
9443        return Some(reference.receiver.clone());
9444    }
9445
9446    reference
9447        .caller_signature
9448        .as_deref()
9449        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
9450}
9451
9452fn rust_receiver_looks_type_like(receiver: &str) -> bool {
9453    receiver
9454        .chars()
9455        .next()
9456        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
9457}
9458
9459fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
9460    scoped_name
9461        .rsplit_once("::")
9462        .map(|(enclosing, _)| enclosing)
9463        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
9464        .map(ToString::to_string)
9465}
9466
9467fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
9468    let params = signature_parameter_text(signature)?;
9469    for param in split_top_level_commas(params) {
9470        let Some((pattern, type_text)) = param.split_once(':') else {
9471            continue;
9472        };
9473        let Some(name) = rust_parameter_name(pattern) else {
9474            continue;
9475        };
9476        if name == receiver {
9477            return normalize_rust_receiver_type(type_text);
9478        }
9479    }
9480    None
9481}
9482
9483fn signature_parameter_text(signature: &str) -> Option<&str> {
9484    let open = signature.find('(')?;
9485    let mut depth = 0usize;
9486    for (offset, ch) in signature[open..].char_indices() {
9487        match ch {
9488            '(' => depth += 1,
9489            ')' => {
9490                depth = depth.saturating_sub(1);
9491                if depth == 0 {
9492                    return Some(&signature[open + 1..open + offset]);
9493                }
9494            }
9495            _ => {}
9496        }
9497    }
9498    None
9499}
9500
9501fn split_top_level_commas(value: &str) -> Vec<&str> {
9502    let mut parts = Vec::new();
9503    let mut start = 0usize;
9504    let mut angle_depth = 0usize;
9505    let mut paren_depth = 0usize;
9506    let mut bracket_depth = 0usize;
9507    for (index, ch) in value.char_indices() {
9508        match ch {
9509            '<' => angle_depth += 1,
9510            '>' => angle_depth = angle_depth.saturating_sub(1),
9511            '(' => paren_depth += 1,
9512            ')' => paren_depth = paren_depth.saturating_sub(1),
9513            '[' => bracket_depth += 1,
9514            ']' => bracket_depth = bracket_depth.saturating_sub(1),
9515            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
9516                let part = value[start..index].trim();
9517                if !part.is_empty() {
9518                    parts.push(part);
9519                }
9520                start = index + ch.len_utf8();
9521            }
9522            _ => {}
9523        }
9524    }
9525    let part = value[start..].trim();
9526    if !part.is_empty() {
9527        parts.push(part);
9528    }
9529    parts
9530}
9531
9532fn rust_parameter_name(pattern: &str) -> Option<&str> {
9533    let mut pattern = pattern.trim();
9534    if let Some(stripped) = pattern.strip_prefix("mut ") {
9535        pattern = stripped.trim_start();
9536    }
9537    pattern
9538        .rsplit(|ch: char| !is_rust_ident_char(ch))
9539        .find(|part| !part.is_empty())
9540}
9541
9542fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
9543    let mut ty = strip_leading_rust_type_modifiers(type_text);
9544    let owned_inner;
9545    if let Some(inner) = single_outer_generic_arg(ty) {
9546        owned_inner = inner.trim().to_string();
9547        ty = strip_leading_rust_type_modifiers(&owned_inner);
9548    }
9549    rust_base_type_ident(ty)
9550}
9551
9552fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
9553    loop {
9554        ty = ty.trim_start();
9555        if let Some(stripped) = ty.strip_prefix('&') {
9556            ty = stripped.trim_start();
9557            if let Some(stripped) = strip_leading_lifetime(ty) {
9558                ty = stripped.trim_start();
9559            }
9560            if let Some(stripped) = ty.strip_prefix("mut ") {
9561                ty = stripped.trim_start();
9562            }
9563            continue;
9564        }
9565        if let Some(stripped) = ty.strip_prefix("mut ") {
9566            ty = stripped.trim_start();
9567            continue;
9568        }
9569        if let Some(stripped) = ty.strip_prefix("dyn ") {
9570            ty = stripped.trim_start();
9571            continue;
9572        }
9573        if let Some(stripped) = ty.strip_prefix("impl ") {
9574            ty = stripped.trim_start();
9575            continue;
9576        }
9577        break ty.trim();
9578    }
9579}
9580
9581fn strip_leading_lifetime(value: &str) -> Option<&str> {
9582    let mut chars = value.char_indices();
9583    let (_, first) = chars.next()?;
9584    if first != '\'' {
9585        return None;
9586    }
9587    for (index, ch) in chars {
9588        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
9589            return Some(&value[index..]);
9590        }
9591    }
9592    Some("")
9593}
9594
9595fn single_outer_generic_arg(ty: &str) -> Option<&str> {
9596    let ty = ty.trim();
9597    let open = ty.find('<')?;
9598    let mut depth = 0usize;
9599    let mut close = None;
9600    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
9601        match ch {
9602            '<' => depth += 1,
9603            '>' => {
9604                depth = depth.saturating_sub(1);
9605                if depth == 0 {
9606                    close = Some(index);
9607                    break;
9608                }
9609            }
9610            _ => {}
9611        }
9612    }
9613    let close = close?;
9614    if !ty[close + 1..].trim().is_empty() {
9615        return None;
9616    }
9617    let inner = &ty[open + 1..close];
9618    let args = split_top_level_commas(inner);
9619    match args.as_slice() {
9620        [arg] => Some(*arg),
9621        _ => None,
9622    }
9623}
9624
9625fn rust_base_type_ident(ty: &str) -> Option<String> {
9626    let ty = ty.trim();
9627    let head = ty
9628        .split([' ', '+', '='])
9629        .find(|part| !part.is_empty())
9630        .unwrap_or(ty);
9631    let head = head.split('<').next().unwrap_or(head).trim();
9632    let ident = head
9633        .rsplit("::")
9634        .next()
9635        .unwrap_or(head)
9636        .trim_matches(|ch: char| !is_rust_ident_char(ch));
9637    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
9638        None
9639    } else {
9640        Some(ident.to_string())
9641    }
9642}
9643
9644fn is_rust_ident_char(ch: char) -> bool {
9645    ch == '_' || ch.is_ascii_alphanumeric()
9646}
9647
9648fn select_type_match_candidate(
9649    reference: &NameMatchRef,
9650    candidates: &[NameMatchCandidate],
9651    receiver_type: &str,
9652) -> Option<NameMatchCandidate> {
9653    let candidates = candidates
9654        .iter()
9655        .filter(|candidate| candidate.node_id != reference.caller_node)
9656        .filter(|candidate| {
9657            type_candidate_matches(candidate, receiver_type, &reference.method_name)
9658        })
9659        .collect::<Vec<_>>();
9660    match candidates.as_slice() {
9661        [candidate] => Some((**candidate).clone()),
9662        _ => None,
9663    }
9664}
9665
9666fn type_candidate_matches(
9667    candidate: &NameMatchCandidate,
9668    receiver_type: &str,
9669    method_name: &str,
9670) -> bool {
9671    let normalized_type = receiver_type.replace('.', "::");
9672    let suffix = format!("{normalized_type}::{method_name}");
9673    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
9674}
9675
9676fn select_name_match_candidate(
9677    reference: &NameMatchRef,
9678    candidates: &[NameMatchCandidate],
9679) -> Option<NameMatchCandidate> {
9680    let candidates = candidates
9681        .iter()
9682        .filter(|candidate| candidate.node_id != reference.caller_node)
9683        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
9684        .collect::<Vec<_>>();
9685    match candidates.as_slice() {
9686        [] => None,
9687        [candidate] => Some((**candidate).clone()),
9688        _ => select_scored_name_match_candidate(reference, &candidates),
9689    }
9690}
9691
9692fn candidate_allowed_for_reference(
9693    reference: &NameMatchRef,
9694    candidate: &NameMatchCandidate,
9695) -> bool {
9696    if !reference.colon_dispatch {
9697        return true;
9698    }
9699
9700    candidate.kind == "method"
9701        && candidate
9702            .scoped_name
9703            .split("::")
9704            .any(|segment| segment == reference.receiver)
9705}
9706
9707fn select_scored_name_match_candidate(
9708    reference: &NameMatchRef,
9709    candidates: &[&NameMatchCandidate],
9710) -> Option<NameMatchCandidate> {
9711    let receiver_words = split_camel_case(&reference.receiver);
9712    if receiver_words.is_empty() {
9713        return None;
9714    }
9715
9716    let mut best: Option<(&NameMatchCandidate, f64)> = None;
9717    let mut tied_best = false;
9718    for candidate in candidates {
9719        let candidate_words = split_camel_case(&candidate.scoped_name);
9720        let overlap = receiver_words
9721            .iter()
9722            .filter(|receiver_word| {
9723                candidate_words
9724                    .iter()
9725                    .any(|candidate_word| candidate_word == *receiver_word)
9726            })
9727            .count() as f64;
9728        let score =
9729            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
9730        match best {
9731            None => {
9732                best = Some((*candidate, score));
9733                tied_best = false;
9734            }
9735            Some((_, best_score)) if score > best_score => {
9736                best = Some((*candidate, score));
9737                tied_best = false;
9738            }
9739            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
9740                tied_best = true;
9741            }
9742            _ => {}
9743        }
9744    }
9745
9746    let (candidate, score) = best?;
9747    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
9748        Some(candidate.clone())
9749    } else {
9750        None
9751    }
9752}
9753
9754fn method_name_match_denylisted(method_name: &str) -> bool {
9755    matches!(
9756        method_name,
9757        "and_then"
9758            | "as_bytes"
9759            | "as_deref"
9760            | "as_mut"
9761            | "as_ref"
9762            | "as_str"
9763            | "borrow"
9764            | "borrow_mut"
9765            | "clear"
9766            | "clone"
9767            | "collect"
9768            | "contains"
9769            | "contains_key"
9770            | "count"
9771            | "dedup"
9772            | "default"
9773            | "drain"
9774            | "ends_with"
9775            | "entry"
9776            | "err"
9777            | "expect"
9778            | "extend"
9779            | "filter"
9780            | "filter_map"
9781            | "find"
9782            | "from"
9783            | "get"
9784            | "get_mut"
9785            | "insert"
9786            | "into"
9787            | "into_iter"
9788            | "is_empty"
9789            | "is_err"
9790            | "is_none"
9791            | "is_ok"
9792            | "is_some"
9793            | "iter"
9794            | "iter_mut"
9795            | "join"
9796            | "len"
9797            | "lock"
9798            | "map"
9799            | "map_err"
9800            | "max"
9801            | "min"
9802            | "new"
9803            | "next"
9804            | "ok"
9805            | "or_default"
9806            | "or_else"
9807            | "or_insert"
9808            | "or_insert_with"
9809            | "parse"
9810            | "pop"
9811            | "position"
9812            | "push"
9813            | "read"
9814            | "recv"
9815            | "remove"
9816            | "replace"
9817            | "retain"
9818            | "send"
9819            | "sort"
9820            | "sort_by"
9821            | "split"
9822            | "starts_with"
9823            | "sum"
9824            | "take"
9825            | "to_owned"
9826            | "to_string"
9827            | "trim"
9828            | "try_from"
9829            | "try_into"
9830            | "unwrap"
9831            | "unwrap_or"
9832            | "unwrap_or_default"
9833            | "unwrap_or_else"
9834            | "with_capacity"
9835            | "write"
9836    )
9837}
9838
9839fn split_camel_case(value: &str) -> Vec<String> {
9840    let chars = value.chars().collect::<Vec<_>>();
9841    let mut normalized = String::with_capacity(value.len() + 8);
9842    for (index, ch) in chars.iter().enumerate() {
9843        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9844        let next = chars.get(index + 1);
9845        let is_separator = ch.is_whitespace()
9846            || matches!(
9847                ch,
9848                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9849            );
9850        if is_separator {
9851            normalized.push(' ');
9852            continue;
9853        }
9854        let camel_boundary = previous.is_some_and(|prev| {
9855            (prev.is_lowercase() && ch.is_uppercase())
9856                || (prev.is_ascii_digit() && ch.is_alphabetic())
9857                || (prev.is_uppercase()
9858                    && ch.is_uppercase()
9859                    && next.is_some_and(|next| next.is_lowercase()))
9860        });
9861        if camel_boundary {
9862            normalized.push(' ');
9863        }
9864        normalized.push(*ch);
9865    }
9866
9867    normalized
9868        .split_whitespace()
9869        .filter(|word| word.len() > 1)
9870        .map(|word| word.to_ascii_lowercase())
9871        .collect()
9872}
9873
9874fn compute_path_proximity(left: &str, right: &str) -> f64 {
9875    let left_dirs = left
9876        .rsplit_once('/')
9877        .map(|(dir, _)| dir)
9878        .unwrap_or_default()
9879        .split('/')
9880        .filter(|part| !part.is_empty());
9881    let right_dirs = right
9882        .rsplit_once('/')
9883        .map(|(dir, _)| dir)
9884        .unwrap_or_default()
9885        .split('/')
9886        .filter(|part| !part.is_empty());
9887
9888    let shared = left_dirs
9889        .zip(right_dirs)
9890        .take_while(|(left, right)| left == right)
9891        .count();
9892    ((shared as f64) * 0.05).min(0.5)
9893}
9894
9895fn mark_backend_state(
9896    tx: &Transaction<'_>,
9897    project_root: &Path,
9898    rel_path: &str,
9899    content_hash: Option<&blake3::Hash>,
9900    status: &str,
9901) -> Result<()> {
9902    clear_backend_state_for_file(tx, project_root, rel_path)?;
9903    let hash = content_hash
9904        .map(|hash| hash_to_hex(*hash))
9905        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9906    tx.execute(
9907        "INSERT OR REPLACE INTO backend_file_state(
9908            backend, workspace_root, file_path, content_hash, status, updated_at
9909        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9910        params![
9911            BACKEND_TREESITTER,
9912            project_root.display().to_string(),
9913            rel_path,
9914            hash,
9915            status,
9916            unix_seconds_now(),
9917        ],
9918    )?;
9919    Ok(())
9920}
9921
9922fn clear_backend_state_for_file(
9923    tx: &Transaction<'_>,
9924    project_root: &Path,
9925    rel_path: &str,
9926) -> Result<()> {
9927    tx.execute(
9928        "DELETE FROM backend_file_state
9929         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9930        params![
9931            BACKEND_TREESITTER,
9932            project_root.display().to_string(),
9933            rel_path
9934        ],
9935    )?;
9936    Ok(())
9937}
9938
9939fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9940    tx.query_row(
9941        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9942        params![rel_path],
9943        |row| {
9944            let hash_text: String = row.get(1)?;
9945            Ok(FileRow {
9946                surface_fingerprint: row.get(0)?,
9947                freshness: FileFreshness {
9948                    content_hash: hash_from_hex(&hash_text)
9949                        .unwrap_or_else(cache_freshness::zero_hash),
9950                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9951                    size: row.get::<_, i64>(3)? as u64,
9952                },
9953            })
9954        },
9955    )
9956    .optional()
9957    .map_err(CallGraphStoreError::from)
9958}
9959
9960fn stored_node_ids_match_extract(
9961    tx: &Transaction<'_>,
9962    rel_path: &str,
9963    extract: &FileExtract,
9964) -> Result<bool> {
9965    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9966    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9967    let mut stored = BTreeSet::new();
9968    for row in rows {
9969        stored.insert(row?);
9970    }
9971    let extracted = extract
9972        .nodes
9973        .iter()
9974        .map(|node| node.id.clone())
9975        .collect::<BTreeSet<_>>();
9976    Ok(stored == extracted)
9977}
9978
9979fn update_file_fresh_metadata(
9980    tx: &Transaction<'_>,
9981    rel_path: &str,
9982    hash: &blake3::Hash,
9983    mtime: SystemTime,
9984    size: u64,
9985) -> Result<()> {
9986    tx.execute(
9987        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9988        params![
9989            rel_path,
9990            system_time_to_ns(mtime),
9991            size as i64,
9992            unix_seconds_now()
9993        ],
9994    )?;
9995    tx.execute(
9996        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9997         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9998        params![
9999            BACKEND_TREESITTER,
10000            rel_path,
10001            hash_to_hex(*hash),
10002            unix_seconds_now(),
10003        ],
10004    )?;
10005    Ok(())
10006}
10007
10008#[derive(Debug, Clone, PartialEq, Eq)]
10009struct DependentRefSelection {
10010    ref_id: String,
10011    caller_file: String,
10012}
10013
10014fn ref_ids_depending_on(
10015    tx: &Transaction<'_>,
10016    project_root: &Path,
10017    rel_path: &str,
10018) -> Result<Vec<DependentRefSelection>> {
10019    let mut stmt = tx.prepare(
10020        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
10021         FROM refs r
10022         WHERE r.caller_file IN (
10023             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
10024         )
10025            OR r.target_file = ?1
10026         ORDER BY r.ref_id",
10027    )?;
10028    let rows = stmt.query_map(params![rel_path], |row| {
10029        Ok(RefDependencyRow {
10030            ref_id: row.get(0)?,
10031            kind: row.get(1)?,
10032            caller_file: row.get(2)?,
10033            module_path: row.get(3)?,
10034            target_file: row.get(4)?,
10035        })
10036    })?;
10037    let mut ids = Vec::new();
10038    for row in rows {
10039        let row = row?;
10040        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
10041            ids.push(DependentRefSelection {
10042                ref_id: row.ref_id,
10043                caller_file: row.caller_file,
10044            });
10045        }
10046    }
10047    Ok(ids)
10048}
10049
10050fn record_dependent_refs(
10051    selected_ref_ids: &mut BTreeSet<String>,
10052    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
10053    dependent_refs: Vec<DependentRefSelection>,
10054) {
10055    for dependent_ref in dependent_refs {
10056        let DependentRefSelection {
10057            ref_id,
10058            caller_file,
10059        } = dependent_ref;
10060        selected_ref_ids.insert(ref_id.clone());
10061        selected_refs_by_caller
10062            .entry(caller_file)
10063            .or_default()
10064            .insert(ref_id);
10065    }
10066}
10067
10068#[cfg(test)]
10069fn refs_by_caller_for_ref_ids(
10070    tx: &Transaction<'_>,
10071    ref_ids: &BTreeSet<String>,
10072) -> Result<BTreeMap<String, BTreeSet<String>>> {
10073    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10074    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
10075    for ref_id in ref_ids {
10076        if let Some(caller) = stmt
10077            .query_row(params![ref_id], |row| row.get::<_, String>(0))
10078            .optional()?
10079        {
10080            by_caller.entry(caller).or_default().insert(ref_id.clone());
10081        }
10082    }
10083    Ok(by_caller)
10084}
10085
10086fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
10087    tx.execute(
10088        "DELETE FROM file_dependencies WHERE file_path = ?1",
10089        params![rel_path],
10090    )?;
10091    delete_refs_for_caller(tx, rel_path)?;
10092    tx.execute(
10093        "DELETE FROM dispatch_hints WHERE file = ?1",
10094        params![rel_path],
10095    )?;
10096    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
10097    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
10098    Ok(())
10099}
10100
10101fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
10102    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
10103    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10104    let mut ids = BTreeSet::new();
10105    for row in rows {
10106        ids.insert(row?);
10107    }
10108    delete_ref_ids(tx, &ids)
10109}
10110
10111fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
10112    for ref_id in ref_ids {
10113        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
10114        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
10115    }
10116    Ok(())
10117}
10118
10119fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
10120    let mut stmt = conn.prepare(
10121        "SELECT source.file_path, source.scoped_name, edges.target_file,
10122                edges.target_symbol, edges.kind, edges.line
10123         FROM edges
10124         JOIN nodes AS source ON source.id = edges.source_node
10125         ORDER BY source.file_path, source.scoped_name, edges.target_file,
10126                  edges.target_symbol, edges.kind, edges.line",
10127    )?;
10128    let rows = stmt.query_map([], |row| {
10129        Ok(StoredEdge {
10130            source_file: row.get(0)?,
10131            source_symbol: row.get(1)?,
10132            target_file: row.get(2)?,
10133            target_symbol: row.get(3)?,
10134            kind: row.get(4)?,
10135            line: row.get::<_, i64>(5)? as u32,
10136        })
10137    })?;
10138    let mut edges = BTreeSet::new();
10139    for row in rows {
10140        edges.insert(row?);
10141    }
10142    Ok(edges)
10143}
10144
10145fn module_target_from_dependencies(
10146    project_root: &Path,
10147    dependencies: &BTreeSet<String>,
10148) -> Option<String> {
10149    dependencies.iter().find_map(|dep| {
10150        let path = project_root.join(dep);
10151        if path.is_file() {
10152            Some(relative_path(project_root, &canonicalize_path(&path)))
10153        } else {
10154            None
10155        }
10156    })
10157}
10158
10159fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
10160    let mut named = HashMap::new();
10161    if let Some(full_ref) = &raw_ref.full_ref {
10162        named = parse_reexport_names(full_ref);
10163    }
10164    ReexportIndex {
10165        target_file,
10166        named,
10167        wildcard: raw_ref.wildcard,
10168    }
10169}
10170
10171fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
10172    let mut names = HashMap::new();
10173    let Some(open) = statement.find('{') else {
10174        return names;
10175    };
10176    let Some(close) = statement[open + 1..]
10177        .find('}')
10178        .map(|offset| open + 1 + offset)
10179    else {
10180        return names;
10181    };
10182    for spec in statement[open + 1..close].split(',') {
10183        let spec = spec.trim();
10184        if spec.is_empty() {
10185            continue;
10186        }
10187        if let Some((source, local)) = spec.split_once(" as ") {
10188            names.insert(local.trim().to_string(), source.trim().to_string());
10189        } else {
10190            names.insert(spec.to_string(), spec.to_string());
10191        }
10192    }
10193    names
10194}
10195
10196#[derive(Debug)]
10197struct RefDependencyRow {
10198    ref_id: String,
10199    kind: String,
10200    caller_file: String,
10201    module_path: Option<String>,
10202    target_file: Option<String>,
10203}
10204
10205fn ref_dependency_row_depends_on(
10206    project_root: &Path,
10207    row: &RefDependencyRow,
10208    rel_path: &str,
10209) -> bool {
10210    if row.target_file.as_deref() == Some(rel_path) {
10211        return true;
10212    }
10213
10214    match row.kind.as_str() {
10215        "call" => true,
10216        "import" | "reexport" => row
10217            .module_path
10218            .as_deref()
10219            .map(|module_path| {
10220                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
10221                    .contains(rel_path)
10222            })
10223            .unwrap_or(false),
10224        "export_alias" => false,
10225        _ => false,
10226    }
10227}
10228
10229fn module_dependencies_for_ref(
10230    project_root: &Path,
10231    caller_file: &str,
10232    module_path: &str,
10233) -> BTreeSet<String> {
10234    module_dependencies(project_root, &project_root.join(caller_file), module_path)
10235}
10236
10237fn import_dependencies(
10238    project_root: &Path,
10239    abs_path: &Path,
10240    imports: &[ImportStatement],
10241) -> BTreeSet<String> {
10242    let mut deps = BTreeSet::new();
10243    for import in imports {
10244        deps.extend(module_dependencies(
10245            project_root,
10246            abs_path,
10247            &import.module_path,
10248        ));
10249    }
10250    deps
10251}
10252
10253fn module_dependencies(
10254    project_root: &Path,
10255    abs_path: &Path,
10256    module_path: &str,
10257) -> BTreeSet<String> {
10258    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
10259    let caller_dir = abs_path.parent().unwrap_or(project_root);
10260    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
10261        deps.insert(relative_path(project_root, &resolved));
10262    }
10263    if module_path.starts_with('.') {
10264        let base = caller_dir.join(module_path);
10265        for candidate in relative_module_candidates(&base) {
10266            deps.insert(relative_path(project_root, &candidate));
10267        }
10268    }
10269    deps
10270}
10271
10272fn rust_module_dependencies(
10273    project_root: &Path,
10274    abs_path: &Path,
10275    module_path: &str,
10276) -> BTreeSet<String> {
10277    let mut deps = BTreeSet::new();
10278    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
10279    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
10280        return deps;
10281    };
10282    let src_prefix = rust_src_prefix(&rel_path);
10283    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
10284    if !path_segments.is_empty() {
10285        rust_push_module_dependency_candidate(
10286            project_root,
10287            &mut deps,
10288            &src_prefix,
10289            &path_segments[..path_segments.len() - 1],
10290        );
10291    }
10292    deps
10293}
10294
10295fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
10296    let path = rust_module_path_without_alias_or_use_list(module_path);
10297    let segments = path
10298        .split("::")
10299        .map(str::trim)
10300        .filter(|segment| !segment.is_empty())
10301        .collect::<Vec<_>>();
10302    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
10303        return None;
10304    }
10305    rust_resolve_segments(rel_path, &segments)
10306}
10307
10308fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
10309    let path = module_path
10310        .trim()
10311        .trim_end_matches(';')
10312        .split_once(" as ")
10313        .map(|(left, _)| left.trim())
10314        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
10315    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
10316}
10317
10318fn rust_push_module_dependency_candidate(
10319    project_root: &Path,
10320    deps: &mut BTreeSet<String>,
10321    src_prefix: &str,
10322    segments: &[String],
10323) {
10324    let candidates = if segments.is_empty() {
10325        vec![
10326            format!("{src_prefix}/lib.rs"),
10327            format!("{src_prefix}/main.rs"),
10328        ]
10329    } else {
10330        vec![
10331            format!("{}/{}.rs", src_prefix, segments.join("/")),
10332            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
10333        ]
10334    };
10335    for candidate in candidates {
10336        if project_root.join(&candidate).is_file() {
10337            deps.insert(candidate);
10338        }
10339    }
10340}
10341
10342fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
10343    let mut candidates = Vec::new();
10344    if base.extension().is_some() {
10345        candidates.push(base.to_path_buf());
10346        return candidates;
10347    }
10348    for ext in JS_TS_EXTENSIONS {
10349        candidates.push(base.with_extension(ext));
10350    }
10351    for ext in JS_TS_EXTENSIONS {
10352        candidates.push(base.join(format!("index.{ext}")));
10353    }
10354    candidates
10355}
10356
10357fn import_local_names(import: &ImportStatement) -> Vec<String> {
10358    let mut names = Vec::new();
10359    if let Some(default) = &import.default_import {
10360        names.push(default.clone());
10361    }
10362    if let Some(namespace) = &import.namespace_import {
10363        names.push(namespace.clone());
10364    }
10365    for name in &import.names {
10366        names.push(crate::imports::specifier_local_name(name).to_string());
10367    }
10368    names
10369}
10370
10371fn import_requested_names(import: &ImportStatement) -> Vec<String> {
10372    import
10373        .names
10374        .iter()
10375        .map(|name| crate::imports::specifier_imported_name(name).to_string())
10376        .collect()
10377}
10378
10379fn import_is_wildcard(import: &ImportStatement) -> bool {
10380    import.namespace_import.is_some() || import.raw_text.contains('*')
10381}
10382
10383fn namespace_alias(full_ref: &str) -> Option<String> {
10384    full_ref
10385        .split_once('.')
10386        .map(|(namespace, _)| namespace.to_string())
10387}
10388
10389fn import_kind_label(kind: ImportKind) -> &'static str {
10390    match kind {
10391        ImportKind::Value => "value",
10392        ImportKind::Type => "type",
10393        ImportKind::SideEffect => "side_effect",
10394    }
10395}
10396
10397fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
10398    match kind {
10399        SymbolKind::Function => "function",
10400        SymbolKind::Class => "class",
10401        SymbolKind::Method => "method",
10402        SymbolKind::Struct => "struct",
10403        SymbolKind::Interface => "interface",
10404        SymbolKind::Enum => "enum",
10405        SymbolKind::TypeAlias => "type_alias",
10406        SymbolKind::Variable => "variable",
10407        SymbolKind::Heading => "heading",
10408        SymbolKind::FileSummary => "file_summary",
10409    }
10410}
10411
10412fn is_type_like(kind: &SymbolKind) -> bool {
10413    matches!(
10414        kind,
10415        SymbolKind::Class
10416            | SymbolKind::Struct
10417            | SymbolKind::Interface
10418            | SymbolKind::Enum
10419            | SymbolKind::TypeAlias
10420    )
10421}
10422
10423fn lang_label(lang: LangId) -> &'static str {
10424    match lang {
10425        LangId::TypeScript => "typescript",
10426        LangId::Tsx => "tsx",
10427        LangId::JavaScript => "javascript",
10428        LangId::Python => "python",
10429        LangId::Rust => "rust",
10430        LangId::Go => "go",
10431        LangId::C => "c",
10432        LangId::Cpp => "cpp",
10433        LangId::Zig => "zig",
10434        LangId::CSharp => "csharp",
10435        LangId::Bash => "bash",
10436        LangId::Html => "html",
10437        LangId::Markdown => "markdown",
10438        LangId::Solidity => "solidity",
10439        LangId::Scss => "scss",
10440        LangId::Vue => "vue",
10441        LangId::Json => "json",
10442        LangId::Scala => "scala",
10443        LangId::Java => "java",
10444        LangId::Ruby => "ruby",
10445        LangId::Kotlin => "kotlin",
10446        LangId::Swift => "swift",
10447        LangId::Php => "php",
10448        LangId::Lua => "lua",
10449        LangId::Perl => "perl",
10450        LangId::Yaml => "yaml",
10451        LangId::Pascal => "pascal",
10452        LangId::R => "r",
10453        LangId::Groovy => "groovy",
10454        LangId::ObjC => "objc",
10455    }
10456}
10457
10458fn lang_from_label(label: &str) -> Option<LangId> {
10459    match label {
10460        "typescript" => Some(LangId::TypeScript),
10461        "tsx" => Some(LangId::Tsx),
10462        "javascript" => Some(LangId::JavaScript),
10463        "python" => Some(LangId::Python),
10464        "rust" => Some(LangId::Rust),
10465        "go" => Some(LangId::Go),
10466        "c" => Some(LangId::C),
10467        "cpp" => Some(LangId::Cpp),
10468        "zig" => Some(LangId::Zig),
10469        "csharp" => Some(LangId::CSharp),
10470        "bash" => Some(LangId::Bash),
10471        "html" => Some(LangId::Html),
10472        "markdown" => Some(LangId::Markdown),
10473        "solidity" => Some(LangId::Solidity),
10474        "scss" => Some(LangId::Scss),
10475        "vue" => Some(LangId::Vue),
10476        "json" => Some(LangId::Json),
10477        "scala" => Some(LangId::Scala),
10478        "java" => Some(LangId::Java),
10479        "ruby" => Some(LangId::Ruby),
10480        "kotlin" => Some(LangId::Kotlin),
10481        "swift" => Some(LangId::Swift),
10482        "php" => Some(LangId::Php),
10483        "lua" => Some(LangId::Lua),
10484        "perl" => Some(LangId::Perl),
10485        "yaml" => Some(LangId::Yaml),
10486        "pascal" => Some(LangId::Pascal),
10487        "r" => Some(LangId::R),
10488        "groovy" => Some(LangId::Groovy),
10489        "objc" => Some(LangId::ObjC),
10490        _ => None,
10491    }
10492}
10493
10494fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
10495    let mut normalized = if files.is_empty() {
10496        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
10497    } else {
10498        files
10499            .iter()
10500            .map(|path| normalize_file_path(project_root, path))
10501            .collect::<Result<Vec<_>>>()?
10502    };
10503    normalized.sort();
10504    normalized.dedup();
10505    Ok(normalized)
10506}
10507
10508fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
10509    let full_path = if path.is_relative() {
10510        project_root.join(path)
10511    } else {
10512        path.to_path_buf()
10513    };
10514    Ok(canonicalize_path(&full_path))
10515}
10516
10517fn canonicalize_path(path: &Path) -> PathBuf {
10518    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
10519}
10520
10521fn relative_path(project_root: &Path, path: &Path) -> String {
10522    if let Ok(stripped) = path.strip_prefix(project_root) {
10523        return stripped.to_string_lossy().replace('\\', "/");
10524    }
10525    let canon_root = canonicalize_path(project_root);
10526    let canon_path = canonicalize_path(path);
10527    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
10528        return stripped.to_string_lossy().replace('\\', "/");
10529    }
10530    canon_path.to_string_lossy().replace('\\', "/")
10531}
10532
10533fn unqualified_name(scoped: &str) -> &str {
10534    if scoped == TOP_LEVEL_SYMBOL {
10535        return scoped;
10536    }
10537    scoped
10538        .rsplit("::")
10539        .next()
10540        .unwrap_or(scoped)
10541        .rsplit('.')
10542        .next()
10543        .unwrap_or(scoped)
10544        .rsplit('#')
10545        .next()
10546        .unwrap_or(scoped)
10547}
10548
10549fn ref_id(parts: &[&str]) -> String {
10550    let joined = parts.join("\0");
10551    hash_to_hex(blake3::hash(joined.as_bytes()))
10552}
10553
10554fn hash_to_hex(hash: blake3::Hash) -> String {
10555    hash.to_hex().to_string()
10556}
10557
10558fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
10559    let bytes = hex_to_bytes(value)?;
10560    Some(blake3::Hash::from_bytes(bytes))
10561}
10562
10563fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
10564    if value.len() != 64 {
10565        return None;
10566    }
10567    let mut bytes = [0u8; 32];
10568    for (index, slot) in bytes.iter_mut().enumerate() {
10569        let start = index * 2;
10570        let end = start + 2;
10571        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
10572    }
10573    Some(bytes)
10574}
10575
10576#[derive(Debug, Clone)]
10577struct LineIndex {
10578    newline_offsets: Vec<usize>,
10579    source_len: usize,
10580}
10581
10582impl LineIndex {
10583    fn new(source: &str) -> Self {
10584        Self {
10585            newline_offsets: source
10586                .bytes()
10587                .enumerate()
10588                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
10589                .collect(),
10590            source_len: source.len(),
10591        }
10592    }
10593
10594    fn byte_to_line(&self, byte_offset: usize) -> u32 {
10595        let byte_offset = byte_offset.min(self.source_len);
10596        self.newline_offsets
10597            .partition_point(|offset| *offset < byte_offset) as u32
10598            + 1
10599    }
10600}
10601
10602fn empty_to_none(value: String) -> Option<String> {
10603    if value.is_empty() {
10604        None
10605    } else {
10606        Some(value)
10607    }
10608}
10609
10610fn bool_int(value: bool) -> i64 {
10611    if value {
10612        1
10613    } else {
10614        0
10615    }
10616}
10617
10618fn system_time_to_ns(time: SystemTime) -> i64 {
10619    time.duration_since(UNIX_EPOCH)
10620        .unwrap_or_default()
10621        .as_nanos()
10622        .min(i64::MAX as u128) as i64
10623}
10624
10625fn ns_to_system_time(value: i64) -> SystemTime {
10626    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
10627}
10628
10629fn unix_seconds_now() -> i64 {
10630    SystemTime::now()
10631        .duration_since(UNIX_EPOCH)
10632        .unwrap_or_default()
10633        .as_secs() as i64
10634}
10635
10636/// Serializes every test that drives the process-wide refresh worker
10637/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
10638/// worker down between another test's enqueue and its flush, deferring the
10639/// batch and zeroing that test's seam counts).
10640#[cfg(test)]
10641pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
10642
10643#[cfg(test)]
10644mod refresh_worker_tests {
10645    use super::*;
10646    use std::fs;
10647    use tempfile::tempdir;
10648
10649    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10650        let temp = tempdir().unwrap();
10651        let root = temp.path().join("root");
10652        let callgraph_dir = temp
10653            .path()
10654            .join("storage")
10655            .join("callgraph")
10656            .join(crate::search_index::artifact_cache_key(&root));
10657        fs::create_dir_all(&root).unwrap();
10658        let source = root.join("main.rs");
10659        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
10660        let (store, _) = CallGraphStore::cold_build_with_lease(
10661            callgraph_dir.clone(),
10662            root.clone(),
10663            std::slice::from_ref(&source),
10664        )
10665        .unwrap();
10666        drop(store);
10667        (temp, root, callgraph_dir, source)
10668    }
10669
10670    fn pending_paths() -> PendingCallGraphStorePaths {
10671        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
10672    }
10673
10674    fn wait_for_refresh_calls(root: &Path, expected: usize) {
10675        let deadline = Instant::now() + Duration::from_secs(12);
10676        while callgraph_refresh_worker_test_counts(root).0 < expected {
10677            assert!(
10678                Instant::now() < deadline,
10679                "timed out waiting for {expected} callgraph refresh worker call(s)"
10680            );
10681            std::thread::sleep(Duration::from_millis(5));
10682        }
10683    }
10684
10685    fn wait_for_refresh_worker_idle() {
10686        let deadline = Instant::now() + Duration::from_secs(12);
10687        loop {
10688            let worker = CALLGRAPH_REFRESH_WORKER
10689                .get_or_init(|| Mutex::new(None))
10690                .lock()
10691                .expect("callgraph refresh worker mutex poisoned")
10692                .clone();
10693            let idle = worker.is_none_or(|worker| {
10694                let queue = worker
10695                    .shared
10696                    .queue
10697                    .lock()
10698                    .expect("callgraph refresh queue mutex poisoned");
10699                queue.active.is_none() && queue.order.is_empty()
10700            });
10701            if idle {
10702                return;
10703            }
10704            assert!(
10705                Instant::now() < deadline,
10706                "timed out waiting for callgraph refresh worker to become idle"
10707            );
10708            std::thread::sleep(Duration::from_millis(5));
10709        }
10710    }
10711
10712    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10713        let temp = tempdir().unwrap();
10714        let root = temp.path().join("workspace");
10715        let callgraph_dir = temp
10716            .path()
10717            .join("storage")
10718            .join("callgraph")
10719            .join(crate::search_index::artifact_cache_key(&root));
10720        fs::create_dir_all(root.join("app/src")).unwrap();
10721        fs::write(
10722            root.join("Cargo.toml"),
10723            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
10724        )
10725        .unwrap();
10726        fs::write(
10727            root.join("app/Cargo.toml"),
10728            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10729        )
10730        .unwrap();
10731        let caller = root.join("app/src/lib.rs");
10732        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
10733        let (store, _) = CallGraphStore::cold_build_with_lease(
10734            callgraph_dir.clone(),
10735            root.clone(),
10736            std::slice::from_ref(&caller),
10737        )
10738        .unwrap();
10739        drop(store);
10740        (temp, root, callgraph_dir, caller)
10741    }
10742
10743    #[test]
10744    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
10745        let _guard = REFRESH_WORKER_TEST_LOCK
10746            .lock()
10747            .unwrap_or_else(std::sync::PoisonError::into_inner);
10748        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10749        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10750        reset_workspace_crate_prefix_build_count(&root);
10751        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10752
10753        for revision in ["first", "second"] {
10754            fs::write(
10755                &caller,
10756                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
10757            )
10758            .unwrap();
10759            enqueue_callgraph_store_refresh(
10760                callgraph_dir.clone(),
10761                root.clone(),
10762                vec![caller.clone()],
10763                pending_paths(),
10764            );
10765            wait_for_refresh_worker_idle();
10766        }
10767
10768        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10769        assert!(flush_callgraph_store_refreshes_with_budget(
10770            Duration::from_secs(5)
10771        ));
10772        clear_callgraph_refresh_worker_test_seam(&root);
10773    }
10774
10775    #[test]
10776    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
10777        let _guard = REFRESH_WORKER_TEST_LOCK
10778            .lock()
10779            .unwrap_or_else(std::sync::PoisonError::into_inner);
10780        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10781        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10782        reset_workspace_crate_prefix_build_count(&root);
10783        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10784
10785        fs::write(
10786            &caller,
10787            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
10788        )
10789        .unwrap();
10790        enqueue_callgraph_store_refresh(
10791            callgraph_dir.clone(),
10792            root.clone(),
10793            vec![caller.clone()],
10794            pending_paths(),
10795        );
10796        wait_for_refresh_worker_idle();
10797        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10798
10799        let added_manifest = root.join("added/Cargo.toml");
10800        let added_source = root.join("added/src/lib.rs");
10801        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
10802        fs::write(
10803            root.join("Cargo.toml"),
10804            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
10805        )
10806        .unwrap();
10807        fs::write(
10808            &added_manifest,
10809            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10810        )
10811        .unwrap();
10812        fs::write(&added_source, "pub fn target() {}\n").unwrap();
10813        fs::write(
10814            &caller,
10815            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
10816        )
10817        .unwrap();
10818
10819        enqueue_callgraph_store_refresh(
10820            callgraph_dir.clone(),
10821            root.clone(),
10822            vec![
10823                root.join("Cargo.toml"),
10824                added_manifest,
10825                added_source,
10826                caller,
10827            ],
10828            pending_paths(),
10829        );
10830        assert!(flush_callgraph_store_refreshes_with_budget(
10831            Duration::from_secs(12)
10832        ));
10833
10834        // This is the negative control for a permanently-static cache: without
10835        // manifest invalidation the build count stays at one and the call remains
10836        // unresolved because `added_crate` was absent when the map was primed.
10837        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
10838        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10839            .unwrap()
10840            .expect("refreshed workspace store");
10841        let tree = store
10842            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
10843            .unwrap();
10844        assert_eq!(tree.children.len(), 1);
10845        assert_eq!(tree.children[0].file, "added/src/lib.rs");
10846        assert_eq!(tree.children[0].name, "target");
10847        assert!(tree.children[0].resolved);
10848        clear_callgraph_refresh_worker_test_seam(&root);
10849    }
10850
10851    #[test]
10852    fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
10853        let _git_env = crate::test_env::hermetic_git_env_guard();
10854        let temp = tempdir().unwrap();
10855        let main = temp.path().join("main");
10856        let root = temp.path().join("worktree");
10857        fs::create_dir_all(&main).unwrap();
10858        let mut git = std::process::Command::new("git");
10859        assert!(
10860            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
10861                .status()
10862                .unwrap()
10863                .success()
10864        );
10865        let source = main.join("lib.rs");
10866        fs::write(&source, "pub fn marker() {}\n").unwrap();
10867        for args in [
10868            vec![
10869                "-C",
10870                main.to_str().unwrap(),
10871                "config",
10872                "user.email",
10873                "test@example.com",
10874            ],
10875            vec![
10876                "-C",
10877                main.to_str().unwrap(),
10878                "config",
10879                "user.name",
10880                "AFT Test",
10881            ],
10882            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
10883            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
10884        ] {
10885            let mut command = std::process::Command::new("git");
10886            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
10887                .status()
10888                .unwrap()
10889                .success());
10890        }
10891        let mut worktree = std::process::Command::new("git");
10892        assert!(crate::test_env::apply_hermetic_git_env(
10893            worktree
10894                .arg("-C")
10895                .arg(&main)
10896                .args(["worktree", "add", "--detach"])
10897                .arg(&root),
10898        )
10899        .status()
10900        .unwrap()
10901        .success());
10902
10903        let project_key = crate::search_index::artifact_cache_key(&root);
10904        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
10905        crate::root_cache::configure_artifact_access(&root, &project_key, true);
10906        let source = root.join("lib.rs");
10907        let error =
10908            CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
10909                .expect_err("borrow-only forced rebuild must remain unsatisfied");
10910
10911        assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
10912    }
10913
10914    #[test]
10915    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
10916        let _guard = REFRESH_WORKER_TEST_LOCK
10917            .lock()
10918            .unwrap_or_else(std::sync::PoisonError::into_inner);
10919        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10920        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10921        let pending = pending_paths();
10922        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10923
10924        let lifecycle = SubcLifecycleAdmission::default();
10925        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10926        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10927        let ticket = CallgraphRefreshTicket::new(
10928            lifecycle,
10929            Arc::clone(&generation),
10930            7,
10931            publish_epoch.clone(),
10932            publish_epoch.current(),
10933        );
10934        // Supersede before the worker runs: the batch must defer, not commit.
10935        generation.store(8, std::sync::atomic::Ordering::SeqCst);
10936        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
10937            .unwrap()
10938            .expect("ready store snapshot");
10939        let refresh_state = CallgraphRefreshState::new(
10940            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
10941            Arc::new(AtomicBool::new(true)),
10942        );
10943
10944        enqueue_callgraph_store_refresh_fenced_with_state(
10945            callgraph_dir,
10946            root.clone(),
10947            vec![source.clone()],
10948            Arc::clone(&pending),
10949            refresh_state,
10950            ticket,
10951        );
10952        assert!(flush_callgraph_store_refreshes_with_budget(
10953            Duration::from_secs(5)
10954        ));
10955        assert_eq!(
10956            callgraph_refresh_worker_test_counts(&root).0,
10957            0,
10958            "superseded batch must not reach refresh_files or self-replay"
10959        );
10960        assert!(
10961            pending.lock().contains(&source),
10962            "superseded batch must defer its paths to the pending sink"
10963        );
10964        clear_callgraph_refresh_worker_test_seam(&root);
10965    }
10966
10967    #[test]
10968    fn superseded_open_failure_defers_without_self_replay() {
10969        let _guard = REFRESH_WORKER_TEST_LOCK
10970            .lock()
10971            .unwrap_or_else(std::sync::PoisonError::into_inner);
10972        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10973        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10974        let pending = pending_paths();
10975        let installed = Arc::new(
10976            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
10977                .unwrap()
10978                .expect("ready store snapshot"),
10979        );
10980        let refresh_state = CallgraphRefreshState::new(
10981            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
10982            Arc::new(AtomicBool::new(true)),
10983        );
10984        assert!(!installed.is_legacy_fallback());
10985        assert!(installed.is_current());
10986        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10987        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10988        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
10989        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
10990
10991        let lifecycle = SubcLifecycleAdmission::default();
10992        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10993        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10994        let ticket = CallgraphRefreshTicket::new(
10995            lifecycle,
10996            Arc::clone(&generation),
10997            7,
10998            publish_epoch.clone(),
10999            publish_epoch.current(),
11000        );
11001        enqueue_callgraph_store_refresh_fenced_with_state(
11002            callgraph_dir,
11003            root.clone(),
11004            vec![source.clone()],
11005            Arc::clone(&pending),
11006            refresh_state,
11007            ticket,
11008        );
11009        held_rx
11010            .recv_timeout(Duration::from_secs(12))
11011            .expect("refresh worker must hold after injected open failure");
11012
11013        // Mark the refresh request obsolete after the injected open failure,
11014        // then unblock the worker before its deferred retry can run.
11015        generation.store(8, std::sync::atomic::Ordering::SeqCst);
11016        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
11017        release_tx
11018            .send(())
11019            .expect("release superseded refresh worker");
11020        wait_for_refresh_worker_idle();
11021
11022        assert_eq!(
11023            callgraph_refresh_worker_test_counts(&root).0,
11024            1,
11025            "superseded open-failure batch must not self-replay"
11026        );
11027        assert_eq!(
11028            callgraph_refresh_worker_test_worker_calls(&root),
11029            1,
11030            "superseded open-failure batch must not create another worker call"
11031        );
11032        assert!(
11033            pending.lock().contains(&source),
11034            "superseded open-failure paths must remain in the pending sink"
11035        );
11036        let tree = installed
11037            .call_tree(Path::new("main.rs"), "entry", 1)
11038            .unwrap();
11039        assert_eq!(
11040            tree.children[0].name, "old_leaf",
11041            "superseded open-failure batch must not converge the store"
11042        );
11043        clear_callgraph_refresh_worker_test_seam(&root);
11044    }
11045
11046    #[test]
11047    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
11048        let _guard = REFRESH_WORKER_TEST_LOCK
11049            .lock()
11050            .unwrap_or_else(std::sync::PoisonError::into_inner);
11051        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11052        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11053        let pending = pending_paths();
11054        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11055
11056        let lifecycle = SubcLifecycleAdmission::default();
11057        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
11058        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
11059        let expected_epoch = publish_epoch.current();
11060        let ticket = CallgraphRefreshTicket::new(
11061            lifecycle,
11062            generation,
11063            3,
11064            publish_epoch.clone(),
11065            expected_epoch,
11066        );
11067        // A cold build published a replacement generation after enqueue.
11068        publish_epoch.next();
11069
11070        enqueue_callgraph_store_refresh_fenced(
11071            callgraph_dir,
11072            root.clone(),
11073            vec![source.clone()],
11074            Arc::clone(&pending),
11075            ticket,
11076        );
11077        assert!(flush_callgraph_store_refreshes_with_budget(
11078            Duration::from_secs(5)
11079        ));
11080        assert_eq!(
11081            callgraph_refresh_worker_test_counts(&root).0,
11082            0,
11083            "epoch-superseded batch must not reach refresh_files"
11084        );
11085        assert!(
11086            pending.lock().contains(&source),
11087            "epoch-superseded batch must defer its paths to the pending sink"
11088        );
11089        clear_callgraph_refresh_worker_test_seam(&root);
11090    }
11091
11092    #[test]
11093    fn fenced_refresh_with_current_ticket_commits_normally() {
11094        let _guard = REFRESH_WORKER_TEST_LOCK
11095            .lock()
11096            .unwrap_or_else(std::sync::PoisonError::into_inner);
11097        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11098        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11099        let pending = pending_paths();
11100        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11101
11102        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
11103
11104        let lifecycle = SubcLifecycleAdmission::default();
11105        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
11106        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
11107        let ticket = CallgraphRefreshTicket::new(
11108            lifecycle,
11109            generation,
11110            5,
11111            publish_epoch.clone(),
11112            publish_epoch.current(),
11113        );
11114
11115        enqueue_callgraph_store_refresh_fenced(
11116            callgraph_dir.clone(),
11117            root.clone(),
11118            vec![source.clone()],
11119            Arc::clone(&pending),
11120            ticket,
11121        );
11122        assert!(flush_callgraph_store_refreshes_with_budget(
11123            Duration::from_secs(5)
11124        ));
11125        assert_eq!(
11126            callgraph_refresh_worker_test_counts(&root).0,
11127            1,
11128            "current ticket must run the refresh"
11129        );
11130        assert!(
11131            pending.lock().is_empty(),
11132            "committed batch must not defer paths"
11133        );
11134
11135        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
11136            .unwrap()
11137            .expect("published generation must remain readable");
11138        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
11139        assert_eq!(
11140            tree.children[0].name, "new_leaf",
11141            "fenced commit must actually persist the refreshed content"
11142        );
11143        clear_callgraph_refresh_worker_test_seam(&root);
11144    }
11145
11146    #[test]
11147    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
11148        let _guard = REFRESH_WORKER_TEST_LOCK
11149            .lock()
11150            .unwrap_or_else(std::sync::PoisonError::into_inner);
11151        // Generous pre-drain: the refresh worker is process-wide, so a prior
11152        // test's still-running batch (slow Windows CI) must fully settle
11153        // before this test enqueues, or its wait deadline absorbs the
11154        // leftover work. Idle workers return immediately.
11155        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11156        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11157        let pending = pending_paths();
11158        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
11159
11160        enqueue_callgraph_store_refresh(
11161            callgraph_dir.clone(),
11162            root.clone(),
11163            vec![source.clone()],
11164            Arc::clone(&pending),
11165        );
11166        wait_for_refresh_calls(&root, 1);
11167        for _ in 0..3 {
11168            enqueue_callgraph_store_refresh(
11169                callgraph_dir.clone(),
11170                root.clone(),
11171                vec![source.clone()],
11172                Arc::clone(&pending),
11173            );
11174        }
11175
11176        assert!(flush_callgraph_store_refreshes_with_budget(
11177            Duration::from_secs(2)
11178        ));
11179        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
11180        assert!(pending.lock().is_empty());
11181        clear_callgraph_refresh_worker_test_seam(&root);
11182    }
11183
11184    #[test]
11185    fn queued_refresh_opens_generation_published_after_enqueue() {
11186        let _guard = REFRESH_WORKER_TEST_LOCK
11187            .lock()
11188            .unwrap_or_else(std::sync::PoisonError::into_inner);
11189        // Generous pre-drain: the refresh worker is process-wide, so a prior
11190        // test's still-running batch (slow Windows CI) must fully settle
11191        // before this test enqueues, or its wait deadline absorbs the
11192        // leftover work. Idle workers return immediately.
11193        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11194        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11195        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
11196        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
11197        let (active_held_rx, active_release_tx) =
11198            install_callgraph_refresh_worker_test_gate(active_root.clone());
11199        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
11200        enqueue_callgraph_store_refresh(
11201            active_dir,
11202            active_root.clone(),
11203            vec![active_source],
11204            pending_paths(),
11205        );
11206        active_held_rx
11207            .recv_timeout(Duration::from_secs(12))
11208            .expect("active refresh worker holds the queue");
11209
11210        fs::write(
11211            &target_source,
11212            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
11213        )
11214        .unwrap();
11215        enqueue_callgraph_store_refresh(
11216            target_dir.clone(),
11217            target_root.clone(),
11218            vec![target_source.clone()],
11219            pending_paths(),
11220        );
11221        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
11222            target_dir.clone(),
11223            target_root.clone(),
11224            std::slice::from_ref(&target_source),
11225        )
11226        .unwrap();
11227        fs::write(
11228            &target_source,
11229            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
11230        )
11231        .unwrap();
11232        drop(new_generation);
11233
11234        active_release_tx
11235            .send(())
11236            .expect("release active refresh worker");
11237        wait_for_refresh_calls(&target_root, 1);
11238        assert!(flush_callgraph_store_refreshes_with_budget(
11239            Duration::from_secs(12)
11240        ));
11241        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
11242            .unwrap()
11243            .expect("current callgraph generation");
11244        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
11245        assert_eq!(tree.children[0].name, "worker_leaf");
11246        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
11247        clear_callgraph_refresh_worker_test_seam(&active_root);
11248        clear_callgraph_refresh_worker_test_seam(&target_root);
11249    }
11250
11251    #[test]
11252    fn refresh_failure_marks_files_stale() {
11253        let _guard = REFRESH_WORKER_TEST_LOCK
11254            .lock()
11255            .unwrap_or_else(std::sync::PoisonError::into_inner);
11256        // Generous pre-drain: the refresh worker is process-wide, so a prior
11257        // test's still-running batch (slow Windows CI) must fully settle
11258        // before this test enqueues, or its wait deadline absorbs the
11259        // leftover work. Idle workers return immediately.
11260        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11261        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11262        let pending = pending_paths();
11263        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
11264
11265        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
11266        assert!(flush_callgraph_store_refreshes_with_budget(
11267            Duration::from_secs(2)
11268        ));
11269
11270        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
11271        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
11272            .unwrap()
11273            .expect("ready callgraph store");
11274        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
11275        clear_callgraph_refresh_worker_test_seam(&root);
11276    }
11277
11278    #[test]
11279    fn bounded_shutdown_defers_unprocessed_batches() {
11280        let _guard = REFRESH_WORKER_TEST_LOCK
11281            .lock()
11282            .unwrap_or_else(std::sync::PoisonError::into_inner);
11283        // Generous pre-drain: the refresh worker is process-wide, so a prior
11284        // test's still-running batch (slow Windows CI) must fully settle
11285        // before this test enqueues, or its wait deadline absorbs the
11286        // leftover work. Idle workers return immediately.
11287        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11288        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11289        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
11290        let active_pending = pending_paths();
11291        let queued_pending = pending_paths();
11292        set_callgraph_refresh_worker_test_seam(
11293            active_root.clone(),
11294            Duration::from_millis(300),
11295            false,
11296        );
11297
11298        enqueue_callgraph_store_refresh(
11299            active_dir,
11300            active_root.clone(),
11301            vec![active_source.clone()],
11302            Arc::clone(&active_pending),
11303        );
11304        wait_for_refresh_calls(&active_root, 1);
11305        enqueue_callgraph_store_refresh(
11306            queued_dir,
11307            queued_root.clone(),
11308            vec![queued_source.clone()],
11309            Arc::clone(&queued_pending),
11310        );
11311
11312        assert!(!flush_callgraph_store_refreshes_with_budget(
11313            Duration::from_millis(20)
11314        ));
11315        assert!(active_pending.lock().contains(&active_source));
11316        assert!(queued_pending.lock().contains(&queued_source));
11317        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
11318        clear_callgraph_refresh_worker_test_seam(&active_root);
11319    }
11320}
11321
11322#[cfg(test)]
11323mod cold_build_insert_tests {
11324    use super::*;
11325    use crate::imports::ImportBlock;
11326    use std::cell::Cell;
11327    use std::fs;
11328    use std::path::{Path, PathBuf};
11329    use tempfile::tempdir;
11330
11331    thread_local! {
11332        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
11333        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
11334        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
11335    }
11336
11337    fn count_caller_traversal_selects(sql: &str) {
11338        let sql = sql.trim_start();
11339        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
11340            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
11341        }
11342        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
11343            && sql.contains("e.target_file =")
11344        {
11345            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
11346        }
11347        if sql.starts_with("WITH requested") {
11348            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
11349        }
11350    }
11351
11352    #[test]
11353    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
11354        let dir = tempdir().unwrap();
11355        let previous_root = dir.path().join("previous-root");
11356        let current_root = dir.path().join("current-root");
11357        fs::create_dir_all(&previous_root).unwrap();
11358        fs::create_dir_all(&current_root).unwrap();
11359        fs::remove_dir(&previous_root).unwrap();
11360        let mut conn = Connection::open_in_memory().unwrap();
11361        initialize_schema(&conn).unwrap();
11362        conn.execute(
11363            "INSERT INTO backend_file_state(
11364                backend, workspace_root, file_path, content_hash, status, updated_at
11365             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
11366            params![previous_root.display().to_string()],
11367        )
11368        .unwrap();
11369
11370        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
11371
11372        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
11373        assert_eq!(
11374            stored_workspace_roots(&conn).unwrap(),
11375            vec![previous_root.display().to_string()]
11376        );
11377    }
11378
11379    #[test]
11380    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
11381        assert_eq!(
11382            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
11383            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
11384        );
11385    }
11386
11387    #[test]
11388    fn legacy_migration_completion_log_has_operator_fields() {
11389        assert_eq!(
11390            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
11391            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
11392        );
11393    }
11394
11395    fn write_generation_with_age(
11396        dir: &Path,
11397        project_key: &str,
11398        ordinal: u64,
11399        age: Duration,
11400    ) -> String {
11401        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
11402        let path = dir.join(&generation);
11403        fs::write(&path, b"sqlite placeholder").unwrap();
11404        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11405        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11406        generation
11407    }
11408
11409    #[test]
11410    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
11411        let dir = tempfile::tempdir().unwrap();
11412        let project_key = "project";
11413        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11414        let previous =
11415            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11416        let pinned =
11417            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11418        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11419
11420        gc_old_generations(dir.path(), project_key, &current);
11421
11422        assert!(dir.path().join(&previous).is_file());
11423        assert!(dir.path().join(&pinned).is_file());
11424
11425        drop(marker);
11426        gc_old_generations(dir.path(), project_key, &current);
11427
11428        assert!(dir.path().join(&previous).is_file());
11429        assert!(!dir.path().join(&pinned).exists());
11430    }
11431
11432    #[test]
11433    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
11434        let dir = tempfile::tempdir().unwrap();
11435        let project_key = "project";
11436        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11437        let _previous =
11438            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11439        let pinned =
11440            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11441        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11442        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
11443
11444        gc_old_generations(dir.path(), project_key, &current);
11445
11446        assert!(dir.path().join(&pinned).is_file());
11447    }
11448
11449    #[test]
11450    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
11451        let dir = tempfile::tempdir().unwrap();
11452        let project_key = "project";
11453        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
11454        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11455        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
11456        let old = write_generation_with_age(
11457            dir.path(),
11458            project_key,
11459            200,
11460            expired + Duration::from_secs(60),
11461        );
11462        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
11463
11464        gc_old_generations(dir.path(), project_key, &current);
11465
11466        assert!(dir.path().join(&current).is_file());
11467        assert!(dir.path().join(&previous).is_file());
11468        assert!(!dir.path().join(&old).exists());
11469    }
11470
11471    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
11472        let path = dir.join(name);
11473        fs::write(&path, b"temp placeholder").unwrap();
11474        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11475        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11476        path
11477    }
11478
11479    #[test]
11480    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
11481        let dir = tempdir().unwrap();
11482        // One directory holds both an aged orphan (with its journal sidecar) and a
11483        // fresh temporary, so this proves the sweep SELECTS by age rather than
11484        // deleting everything in the directory.
11485        let aged = "project.g100.1.sqlite.tmp.1.200";
11486        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
11487        let fresh = "project.g300.1.sqlite.tmp.1.400";
11488        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
11489        write_build_temp_with_age(dir.path(), aged, aged_age);
11490        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
11491        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11492
11493        sweep_orphaned_build_temps(dir.path());
11494
11495        assert!(
11496            !dir.path().join(aged).exists(),
11497            "aged orphan must be removed"
11498        );
11499        assert!(
11500            !dir.path().join(aged_journal).exists(),
11501            "aged journal sidecar must be removed"
11502        );
11503        assert!(
11504            dir.path().join(fresh).is_file(),
11505            "fresh temporary must survive"
11506        );
11507    }
11508
11509    #[test]
11510    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
11511        let storage = tempdir().unwrap();
11512        let storage_root = storage.path();
11513        // The production shape: a legacy per-harness store whose root no longer
11514        // builds there — no `.current` pointer, no running build — so the per-root
11515        // cleanup never fires for it. A sibling root still building in the
11516        // root-keyed store triggers the store-wide sweep, which must reach into the
11517        // legacy directory and reclaim the orphan.
11518        let legacy_dir = storage_root.join("opencode").join("callgraph");
11519        fs::create_dir_all(&legacy_dir).unwrap();
11520        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
11521        write_build_temp_with_age(
11522            &legacy_dir,
11523            orphan,
11524            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11525        );
11526        assert!(
11527            !legacy_dir.join("deadbeef.current").exists(),
11528            "the dead root has no current pointer"
11529        );
11530
11531        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
11532        fs::create_dir_all(&root_keyed_dir).unwrap();
11533
11534        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
11535
11536        assert!(
11537            !legacy_dir.join(orphan).exists(),
11538            "legacy orphan must be reclaimed by the store-wide sweep"
11539        );
11540    }
11541
11542    #[test]
11543    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
11544        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
11545        // everything (min_age = 0) removes the fresh temporary that the real 24h
11546        // threshold spares in the test above. If a mutation to the age check leaves
11547        // the fresh file in place here, the predicate is no longer doing the
11548        // selection work the fresh-survives assertion relies on.
11549        let dir = tempdir().unwrap();
11550        let fresh = "project.g300.1.sqlite.tmp.1.400";
11551        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11552
11553        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
11554
11555        assert!(
11556            !dir.path().join(fresh).exists(),
11557            "with the age predicate forced open, the fresh temporary is removed"
11558        );
11559    }
11560
11561    #[test]
11562    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
11563        let dir = tempdir().unwrap();
11564        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
11565        // to be swept, plus a live read marker, is generation GC's jurisdiction.
11566        // The orphan sweep must not intersect it.
11567        let generation = write_generation_with_age(
11568            dir.path(),
11569            "project",
11570            400,
11571            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11572        );
11573        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11574
11575        sweep_orphaned_build_temps(dir.path());
11576
11577        assert!(
11578            dir.path().join(&generation).is_file(),
11579            "completed generation must survive the orphan sweep"
11580        );
11581        assert!(
11582            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
11583            "read marker must survive the orphan sweep"
11584        );
11585    }
11586
11587    #[test]
11588    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
11589        let dir = tempfile::tempdir().unwrap();
11590        let project_key = "project".to_string();
11591        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
11592        let sqlite_path = dir.path().join(&generation);
11593        fs::remove_file(&sqlite_path).unwrap();
11594        let conn = Connection::open(&sqlite_path).unwrap();
11595        let store = CallGraphStore::from_connection(
11596            dir.path().to_path_buf(),
11597            project_key,
11598            sqlite_path,
11599            dir.path().to_path_buf(),
11600            false,
11601            Some(generation.clone()),
11602            None,
11603            None,
11604            conn,
11605        );
11606
11607        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11608        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
11609
11610        drop(marker);
11611        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
11612    }
11613
11614    #[test]
11615    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
11616        let dir = tempdir().expect("temp dir");
11617        let file = dir.path().join("main.ts");
11618        fs::write(&file, "export function main() {}\n").expect("write fixture");
11619        let store = CallGraphStore::open(
11620            dir.path().join(".store-readiness-cache"),
11621            dir.path().to_path_buf(),
11622        )
11623        .expect("open store");
11624        {
11625            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11626            conn.trace(Some(count_caller_traversal_selects));
11627        }
11628
11629        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11630        assert!(store.indexed_file_count().is_err());
11631        assert!(store.indexed_file_count().is_err());
11632        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
11633
11634        store
11635            .cold_build(std::slice::from_ref(&file))
11636            .expect("cold build");
11637        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11638        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
11639        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
11640        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
11641
11642        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11643        conn.trace(None);
11644    }
11645
11646    #[test]
11647    fn callers_depth_boundary_batches_sqlite_counts() {
11648        const CALLER_COUNT: usize = 1_000;
11649
11650        let dir = tempdir().expect("temp dir");
11651        let file = dir.path().join("main.ts");
11652        let mut source = String::from("export function sharedHotHelper() {}\n");
11653        for index in 0..CALLER_COUNT {
11654            source.push_str(&format!(
11655                "export function caller{index}() {{ sharedHotHelper(); }}\n"
11656            ));
11657        }
11658        fs::write(&file, source).expect("write fixture");
11659
11660        let store = CallGraphStore::open(
11661            dir.path().join(".store-callers-query-fanout"),
11662            dir.path().to_path_buf(),
11663        )
11664        .expect("open store");
11665        store
11666            .cold_build(std::slice::from_ref(&file))
11667            .expect("cold build");
11668
11669        CALLER_QUERY_SELECTS.with(|count| count.set(0));
11670        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
11671        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11672        {
11673            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11674            conn.trace(Some(count_caller_traversal_selects));
11675        }
11676
11677        let started = Instant::now();
11678        let result = crate::commands::callgraph_store_adapter::callers_result(
11679            &store,
11680            Path::new("main.ts"),
11681            "sharedHotHelper",
11682            1,
11683            true,
11684        )
11685        .expect("callers result");
11686        let elapsed = started.elapsed();
11687
11688        {
11689            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11690            conn.trace(None);
11691        }
11692        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
11693        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
11694        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
11695        eprintln!(
11696            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
11697            result.total_callers,
11698            caller_queries,
11699            boundary_queries,
11700            total_selects,
11701            elapsed.as_secs_f64() * 1_000.0
11702        );
11703
11704        assert_eq!(result.total_callers, CALLER_COUNT);
11705        assert_eq!(caller_queries, 1);
11706        assert_eq!(boundary_queries, 3);
11707        assert_eq!(total_selects, 9);
11708    }
11709
11710    #[test]
11711    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
11712        let dir = tempdir().expect("temp dir");
11713        let file = dir.path().join("main.ts");
11714        fs::write(
11715            &file,
11716            r#"export function topA() {
11717  root();
11718}
11719
11720export function topB() {
11721  root();
11722}
11723
11724export function root() {
11725  leaf();
11726  missing();
11727}
11728
11729export function leaf() {}
11730"#,
11731        )
11732        .expect("write fixture");
11733
11734        let store = CallGraphStore::open(
11735            dir.path().join(".store-depth-boundary-counts"),
11736            dir.path().to_path_buf(),
11737        )
11738        .expect("open store");
11739        store
11740            .cold_build(std::slice::from_ref(&file))
11741            .expect("cold build");
11742
11743        let root = store
11744            .node_for(Path::new("main.ts"), "root")
11745            .expect("root node");
11746        let leaf = store
11747            .node_for(Path::new("main.ts"), "leaf")
11748            .expect("leaf node");
11749
11750        let (full_forward_len, full_direct_len) = {
11751            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11752            conn.execute(
11753                "INSERT INTO edges (
11754                    edge_id, ref_id, source_node, target_node, target_file,
11755                    target_symbol, kind, line, provenance
11756                 ) VALUES (
11757                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
11758                    ?2, ?3, 'call', 98, ?4
11759                 )",
11760                rusqlite::params![
11761                    &root.node_id,
11762                    &leaf.file,
11763                    &leaf.symbol,
11764                    PROVENANCE_TREESITTER
11765                ],
11766            )
11767            .expect("insert dangling forward edge");
11768            conn.execute(
11769                "INSERT INTO edges (
11770                    edge_id, ref_id, source_node, target_node, target_file,
11771                    target_symbol, kind, line, provenance
11772                 ) VALUES (
11773                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
11774                    ?1, ?2, ?3, 'call', 99, ?4
11775                 )",
11776                rusqlite::params![
11777                    &root.node_id,
11778                    &root.file,
11779                    &root.symbol,
11780                    PROVENANCE_TREESITTER
11781                ],
11782            )
11783            .expect("insert dangling direct-caller edge");
11784
11785            let full_forward_len = forward_calls_for_node(&conn, &root)
11786                .expect("full forward calls")
11787                .len();
11788            let counted_forward_len =
11789                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
11790            assert_eq!(
11791                counted_forward_len, full_forward_len,
11792                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
11793            );
11794
11795            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
11796                .expect("full direct callers");
11797            let full_direct_len = full_direct.len();
11798            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
11799                .expect("counted direct callers");
11800            assert_eq!(
11801                counted_direct_len, full_direct_len,
11802                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
11803            );
11804
11805            let distinct_direct_len = full_direct
11806                .iter()
11807                .map(|site| {
11808                    (
11809                        site.caller.file.clone(),
11810                        site.line,
11811                        site.target_file.clone(),
11812                        site.target_symbol.clone(),
11813                    )
11814                })
11815                .collect::<BTreeSet<_>>()
11816                .len();
11817            let batch_counts = direct_caller_counts_for_tuples(
11818                &conn,
11819                &[
11820                    (root.file.clone(), root.symbol.clone()),
11821                    (root.file.clone(), root.symbol.clone()),
11822                    (leaf.file.clone(), leaf.symbol.clone()),
11823                ],
11824            )
11825            .expect("batched direct-caller counts");
11826            assert_eq!(batch_counts.len(), 2);
11827            assert_eq!(
11828                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
11829                Some(&distinct_direct_len)
11830            );
11831
11832            (full_forward_len, full_direct_len)
11833        };
11834
11835        assert_eq!(
11836            full_forward_len, 2,
11837            "fixture root should have one resolved and one unresolved outgoing call"
11838        );
11839        assert_eq!(
11840            full_direct_len, 2,
11841            "fixture root should have two real direct callers"
11842        );
11843
11844        let tree = store
11845            .call_tree(Path::new("main.ts"), "root", 0)
11846            .expect("call tree");
11847        assert!(tree.depth_limited);
11848        assert_eq!(tree.children.len(), 0);
11849        assert_eq!(
11850            tree.truncated, full_forward_len,
11851            "call_tree depth boundary must report the full forward-call list length"
11852        );
11853
11854        let callers = store
11855            .callers_of(Path::new("main.ts"), "leaf", 0)
11856            .expect("callers");
11857        assert!(callers.depth_limited);
11858        assert_eq!(callers.callers.len(), 1);
11859        assert_eq!(callers.callers[0].caller.symbol, "root");
11860        assert_eq!(
11861            callers.truncated, full_direct_len,
11862            "callers depth boundary must report the full direct-caller list length"
11863        );
11864    }
11865
11866    #[test]
11867    fn source_freshness_matches_cache_collect_for_same_bytes() {
11868        let dir = tempdir().expect("temp dir");
11869        let path = dir.path().join("fixture.ts");
11870        let source = "export function main() { return helper(); }\n";
11871        fs::write(&path, source).expect("write fixture");
11872
11873        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
11874        let actual =
11875            collect_source_freshness(&path, source).expect("collect freshness from source");
11876
11877        assert_eq!(actual, expected);
11878    }
11879
11880    #[test]
11881    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
11882        let root = tempfile::tempdir().unwrap();
11883        let callgraph_dir = tempfile::tempdir().unwrap();
11884        let source_dir = root.path().join("src");
11885        std::fs::create_dir_all(&source_dir).unwrap();
11886        let source = source_dir.join("lib.rs");
11887        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
11888        let files = vec![source.clone()];
11889        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
11890        let old_epoch = epoch.next();
11891        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
11892        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
11893        let old_epoch_flag = epoch.clone();
11894        let old_dir = callgraph_dir.path().to_path_buf();
11895        let old_root = root.path().to_path_buf();
11896        let old_files = files.clone();
11897        let old = std::thread::spawn(move || {
11898            set_cold_build_before_publish_observer(Some(Arc::new(move || {
11899                reached_tx.send(()).unwrap();
11900                release_rx.recv().unwrap();
11901            })));
11902            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
11903                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
11904            });
11905            set_cold_build_before_publish_observer(None);
11906            result
11907        });
11908        // Positive wait: the older build runs a real cold build (git probe +
11909        // SQLite schema init) before the barrier, which can exceed 5s on a
11910        // contended Windows CI runner. Only negative waits stay short.
11911        reached_rx
11912            .recv_timeout(Duration::from_secs(30))
11913            .expect("older build did not reach its publication barrier");
11914
11915        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
11916        let new_epoch = epoch.next();
11917        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
11918            CallGraphStore::cold_build_with_lease(
11919                callgraph_dir.path().to_path_buf(),
11920                root.path().to_path_buf(),
11921                &files,
11922            )
11923        })
11924        .expect("newer build should publish");
11925        drop(new_store);
11926
11927        release_tx.send(()).unwrap();
11928        assert!(matches!(
11929            old.join().unwrap(),
11930            Err(CallGraphStoreError::Superseded)
11931        ));
11932
11933        let current = CallGraphStore::open_readonly(
11934            callgraph_dir.path().to_path_buf(),
11935            root.path().to_path_buf(),
11936        )
11937        .unwrap()
11938        .expect("current callgraph generation");
11939        assert_eq!(
11940            current
11941                .nodes_matching("new_generation_marker")
11942                .unwrap()
11943                .len(),
11944            1
11945        );
11946        assert!(current
11947            .nodes_matching("old_generation_marker")
11948            .unwrap()
11949            .is_empty());
11950    }
11951
11952    #[test]
11953    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
11954        let dir = tempdir().expect("temp dir");
11955        let project_root = dir.path();
11956        let extract = fixture_extract(project_root);
11957        let resolved = fixture_resolved(&extract);
11958
11959        let reference = build_reference_connection(project_root, &extract, &resolved);
11960        let optimized = build_optimized_connection(project_root, &extract, &resolved);
11961
11962        for table in [
11963            "files",
11964            "nodes",
11965            "file_dependencies",
11966            "dispatch_hints",
11967            "refs",
11968            "edges",
11969        ] {
11970            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
11971            // the reference and optimized builds run sequentially and can straddle a
11972            // one-second tick under load, so it is legitimately allowed to differ.
11973            // This mirrors the existing exclusions of `backend_file_state.updated_at`
11974            // and the chunked-vs-unchunked sibling test. The check is for structural
11975            // row equivalence of the optimized bulk insert, not wall-clock equality.
11976            let excluded: &[&str] = if table == "files" {
11977                &["indexed_at"]
11978            } else {
11979                &[]
11980            };
11981            assert_eq!(
11982                table_rows_without(&reference, table, excluded),
11983                table_rows_without(&optimized, table, excluded),
11984                "table `{table}` rows must match apart from wall-clock columns"
11985            );
11986        }
11987        assert_eq!(
11988            backend_state_rows(&reference),
11989            backend_state_rows(&optimized),
11990            "backend freshness rows must match apart from updated_at"
11991        );
11992        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
11993    }
11994
11995    #[test]
11996    fn cold_build_chunked_matches_unchunked_logical_rows() {
11997        let dir = tempdir().expect("temp dir");
11998        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
11999        write_chunked_equivalence_fixture(&project_root);
12000        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
12001        assert!(
12002            files.len() > 6,
12003            "fixture should be large enough to split into multiple chunks"
12004        );
12005
12006        let unchunked = CallGraphStore::open(
12007            project_root.join(".store-unchunked"),
12008            project_root.to_path_buf(),
12009        )
12010        .expect("open unchunked store");
12011        let unchunked_stats = unchunked
12012            .cold_build_chunked(&files, 0)
12013            .expect("unchunked cold build");
12014
12015        let chunked = CallGraphStore::open(
12016            project_root.join(".store-chunked"),
12017            project_root.to_path_buf(),
12018        )
12019        .expect("open chunked store");
12020        let chunked_stats = chunked
12021            .cold_build_chunked(&files, 3)
12022            .expect("chunked cold build");
12023
12024        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
12025        assert_eq!(
12026            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
12027            chunked.edge_snapshot().expect("chunked edge snapshot"),
12028            "public edge snapshots must match"
12029        );
12030
12031        let dispatch_edges = {
12032            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
12033            conn.query_row(
12034                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
12035                [],
12036                |row| row.get::<_, i64>(0),
12037            )
12038            .expect("count dispatch edges")
12039        };
12040        assert!(
12041            dispatch_edges > 0,
12042            "fixture must exercise method-dispatch edge insertion"
12043        );
12044
12045        for table in [
12046            "edges",
12047            "refs",
12048            "nodes",
12049            "file_dependencies",
12050            "dispatch_hints",
12051        ] {
12052            assert_eq!(
12053                graph_table_rows(&unchunked, table),
12054                graph_table_rows(&chunked, table),
12055                "chunked cold build must match unchunked rows for {table}"
12056            );
12057        }
12058        assert_eq!(
12059            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
12060            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
12061            "files rows must match apart from indexed_at"
12062        );
12063        assert_eq!(
12064            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
12065            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
12066            "backend freshness rows must match apart from updated_at"
12067        );
12068
12069        let published_dir = project_root.join(".store-published");
12070        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
12071            published_dir.clone(),
12072            project_root.to_path_buf(),
12073            &files,
12074            0,
12075        )
12076        .expect("published unchunked cold build");
12077        assert!(
12078            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
12079                .expect("needs_cold_build after publish"),
12080            "published store should be ready"
12081        );
12082        drop(_published);
12083        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
12084            published_dir,
12085            project_root.to_path_buf(),
12086            &files,
12087            3,
12088        )
12089        .expect("ensure with a different chunk size");
12090        assert!(
12091            rebuild_stats.is_none(),
12092            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
12093        );
12094    }
12095
12096    // Perf A/B bench (not a gate): measures cold_build wall time at a given
12097    // chunk size against a real repo. Driven by env so the same binary can A/B
12098    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
12099    // memory work. Run:
12100    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
12101    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
12102    #[test]
12103    #[ignore]
12104    fn bench_cold_build_chunk() {
12105        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
12106        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
12107            .expect("AFT_PERF_CHUNK")
12108            .parse()
12109            .expect("AFT_PERF_CHUNK must be a non-negative integer");
12110        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
12111        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
12112        let dir = tempdir().expect("temp dir");
12113        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
12114            .expect("open store");
12115        let started = Instant::now();
12116        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
12117        let ms = started.elapsed().as_millis();
12118        println!(
12119            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
12120            stats.files, stats.nodes, stats.refs, stats.edges
12121        );
12122    }
12123
12124    #[test]
12125    fn persisted_workspace_reexport_selects_its_package_dependency() {
12126        let root = tempdir().expect("temp dir");
12127        let dependencies = BTreeSet::from([
12128            "packages/aft-bridge/src/index.ts".to_string(),
12129            "packages/opencode-plugin/src/types.ts".to_string(),
12130        ]);
12131        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
12132
12133        assert_eq!(
12134            stored_dependencies_for_module(
12135                root.path(),
12136                "packages/opencode-plugin/src/shared/bash-hints.ts",
12137                "@cortexkit/aft-bridge",
12138                &dependencies,
12139                &indexed_files,
12140            ),
12141            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
12142        );
12143    }
12144
12145    #[test]
12146    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
12147        let dir = tempdir().expect("temp dir");
12148        let project_root = dir.path();
12149        let files =
12150            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
12151        let index_path = project_root.join("src/index.ts");
12152
12153        let store = CallGraphStore::open(
12154            project_root.join(".store-incremental-barrel"),
12155            project_root.to_path_buf(),
12156        )
12157        .expect("open incremental store");
12158        store.cold_build(&files).expect("initial cold build");
12159
12160        {
12161            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
12162            let tx = conn.transaction().expect("dependency transaction");
12163            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
12164                .expect("dependent refs for barrel");
12165            let selected_ref_ids = dependent_refs
12166                .iter()
12167                .map(|dependent_ref| dependent_ref.ref_id.clone())
12168                .collect::<BTreeSet<_>>();
12169            let mut threaded_ref_ids = BTreeSet::new();
12170            let mut threaded_by_caller = BTreeMap::new();
12171            record_dependent_refs(
12172                &mut threaded_ref_ids,
12173                &mut threaded_by_caller,
12174                dependent_refs,
12175            );
12176            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
12177                .expect("old per-ref caller lookup");
12178
12179            assert_eq!(threaded_ref_ids, selected_ref_ids);
12180            assert_eq!(threaded_by_caller, old_by_caller);
12181            for consumer in [
12182                "src/consumer_a.ts",
12183                "src/consumer_b.ts",
12184                "src/consumer_c.ts",
12185            ] {
12186                assert!(
12187                    threaded_by_caller.contains_key(consumer),
12188                    "barrel edit should select dependent refs from {consumer}"
12189                );
12190            }
12191        }
12192
12193        fs::write(
12194            &index_path,
12195            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
12196        )
12197        .expect("edit barrel");
12198        let stats = store
12199            .refresh_files(std::slice::from_ref(&index_path))
12200            .expect("incremental refresh");
12201        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
12202        assert!(
12203            stats.dependency_selected_refs > 0,
12204            "barrel surface edit should select dependent refs"
12205        );
12206
12207        let cold_store = CallGraphStore::open(
12208            project_root.join(".store-cold-barrel"),
12209            project_root.to_path_buf(),
12210        )
12211        .expect("open cold rebuild store");
12212        cold_store
12213            .cold_build(&files)
12214            .expect("comparison cold build");
12215
12216        for table in [
12217            "nodes",
12218            "refs",
12219            "file_dependencies",
12220            "edges",
12221            "dispatch_hints",
12222        ] {
12223            assert_eq!(
12224                graph_table_rows(&store, table),
12225                graph_table_rows(&cold_store, table),
12226                "incremental refresh {table} rows must match cold rebuild"
12227            );
12228        }
12229
12230        let consumer_path = project_root.join("src/consumer_a.ts");
12231        fs::write(
12232            &consumer_path,
12233            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
12234        )
12235        .expect("edit barrel consumer");
12236        store
12237            .refresh_files(std::slice::from_ref(&consumer_path))
12238            .expect("refresh consumer through unchanged barrel");
12239        cold_store
12240            .cold_build(&files)
12241            .expect("comparison cold rebuild after consumer refresh");
12242        for table in [
12243            "nodes",
12244            "refs",
12245            "file_dependencies",
12246            "edges",
12247            "dispatch_hints",
12248        ] {
12249            assert_eq!(
12250                graph_table_rows(&store, table),
12251                graph_table_rows(&cold_store, table),
12252                "refresh through a persisted barrel must preserve cold-build {table} rows"
12253            );
12254        }
12255    }
12256
12257    fn build_reference_connection(
12258        project_root: &Path,
12259        extract: &FileExtract,
12260        resolved: &ResolvedRef,
12261    ) -> Connection {
12262        let mut conn = Connection::open_in_memory().expect("open reference db");
12263        configure_build_connection(&conn).expect("configure reference db");
12264        initialize_schema(&conn).expect("initialize reference schema");
12265        {
12266            let tx = conn.transaction().expect("reference transaction");
12267            clear_tables(&tx).expect("reference clear");
12268            insert_meta(&tx).expect("reference meta");
12269            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
12270            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
12271            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12272                .expect("reference dispatch edges");
12273            assert_eq!(supplemental, 0);
12274            tx.commit().expect("reference commit");
12275        }
12276        conn
12277    }
12278
12279    fn build_optimized_connection(
12280        project_root: &Path,
12281        extract: &FileExtract,
12282        resolved: &ResolvedRef,
12283    ) -> Connection {
12284        let mut conn = Connection::open_in_memory().expect("open optimized db");
12285        configure_build_connection(&conn).expect("configure optimized db");
12286        initialize_schema(&conn).expect("initialize optimized schema");
12287        {
12288            let tx = conn.transaction().expect("optimized transaction");
12289            clear_tables(&tx).expect("optimized clear");
12290            insert_meta(&tx).expect("optimized meta");
12291            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
12292            {
12293                let workspace_root = project_root.display().to_string();
12294                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
12295                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
12296                    .expect("optimized file extract");
12297                insert_resolved_ref_prepared(&mut inserts, resolved)
12298                    .expect("optimized resolved ref");
12299            }
12300            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
12301            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12302                .expect("optimized dispatch edges");
12303            assert_eq!(supplemental, 0);
12304            tx.commit().expect("optimized commit");
12305        }
12306        conn
12307    }
12308
12309    fn fixture_extract(_project_root: &Path) -> FileExtract {
12310        let rel_path = "src/main.ts".to_string();
12311        let target_path = "src/helper.ts".to_string();
12312        let node = NodeRecord {
12313            id: "node-main".to_string(),
12314            file_path: rel_path.clone(),
12315            name: "main".to_string(),
12316            scoped_name: "main".to_string(),
12317            kind: "function".to_string(),
12318            range: Range {
12319                start_line: 0,
12320                start_col: 0,
12321                end_line: 0,
12322                end_col: 32,
12323            },
12324            range_ordinal: 0,
12325            signature: Some("export function main()".to_string()),
12326            exported: true,
12327            is_default_export: false,
12328            is_type_like: false,
12329            is_callgraph_entry_point: true,
12330        };
12331        let mut dependencies = BTreeSet::new();
12332        dependencies.insert(target_path.clone());
12333        let raw_ref = RawRef {
12334            ref_id: "ref-main-helper".to_string(),
12335            caller_node: Some(node.id.clone()),
12336            caller_symbol: Some(node.scoped_name.clone()),
12337            caller_file: rel_path.clone(),
12338            kind: "call".to_string(),
12339            short_name: Some("helper".to_string()),
12340            full_ref: Some("helper".to_string()),
12341            module_path: None,
12342            import_kind: None,
12343            local_name: Some("helper".to_string()),
12344            requested_name: Some("helper".to_string()),
12345            namespace_alias: None,
12346            wildcard: false,
12347            line: 1,
12348            byte_start: 24,
12349            byte_end: 32,
12350            dependencies,
12351        };
12352        FileExtract {
12353            rel_path,
12354            freshness: FileFreshness {
12355                mtime: UNIX_EPOCH + Duration::from_secs(123),
12356                size: 40,
12357                content_hash: cache_freshness::hash_bytes(b"fixture source"),
12358            },
12359            lang: LangId::TypeScript,
12360            data: FileCallData {
12361                calls_by_symbol: HashMap::new(),
12362                exported_symbols: Vec::new(),
12363                symbol_metadata: HashMap::new(),
12364                default_export_symbol: None,
12365                import_block: ImportBlock::empty(),
12366                lang: LangId::TypeScript,
12367            },
12368            nodes: vec![node.clone()],
12369            raw_refs: vec![raw_ref],
12370            dispatch_hints: vec![DispatchHint {
12371                id: "dispatch-main-helper".to_string(),
12372                method_name: "helper".to_string(),
12373                caller_node: node.id,
12374                file: "src/main.ts".to_string(),
12375                line: 1,
12376                byte_start: 24,
12377                byte_end: 32,
12378            }],
12379            surface_fingerprint: "surface".to_string(),
12380        }
12381    }
12382
12383    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
12384        let raw = extract.raw_refs[0].clone();
12385        let mut dependencies = raw.dependencies.clone();
12386        dependencies.insert("src/helper.ts".to_string());
12387        ResolvedRef {
12388            edge: Some(EdgeRecord {
12389                edge_id: "edge-main-helper".to_string(),
12390                source_node: raw.caller_node.clone().expect("caller node"),
12391                target_node: Some("node-helper".to_string()),
12392                target_file: "src/helper.ts".to_string(),
12393                target_symbol: "helper".to_string(),
12394                kind: "call".to_string(),
12395                line: raw.line,
12396            }),
12397            raw,
12398            status: "resolved".to_string(),
12399            target_node: Some("node-helper".to_string()),
12400            target_file: Some("src/helper.ts".to_string()),
12401            target_symbol: Some("helper".to_string()),
12402            dependencies,
12403        }
12404    }
12405
12406    fn write_chunked_equivalence_fixture(project_root: &Path) {
12407        let ts_dir = project_root.join("ts");
12408        fs::create_dir_all(&ts_dir).expect("create ts dir");
12409        fs::write(
12410            ts_dir.join("leaf.ts"),
12411            "export function leaf(value: number) {\n  return value + 1;\n}\n",
12412        )
12413        .expect("write ts leaf");
12414        fs::write(
12415            ts_dir.join("mid.ts"),
12416            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
12417        )
12418        .expect("write ts mid");
12419        fs::write(
12420            ts_dir.join("entry.ts"),
12421            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
12422        )
12423        .expect("write ts entry");
12424        fs::write(
12425            ts_dir.join("worker.ts"),
12426            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
12427        )
12428        .expect("write ts worker");
12429        for idx in 0..4 {
12430            fs::write(
12431                ts_dir.join(format!("extra_{idx}.ts")),
12432                format!(
12433                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
12434                ),
12435            )
12436            .expect("write ts extra");
12437        }
12438
12439        let rust_dir = project_root.join("src");
12440        let commands_dir = rust_dir.join("commands");
12441        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
12442        fs::write(
12443            rust_dir.join("context.rs"),
12444            r#"pub struct AppContext;
12445
12446impl AppContext {
12447    pub fn callgraph_store_for_ops(&self) -> usize {
12448        1
12449    }
12450}
12451"#,
12452        )
12453        .expect("write rust context");
12454        fs::write(
12455            rust_dir.join("lib.rs"),
12456            "pub mod context;\npub mod commands;\n",
12457        )
12458        .expect("write rust lib");
12459        fs::write(
12460            commands_dir.join("mod.rs"),
12461            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
12462        )
12463        .expect("write rust commands mod");
12464        for name in ["callers", "impact", "trace_to"] {
12465            fs::write(
12466                commands_dir.join(format!("{name}.rs")),
12467                format!(
12468                    r#"use crate::context::AppContext;
12469
12470pub fn handle_{name}(ctx: &AppContext) -> usize {{
12471    ctx.callgraph_store_for_ops()
12472}}
12473"#
12474                ),
12475            )
12476            .expect("write rust command");
12477        }
12478    }
12479
12480    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
12481        let src_dir = project_root.join("src");
12482        fs::create_dir_all(&src_dir).expect("create src dir");
12483
12484        let target_path = src_dir.join("target.ts");
12485        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
12486            .expect("write target");
12487
12488        let index_path = src_dir.join("index.ts");
12489        fs::write(&index_path, barrel_source).expect("write barrel");
12490
12491        let mut files = vec![target_path, index_path];
12492        for (file_name, function_name) in [
12493            ("consumer_a.ts", "consumerA"),
12494            ("consumer_b.ts", "consumerB"),
12495            ("consumer_c.ts", "consumerC"),
12496        ] {
12497            let path = src_dir.join(file_name);
12498            fs::write(
12499                &path,
12500                format!(
12501                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
12502                ),
12503            )
12504            .expect("write consumer");
12505            files.push(path);
12506        }
12507        files
12508    }
12509
12510    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
12511        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12512        table_rows(&conn, table)
12513    }
12514
12515    fn graph_table_rows_without(
12516        store: &CallGraphStore,
12517        table: &str,
12518        excluded_columns: &[&str],
12519    ) -> Vec<String> {
12520        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12521        table_rows_without(&conn, table, excluded_columns)
12522    }
12523
12524    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
12525        table_rows_without(conn, table, &[])
12526    }
12527
12528    fn table_rows_without(
12529        conn: &Connection,
12530        table: &str,
12531        excluded_columns: &[&str],
12532    ) -> Vec<String> {
12533        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
12534        let columns: Vec<String> = conn
12535            .prepare(&format!("PRAGMA table_info({table})"))
12536            .expect("prepare table_info")
12537            .query_map([], |row| row.get::<_, String>(1))
12538            .expect("query table_info")
12539            .collect::<std::result::Result<Vec<String>, _>>()
12540            .expect("collect columns")
12541            .into_iter()
12542            .filter(|column| !excluded_columns.contains(column.as_str()))
12543            .collect();
12544        let sql = format!(
12545            "SELECT {} FROM {table} ORDER BY {}",
12546            columns.join(", "),
12547            columns.join(", ")
12548        );
12549        conn.prepare(&sql)
12550            .expect("prepare table rows")
12551            .query_map([], |row| row_to_strings(row, columns.len()))
12552            .expect("query table rows")
12553            .collect::<std::result::Result<_, _>>()
12554            .expect("collect table rows")
12555    }
12556
12557    fn assert_cold_build_stats_match_except_elapsed(
12558        expected: &ColdBuildStats,
12559        actual: &ColdBuildStats,
12560    ) {
12561        assert_eq!(actual.files, expected.files, "file counts must match");
12562        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
12563        assert_eq!(actual.refs, expected.refs, "ref counts must match");
12564        assert_eq!(actual.edges, expected.edges, "edge counts must match");
12565        assert_eq!(
12566            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
12567            expected
12568                .failed_files
12569                .iter()
12570                .cloned()
12571                .collect::<BTreeSet<_>>(),
12572            "failed file sets must match"
12573        );
12574    }
12575
12576    fn backend_state_rows(conn: &Connection) -> Vec<String> {
12577        conn.prepare(
12578            "SELECT backend, workspace_root, file_path, content_hash, status
12579             FROM backend_file_state
12580             ORDER BY backend, workspace_root, file_path, content_hash, status",
12581        )
12582        .expect("prepare backend rows")
12583        .query_map([], |row| row_to_strings(row, 5))
12584        .expect("query backend rows")
12585        .collect::<std::result::Result<_, _>>()
12586        .expect("collect backend rows")
12587    }
12588
12589    fn secondary_indexes(conn: &Connection) -> Vec<String> {
12590        let mut indexes = Vec::new();
12591        for table in [
12592            "files",
12593            "nodes",
12594            "refs",
12595            "file_dependencies",
12596            "edges",
12597            "dispatch_hints",
12598            "type_ref_names",
12599            "backend_file_state",
12600            "meta",
12601        ] {
12602            let sql = format!("PRAGMA index_list({table})");
12603            let mut stmt = conn.prepare(&sql).expect("prepare index list");
12604            let rows = stmt
12605                .query_map([], |row| row.get::<_, String>(1))
12606                .expect("query index list");
12607            for name in rows {
12608                let name = name.expect("index name");
12609                if name.starts_with("idx_") {
12610                    indexes.push(format!("{table}:{name}"));
12611                }
12612            }
12613        }
12614        indexes.sort();
12615        indexes
12616    }
12617
12618    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
12619        let mut values = Vec::with_capacity(len);
12620        for index in 0..len {
12621            let value = row.get_ref(index)?;
12622            values.push(match value {
12623                rusqlite::types::ValueRef::Null => "NULL".to_string(),
12624                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
12625                rusqlite::types::ValueRef::Real(value) => value.to_string(),
12626                rusqlite::types::ValueRef::Text(value) => {
12627                    String::from_utf8_lossy(value).into_owned()
12628                }
12629                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
12630            });
12631        }
12632        Ok(values.join("\u{1f}"))
12633    }
12634}
12635
12636#[cfg(test)]
12637mod rust_resolution_tests {
12638    use super::*;
12639    use crate::inspect::job::CallgraphSnapshot;
12640    use std::fs;
12641    use tempfile::tempdir;
12642
12643    #[test]
12644    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
12645        let dir = tempdir().expect("tempdir");
12646        let root = dir.path();
12647        write_rust_manifest(root, "scoped-alias-fixture");
12648        write_file(
12649            root,
12650            "src/lib.rs",
12651            r#"pub mod finalization_contract;
12652
12653pub fn run_alias() {
12654    use crate::finalization_contract as fc;
12655    fc::check_mason_contract();
12656}
12657"#,
12658        );
12659        write_file(
12660            root,
12661            "src/finalization_contract.rs",
12662            r#"pub fn check_mason_contract() {}
12663fn planted_dead() {}
12664"#,
12665        );
12666
12667        let (store, snapshot) = cold_build_twice(root);
12668        assert_direct_caller(
12669            &store,
12670            "src/finalization_contract.rs",
12671            "check_mason_contract",
12672            "src/lib.rs",
12673            "run_alias",
12674        );
12675        assert_projected_call(
12676            root,
12677            &snapshot,
12678            "src/finalization_contract.rs",
12679            "check_mason_contract",
12680        );
12681        assert_no_projected_call(
12682            root,
12683            &snapshot,
12684            "src/finalization_contract.rs",
12685            "planted_dead",
12686        );
12687        assert!(
12688            store
12689                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
12690                .expect("planted dead callers")
12691                .is_empty(),
12692            "planted-dead guard should stay without callers"
12693        );
12694    }
12695
12696    #[test]
12697    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
12698        let dir = tempdir().expect("tempdir");
12699        let root = dir.path();
12700        write_rust_manifest(root, "inline-module-fixture");
12701        write_file(
12702            root,
12703            "src/lib.rs",
12704            r#"mod work_graph { fn operations() {} }
12705mod manifest { fn operations() {} }
12706mod audit { fn operations() {} }
12707mod dispatch { fn operations() {} }
12708mod finalization { fn operations() {} }
12709
12710pub fn run_inline_operations() {
12711    work_graph::operations();
12712    manifest::operations();
12713    audit::operations();
12714    dispatch::operations();
12715    finalization::operations();
12716}
12717
12718fn planted_dead() {}
12719"#,
12720        );
12721
12722        let (store, snapshot) = cold_build_twice(root);
12723        for module in [
12724            "work_graph",
12725            "manifest",
12726            "audit",
12727            "dispatch",
12728            "finalization",
12729        ] {
12730            assert_direct_caller(
12731                &store,
12732                "src/lib.rs",
12733                &format!("{module}::operations"),
12734                "src/lib.rs",
12735                "run_inline_operations",
12736            );
12737        }
12738        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
12739        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12740    }
12741
12742    #[test]
12743    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
12744        let dir = tempdir().expect("tempdir");
12745        let root = dir.path();
12746        fs::write(
12747            root.join("Cargo.toml"),
12748            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
12749        )
12750        .expect("write workspace manifest");
12751        write_file(
12752            root,
12753            "crates/but-action/Cargo.toml",
12754            r#"[package]
12755name = "but-action"
12756version = "0.1.0"
12757edition = "2021"
12758"#,
12759        );
12760        write_file(
12761            root,
12762            "crates/but-action/src/lib.rs",
12763            "mod action;\npub use action::{list_actions};\n",
12764        );
12765        write_file(
12766            root,
12767            "crates/but-action/src/action.rs",
12768            "pub fn list_actions() {}\nfn planted_dead() {}\n",
12769        );
12770        write_file(
12771            root,
12772            "crates/app/Cargo.toml",
12773            r#"[package]
12774name = "app"
12775version = "0.1.0"
12776edition = "2021"
12777"#,
12778        );
12779        write_file(
12780            root,
12781            "crates/app/src/lib.rs",
12782            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
12783        );
12784
12785        let (store, snapshot) = cold_build_twice(root);
12786        assert_direct_caller(
12787            &store,
12788            "crates/but-action/src/action.rs",
12789            "list_actions",
12790            "crates/app/src/lib.rs",
12791            "run_actions",
12792        );
12793        assert!(
12794            store
12795                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
12796                .expect("lib reexport callers")
12797                .is_empty(),
12798            "call should target the reexported source function, not lib.rs"
12799        );
12800        assert_projected_call(
12801            root,
12802            &snapshot,
12803            "crates/but-action/src/action.rs",
12804            "list_actions",
12805        );
12806        assert_no_projected_call(
12807            root,
12808            &snapshot,
12809            "crates/but-action/src/action.rs",
12810            "planted_dead",
12811        );
12812    }
12813
12814    #[test]
12815    fn rust_generic_self_turbofish_method_dispatch_resolves() {
12816        let dir = tempdir().expect("tempdir");
12817        let root = dir.path();
12818        write_rust_manifest(root, "generic-self-fixture");
12819        write_file(
12820            root,
12821            "src/lib.rs",
12822            r#"pub struct Matcher;
12823
12824impl Matcher {
12825    pub fn run(&self) -> bool {
12826        self.fuzzy_match_optimal::<usize>("needle")
12827    }
12828
12829    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
12830        let _ = std::marker::PhantomData::<T>;
12831        true
12832    }
12833
12834    fn planted_dead(&self) {}
12835}
12836
12837pub fn entry() -> bool {
12838    let matcher = Matcher;
12839    matcher.run()
12840}
12841"#,
12842        );
12843
12844        let (store, snapshot) = cold_build_twice(root);
12845        assert_direct_caller(
12846            &store,
12847            "src/lib.rs",
12848            "Matcher::fuzzy_match_optimal",
12849            "src/lib.rs",
12850            "Matcher::run",
12851        );
12852        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
12853        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12854    }
12855
12856    #[test]
12857    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
12858        let dir = tempdir().expect("tempdir");
12859        let root = dir.path();
12860        write_rust_manifest(root, "manifest-operations-fixture");
12861        write_file(
12862            root,
12863            "src/main.rs",
12864            r#"mod dispatch;
12865use dispatch::{manifest_operations};
12866
12867fn main() {
12868    manifest_operations();
12869}
12870"#,
12871        );
12872        write_file(
12873            root,
12874            "src/dispatch.rs",
12875            r#"mod work_graph { fn operations() {} }
12876mod manifest { fn operations() {} }
12877mod audit { fn operations() {} }
12878mod descriptor { fn operations() {} }
12879mod writer { fn operations() {} }
12880
12881pub fn manifest_operations() {
12882    manifest::operations();
12883}
12884
12885pub fn work_graph_operations() {
12886    work_graph::operations();
12887}
12888
12889pub fn audit_operations() {
12890    audit::operations();
12891}
12892
12893pub fn descriptor_operations() {
12894    descriptor::operations();
12895}
12896
12897pub fn writer_operations() {
12898    writer::operations();
12899}
12900
12901fn planted_dead() {}
12902"#,
12903        );
12904
12905        let (store, snapshot) = cold_build_twice(root);
12906        assert_direct_caller(
12907            &store,
12908            "src/dispatch.rs",
12909            "manifest_operations",
12910            "src/main.rs",
12911            "main",
12912        );
12913        assert_direct_caller(
12914            &store,
12915            "src/dispatch.rs",
12916            "manifest::operations",
12917            "src/dispatch.rs",
12918            "manifest_operations",
12919        );
12920        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
12921        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
12922        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
12923    }
12924
12925    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
12926        let files = rust_files(root);
12927        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
12928            .expect("open first store");
12929        first.cold_build(&files).expect("first cold build");
12930        let first_snapshot =
12931            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
12932
12933        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
12934            .expect("open second store");
12935        second.cold_build(&files).expect("second cold build");
12936        let second_snapshot =
12937            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
12938
12939        assert_eq!(
12940            projection_rows(&first_snapshot),
12941            projection_rows(&second_snapshot),
12942            "cold-build projection should be deterministic"
12943        );
12944        (first, first_snapshot)
12945    }
12946
12947    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
12948        let mut rows = Vec::new();
12949        for export in &snapshot.exported_symbols {
12950            rows.push(format!(
12951                "export\t{}\t{}\t{}\t{}",
12952                export.file.display(),
12953                export.symbol,
12954                export.kind,
12955                export.line
12956            ));
12957        }
12958        for call in &snapshot.outbound_calls {
12959            rows.push(format!(
12960                "call\t{}\t{}\t{}\t{}\t{}",
12961                call.caller_file.display(),
12962                call.caller_symbol,
12963                call.target,
12964                call.line,
12965                call.provenance
12966            ));
12967        }
12968        for file in &snapshot.entry_points {
12969            rows.push(format!("entry_file\t{}", file.display()));
12970        }
12971        for (file, symbols) in &snapshot.entry_point_symbols {
12972            for symbol in symbols {
12973                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
12974            }
12975        }
12976        rows.sort();
12977        rows
12978    }
12979
12980    fn assert_direct_caller(
12981        store: &CallGraphStore,
12982        target_rel: &str,
12983        target_symbol: &str,
12984        caller_rel: &str,
12985        caller_symbol: &str,
12986    ) {
12987        let callers = store
12988            .direct_callers_of(Path::new(target_rel), target_symbol)
12989            .unwrap_or_else(|error| {
12990                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
12991            });
12992        assert!(
12993            callers.iter().any(|site| {
12994                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
12995            }),
12996            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
12997        );
12998    }
12999
13000    fn assert_projected_call(
13001        root: &Path,
13002        snapshot: &CallgraphSnapshot,
13003        target_rel: &str,
13004        symbol: &str,
13005    ) {
13006        let target = projected_target(root, target_rel, symbol);
13007        assert!(
13008            snapshot.outbound_calls.iter().any(|call| {
13009                call.target == target
13010                    || call.target.starts_with(&format!(
13011                        "{target}{}",
13012                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
13013                    ))
13014            }),
13015            "expected projected call to {target}; calls: {:#?}",
13016            snapshot.outbound_calls
13017        );
13018    }
13019
13020    fn assert_no_projected_call(
13021        root: &Path,
13022        snapshot: &CallgraphSnapshot,
13023        target_rel: &str,
13024        symbol: &str,
13025    ) {
13026        let target = projected_target(root, target_rel, symbol);
13027        assert!(
13028            snapshot.outbound_calls.iter().all(|call| {
13029                call.target != target
13030                    && !call.target.starts_with(&format!(
13031                        "{target}{}",
13032                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
13033                    ))
13034            }),
13035            "did not expect projected call to {target}; calls: {:#?}",
13036            snapshot.outbound_calls
13037        );
13038    }
13039
13040    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
13041        // Projection targets carry the normalized (verbatim-stripped)
13042        // canonical form; bare fs::canonicalize diverges on Windows.
13043        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
13044        format!("{}::{symbol}", path.display())
13045    }
13046
13047    fn write_rust_manifest(root: &Path, name: &str) {
13048        write_file(
13049            root,
13050            "Cargo.toml",
13051            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
13052        );
13053    }
13054
13055    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
13056        let path = root.join(rel_path);
13057        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
13058        fs::write(&path, source).expect("write fixture file");
13059        path
13060    }
13061
13062    fn rust_files(root: &Path) -> Vec<PathBuf> {
13063        let mut files = Vec::new();
13064        collect_rust_files(root, &mut files);
13065        files.sort();
13066        files
13067    }
13068
13069    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
13070        for entry in fs::read_dir(dir).expect("read fixture dir") {
13071            let entry = entry.expect("read fixture entry");
13072            let path = entry.path();
13073            if path.is_dir() {
13074                let name = path
13075                    .file_name()
13076                    .and_then(|name| name.to_str())
13077                    .unwrap_or("");
13078                if !name.starts_with(".store") {
13079                    collect_rust_files(&path, files);
13080                }
13081            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
13082                files.push(path);
13083            }
13084        }
13085    }
13086}
13087
13088#[cfg(test)]
13089mod build_pool_tests {
13090    use super::build_pool_size;
13091
13092    #[test]
13093    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
13094        let size = build_pool_size();
13095        // Never zero, never the full core count, never above the 8 cap — this is
13096        // the starvation guard for the cold-build's all-cores tree-sitter pass.
13097        assert!(size >= 1, "pool size must be at least 1");
13098        assert!(size <= 8, "pool size must be capped at 8, got {size}");
13099
13100        let cores = std::thread::available_parallelism()
13101            .map(|p| p.get())
13102            .unwrap_or(1);
13103        let expected = cores.div_ceil(2).clamp(1, 8);
13104        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
13105    }
13106}
13107
13108#[cfg(test)]
13109mod reexport_resolution_tests {
13110    use super::*;
13111
13112    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
13113        ProjectIndex {
13114            project_root: PathBuf::from("/fixture"),
13115            files: files.into_iter().collect(),
13116            caller_data: HashMap::new(),
13117            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
13118        }
13119    }
13120
13121    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
13122        DbFileIndex {
13123            lang: None,
13124            exports: HashSet::new(),
13125            default_export: None,
13126            export_aliases: HashMap::new(),
13127            node_by_scoped: HashMap::new(),
13128            node_by_bare: HashMap::new(),
13129            module_targets: HashMap::new(),
13130            reexports: reexport_targets
13131                .iter()
13132                .map(|target| ReexportIndex {
13133                    target_file: Some((*target).to_string()),
13134                    named: HashMap::new(),
13135                    wildcard: true,
13136                })
13137                .collect(),
13138        }
13139    }
13140
13141    /// A dense wildcard re-export cycle (barrel files re-exporting each
13142    /// other) must resolve in O(files), not O(branching^depth). Without the
13143    /// resolver's memoization, resolving a MISSING symbol through this
13144    /// 12-file complete digraph explores ~11^16 paths and this test never
13145    /// finishes: the depth cap bounds path length, not path count, and one
13146    /// such resolution can pin a worker thread at 100% CPU indefinitely.
13147    #[test]
13148    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
13149        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
13150        let files = names
13151            .iter()
13152            .map(|name| {
13153                let targets: Vec<&str> = names
13154                    .iter()
13155                    .filter(|other| *other != name)
13156                    .map(String::as_str)
13157                    .collect();
13158                (name.clone(), barrel_file(&targets))
13159            })
13160            .collect();
13161        let index = barrel_index(files);
13162
13163        assert_eq!(
13164            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
13165            None
13166        );
13167    }
13168
13169    /// Depth-dominance counterexample: the walk first reaches `shared` down a
13170    /// 16-hop chain (no budget left for its leaf), then reaches it again
13171    /// directly at depth 1. Plain visited-set pruning would skip the second
13172    /// visit and lose a resolution the capped resolver finds; the
13173    /// depth-dominance memo revisits because the second arrival is shallower.
13174    #[test]
13175    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
13176        let mut leaf = barrel_file(&[]);
13177        leaf.exports.insert("deep_symbol".to_string());
13178        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
13179        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
13180        // entry's SECOND reexport goes straight to shared.
13181        files.push((
13182            "src/entry.ts".to_string(),
13183            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
13184        ));
13185        for i in 0..15 {
13186            let next = if i == 14 {
13187                "src/shared.ts".to_string()
13188            } else {
13189                format!("src/chain{}.ts", i + 1)
13190            };
13191            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
13192        }
13193        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
13194        files.push(("src/leaf.ts".to_string(), leaf));
13195        let index = barrel_index(files);
13196
13197        assert_eq!(
13198            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
13199            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
13200            "a shallower re-visit must not be pruned by a deeper capped visit"
13201        );
13202    }
13203
13204    #[test]
13205    fn symbol_reachable_through_reexport_cycle_still_resolves() {
13206        let mut leaf = barrel_file(&[]);
13207        leaf.exports.insert("real_symbol".to_string());
13208        let index = barrel_index(vec![
13209            (
13210                "src/a.ts".to_string(),
13211                barrel_file(&["src/b.ts", "src/a.ts"]),
13212            ),
13213            (
13214                "src/b.ts".to_string(),
13215                barrel_file(&["src/a.ts", "src/leaf.ts"]),
13216            ),
13217            ("src/leaf.ts".to_string(), leaf),
13218        ]);
13219
13220        assert_eq!(
13221            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
13222            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
13223        );
13224    }
13225}
13226
13227#[cfg(test)]
13228mod method_dispatch_inference_tests {
13229    use super::*;
13230    use std::fs;
13231    use tempfile::tempdir;
13232
13233    #[test]
13234    fn java_field_receiver_type_selects_declared_class_method() {
13235        let source = r#"class EntryPoint {
13236    private UserService userService;
13237
13238    void handle() {
13239        userService.find();
13240    }
13241}
13242
13243class UserService {
13244    void find() {}
13245}
13246
13247class AuditService {
13248    void find() {}
13249}
13250"#;
13251        let dir = tempdir().expect("temp dir");
13252        let root = dir.path();
13253        write_fixture(root, "src/EntryPoint.java", source);
13254        let reference = reference(
13255            "java",
13256            "src/EntryPoint.java",
13257            "EntryPoint::handle",
13258            "userService",
13259            "find",
13260            line_of(source, "userService.find()"),
13261        );
13262        let mut cache = DispatchSourceCache::new();
13263
13264        let receiver_type =
13265            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
13266        assert_eq!(receiver_type, "UserService");
13267
13268        let candidates = vec![
13269            method_candidate("audit", "AuditService::find"),
13270            method_candidate("user", "UserService::find"),
13271        ];
13272        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
13273            .expect("type candidate");
13274        assert_eq!(selected.scoped_name, "UserService::find");
13275
13276        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
13277        assert!(
13278            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
13279        );
13280    }
13281
13282    #[test]
13283    fn kotlin_property_and_local_value_types_are_inferred() {
13284        let source = r#"class Handler {
13285    private val auditService: AuditService = AuditService()
13286
13287    fun handle() {
13288        auditService.find()
13289        val userService: UserService = UserService()
13290        userService.find()
13291        val billingService = BillingService()
13292        billingService.find()
13293    }
13294}
13295
13296class UserService { fun find() {} }
13297class AuditService { fun find() {} }
13298class BillingService { fun find() {} }
13299"#;
13300        let dir = tempdir().expect("temp dir");
13301        let root = dir.path();
13302        write_fixture(root, "src/Handler.kt", source);
13303        let mut cache = DispatchSourceCache::new();
13304
13305        let audit_ref = reference(
13306            "kotlin",
13307            "src/Handler.kt",
13308            "Handler::handle",
13309            "auditService",
13310            "find",
13311            line_of(source, "auditService.find()"),
13312        );
13313        assert_eq!(
13314            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
13315            Some("AuditService")
13316        );
13317
13318        let user_ref = reference(
13319            "kotlin",
13320            "src/Handler.kt",
13321            "Handler::handle",
13322            "userService",
13323            "find",
13324            line_of(source, "userService.find()"),
13325        );
13326        assert_eq!(
13327            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
13328            Some("UserService")
13329        );
13330
13331        let billing_ref = reference(
13332            "kotlin",
13333            "src/Handler.kt",
13334            "Handler::handle",
13335            "billingService",
13336            "find",
13337            line_of(source, "billingService.find()"),
13338        );
13339        assert_eq!(
13340            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
13341            Some("BillingService")
13342        );
13343    }
13344
13345    #[test]
13346    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
13347        let source = r#"struct Foo { void run(); };
13348struct PointerFoo { void run(); };
13349struct FactoryFoo { void run(); };
13350FactoryFoo makeFactoryFoo();
13351
13352void handle() {
13353    Foo foo;
13354    foo.run();
13355    PointerFoo* pointerFoo = nullptr;
13356    pointerFoo->run();
13357    auto factoryFoo = makeFactoryFoo();
13358    factoryFoo.run();
13359}
13360"#;
13361        let dir = tempdir().expect("temp dir");
13362        let root = dir.path();
13363        write_fixture(root, "src/fixture.cpp", source);
13364        let mut cache = DispatchSourceCache::new();
13365
13366        let foo_ref = reference(
13367            "cpp",
13368            "src/fixture.cpp",
13369            "handle",
13370            "foo",
13371            "run",
13372            line_of(source, "foo.run()"),
13373        );
13374        assert_eq!(
13375            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
13376            Some("Foo")
13377        );
13378
13379        let pointer_ref = reference(
13380            "cpp",
13381            "src/fixture.cpp",
13382            "handle",
13383            "pointerFoo",
13384            "run",
13385            line_of(source, "pointerFoo->run()"),
13386        );
13387        assert_eq!(
13388            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
13389            Some("PointerFoo")
13390        );
13391
13392        let factory_ref = reference(
13393            "cpp",
13394            "src/fixture.cpp",
13395            "handle",
13396            "factoryFoo",
13397            "run",
13398            line_of(source, "factoryFoo.run()"),
13399        );
13400        assert_eq!(
13401            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
13402            Some("FactoryFoo")
13403        );
13404    }
13405
13406    #[test]
13407    fn unknown_java_receiver_still_uses_name_match_fallback() {
13408        let source = r#"class EntryPoint {
13409    void handle() {
13410        service.runSpecial();
13411    }
13412}
13413
13414class OnlyService {
13415    void runSpecial() {}
13416}
13417"#;
13418        let dir = tempdir().expect("temp dir");
13419        let root = dir.path();
13420        write_fixture(root, "src/EntryPoint.java", source);
13421        let reference = reference(
13422            "java",
13423            "src/EntryPoint.java",
13424            "EntryPoint::handle",
13425            "service",
13426            "runSpecial",
13427            line_of(source, "service.runSpecial()"),
13428        );
13429        let mut cache = DispatchSourceCache::new();
13430
13431        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
13432        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
13433        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
13434        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
13435    }
13436
13437    fn reference(
13438        lang: &str,
13439        caller_file: &str,
13440        caller_symbol: &str,
13441        receiver: &str,
13442        method_name: &str,
13443        line: u32,
13444    ) -> NameMatchRef {
13445        NameMatchRef {
13446            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
13447            caller_node: format!("{caller_symbol}:node"),
13448            caller_file: caller_file.to_string(),
13449            caller_symbol: caller_symbol.to_string(),
13450            caller_signature: None,
13451            receiver: receiver.to_string(),
13452            method_name: method_name.to_string(),
13453            colon_dispatch: false,
13454            line,
13455            lang: lang.to_string(),
13456        }
13457    }
13458
13459    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
13460        NameMatchCandidate {
13461            node_id: node_id.to_string(),
13462            file_path: "src/targets.fixture".to_string(),
13463            scoped_name: scoped_name.to_string(),
13464            kind: "method".to_string(),
13465        }
13466    }
13467
13468    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
13469        let path = root.join(rel_path);
13470        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
13471        fs::write(path, source).expect("write fixture");
13472    }
13473
13474    fn line_of(source: &str, needle: &str) -> u32 {
13475        source
13476            .lines()
13477            .position(|line| line.contains(needle))
13478            .map(|index| index as u32 + 1)
13479            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
13480    }
13481}