Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44/// Marker-protected generations older than this absolute age are reclaimed even
45/// if a stale reader marker remains. Current and newest-previous generations are
46/// always retained, bounding the root-keyed callgraph store to roughly two or
47/// three large generations without adding user-visible configuration.
48const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56/// Keep SQLite's per-connection page cache below the staged build working-set
57/// budget; negative values are KiB per SQLite's `cache_size` pragma.
58const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
59const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
60
61// Cold-build working-set limits are implementation constants rather than user
62// knobs so a large non-git root cannot accidentally opt back into an OOM path.
63const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
64const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
65// A 20k-reference resolver window kept peak RSS working-set shaped in the
66// committed 20k/40k corpus harness; 100k rows did not.
67const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
68const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
69const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
70const STAGED_BUILD_PHASE: &str = "staged_build_phase";
71const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
72
73fn write_amplification_baseline_enabled() -> bool {
74    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
75}
76
77type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
78pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
79
80static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
81    OnceLock::new();
82
83/// Install a process-local phase hook for the reproducible cold-build harness.
84/// Production callers leave it unset, so phase reporting adds no allocation on
85/// the build path.
86pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
87    *COLD_BUILD_PHASE_OBSERVER
88        .get_or_init(|| Mutex::new(None))
89        .lock()
90        .expect("cold build phase observer mutex poisoned") = observer;
91}
92
93fn note_cold_build_phase(phase: &'static str) {
94    if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
95        .get_or_init(|| Mutex::new(None))
96        .lock()
97        .expect("cold build phase observer mutex poisoned")
98        .as_ref()
99        .cloned()
100    {
101        observer(phase);
102    }
103}
104
105#[cfg(test)]
106fn note_cold_build_commit_barrier(phase: &'static str) {
107    note_cold_build_phase(phase);
108}
109
110#[cfg(not(test))]
111fn note_cold_build_commit_barrier(_phase: &'static str) {}
112
113#[derive(Clone, Debug, Eq, Hash, PartialEq)]
114struct RebuildCooldownKey {
115    callgraph_dir: PathBuf,
116    project_key: String,
117}
118
119#[derive(Clone, Debug)]
120struct RebuildCooldownRecord {
121    project_root: PathBuf,
122    published_at: Instant,
123    cross_root_cooldown_armed: bool,
124}
125
126// Prevent repeated rebuilds when requests rapidly switch between project
127// roots. Allow the first successful rebuild for a different root; after that
128// transition, report the artifact as unavailable instead of publishing another
129// complete generation. Record only successful publications in this map.
130static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
131    OnceLock::new();
132
133#[derive(Clone, Debug, Eq, Hash, PartialEq)]
134struct RootRepairWarningKey {
135    project_key: String,
136}
137
138#[derive(Clone, Debug)]
139struct RootRepairWarningRecord {
140    window_start: Instant,
141    last_emitted: Instant,
142    entry_count: u64,
143    suppressed: u64,
144}
145
146static ROOT_REPAIR_WARNINGS: OnceLock<
147    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
148> = OnceLock::new();
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub(crate) struct CallgraphWriteMetricsSnapshot {
152    pub commits_60s: u64,
153    pub pages_or_bytes_written_60s: u64,
154}
155
156#[derive(Debug, Default)]
157struct CallgraphWriteMetrics {
158    window_start_ms: AtomicU64,
159    commits_60s: AtomicU64,
160    pages_or_bytes_written_60s: AtomicU64,
161}
162
163static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
164    OnceLock::new();
165
166fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
167    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
168    let mut metrics = metrics
169        .lock()
170        .expect("callgraph write metrics mutex poisoned");
171    Arc::clone(
172        metrics
173            .entry(project_key.to_string())
174            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
175    )
176}
177
178fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
179    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
180    if current_start == 0 {
181        let _ = metrics.window_start_ms.compare_exchange(
182            0,
183            now_ms,
184            AtomicOrdering::AcqRel,
185            AtomicOrdering::Acquire,
186        );
187        return;
188    }
189    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
190        return;
191    }
192    if metrics
193        .window_start_ms
194        .compare_exchange(
195            current_start,
196            now_ms,
197            AtomicOrdering::AcqRel,
198            AtomicOrdering::Acquire,
199        )
200        .is_ok()
201    {
202        metrics.commits_60s.store(0, AtomicOrdering::Release);
203        metrics
204            .pages_or_bytes_written_60s
205            .store(0, AtomicOrdering::Release);
206    }
207}
208
209impl CallgraphWriteMetrics {
210    fn record_commit(&self, pages_or_bytes_written: u64) {
211        let now_ms = unix_millis_now();
212        roll_callgraph_write_metric_window(self, now_ms);
213        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
214        self.pages_or_bytes_written_60s
215            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
216    }
217
218    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
219        roll_callgraph_write_metric_window(self, unix_millis_now());
220        CallgraphWriteMetricsSnapshot {
221            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
222            pages_or_bytes_written_60s: self
223                .pages_or_bytes_written_60s
224                .load(AtomicOrdering::Acquire),
225        }
226    }
227}
228
229pub(crate) fn callgraph_write_metrics_for_project(
230    project_key: &str,
231) -> CallgraphWriteMetricsSnapshot {
232    callgraph_write_metrics_for_key(project_key).snapshot()
233}
234
235pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
236    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
237        return CallgraphWriteMetricsSnapshot::default();
238    };
239    let metrics = metrics
240        .lock()
241        .expect("callgraph write metrics mutex poisoned");
242    metrics.values().map(|metrics| metrics.snapshot()).fold(
243        CallgraphWriteMetricsSnapshot::default(),
244        |total, current| CallgraphWriteMetricsSnapshot {
245            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
246            pages_or_bytes_written_60s: total
247                .pages_or_bytes_written_60s
248                .saturating_add(current.pages_or_bytes_written_60s),
249        },
250    )
251}
252
253const ROOT_REPAIR_WARNING_TEXT: &str =
254    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
255
256fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
257    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
258    let mut warnings = warnings.lock().ok()?;
259    let entry = warnings.entry(key);
260    let record = match entry {
261        Entry::Vacant(entry) => {
262            entry.insert(RootRepairWarningRecord {
263                window_start: now,
264                last_emitted: now,
265                entry_count: 1,
266                suppressed: 0,
267            });
268            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
269        }
270        Entry::Occupied(entry) => entry.into_mut(),
271    };
272
273    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
274        let suppressed = record.suppressed;
275        record.window_start = now;
276        record.last_emitted = now;
277        record.entry_count = 1;
278        record.suppressed = 0;
279        return Some(if suppressed == 0 {
280            ROOT_REPAIR_WARNING_TEXT.to_string()
281        } else {
282            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
283        });
284    }
285
286    record.entry_count = record.entry_count.saturating_add(1);
287    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
288        record.suppressed = record.suppressed.saturating_add(1);
289        None
290    } else {
291        record.last_emitted = now;
292        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
293    }
294}
295
296pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
297    next_root_repair_warning(
298        RootRepairWarningKey {
299            project_key: project_key.to_string(),
300        },
301        Instant::now(),
302    )
303}
304
305/// Return the number of repair entries in the active 60-second window.
306///
307/// The window start is returned for callers that need to show freshness without
308/// adding another status verdict or turning this into a user-facing setting.
309pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
310    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
311    let warnings = warnings.lock().ok()?;
312    let record = warnings.get(&RootRepairWarningKey {
313        project_key: project_key.to_string(),
314    })?;
315    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
316        .then_some((record.entry_count, record.window_start))
317}
318
319pub(crate) fn repair_entry_rate_total() -> u64 {
320    let Ok(warnings) = ROOT_REPAIR_WARNINGS
321        .get_or_init(|| Mutex::new(HashMap::new()))
322        .lock()
323    else {
324        return 0;
325    };
326    let now = Instant::now();
327    warnings
328        .values()
329        .filter(|record| {
330            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
331        })
332        .map(|record| record.entry_count)
333        .sum()
334}
335
336#[cfg(test)]
337pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
338    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
339    let mut warnings = warnings.lock().unwrap();
340    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
341        project_key: project_key.to_string(),
342    }) {
343        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
344    }
345}
346
347#[cfg(test)]
348mod root_repair_warning_tests {
349    use super::*;
350
351    #[test]
352    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
353        let key = RootRepairWarningKey {
354            project_key: "test-project".to_string(),
355        };
356        let first_at = Instant::now();
357        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
358        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
359        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
360        assert_eq!(
361            repair_entry_rate("test-project").map(|rate| rate.0),
362            Some(2)
363        );
364
365        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
366        assert!(repeated.ends_with("(repeated 1x in 60s)"));
367        expire_repair_entry_window_for_test("test-project");
368        assert!(repair_entry_rate("test-project").is_none());
369    }
370}
371
372#[cfg(test)]
373mod write_amplification_tests {
374    use super::*;
375    use std::fs;
376    use tempfile::tempdir;
377
378    #[test]
379    fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
380        let temp = tempdir().unwrap();
381        let sqlite_path = temp.path().join("contended.sqlite");
382        let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
383        blocker
384            .execute_batch(
385                "PRAGMA journal_mode=DELETE;
386                 CREATE TABLE lock_probe (value INTEGER NOT NULL);
387                 INSERT INTO lock_probe VALUES (1);
388                 BEGIN EXCLUSIVE;
389                 UPDATE lock_probe SET value = 2;",
390            )
391            .expect("hold exclusive write transaction");
392
393        let (started_tx, started_rx) = std::sync::mpsc::channel();
394        let configure = std::thread::spawn(move || {
395            let conn = Connection::open(sqlite_path).expect("open contending connection");
396            started_tx.send(()).expect("signal configure start");
397            configure_connection(&conn)
398        });
399        started_rx.recv().expect("configure thread started");
400        std::thread::sleep(Duration::from_millis(100));
401        blocker.execute_batch("COMMIT").expect("release write lock");
402
403        configure
404            .join()
405            .expect("configure thread joined")
406            .expect("WAL setup waits for the writer instead of failing locked");
407    }
408
409    #[test]
410    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
411        let temp = tempdir().unwrap();
412        let root = temp.path().join("root");
413        fs::create_dir_all(&root).unwrap();
414        let source = root.join("main.ts");
415        fs::write(&source, "export function main() {}\n").unwrap();
416        let store_dir = temp.path().join("store");
417        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
418
419        let conn = store.conn.lock().unwrap();
420        let synchronous: i64 = conn
421            .pragma_query_value(None, "synchronous", |row| row.get(0))
422            .unwrap();
423        let autocheckpoint: i64 = conn
424            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
425            .unwrap();
426        let cache_size: i64 = conn
427            .pragma_query_value(None, "cache_size", |row| row.get(0))
428            .unwrap();
429        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
430        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
431        assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
432        drop(conn);
433        store.cold_build(std::slice::from_ref(&source)).unwrap();
434        drop(store);
435
436        let readonly = CallGraphStore::open_readonly(store_dir, root)
437            .unwrap()
438            .expect("writer-created empty schema should be readable");
439        let conn = readonly.inner.conn.lock().unwrap();
440        let synchronous: i64 = conn
441            .pragma_query_value(None, "synchronous", |row| row.get(0))
442            .unwrap();
443        assert_eq!(synchronous, 1);
444    }
445
446    #[test]
447    fn own_refresh_skips_identical_extract_but_not_position_shift() {
448        let temp = tempdir().unwrap();
449        let root = temp.path().join("root");
450        fs::create_dir_all(&root).unwrap();
451        let source = root.join("main.ts");
452        fs::write(&source, "export function main() { return 1; }\n").unwrap();
453        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
454        store.cold_build(std::slice::from_ref(&source)).unwrap();
455        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
456        assert!(write_metrics.commits_60s > 0);
457        assert!(write_metrics.pages_or_bytes_written_60s > 0);
458
459        let before = store.conn.lock().unwrap().total_changes();
460        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
461        let (stats, _) = store
462            .refresh_files_profiled(std::slice::from_ref(&source))
463            .unwrap();
464        let after = store.conn.lock().unwrap().total_changes();
465        assert_eq!(stats.unchanged_extract_files, 1);
466        assert_eq!(stats.refreshed_own_files, 0);
467        assert_eq!(
468            after - before,
469            3,
470            "files, backend freshness, and the durable projection revision update"
471        );
472
473        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
474        let (shifted_stats, _) = store
475            .refresh_files_profiled(std::slice::from_ref(&source))
476            .unwrap();
477        assert_eq!(shifted_stats.unchanged_extract_files, 0);
478        assert_eq!(shifted_stats.refreshed_own_files, 1);
479    }
480
481    #[test]
482    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
483        let now = Instant::now();
484        assert!(idle_checkpoint_due(None, now));
485        assert!(!idle_checkpoint_due(
486            Some(now),
487            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
488        ));
489        assert!(idle_checkpoint_due(
490            Some(now),
491            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
492        ));
493    }
494
495    #[test]
496    fn write_metrics_decay_after_the_sixty_second_window() {
497        let key = format!("metrics-test-{}", now_nanos());
498        let metrics = callgraph_write_metrics_for_key(&key);
499        metrics.record_commit(17);
500        assert_eq!(metrics.snapshot().commits_60s, 1);
501        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
502        metrics.window_start_ms.store(
503            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
504            AtomicOrdering::Release,
505        );
506        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
507    }
508}
509
510#[cfg(test)]
511type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
512// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
513// thread running the cold build, and the only caller (a test) installs and
514// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
515// across parallel tests — one test's installed observer fired during ANOTHER
516// test's `cold_build_with_lease`, asserting against the wrong build's edges
517// (flaked on Windows CI under parallel scheduling). Production never sets it.
518thread_local! {
519    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
520        const { std::cell::RefCell::new(None) };
521    #[cfg(test)]
522    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
523        const { std::cell::RefCell::new(None) };
524    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
525        const { std::cell::RefCell::new(None) };
526    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
527    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
528        const { std::cell::Cell::new(false) };
529    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
530        const { std::cell::RefCell::new(None) };
531    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
532        const { std::cell::RefCell::new(None) };
533}
534
535mod dead_code_projection;
536pub use dead_code_projection::project_dead_code_snapshot;
537pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
538#[cfg(test)]
539pub(crate) use dead_code_projection::set_projection_before_open_observer;
540
541#[doc(hidden)]
542pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
543    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
544}
545
546#[cfg(test)]
547fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
548    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
549}
550
551#[cfg(test)]
552fn notify_cold_build_before_publish_observer() {
553    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
554    if let Some(observer) = observer {
555        observer();
556    }
557}
558
559#[cfg(not(test))]
560fn notify_cold_build_before_publish_observer() {}
561
562#[doc(hidden)]
563pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
564    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
565}
566
567#[doc(hidden)]
568pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
569    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
570}
571
572#[doc(hidden)]
573pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
574    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
575}
576
577struct PublishAdmissionGuard {
578    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
579}
580
581impl Drop for PublishAdmissionGuard {
582    fn drop(&mut self) {
583        PUBLISH_ADMISSION.with(|slot| {
584            *slot.borrow_mut() = self.previous.take();
585        });
586    }
587}
588
589pub(crate) fn with_publish_epoch<R>(
590    epoch: crate::root_cache::ArtifactPublishEpoch,
591    expected: u64,
592    run: impl FnOnce() -> R,
593) -> R {
594    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
595    let _guard = PublishAdmissionGuard { previous };
596    run()
597}
598
599fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
600    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
601    match admission {
602        Some((epoch, expected)) => epoch
603            .run_if_current(expected, publish)
604            .unwrap_or(Err(CallGraphStoreError::Superseded)),
605        None => publish(),
606    }
607}
608
609struct RefreshCommitAdmissionGuard {
610    previous: Option<(
611        SubcLifecycleAdmission,
612        Arc<std::sync::atomic::AtomicU64>,
613        u64,
614    )>,
615}
616
617impl Drop for RefreshCommitAdmissionGuard {
618    fn drop(&mut self) {
619        REFRESH_COMMIT_ADMISSION.with(|slot| {
620            *slot.borrow_mut() = self.previous.take();
621        });
622    }
623}
624
625fn with_refresh_commit_admission<R>(
626    lifecycle: SubcLifecycleAdmission,
627    generation_flag: Arc<std::sync::atomic::AtomicU64>,
628    expected_generation: u64,
629    run: impl FnOnce() -> R,
630) -> R {
631    let previous = REFRESH_COMMIT_ADMISSION
632        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
633    let _guard = RefreshCommitAdmissionGuard { previous };
634    run()
635}
636
637fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
638    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
639    let commit = || {
640        publish_if_current(|| {
641            tx.commit()?;
642            Ok(())
643        })
644    };
645    match admission {
646        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
647            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
648            .unwrap_or(Err(CallGraphStoreError::Superseded)),
649        None => commit(),
650    }
651}
652
653fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
654    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
655    if let Some(observer) = observer {
656        observer(temp_path, target_path);
657    }
658}
659
660#[derive(Debug)]
661pub enum CallGraphStoreError {
662    Io(std::io::Error),
663    Sqlite(rusqlite::Error),
664    Json(serde_json::Error),
665    Aft(AftError),
666    Lock(crate::fs_lock::AcquireError),
667    MissingCallerData { file: String },
668    Unavailable(String),
669    Suspended(crate::build_breaker::BuildSuspension),
670    Superseded,
671    StaleFiles(Vec<String>),
672}
673
674impl CallGraphStoreError {
675    pub(crate) fn is_transient_lock_contention(&self) -> bool {
676        matches!(
677            self,
678            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
679                if matches!(
680                    error.code,
681                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
682                )
683        )
684    }
685}
686
687impl fmt::Display for CallGraphStoreError {
688    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
689        match self {
690            Self::Io(error) => write!(formatter, "I/O error: {error}"),
691            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
692            Self::Json(error) => write!(formatter, "json error: {error}"),
693            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
694            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
695            Self::MissingCallerData { file } => {
696                write!(formatter, "missing extracted caller data for {file}")
697            }
698            Self::Unavailable(message) => {
699                write!(formatter, "callgraph store unavailable: {message}")
700            }
701            Self::Suspended(suspension) => write!(
702                formatter,
703                "callgraph build suspended for {} after {} deaths ({})",
704                suspension.domain.as_str(),
705                suspension.death_count,
706                suspension.reason
707            ),
708            Self::Superseded => {
709                write!(formatter, "callgraph store build superseded before publish")
710            }
711            Self::StaleFiles(files) => {
712                write!(
713                    formatter,
714                    "callgraph store has stale files: {}",
715                    files.join(", ")
716                )
717            }
718        }
719    }
720}
721
722impl std::error::Error for CallGraphStoreError {}
723
724impl From<std::io::Error> for CallGraphStoreError {
725    fn from(error: std::io::Error) -> Self {
726        Self::Io(error)
727    }
728}
729
730impl From<rusqlite::Error> for CallGraphStoreError {
731    fn from(error: rusqlite::Error) -> Self {
732        Self::Sqlite(error)
733    }
734}
735
736impl From<serde_json::Error> for CallGraphStoreError {
737    fn from(error: serde_json::Error) -> Self {
738        Self::Json(error)
739    }
740}
741
742impl From<AftError> for CallGraphStoreError {
743    fn from(error: AftError) -> Self {
744        Self::Aft(error)
745    }
746}
747
748impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
749    fn from(error: crate::fs_lock::AcquireError) -> Self {
750        Self::Lock(error)
751    }
752}
753
754pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
755
756/// Config flag name gating whether the store is opened (default on). Production
757/// commands open it through `open_if_enabled` so the substrate can be disabled
758/// without code changes.
759pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
760
761#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
762pub struct CallGraphStoreOptions {
763    pub enabled: bool,
764}
765
766pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
767
768/// Shared context state that lets the refresh worker observe a store installed
769/// after its batch was opened. The worker clones the installed store Arc before
770/// checking it, so no context lock guard crosses the check or enqueue call.
771#[derive(Clone)]
772pub(crate) struct CallgraphRefreshState {
773    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
774    heavy_root_work_allowed: Arc<AtomicBool>,
775}
776
777impl CallgraphRefreshState {
778    pub(crate) fn new(
779        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
780        heavy_root_work_allowed: Arc<AtomicBool>,
781    ) -> Self {
782        Self {
783            store,
784            heavy_root_work_allowed,
785        }
786    }
787
788    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
789        self.store
790            .read()
791            .unwrap_or_else(std::sync::PoisonError::into_inner)
792            .as_ref()
793            .map(Arc::clone)
794    }
795}
796
797type WorkspaceCratePrefixes = HashMap<String, String>;
798
799#[derive(Clone, Debug, Default)]
800struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
801
802const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
803
804pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
805    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
806}
807
808#[derive(Clone, Debug, Hash, PartialEq, Eq)]
809struct RefreshRoot {
810    callgraph_dir: PathBuf,
811    project_root: PathBuf,
812}
813
814#[derive(Clone)]
815pub(crate) struct CallgraphRefreshTicket {
816    lifecycle: SubcLifecycleAdmission,
817    generation_flag: Arc<std::sync::atomic::AtomicU64>,
818    expected_generation: u64,
819    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
820    expected_publish_epoch: u64,
821}
822
823impl CallgraphRefreshTicket {
824    pub(crate) fn new(
825        lifecycle: SubcLifecycleAdmission,
826        generation_flag: Arc<std::sync::atomic::AtomicU64>,
827        expected_generation: u64,
828        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
829        expected_publish_epoch: u64,
830    ) -> Self {
831        Self {
832            lifecycle,
833            generation_flag,
834            expected_generation,
835            publish_epoch,
836            expected_publish_epoch,
837        }
838    }
839
840    fn is_current(&self) -> bool {
841        self.lifecycle
842            .is_current(self.generation_flag.as_ref(), self.expected_generation)
843            && self.publish_epoch.current() == self.expected_publish_epoch
844    }
845}
846
847#[derive(Clone)]
848struct RefreshBatch {
849    root: RefreshRoot,
850    paths: BTreeSet<PathBuf>,
851    pending_sinks: Vec<PendingCallGraphStorePaths>,
852    refresh_states: Vec<CallgraphRefreshState>,
853    ticket: Option<CallgraphRefreshTicket>,
854}
855
856impl RefreshBatch {
857    fn defer(&self) {
858        for sink in &self.pending_sinks {
859            sink.lock().extend(self.paths.iter().cloned());
860        }
861    }
862
863    fn defer_after_open_failure(&self) {
864        self.defer();
865        if self
866            .ticket
867            .as_ref()
868            .is_some_and(|ticket| !ticket.is_current())
869            || !self
870                .refresh_states
871                .iter()
872                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
873        {
874            return;
875        }
876
877        let ready_store_installed = self.refresh_states.iter().any(|state| {
878            let store = state.installed_store_snapshot();
879            store.is_some_and(|store| {
880                store.project_root() == self.root.project_root
881                    && !store.is_legacy_fallback()
882                    && store.is_current()
883            })
884        });
885        if !ready_store_installed {
886            return;
887        }
888
889        // This re-check and the ready-store install's pending-sink take form a
890        // check-then-act handoff: after this defer, exactly one site observes
891        // the parked paths with a ready current store, so no polling is needed.
892        for sink in &self.pending_sinks {
893            let paths = {
894                let mut pending = sink.lock();
895                self.paths
896                    .iter()
897                    .filter(|path| pending.remove(*path))
898                    .cloned()
899                    .collect::<Vec<_>>()
900            };
901            if paths.is_empty() {
902                continue;
903            }
904            let _ = enqueue_callgraph_store_refresh_inner(
905                self.root.callgraph_dir.clone(),
906                self.root.project_root.clone(),
907                paths,
908                Arc::clone(sink),
909                self.refresh_states.clone(),
910                self.ticket.clone(),
911            );
912        }
913    }
914
915    fn merge(
916        &mut self,
917        paths: impl IntoIterator<Item = PathBuf>,
918        sink: PendingCallGraphStorePaths,
919        refresh_states: Vec<CallgraphRefreshState>,
920        ticket: Option<CallgraphRefreshTicket>,
921    ) {
922        self.paths.extend(paths);
923        if ticket.is_some() {
924            self.ticket = ticket;
925        }
926        if !self
927            .pending_sinks
928            .iter()
929            .any(|existing| Arc::ptr_eq(existing, &sink))
930        {
931            self.pending_sinks.push(sink);
932        }
933        for refresh_state in refresh_states {
934            if !self.refresh_states.iter().any(|existing| {
935                Arc::ptr_eq(&existing.store, &refresh_state.store)
936                    && Arc::ptr_eq(
937                        &existing.heavy_root_work_allowed,
938                        &refresh_state.heavy_root_work_allowed,
939                    )
940            }) {
941                self.refresh_states.push(refresh_state);
942            }
943        }
944    }
945}
946
947#[derive(Default)]
948struct RefreshQueue {
949    order: VecDeque<RefreshRoot>,
950    queued: HashMap<RefreshRoot, RefreshBatch>,
951    active: Option<RefreshBatch>,
952    shutdown_requested: bool,
953}
954
955struct RefreshWorkerShared {
956    queue: Mutex<RefreshQueue>,
957    wake: Condvar,
958}
959
960struct RefreshWorker {
961    shared: Arc<RefreshWorkerShared>,
962    thread: Mutex<Option<JoinHandle<()>>>,
963}
964
965struct RefreshWorkerWatchdog {
966    first_path: PathBuf,
967    batch_len: usize,
968    started: Instant,
969}
970
971impl RefreshWorkerWatchdog {
972    fn start(paths: &[PathBuf]) -> Self {
973        Self {
974            first_path: paths
975                .first()
976                .expect("non-empty callgraph refresh batch has a first path")
977                .clone(),
978            batch_len: paths.len(),
979            started: Instant::now(),
980        }
981    }
982}
983
984impl Drop for RefreshWorkerWatchdog {
985    fn drop(&mut self) {
986        let elapsed = self.started.elapsed();
987        if elapsed < REFRESH_WORKER_WARN_AFTER {
988            return;
989        }
990        let path = if self.batch_len == 1 {
991            self.first_path.display().to_string()
992        } else {
993            format!(
994                "{} (+{} paths)",
995                self.first_path.display(),
996                self.batch_len - 1
997            )
998        };
999        log::warn!(
1000            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1001            path,
1002            elapsed.as_millis()
1003        );
1004        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1005            log::warn!(
1006                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1007                path,
1008                elapsed.as_millis()
1009            );
1010        }
1011    }
1012}
1013
1014impl RefreshWorker {
1015    fn spawn() -> Arc<Self> {
1016        let shared = Arc::new(RefreshWorkerShared {
1017            queue: Mutex::new(RefreshQueue::default()),
1018            wake: Condvar::new(),
1019        });
1020        let thread_shared = Arc::clone(&shared);
1021        let thread = std::thread::Builder::new()
1022            .name("aft-callgraph-refresh".to_string())
1023            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1024            .expect("failed to spawn callgraph refresh worker");
1025        Arc::new(Self {
1026            shared,
1027            thread: Mutex::new(Some(thread)),
1028        })
1029    }
1030
1031    fn enqueue(
1032        &self,
1033        root: RefreshRoot,
1034        paths: Vec<PathBuf>,
1035        pending_sink: PendingCallGraphStorePaths,
1036        refresh_states: Vec<CallgraphRefreshState>,
1037        ticket: Option<CallgraphRefreshTicket>,
1038    ) -> bool {
1039        let mut queue = self
1040            .shared
1041            .queue
1042            .lock()
1043            .expect("callgraph refresh queue mutex poisoned");
1044        if queue.shutdown_requested {
1045            pending_sink.lock().extend(paths);
1046            return false;
1047        }
1048        if let Some(batch) = queue.queued.get_mut(&root) {
1049            batch.merge(paths, pending_sink, refresh_states, ticket);
1050        } else {
1051            queue.order.push_back(root.clone());
1052            queue.queued.insert(
1053                root.clone(),
1054                RefreshBatch {
1055                    root,
1056                    paths: paths.into_iter().collect(),
1057                    pending_sinks: vec![pending_sink],
1058                    refresh_states,
1059                    ticket,
1060                },
1061            );
1062        }
1063        self.shared.wake.notify_one();
1064        true
1065    }
1066
1067    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1068        let deadline = Instant::now() + budget;
1069        let mut queue = self
1070            .shared
1071            .queue
1072            .lock()
1073            .expect("callgraph refresh queue mutex poisoned");
1074        queue.shutdown_requested = true;
1075        self.shared.wake.notify_one();
1076        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1077            let remaining = deadline.saturating_duration_since(Instant::now());
1078            let (next, _) = self
1079                .shared
1080                .wake
1081                .wait_timeout(queue, remaining)
1082                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1083            queue = next;
1084        }
1085        let drained = queue.active.is_none() && queue.order.is_empty();
1086        if !drained {
1087            if let Some(active) = queue.active.as_ref() {
1088                active.defer();
1089            }
1090            for batch in queue.queued.values() {
1091                batch.defer();
1092            }
1093            queue.order.clear();
1094            queue.queued.clear();
1095        }
1096        drop(queue);
1097
1098        if drained {
1099            if let Some(thread) = self
1100                .thread
1101                .lock()
1102                .expect("callgraph refresh worker thread mutex poisoned")
1103                .take()
1104            {
1105                let _ = thread.join();
1106            }
1107        }
1108        drained
1109    }
1110}
1111
1112static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1113
1114pub fn enqueue_callgraph_store_refresh(
1115    callgraph_dir: PathBuf,
1116    project_root: PathBuf,
1117    paths: Vec<PathBuf>,
1118    pending_sink: PendingCallGraphStorePaths,
1119) -> bool {
1120    enqueue_callgraph_store_refresh_inner(
1121        callgraph_dir,
1122        project_root,
1123        paths,
1124        pending_sink,
1125        Vec::new(),
1126        None,
1127    )
1128}
1129
1130#[cfg(test)]
1131pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1132    callgraph_dir: PathBuf,
1133    project_root: PathBuf,
1134    paths: Vec<PathBuf>,
1135    pending_sink: PendingCallGraphStorePaths,
1136    ticket: CallgraphRefreshTicket,
1137) -> bool {
1138    enqueue_callgraph_store_refresh_inner(
1139        callgraph_dir,
1140        project_root,
1141        paths,
1142        pending_sink,
1143        Vec::new(),
1144        Some(ticket),
1145    )
1146}
1147
1148pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1149    callgraph_dir: PathBuf,
1150    project_root: PathBuf,
1151    paths: Vec<PathBuf>,
1152    pending_sink: PendingCallGraphStorePaths,
1153    refresh_state: CallgraphRefreshState,
1154    ticket: CallgraphRefreshTicket,
1155) -> bool {
1156    enqueue_callgraph_store_refresh_inner(
1157        callgraph_dir,
1158        project_root,
1159        paths,
1160        pending_sink,
1161        vec![refresh_state],
1162        Some(ticket),
1163    )
1164}
1165
1166fn enqueue_callgraph_store_refresh_inner(
1167    callgraph_dir: PathBuf,
1168    project_root: PathBuf,
1169    paths: Vec<PathBuf>,
1170    pending_sink: PendingCallGraphStorePaths,
1171    refresh_states: Vec<CallgraphRefreshState>,
1172    ticket: Option<CallgraphRefreshTicket>,
1173) -> bool {
1174    if paths.is_empty() {
1175        return true;
1176    }
1177    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1178    let worker = {
1179        let mut worker = slot
1180            .lock()
1181            .expect("callgraph refresh worker mutex poisoned");
1182        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1183    };
1184    worker.enqueue(
1185        RefreshRoot {
1186            callgraph_dir,
1187            project_root,
1188        },
1189        paths,
1190        pending_sink,
1191        refresh_states,
1192        ticket,
1193    )
1194}
1195
1196pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1197    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1198}
1199
1200#[doc(hidden)]
1201pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1202    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1203    let worker = slot
1204        .lock()
1205        .expect("callgraph refresh worker mutex poisoned")
1206        .clone();
1207    let Some(worker) = worker else {
1208        return true;
1209    };
1210    let drained = worker.shutdown_with_budget(budget);
1211    if drained {
1212        let mut current = slot
1213            .lock()
1214            .expect("callgraph refresh worker mutex poisoned");
1215        if current
1216            .as_ref()
1217            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1218        {
1219            *current = None;
1220        }
1221    }
1222    drained
1223}
1224
1225fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1226    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1227}
1228
1229fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1230    // The worker owns these caches so maps are shared only by refreshes for the
1231    // same canonical root and disappear when the worker shuts down.
1232    let mut workspace_crate_prefixes = HashMap::new();
1233    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1234    loop {
1235        let batch = {
1236            let mut queue = shared
1237                .queue
1238                .lock()
1239                .expect("callgraph refresh queue mutex poisoned");
1240            loop {
1241                if let Some(root) = queue.order.pop_front() {
1242                    let batch = queue
1243                        .queued
1244                        .remove(&root)
1245                        .expect("queued callgraph refresh root has a batch");
1246                    queue.active = Some(batch.clone());
1247                    break batch;
1248                }
1249                if queue.shutdown_requested {
1250                    return;
1251                }
1252                queue = shared
1253                    .wake
1254                    .wait(queue)
1255                    .expect("callgraph refresh queue mutex poisoned while waiting");
1256            }
1257        };
1258
1259        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1260
1261        let mut queue = shared
1262            .queue
1263            .lock()
1264            .expect("callgraph refresh queue mutex poisoned");
1265        queue.active = None;
1266        let became_idle = queue.order.is_empty();
1267        shared.wake.notify_all();
1268        drop(queue);
1269
1270        if became_idle {
1271            let checkpoint_due = idle_checkpoint_due(
1272                last_idle_checkpoints.get(&batch.root).copied(),
1273                Instant::now(),
1274            );
1275            if checkpoint_due {
1276                if let Some(store) = store {
1277                    if store.checkpoint_wal_truncate() {
1278                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1279                    }
1280                }
1281            }
1282        }
1283    }
1284}
1285
1286fn process_callgraph_refresh_batch(
1287    batch: &RefreshBatch,
1288    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1289) -> Option<CallGraphStore> {
1290    // A manifest event is an invalidation signal, not a source file to parse.
1291    // Drop the root's map even for a superseded batch: the filesystem changed,
1292    // and a later configure must never inherit crate membership from before it.
1293    if batch
1294        .paths
1295        .iter()
1296        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1297    {
1298        workspace_crate_prefixes.remove(&batch.root);
1299    }
1300
1301    let paths = batch
1302        .paths
1303        .iter()
1304        .filter(|path| crate::parser::detect_language(path).is_some())
1305        .cloned()
1306        .collect::<Vec<_>>();
1307    if paths.is_empty() {
1308        return None;
1309    }
1310    note_refresh_worker_batch_for_test(&batch.root.project_root);
1311    if batch
1312        .ticket
1313        .as_ref()
1314        .is_some_and(|ticket| !ticket.is_current())
1315    {
1316        // Superseded before starting: park the paths so the next configure's
1317        // pending replay (or unbind cleanup) decides their fate.
1318        batch.defer();
1319        return None;
1320    }
1321    let workspace_crate_prefix_cache =
1322        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1323    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1324    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1325    note_refresh_worker_call_for_test(&batch.root.project_root);
1326    let opened = if test_seam.fail_open {
1327        Ok(None)
1328    } else {
1329        CallGraphStore::open_ready(
1330            batch.root.callgraph_dir.clone(),
1331            batch.root.project_root.clone(),
1332        )
1333    };
1334    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1335        // The gate is deliberately after open_ready so tests can hold a failed
1336        // open between its result and the defer that parks the batch.
1337        let _ = gate.held_tx.send(());
1338        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1339    }
1340    let store = match opened {
1341        Ok(Some(store)) => store,
1342        Ok(None) => {
1343            batch.defer_after_open_failure();
1344            return None;
1345        }
1346        Err(error) => {
1347            batch.defer_after_open_failure();
1348            crate::slog_warn!(
1349                "callgraph store writer open failed during refresh; deferred paths: {}",
1350                error
1351            );
1352            return None;
1353        }
1354    };
1355    if !test_seam.delay.is_zero() {
1356        std::thread::sleep(test_seam.delay);
1357    }
1358    if batch
1359        .ticket
1360        .as_ref()
1361        .is_some_and(|ticket| !ticket.is_current())
1362    {
1363        // This is a superseded-ticket defer, not an open-failure defer: leave
1364        // the paths for the replacement configure instead of self-replaying.
1365        batch.defer();
1366        return Some(store);
1367    }
1368    let refresh_result = if test_seam.fail_refresh {
1369        Err(CallGraphStoreError::Unavailable(
1370            "injected refresh worker failure".to_string(),
1371        ))
1372    } else if let Some(ticket) = &batch.ticket {
1373        with_publish_epoch(
1374            ticket.publish_epoch.clone(),
1375            ticket.expected_publish_epoch,
1376            || {
1377                with_refresh_commit_admission(
1378                    ticket.lifecycle.clone(),
1379                    Arc::clone(&ticket.generation_flag),
1380                    ticket.expected_generation,
1381                    || {
1382                        store
1383                            .refresh_files_with_workspace_crate_prefix_cache(
1384                                &paths,
1385                                workspace_crate_prefix_cache.clone(),
1386                            )
1387                            .map(|_| ())
1388                    },
1389                )
1390            },
1391        )
1392    } else {
1393        store
1394            .refresh_files_with_workspace_crate_prefix_cache(
1395                &paths,
1396                workspace_crate_prefix_cache.clone(),
1397            )
1398            .map(|_| ())
1399    };
1400    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1401        // The commit lost the fence race: a newer configure or publication
1402        // owns the store now. Defer instead of stale-marking — the paths were
1403        // never committed, and the replacement generation re-indexes them.
1404        batch.defer();
1405        return Some(store);
1406    }
1407    if let Err(error) = refresh_result {
1408        crate::slog_warn!("callgraph store refresh failed: {}", error);
1409        match store.mark_files_stale(&paths) {
1410            Ok(marked) => {
1411                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1412                crate::slog_warn!(
1413                    "marked {} callgraph store file(s) stale after refresh failure",
1414                    marked.len()
1415                );
1416            }
1417            Err(mark_error) => crate::slog_warn!(
1418                "failed to mark callgraph store files stale after refresh failure: {}",
1419                mark_error
1420            ),
1421        }
1422    } else {
1423        crate::logging::note_callgraph_invalidations(paths.len());
1424    }
1425    Some(store)
1426}
1427
1428fn workspace_crate_prefix_cache_for_root(
1429    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1430    root: &RefreshRoot,
1431) -> WorkspaceCratePrefixCache {
1432    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1433        // Eviction only costs a future rebuild; it cannot make resolution stale.
1434        if let Some(evicted) = caches.keys().next().cloned() {
1435            caches.remove(&evicted);
1436        }
1437    }
1438    caches.entry(root.clone()).or_default().clone()
1439}
1440
1441#[derive(Clone, Copy, Default)]
1442struct RefreshWorkerTestSeam {
1443    delay: Duration,
1444    fail_refresh: bool,
1445    fail_open: bool,
1446    refresh_calls: usize,
1447    worker_calls: usize,
1448    stale_marks: usize,
1449}
1450
1451static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1452    OnceLock::new();
1453
1454struct RefreshWorkerTestGate {
1455    held_tx: crossbeam_channel::Sender<()>,
1456    release_rx: crossbeam_channel::Receiver<()>,
1457}
1458
1459static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1460    OnceLock::new();
1461
1462#[doc(hidden)]
1463pub fn install_callgraph_refresh_worker_test_gate(
1464    project_root: PathBuf,
1465) -> (
1466    crossbeam_channel::Receiver<()>,
1467    crossbeam_channel::Sender<()>,
1468) {
1469    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1470    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1471    REFRESH_WORKER_TEST_GATES
1472        .get_or_init(|| Mutex::new(HashMap::new()))
1473        .lock()
1474        .expect("callgraph refresh test gate mutex poisoned")
1475        .insert(
1476            project_root,
1477            RefreshWorkerTestGate {
1478                held_tx,
1479                release_rx,
1480            },
1481        );
1482    (held_rx, release_tx)
1483}
1484
1485fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1486    REFRESH_WORKER_TEST_GATES
1487        .get_or_init(|| Mutex::new(HashMap::new()))
1488        .lock()
1489        .expect("callgraph refresh test gate mutex poisoned")
1490        .remove(project_root)
1491}
1492
1493fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1494    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1495        return RefreshWorkerTestSeam::default();
1496    };
1497    seams
1498        .lock()
1499        .expect("callgraph refresh test seam mutex poisoned")
1500        .get(project_root)
1501        .copied()
1502        .unwrap_or_default()
1503}
1504
1505fn note_refresh_worker_batch_for_test(project_root: &Path) {
1506    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1507        if let Some(seam) = seams
1508            .lock()
1509            .expect("callgraph refresh test seam mutex poisoned")
1510            .get_mut(project_root)
1511        {
1512            seam.worker_calls += 1;
1513        }
1514    }
1515}
1516
1517fn note_refresh_worker_call_for_test(project_root: &Path) {
1518    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1519        if let Some(seam) = seams
1520            .lock()
1521            .expect("callgraph refresh test seam mutex poisoned")
1522            .get_mut(project_root)
1523        {
1524            seam.refresh_calls += 1;
1525        }
1526    }
1527}
1528
1529fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1530    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1531        if let Some(seam) = seams
1532            .lock()
1533            .expect("callgraph refresh test seam mutex poisoned")
1534            .get_mut(project_root)
1535        {
1536            seam.stale_marks += 1;
1537        }
1538    }
1539}
1540
1541#[doc(hidden)]
1542pub fn set_callgraph_refresh_worker_test_seam(
1543    project_root: PathBuf,
1544    delay: Duration,
1545    fail_refresh: bool,
1546) {
1547    REFRESH_WORKER_TEST_SEAMS
1548        .get_or_init(|| Mutex::new(HashMap::new()))
1549        .lock()
1550        .expect("callgraph refresh test seam mutex poisoned")
1551        .insert(
1552            project_root,
1553            RefreshWorkerTestSeam {
1554                delay,
1555                fail_refresh,
1556                ..RefreshWorkerTestSeam::default()
1557            },
1558        );
1559}
1560
1561#[doc(hidden)]
1562pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1563    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1564        if let Some(seam) = seams
1565            .lock()
1566            .expect("callgraph refresh test seam mutex poisoned")
1567            .get_mut(&project_root)
1568        {
1569            seam.fail_open = enabled;
1570        }
1571    }
1572}
1573
1574#[doc(hidden)]
1575pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1576    let seam = refresh_worker_test_seam(project_root);
1577    (seam.refresh_calls, seam.stale_marks)
1578}
1579
1580#[doc(hidden)]
1581pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1582    refresh_worker_test_seam(project_root).worker_calls
1583}
1584
1585#[doc(hidden)]
1586pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1587    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1588        seams
1589            .lock()
1590            .expect("callgraph refresh test seam mutex poisoned")
1591            .remove(project_root);
1592    }
1593}
1594
1595#[derive(Debug)]
1596pub struct CallGraphStore {
1597    project_root: PathBuf,
1598    project_key: String,
1599    /// The concrete on-disk DB file this store opened. With the generation
1600    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1601    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1602    sqlite_path: PathBuf,
1603    /// Root-keyed directory whose pointer controls this store. For a legacy
1604    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1605    /// newly published root-keyed generation invalidates the fallback reader.
1606    publication_dir: PathBuf,
1607    /// True only when the root-keyed read path opened data from a legacy
1608    /// harness partition. Writer-capable callers use this to schedule migration
1609    /// without making read-only/worktree callers acquire a writer lease.
1610    legacy_fallback: bool,
1611    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1612    /// or `None` when it opened the legacy single-file DB. Used to detect when
1613    /// another process has published a newer generation so this process can
1614    /// drop its connection and reopen (see `current_generation`).
1615    generation: Option<String>,
1616    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1617    read_marker: Option<crate::root_cache::ReadMarker>,
1618    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1619    // Failed validations are not cached, so a later successful build remains visible.
1620    database_ready: AtomicBool,
1621    write_metrics: Arc<CallgraphWriteMetrics>,
1622    conn: Mutex<Connection>,
1623}
1624
1625#[derive(Debug)]
1626pub struct ReadonlyCallGraphStore {
1627    inner: CallGraphStore,
1628}
1629
1630pub trait CallGraphRead {
1631    fn project_root(&self) -> &Path;
1632    fn project_key(&self) -> &str;
1633    fn sqlite_path(&self) -> &Path;
1634    fn is_current(&self) -> bool;
1635    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1636    fn indexed_file_count(&self) -> Result<usize>;
1637    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1638    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1639    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1640    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1641    fn direct_callers_for_symbols(
1642        &self,
1643        targets: &[(String, String)],
1644    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1645        targets
1646            .iter()
1647            .cloned()
1648            .map(|target| {
1649                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1650                Ok((target, callers))
1651            })
1652            .collect()
1653    }
1654    fn direct_caller_counts_of(
1655        &self,
1656        targets: &[(String, String)],
1657    ) -> Result<HashMap<(String, String), usize>>;
1658    fn outgoing_calls_for_symbols(
1659        &self,
1660        sources: &[(String, String)],
1661    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1662    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1663        -> Result<StoreCallersResult>;
1664    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1665    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1666    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1667    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1668    fn call_tree(
1669        &self,
1670        file_rel: &Path,
1671        symbol: &str,
1672        depth: usize,
1673    ) -> Result<callgraph::CallTreeNode>;
1674    fn trace_to(
1675        &self,
1676        file_rel: &Path,
1677        symbol: &str,
1678        max_depth: usize,
1679    ) -> Result<callgraph::TraceToResult>;
1680    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1681    fn trace_to_symbol(
1682        &self,
1683        file_rel: &Path,
1684        symbol: &str,
1685        to_symbol: &str,
1686        to_file: Option<&Path>,
1687        max_depth: usize,
1688    ) -> Result<callgraph::TraceToSymbolResult>;
1689}
1690
1691#[derive(Debug, Clone, PartialEq, Eq)]
1692enum OpenRootRepair {
1693    None,
1694    ReRooted,
1695    NeedsRebuild {
1696        previous_roots: Vec<String>,
1697        current_root: String,
1698        reason: String,
1699    },
1700}
1701
1702struct OpenedStore {
1703    store: CallGraphStore,
1704    root_repair: OpenRootRepair,
1705}
1706
1707#[derive(Clone, Debug)]
1708struct LegacyCallgraphPartition {
1709    harness: String,
1710    dir: PathBuf,
1711    key: String,
1712    bytes: u64,
1713    freshness: Option<SystemTime>,
1714}
1715
1716#[derive(Clone, Debug)]
1717struct LegacyCallgraphTarget {
1718    partition: LegacyCallgraphPartition,
1719    sqlite_path: PathBuf,
1720    generation: Option<String>,
1721    source_bytes: u64,
1722    source_blake3: String,
1723}
1724
1725#[derive(Clone, Debug)]
1726struct SourceFingerprint {
1727    bytes: u64,
1728    blake3: String,
1729}
1730
1731#[derive(Clone, Debug)]
1732struct PublishedLegacyMigration {
1733    generation: String,
1734    migrated_bytes: u64,
1735}
1736
1737#[derive(Debug, Clone)]
1738pub struct ColdBuildStats {
1739    pub files: usize,
1740    pub nodes: usize,
1741    pub refs: usize,
1742    pub edges: usize,
1743    pub failed_files: Vec<String>,
1744    pub elapsed_ms: u128,
1745}
1746
1747#[derive(Debug, Clone)]
1748pub struct IncrementalStats {
1749    pub changed_files: Vec<String>,
1750    pub surface_changed: Vec<String>,
1751    pub deleted_files: Vec<String>,
1752    pub dependency_selected_refs: usize,
1753    pub refreshed_own_files: usize,
1754    pub unchanged_extract_files: usize,
1755}
1756
1757/// Phase timings for the copy-based incremental refresh benchmark.
1758#[doc(hidden)]
1759#[derive(Debug, Clone, Default, PartialEq, Eq)]
1760pub struct RefreshFilesProfile {
1761    pub parse: Duration,
1762    pub dependency_selection: Duration,
1763    pub row_deletes: Duration,
1764    pub row_inserts: Duration,
1765    pub dependent_parse: Duration,
1766    pub index_load: Duration,
1767    pub ref_resolution: Duration,
1768    pub method_dispatch: Duration,
1769    pub commit: Duration,
1770    pub total: Duration,
1771}
1772
1773impl RefreshFilesProfile {
1774    pub fn report(&self) -> String {
1775        format!(
1776            "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",
1777            self.parse.as_millis(),
1778            self.dependency_selection.as_millis(),
1779            self.row_deletes.as_millis(),
1780            self.row_inserts.as_millis(),
1781            self.dependent_parse.as_millis(),
1782            self.index_load.as_millis(),
1783            self.ref_resolution.as_millis(),
1784            self.method_dispatch.as_millis(),
1785            self.commit.as_millis(),
1786            self.total.as_millis(),
1787        )
1788    }
1789}
1790
1791#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1792pub struct StoredEdge {
1793    pub source_file: String,
1794    pub source_symbol: String,
1795    pub target_file: String,
1796    pub target_symbol: String,
1797    pub kind: String,
1798    pub line: u32,
1799}
1800
1801#[derive(Debug, Clone, PartialEq, Eq)]
1802pub struct StoreNode {
1803    node_id: String,
1804    pub file: String,
1805    pub symbol: String,
1806    pub name: String,
1807    pub kind: String,
1808    pub line: u32,
1809    pub end_line: u32,
1810    pub signature: Option<String>,
1811    pub exported: bool,
1812    pub is_entry_point: bool,
1813    pub lang: LangId,
1814}
1815
1816#[cfg(test)]
1817impl StoreNode {
1818    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1819        Self {
1820            node_id: format!("{file}:{symbol}"),
1821            file: file.to_string(),
1822            symbol: symbol.to_string(),
1823            name: symbol.to_string(),
1824            kind: "function".to_string(),
1825            line: 1,
1826            end_line: 1,
1827            signature: None,
1828            exported: is_entry_point,
1829            is_entry_point,
1830            lang: LangId::TypeScript,
1831        }
1832    }
1833}
1834
1835#[derive(Debug, Clone, PartialEq, Eq)]
1836pub struct StoreCallSite {
1837    pub caller: StoreNode,
1838    pub target_file: String,
1839    pub target_symbol: String,
1840    pub target: Option<StoreNode>,
1841    pub line: u32,
1842    pub byte_start: usize,
1843    pub byte_end: usize,
1844    pub resolved: bool,
1845    pub provenance: String,
1846}
1847
1848impl StoreCallSite {
1849    pub fn approximate(&self) -> bool {
1850        self.provenance == PROVENANCE_NAME_MATCH
1851    }
1852
1853    pub fn resolved_by(&self) -> &str {
1854        &self.provenance
1855    }
1856
1857    pub fn supplemental_resolution(&self) -> Option<&str> {
1858        match self.provenance.as_str() {
1859            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1860            _ => None,
1861        }
1862    }
1863}
1864
1865#[derive(Debug, Clone, PartialEq, Eq)]
1866pub struct StoreUnresolvedCall {
1867    pub caller: StoreNode,
1868    pub symbol: String,
1869    pub full_ref: Option<String>,
1870    pub line: u32,
1871    pub byte_start: usize,
1872    pub byte_end: usize,
1873}
1874
1875#[derive(Debug, Clone, PartialEq, Eq)]
1876pub struct StoreCallersResult {
1877    pub target: StoreNode,
1878    pub callers: Vec<StoreCallSite>,
1879    pub scanned_files: usize,
1880    pub depth_limited: bool,
1881    pub truncated: usize,
1882}
1883
1884#[derive(Debug, Clone, PartialEq, Eq)]
1885pub struct StoreImpactCaller {
1886    pub site: StoreCallSite,
1887    pub signature: Option<String>,
1888    pub is_entry_point: bool,
1889    pub call_expression: Option<String>,
1890    pub parameters: Vec<String>,
1891}
1892
1893#[derive(Debug, Clone, PartialEq, Eq)]
1894pub struct StoreImpactResult {
1895    pub target: StoreNode,
1896    pub parameters: Vec<String>,
1897    pub callers: Vec<StoreImpactCaller>,
1898    pub depth_limited: bool,
1899    pub truncated: usize,
1900}
1901
1902#[derive(Debug, Clone)]
1903struct ExtractFailure {
1904    rel_path: String,
1905    freshness: Option<FileFreshness>,
1906}
1907
1908#[derive(Debug, Clone)]
1909struct BuildExtractsResult {
1910    extracts: Vec<FileExtract>,
1911    failures: Vec<ExtractFailure>,
1912}
1913
1914#[derive(Debug, Clone)]
1915enum StoreForwardCall {
1916    Resolved(StoreCallSite),
1917    Unresolved(StoreUnresolvedCall),
1918}
1919
1920impl StoreForwardCall {
1921    fn byte_start(&self) -> usize {
1922        match self {
1923            Self::Resolved(site) => site.byte_start,
1924            Self::Unresolved(call) => call.byte_start,
1925        }
1926    }
1927
1928    fn line(&self) -> u32 {
1929        match self {
1930            Self::Resolved(site) => site.line,
1931            Self::Unresolved(call) => call.line,
1932        }
1933    }
1934}
1935
1936#[derive(Debug, Clone)]
1937struct FileExtract {
1938    rel_path: String,
1939    freshness: FileFreshness,
1940    lang: LangId,
1941    data: FileCallData,
1942    nodes: Vec<NodeRecord>,
1943    raw_refs: Vec<RawRef>,
1944    dispatch_hints: Vec<DispatchHint>,
1945    surface_fingerprint: String,
1946}
1947
1948#[derive(Debug, Clone)]
1949struct NodeRecord {
1950    id: String,
1951    file_path: String,
1952    name: String,
1953    scoped_name: String,
1954    kind: String,
1955    range: Range,
1956    range_ordinal: u32,
1957    signature: Option<String>,
1958    exported: bool,
1959    is_default_export: bool,
1960    is_type_like: bool,
1961    is_callgraph_entry_point: bool,
1962}
1963
1964#[derive(Debug, Clone)]
1965struct RawRef {
1966    ref_id: String,
1967    caller_node: Option<String>,
1968    caller_symbol: Option<String>,
1969    caller_file: String,
1970    kind: String,
1971    short_name: Option<String>,
1972    full_ref: Option<String>,
1973    module_path: Option<String>,
1974    import_kind: Option<String>,
1975    local_name: Option<String>,
1976    requested_name: Option<String>,
1977    namespace_alias: Option<String>,
1978    wildcard: bool,
1979    line: u32,
1980    byte_start: usize,
1981    byte_end: usize,
1982    dependencies: BTreeSet<String>,
1983}
1984
1985/// A raw reference read from the durable staging table with its SQLite ordering
1986/// key. The ordering key is advanced only in the same transaction that writes
1987/// the resolved result, so a crash resumes at a committed window boundary.
1988#[derive(Debug)]
1989struct StagedRef {
1990    rowid: u64,
1991    raw: RawRef,
1992}
1993
1994#[derive(Debug, Clone)]
1995struct ResolvedRef {
1996    raw: RawRef,
1997    status: String,
1998    target_node: Option<String>,
1999    target_file: Option<String>,
2000    target_symbol: Option<String>,
2001    dependencies: BTreeSet<String>,
2002    edge: Option<EdgeRecord>,
2003}
2004
2005#[derive(Debug, Clone)]
2006struct EdgeRecord {
2007    edge_id: String,
2008    source_node: String,
2009    target_node: Option<String>,
2010    target_file: String,
2011    target_symbol: String,
2012    kind: String,
2013    line: u32,
2014}
2015
2016#[derive(Debug, Clone)]
2017struct DispatchHint {
2018    id: String,
2019    method_name: String,
2020    caller_node: String,
2021    file: String,
2022    line: u32,
2023    byte_start: usize,
2024    byte_end: usize,
2025}
2026
2027#[derive(Debug, Clone)]
2028struct NameMatchRef {
2029    ref_id: String,
2030    caller_node: String,
2031    caller_file: String,
2032    caller_symbol: String,
2033    caller_signature: Option<String>,
2034    receiver_expression: String,
2035    receiver: String,
2036    method_name: String,
2037    colon_dispatch: bool,
2038    line: u32,
2039    lang: String,
2040}
2041
2042#[derive(Debug, Clone)]
2043struct NameMatchCandidate {
2044    node_id: String,
2045    file_path: String,
2046    scoped_name: String,
2047    kind: String,
2048    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2049    start_line: u32,
2050}
2051
2052#[derive(Debug, Clone)]
2053struct FileRow {
2054    surface_fingerprint: String,
2055    freshness: FileFreshness,
2056}
2057
2058#[derive(Debug, Clone)]
2059struct DbFileIndex {
2060    lang: Option<LangId>,
2061    exports: HashSet<String>,
2062    default_export: Option<String>,
2063    export_aliases: HashMap<String, String>,
2064    node_by_scoped: HashMap<String, String>,
2065    node_by_bare: HashMap<String, String>,
2066    node_kind_by_id: HashMap<String, String>,
2067    module_targets: HashMap<String, Option<String>>,
2068    reexports: Vec<ReexportIndex>,
2069}
2070
2071#[derive(Debug, Clone)]
2072struct ReexportIndex {
2073    target_file: Option<String>,
2074    named: HashMap<String, String>,
2075    wildcard: bool,
2076}
2077
2078#[derive(Debug, Clone)]
2079struct ProjectIndex<'a> {
2080    project_root: PathBuf,
2081    files: HashMap<String, DbFileIndex>,
2082    caller_data: HashMap<String, &'a FileCallData>,
2083    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2084    /// watcher events replace the cache before another batch can resolve refs.
2085    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2086    /// its own workspace mapping.
2087    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2088}
2089
2090/// Resolution reads symbols and exports through one interface. Incremental
2091/// refreshes use the in-memory index, while cold builds query only the rows
2092/// needed by the active caller from SQLite.
2093trait ResolverIndex {
2094    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2095    fn lang_for(&self, file: &str) -> Option<LangId>;
2096    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2097    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2098    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2099    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2100    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2101    fn has_export(&self, file: &str, symbol: &str) -> bool;
2102    fn default_export(&self, file: &str) -> Option<String>;
2103    fn contains_file(&self, file: &str) -> bool;
2104    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2105    fn inline_scoped_target(
2106        &self,
2107        caller_file: &str,
2108        module_segments: &[String],
2109        short_name: &str,
2110    ) -> Option<(String, String)>;
2111}
2112
2113impl ResolverIndex for ProjectIndex<'_> {
2114    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2115        self.caller_data.get(file).copied()
2116    }
2117
2118    fn lang_for(&self, file: &str) -> Option<LangId> {
2119        self.lang_for(file)
2120    }
2121
2122    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2123        self.module_target(caller_file, module_path)
2124    }
2125
2126    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2127        self.reexports_for(file).to_vec()
2128    }
2129
2130    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2131        self.node_for_symbol(file, symbol)
2132    }
2133
2134    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2135        self.node_is_callable(file, node_id)
2136    }
2137
2138    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2139        self.files
2140            .get(file)
2141            .and_then(|item| item.export_aliases.get(symbol))
2142            .cloned()
2143    }
2144
2145    fn has_export(&self, file: &str, symbol: &str) -> bool {
2146        self.files
2147            .get(file)
2148            .is_some_and(|item| item.exports.contains(symbol))
2149    }
2150
2151    fn default_export(&self, file: &str) -> Option<String> {
2152        self.files
2153            .get(file)
2154            .and_then(|item| item.default_export.clone())
2155    }
2156
2157    fn contains_file(&self, file: &str) -> bool {
2158        self.files.contains_key(file)
2159    }
2160
2161    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2162        self.workspace_crate_prefixes
2163            .0
2164            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2165            .get(crate_name)
2166            .cloned()
2167    }
2168
2169    fn inline_scoped_target(
2170        &self,
2171        caller_file: &str,
2172        module_segments: &[String],
2173        short_name: &str,
2174    ) -> Option<(String, String)> {
2175        let src_prefix = rust_src_prefix(caller_file);
2176        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2177        file_paths.sort();
2178        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2179            let caller = file_paths.remove(position);
2180            file_paths.insert(0, caller);
2181        }
2182        for file_path in file_paths {
2183            if self.lang_for(&file_path) != Some(LangId::Rust)
2184                || rust_src_prefix(&file_path) != src_prefix
2185            {
2186                continue;
2187            }
2188            let file_module_segments = rust_module_segments_for_rel(&file_path);
2189            if !module_segments.starts_with(&file_module_segments) {
2190                continue;
2191            }
2192            let scoped_segments = &module_segments[file_module_segments.len()..];
2193            if scoped_segments.is_empty() {
2194                continue;
2195            }
2196            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2197            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2198                return Some((file_path, scoped_symbol));
2199            }
2200        }
2201        None
2202    }
2203}
2204
2205/// A cold-build resolver view that loads one file's index at a time. Keeping the
2206/// complete staged corpus in SQLite makes the heap proportional to the active
2207/// reference window rather than to the number of project files.
2208struct DiskProjectIndex<'a> {
2209    project_root: &'a Path,
2210    conn: &'a Connection,
2211    caller_file: &'a str,
2212    caller_data: &'a FileCallData,
2213    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2214}
2215
2216impl DiskProjectIndex<'_> {
2217    fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2218        let lang: String = self
2219            .conn
2220            .query_row(
2221                "SELECT lang FROM files WHERE path = ?1",
2222                params![rel_path],
2223                |row| row.get(0),
2224            )
2225            .optional()
2226            .ok()??;
2227        let mut index = DbFileIndex {
2228            lang: lang_from_label(&lang),
2229            exports: HashSet::new(),
2230            default_export: None,
2231            export_aliases: HashMap::new(),
2232            node_by_scoped: HashMap::new(),
2233            node_by_bare: HashMap::new(),
2234            node_kind_by_id: HashMap::new(),
2235            module_targets: HashMap::new(),
2236            reexports: Vec::new(),
2237        };
2238        let mut nodes = self
2239            .conn
2240            .prepare(
2241                "SELECT id, name, scoped_name, kind, exported, is_default_export
2242                 FROM nodes WHERE file_path = ?1",
2243            )
2244            .ok()?;
2245        let rows = nodes
2246            .query_map(params![rel_path], |row| {
2247                Ok((
2248                    row.get::<_, String>(0)?,
2249                    row.get::<_, String>(1)?,
2250                    row.get::<_, String>(2)?,
2251                    row.get::<_, String>(3)?,
2252                    row.get::<_, i64>(4)? != 0,
2253                    row.get::<_, i64>(5)? != 0,
2254                ))
2255            })
2256            .ok()?
2257            .collect::<std::result::Result<Vec<_>, _>>()
2258            .ok()?;
2259        drop(nodes);
2260        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2261            if exported {
2262                index.exports.insert(name.clone());
2263                index.exports.insert(scoped_name.clone());
2264            }
2265            if is_default_export {
2266                index.default_export = Some(scoped_name.clone());
2267            }
2268            index.node_by_scoped.insert(scoped_name, id.clone());
2269            index.node_by_bare.entry(name).or_insert(id.clone());
2270            index.node_kind_by_id.insert(id, kind);
2271        }
2272
2273        let mut refs = self
2274            .conn
2275            .prepare(
2276                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2277                 FROM refs
2278                 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2279            )
2280            .ok()?;
2281        let rows = refs
2282            .query_map(params![rel_path], |row| {
2283                Ok((
2284                    row.get::<_, String>(0)?,
2285                    row.get::<_, String>(1)?,
2286                    row.get::<_, Option<String>>(2)?,
2287                    row.get::<_, Option<String>>(3)?,
2288                    row.get::<_, i64>(4)? != 0,
2289                    row.get::<_, Option<String>>(5)?,
2290                    row.get::<_, Option<String>>(6)?,
2291                ))
2292            })
2293            .ok()?
2294            .collect::<std::result::Result<Vec<_>, _>>()
2295            .ok()?;
2296        drop(refs);
2297        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2298            if kind == "export_alias" {
2299                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2300                    index.export_aliases.insert(exported, source);
2301                }
2302                continue;
2303            }
2304            let Some(module_path) = module_path else {
2305                continue;
2306            };
2307            let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2308                self.conn
2309                    .query_row(
2310                        "SELECT d.dep_file
2311                         FROM file_dependencies d
2312                         JOIN files f ON f.path = d.dep_file
2313                         WHERE d.file_path = ?1
2314                         ORDER BY d.dep_file
2315                         LIMIT 1",
2316                        params![rel_path],
2317                        |row| row.get::<_, String>(0),
2318                    )
2319                    .optional()
2320                    .ok()
2321                    .flatten()
2322            });
2323            index
2324                .module_targets
2325                .entry(module_path.clone())
2326                .or_insert_with(|| target_file.clone());
2327            if kind == "reexport" {
2328                let raw = RawRef {
2329                    ref_id,
2330                    caller_node: None,
2331                    caller_symbol: None,
2332                    caller_file: rel_path.to_string(),
2333                    kind,
2334                    short_name: None,
2335                    full_ref,
2336                    module_path: Some(module_path),
2337                    import_kind: Some("reexport".to_string()),
2338                    local_name: None,
2339                    requested_name: None,
2340                    namespace_alias: None,
2341                    wildcard,
2342                    line: 0,
2343                    byte_start: 0,
2344                    byte_end: 0,
2345                    dependencies: BTreeSet::new(),
2346                };
2347                index
2348                    .reexports
2349                    .push(reexport_index_from_raw(&raw, target_file));
2350            }
2351        }
2352        Some(index)
2353    }
2354
2355    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2356        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2357        let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2358        let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2359        self.contains_file(&rel_path).then_some(rel_path)
2360    }
2361}
2362
2363impl ResolverIndex for DiskProjectIndex<'_> {
2364    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2365        (file == self.caller_file).then_some(self.caller_data)
2366    }
2367
2368    fn lang_for(&self, file: &str) -> Option<LangId> {
2369        self.file_index(file).and_then(|index| index.lang)
2370    }
2371
2372    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2373        self.file_index(caller_file)
2374            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2375    }
2376
2377    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2378        self.file_index(file)
2379            .map(|index| index.reexports)
2380            .unwrap_or_default()
2381    }
2382
2383    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2384        self.file_index(file).and_then(|index| {
2385            index
2386                .node_by_scoped
2387                .get(symbol)
2388                .cloned()
2389                .or_else(|| index.node_by_bare.get(symbol).cloned())
2390        })
2391    }
2392
2393    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2394        self.file_index(file)
2395            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2396            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2397    }
2398
2399    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2400        self.file_index(file)
2401            .and_then(|index| index.export_aliases.get(symbol).cloned())
2402    }
2403
2404    fn has_export(&self, file: &str, symbol: &str) -> bool {
2405        self.file_index(file)
2406            .is_some_and(|index| index.exports.contains(symbol))
2407    }
2408
2409    fn default_export(&self, file: &str) -> Option<String> {
2410        self.file_index(file).and_then(|index| index.default_export)
2411    }
2412
2413    fn contains_file(&self, file: &str) -> bool {
2414        self.conn
2415            .query_row(
2416                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2417                params![file],
2418                |_| Ok(()),
2419            )
2420            .is_ok()
2421    }
2422
2423    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2424        self.workspace_crate_prefixes
2425            .0
2426            .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2427            .get(crate_name)
2428            .cloned()
2429    }
2430
2431    fn inline_scoped_target(
2432        &self,
2433        caller_file: &str,
2434        module_segments: &[String],
2435        short_name: &str,
2436    ) -> Option<(String, String)> {
2437        let src_prefix = rust_src_prefix(caller_file);
2438        let check = |file_path: String| {
2439            let file_module_segments = rust_module_segments_for_rel(&file_path);
2440            if rust_src_prefix(&file_path) != src_prefix
2441                || !module_segments.starts_with(&file_module_segments)
2442            {
2443                return None;
2444            }
2445            let scoped_segments = &module_segments[file_module_segments.len()..];
2446            if scoped_segments.is_empty() {
2447                return None;
2448            }
2449            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2450            self.node_for_symbol(&file_path, &scoped_symbol)
2451                .map(|_| (file_path, scoped_symbol))
2452        };
2453        if let Some(target) = check(caller_file.to_string()) {
2454            return Some(target);
2455        }
2456        let mut statement = self
2457            .conn
2458            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2459            .ok()?;
2460        let rows = statement
2461            .query_map(params![caller_file], |row| row.get::<_, String>(0))
2462            .ok()?;
2463        for path in rows.flatten() {
2464            if let Some(target) = check(path) {
2465                return Some(target);
2466            }
2467        }
2468        None
2469    }
2470}
2471
2472impl CallGraphStore {
2473    pub fn open_if_enabled(
2474        options: CallGraphStoreOptions,
2475        callgraph_dir: PathBuf,
2476        project_root: PathBuf,
2477    ) -> Result<Option<Self>> {
2478        if !options.enabled {
2479            return Ok(None);
2480        }
2481        Self::open(callgraph_dir, project_root).map(Some)
2482    }
2483
2484    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2485        let project_key = crate::search_index::artifact_cache_key(&project_root);
2486        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2487        else {
2488            return Err(CallGraphStoreError::Unavailable(
2489                "writer capability denied; use the read-only callgraph opener".to_string(),
2490            ));
2491        };
2492        std::fs::create_dir_all(&callgraph_dir)?;
2493        // Resolve the current generation via the pointer (falling back to the
2494        // legacy single-file DB). If nothing is published yet, open the legacy
2495        // path so a brand-new store still gets a writable DB + schema.
2496        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2497            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2498        let OpenedStore { store, root_repair } = Self::open_at_path(
2499            project_root.clone(),
2500            project_key,
2501            sqlite_path,
2502            generation,
2503            true,
2504            Some(Arc::clone(&writer_lease)),
2505            None,
2506        )?;
2507        match root_repair {
2508            OpenRootRepair::NeedsRebuild { .. } => {
2509                log_root_repair_rebuild(&root_repair);
2510                drop(store);
2511                drop(writer_lease);
2512                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2513                let (store, _stats) =
2514                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2515                Ok(store)
2516            }
2517            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2518        }
2519    }
2520
2521    pub fn open_readonly(
2522        callgraph_dir: PathBuf,
2523        project_root: PathBuf,
2524    ) -> Result<Option<ReadonlyCallGraphStore>> {
2525        let project_key = crate::search_index::artifact_cache_key(&project_root);
2526        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2527        {
2528            let conn = open_readonly_connection(&sqlite_path)?;
2529            if !database_ready(&conn).unwrap_or(false) {
2530                return Ok(None);
2531            }
2532            let marker_label = generation.as_deref().unwrap_or("legacy");
2533            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2534            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2535                Self::from_connection(
2536                    project_root,
2537                    project_key,
2538                    sqlite_path,
2539                    callgraph_dir,
2540                    false,
2541                    generation,
2542                    None,
2543                    Some(read_marker),
2544                    conn,
2545                ),
2546            )));
2547        }
2548
2549        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2550            return Ok(None);
2551        };
2552        crate::slog_warn!(
2553            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2554            target.partition.harness,
2555            target.sqlite_path.display()
2556        );
2557        let conn = open_readonly_connection(&target.sqlite_path)?;
2558        if !database_ready(&conn).unwrap_or(false) {
2559            return Ok(None);
2560        }
2561        let marker_label =
2562            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2563        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2564        Ok(Some(ReadonlyCallGraphStore::from_inner(
2565            Self::from_connection(
2566                project_root,
2567                project_key,
2568                target.sqlite_path,
2569                callgraph_dir,
2570                true,
2571                target.generation,
2572                None,
2573                Some(read_marker),
2574                conn,
2575            ),
2576        )))
2577    }
2578
2579    /// Open the currently-published ready store with write access so moved-root
2580    /// metadata can be repaired before projection readers consume it. Unlike
2581    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2582    /// ready generation exists, it returns `Ok(None)` instead of creating an
2583    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2584    pub fn open_ready_repairing(
2585        callgraph_dir: PathBuf,
2586        project_root: PathBuf,
2587    ) -> Result<Option<Self>> {
2588        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2589    }
2590
2591    /// Open a ready store for bounded maintenance work without repairing root
2592    /// metadata or starting a cold rebuild. A store that needs either action is
2593    /// reported as unavailable so a background build can own that work.
2594    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2595        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2596    }
2597
2598    pub fn open_ready_no_rebuild(
2599        callgraph_dir: PathBuf,
2600        project_root: PathBuf,
2601    ) -> Result<Option<Self>> {
2602        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2603    }
2604
2605    fn open_ready_with_rebuild_policy(
2606        callgraph_dir: PathBuf,
2607        project_root: PathBuf,
2608        allow_cold_build: bool,
2609        allow_root_repair: bool,
2610    ) -> Result<Option<Self>> {
2611        let project_key = crate::search_index::artifact_cache_key(&project_root);
2612        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2613        else {
2614            return Ok(None);
2615        };
2616        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2617        else {
2618            return Ok(None);
2619        };
2620        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2621            project_root.clone(),
2622            project_key.clone(),
2623            sqlite_path,
2624            generation,
2625            true,
2626            Some(Arc::clone(&writer_lease)),
2627            None,
2628            allow_root_repair,
2629        )?;
2630        match root_repair {
2631            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2632                log_root_repair_rebuild(&root_repair);
2633                drop(store);
2634                drop(writer_lease);
2635                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2636                let (store, _stats) =
2637                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2638                Ok(Some(store))
2639            }
2640            OpenRootRepair::NeedsRebuild { .. } => {
2641                if let Some(message) = note_repair_entry(&project_key) {
2642                    crate::slog_warn!("{message}");
2643                }
2644                Ok(None)
2645            }
2646            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2647        }
2648    }
2649
2650    pub fn cold_build_with_lease(
2651        callgraph_dir: PathBuf,
2652        project_root: PathBuf,
2653        files: &[PathBuf],
2654    ) -> Result<(Self, ColdBuildStats)> {
2655        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2656    }
2657
2658    pub fn cold_build_with_lease_chunked(
2659        callgraph_dir: PathBuf,
2660        project_root: PathBuf,
2661        files: &[PathBuf],
2662        chunk_size: usize,
2663    ) -> Result<(Self, ColdBuildStats)> {
2664        Self::cold_build_with_lease_chunked_inner(
2665            callgraph_dir,
2666            project_root,
2667            files,
2668            chunk_size,
2669            false,
2670        )
2671    }
2672
2673    pub(crate) fn force_cold_build_with_lease_chunked(
2674        callgraph_dir: PathBuf,
2675        project_root: PathBuf,
2676        files: &[PathBuf],
2677        chunk_size: usize,
2678    ) -> Result<(Self, ColdBuildStats)> {
2679        Self::cold_build_with_lease_chunked_inner(
2680            callgraph_dir,
2681            project_root,
2682            files,
2683            chunk_size,
2684            true,
2685        )
2686    }
2687
2688    fn cold_build_with_lease_chunked_inner(
2689        callgraph_dir: PathBuf,
2690        project_root: PathBuf,
2691        files: &[PathBuf],
2692        chunk_size: usize,
2693        require_new_publication: bool,
2694    ) -> Result<(Self, ColdBuildStats)> {
2695        let project_key = crate::search_index::artifact_cache_key(&project_root);
2696        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2697        else {
2698            let operation = if require_new_publication {
2699                "forced rebuild"
2700            } else {
2701                "cold build"
2702            };
2703            return Err(CallGraphStoreError::Unavailable(format!(
2704                "{operation} could not acquire writer capability"
2705            )));
2706        };
2707        std::fs::create_dir_all(&callgraph_dir)?;
2708        let (stats, generation) = Self::cold_build_publish_locked(
2709            &callgraph_dir,
2710            &project_root,
2711            &project_key,
2712            files,
2713            chunk_size,
2714            Arc::clone(&writer_lease),
2715        )?;
2716        let store = Self::open_generation(
2717            &callgraph_dir,
2718            project_root,
2719            project_key,
2720            generation,
2721            writer_lease,
2722        )?;
2723        Ok((store, stats))
2724    }
2725
2726    pub fn ensure_built_with_lease(
2727        callgraph_dir: PathBuf,
2728        project_root: PathBuf,
2729        files: &[PathBuf],
2730    ) -> Result<(Self, Option<ColdBuildStats>)> {
2731        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2732    }
2733
2734    pub fn ensure_built_with_lease_chunked(
2735        callgraph_dir: PathBuf,
2736        project_root: PathBuf,
2737        files: &[PathBuf],
2738        chunk_size: usize,
2739    ) -> Result<(Self, Option<ColdBuildStats>)> {
2740        let project_key = crate::search_index::artifact_cache_key(&project_root);
2741        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2742        else {
2743            return Err(CallGraphStoreError::Unavailable(
2744                "callgraph ensure could not acquire writer capability".to_string(),
2745            ));
2746        };
2747        std::fs::create_dir_all(&callgraph_dir)?;
2748        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2749        // Another process may have published a ready generation while we waited
2750        // for the lock — open it instead of rebuilding. If that generation is
2751        // from this same project at an older filesystem root, repair the root
2752        // metadata in-place while still holding the build lease. If data rows
2753        // contain absolute paths, publish a fresh generation under this lease
2754        // rather than recursively reacquiring the same lock.
2755        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2756        {
2757            let OpenedStore { store, root_repair } = Self::open_at_path(
2758                project_root.clone(),
2759                project_key.clone(),
2760                sqlite_path,
2761                generation,
2762                true,
2763                Some(Arc::clone(&writer_lease)),
2764                None,
2765            )?;
2766            match root_repair {
2767                OpenRootRepair::NeedsRebuild { .. } => {
2768                    log_root_repair_rebuild(&root_repair);
2769                    drop(store);
2770                    let (stats, generation) = Self::cold_build_publish_locked(
2771                        &callgraph_dir,
2772                        &project_root,
2773                        &project_key,
2774                        files,
2775                        chunk_size,
2776                        Arc::clone(&writer_lease),
2777                    )?;
2778                    let store = Self::open_generation(
2779                        &callgraph_dir,
2780                        project_root,
2781                        project_key,
2782                        generation,
2783                        writer_lease,
2784                    )?;
2785                    return Ok((store, Some(stats)));
2786                }
2787                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2788                    return Ok((store, None));
2789                }
2790            }
2791        }
2792        if let Some(store) = try_legacy_migration_or_fallback(
2793            &callgraph_dir,
2794            &project_root,
2795            &project_key,
2796            Arc::clone(&writer_lease),
2797        )? {
2798            return Ok((store, None));
2799        }
2800        let (stats, generation) = Self::cold_build_publish_locked(
2801            &callgraph_dir,
2802            &project_root,
2803            &project_key,
2804            files,
2805            chunk_size,
2806            Arc::clone(&writer_lease),
2807        )?;
2808        let store = Self::open_generation(
2809            &callgraph_dir,
2810            project_root,
2811            project_key,
2812            generation,
2813            writer_lease,
2814        )?;
2815        Ok((store, Some(stats)))
2816    }
2817
2818    /// Migrate a legacy harness-partition store without falling through to a
2819    /// cold build. This is used after a query has already opened a read-only
2820    /// fallback: the caller runs it on the same limited background lane as cold
2821    /// builds while queries continue using that fallback. Public so crash/retry
2822    /// tests can drive the migration synchronously on a thread where the
2823    /// thread-local failure seams apply.
2824    pub fn migrate_legacy_with_lease(
2825        callgraph_dir: PathBuf,
2826        project_root: PathBuf,
2827    ) -> Result<Option<Self>> {
2828        let project_key = crate::search_index::artifact_cache_key(&project_root);
2829        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2830        else {
2831            return Ok(None);
2832        };
2833        std::fs::create_dir_all(&callgraph_dir)?;
2834        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2835
2836        // Another writer may have completed the migration while this worker was
2837        // waiting for the lease. Adopt its root-keyed generation rather than
2838        // copying the legacy source a second time.
2839        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2840        {
2841            let OpenedStore { store, root_repair } = Self::open_at_path(
2842                project_root,
2843                project_key,
2844                sqlite_path,
2845                generation,
2846                true,
2847                Some(writer_lease),
2848                None,
2849            )?;
2850            return match root_repair {
2851                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2852                OpenRootRepair::NeedsRebuild { reason, .. } => {
2853                    Err(CallGraphStoreError::Unavailable(format!(
2854                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2855                    )))
2856                }
2857            };
2858        }
2859
2860        let store = try_legacy_migration_or_fallback(
2861            &callgraph_dir,
2862            &project_root,
2863            &project_key,
2864            writer_lease,
2865        )?;
2866        // A disk-floor or backup-budget failure returns a readable legacy store.
2867        // Keep the already-resident fallback instead of sending this duplicate
2868        // reader through the background-install channel.
2869        Ok(store.filter(|store| !store.is_legacy_fallback()))
2870    }
2871
2872    /// Build a fresh DB and publish it as a new generation, then atomically flip
2873    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2874    /// succeeds even when other processes hold an older generation open (the
2875    /// multi-TUI Windows case). The builder owns the temp + generation files
2876    /// exclusively (unique pid+nanos names), so it can rename/replace them
2877    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2878    ///
2879    /// Returns the published generation file name so callers open exactly the
2880    /// generation they built (avoiding a race where a concurrent build's flip
2881    /// would otherwise reopen a different generation).
2882    fn cold_build_publish_locked(
2883        callgraph_dir: &Path,
2884        project_root: &Path,
2885        project_key: &str,
2886        files: &[PathBuf],
2887        chunk_size: usize,
2888        writer_lease: Arc<crate::root_cache::WriterLease>,
2889    ) -> Result<(ColdBuildStats, String)> {
2890        if let Some((previous_root, remaining)) =
2891            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2892        {
2893            return Err(CallGraphStoreError::Unavailable(format!(
2894                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2895                previous_root.display(),
2896                remaining.as_millis()
2897            )));
2898        }
2899        let breaker = crate::build_breaker::BuildDeathBreaker::open(
2900            callgraph_dir.join("build-breaker.sqlite"),
2901        )
2902        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2903
2904        let generation = generation_file_name(project_key);
2905        let gen_path = callgraph_dir.join(&generation);
2906        // A writer lease makes this root/domain's staging generation exclusive.
2907        // Keep its identity stable so a replacement process adopts committed
2908        // batches instead of minting a second temp and starting from zero.
2909        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2910        let adopting_staging = temp_path.exists();
2911        if !adopting_staging {
2912            remove_sqlite_file_set(&temp_path);
2913        }
2914
2915        let (stats, breaker_key) = {
2916            if adopting_staging {
2917                crate::slog_info!(
2918                    "resuming callgraph cold build from staged generation {}",
2919                    temp_path.display()
2920                );
2921            }
2922            let temp_store = Self::open_at_path(
2923                project_root.to_path_buf(),
2924                project_key.to_string(),
2925                temp_path.clone(),
2926                None,
2927                false,
2928                Some(Arc::clone(&writer_lease)),
2929                None,
2930            )?
2931            .store;
2932            // Admission must precede every expensive build phase and every
2933            // staging write: a suspended root is refused before the process
2934            // spends anything, and a death during enumeration is attributable
2935            // to an admitted attempt. The breaker key needs the corpus
2936            // fingerprint, so that one input is resolved by a standalone
2937            // streaming walk first (sanctioned pre-admission work) - the
2938            // inventory pass below recomputes it while staging; the staged
2939            // value governs resume cursors, while the admission key stays
2940            // pinned to the admitted fingerprint so a file racing the walk
2941            // cannot detach the attempt from its breaker record.
2942            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
2943            let breaker_key = crate::build_breaker::BreakerKey::new(
2944                project_root.display().to_string(),
2945                crate::build_breaker::BuildDomain::CallgraphCold,
2946                admission_fingerprint,
2947            );
2948            match breaker
2949                .admit(&breaker_key, 0)
2950                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
2951            {
2952                crate::build_breaker::BreakerAdmission::Admitted(_) => {}
2953                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
2954                    return Err(CallGraphStoreError::Suspended(suspension));
2955                }
2956            }
2957            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
2958            let stats = temp_store
2959                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
2960            let _ = temp_store.checkpoint_wal_truncate();
2961            temp_store.prepare_for_atomic_swap()?;
2962            (stats, breaker_key)
2963        };
2964
2965        notify_cold_build_before_publish_observer();
2966        let publication = publish_if_current(|| {
2967            verify_writer_lease(&writer_lease)?;
2968            // Move the finished build to its final generation path. This target is
2969            // brand-new and owned by us, so the rename never hits an open file.
2970            remove_sqlite_file_set(&gen_path);
2971            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2972            crate::fs_lock::sync_parent(&gen_path);
2973            remove_sqlite_sidecars(&gen_path);
2974
2975            notify_cold_build_swap_observer(&temp_path, &gen_path);
2976
2977            // Atomically publish the new generation, then best-effort GC old ones.
2978            verify_writer_lease(&writer_lease)?;
2979            publish_pointer(callgraph_dir, project_key, &generation)?;
2980            gc_old_generations(callgraph_dir, project_key, &generation);
2981            // Store-wide orphan sweep on the same cadence: reclaims aged build
2982            // temps for roots that no longer build here, which the per-root GC
2983            // above never reaches.
2984            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2985            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2986                let inspect_root =
2987                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2988                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2989                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2990            }
2991            Ok(())
2992        });
2993        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2994            remove_sqlite_file_set(&temp_path);
2995        }
2996        publication?;
2997        // Pointer publication is the only automatic breaker reset. The staging
2998        // batches above never reset history because a process can die after them.
2999        breaker
3000            .record_ready_publication(&breaker_key)
3001            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3002        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3003        Ok((stats, generation))
3004    }
3005
3006    /// Open a specific just-published generation (read-write, WAL) so a builder
3007    /// returns a store pinned to exactly what it built.
3008    fn open_generation(
3009        callgraph_dir: &Path,
3010        project_root: PathBuf,
3011        project_key: String,
3012        generation: String,
3013        writer_lease: Arc<crate::root_cache::WriterLease>,
3014    ) -> Result<Self> {
3015        let gen_path = callgraph_dir.join(&generation);
3016        Ok(Self::open_at_path(
3017            project_root,
3018            project_key,
3019            gen_path,
3020            Some(generation),
3021            true,
3022            Some(writer_lease),
3023            None,
3024        )?
3025        .store)
3026    }
3027
3028    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3029        let project_key = crate::search_index::artifact_cache_key(project_root);
3030        // A cold build is needed unless a ready generation (or ready legacy DB)
3031        // is currently published.
3032        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3033    }
3034
3035    /// Check the durable callgraph-domain breaker before a query starts a cold
3036    /// worker. This only runs while no ready generation exists; it never builds
3037    /// inline and lets a tripped root return a terminal answer instead of an
3038    /// endless `Building` response.
3039    pub fn cold_build_suspension(
3040        callgraph_dir: &Path,
3041        project_root: &Path,
3042    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3043        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3044        if !breaker_path.exists() {
3045            return Ok(None);
3046        }
3047        let key = crate::build_breaker::BreakerKey::new(
3048            project_root.display().to_string(),
3049            crate::build_breaker::BuildDomain::CallgraphCold,
3050            callgraph_corpus_fingerprint(project_root)?,
3051        );
3052        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3053            .and_then(|breaker| breaker.suspension(&key))
3054            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3055    }
3056
3057    fn open_at_path(
3058        project_root: PathBuf,
3059        project_key: String,
3060        sqlite_path: PathBuf,
3061        generation: Option<String>,
3062        use_wal: bool,
3063        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3064        read_marker: Option<crate::root_cache::ReadMarker>,
3065    ) -> Result<OpenedStore> {
3066        Self::open_at_path_with_root_repair(
3067            project_root,
3068            project_key,
3069            sqlite_path,
3070            generation,
3071            use_wal,
3072            writer_lease,
3073            read_marker,
3074            true,
3075        )
3076    }
3077
3078    fn open_at_path_with_root_repair(
3079        project_root: PathBuf,
3080        project_key: String,
3081        sqlite_path: PathBuf,
3082        generation: Option<String>,
3083        use_wal: bool,
3084        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3085        read_marker: Option<crate::root_cache::ReadMarker>,
3086        allow_root_repair: bool,
3087    ) -> Result<OpenedStore> {
3088        if let Some(lease) = writer_lease.as_ref() {
3089            verify_writer_lease(lease)?;
3090        }
3091        if let Some(parent) = sqlite_path.parent() {
3092            std::fs::create_dir_all(parent)?;
3093        }
3094        let mut conn = Connection::open(&sqlite_path)?;
3095        if use_wal {
3096            configure_connection(&conn)?;
3097        } else {
3098            configure_build_connection(&conn)?;
3099        }
3100        if let Some(lease) = writer_lease.as_ref() {
3101            verify_writer_lease(lease)?;
3102        }
3103        initialize_schema(&conn)?;
3104        if let Some(lease) = writer_lease.as_ref() {
3105            verify_writer_lease(lease)?;
3106        }
3107        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3108        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3109            (Some(marker), _, _) => Some(marker),
3110            (None, Some(label), Some(cache_dir)) => {
3111                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3112            }
3113            (None, _, _) => None,
3114        };
3115        let publication_dir = sqlite_path
3116            .parent()
3117            .map(Path::to_path_buf)
3118            .unwrap_or_default();
3119        let store = Self::from_connection(
3120            project_root,
3121            project_key,
3122            sqlite_path,
3123            publication_dir,
3124            false,
3125            generation,
3126            writer_lease,
3127            read_marker,
3128            conn,
3129        );
3130        Ok(OpenedStore { store, root_repair })
3131    }
3132
3133    fn prepare_for_atomic_swap(&self) -> Result<()> {
3134        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3135        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3136        Ok(())
3137    }
3138
3139    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3140        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3141            self.sqlite_path
3142                .parent()
3143                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3144        });
3145        if protected_reader {
3146            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3147        } else {
3148            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3149        }
3150    }
3151
3152    fn from_connection(
3153        project_root: PathBuf,
3154        project_key: String,
3155        sqlite_path: PathBuf,
3156        publication_dir: PathBuf,
3157        legacy_fallback: bool,
3158        generation: Option<String>,
3159        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3160        read_marker: Option<crate::root_cache::ReadMarker>,
3161        conn: Connection,
3162    ) -> Self {
3163        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3164        Self {
3165            project_root,
3166            project_key,
3167            sqlite_path,
3168            publication_dir,
3169            legacy_fallback,
3170            generation,
3171            writer_lease,
3172            read_marker,
3173            database_ready: AtomicBool::new(false),
3174            write_metrics,
3175            conn: Mutex::new(conn),
3176        }
3177    }
3178
3179    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3180        if self.database_ready.load(AtomicOrdering::Acquire) {
3181            return Ok(());
3182        }
3183        ensure_database_ready(conn)?;
3184        self.database_ready.store(true, AtomicOrdering::Release);
3185        Ok(())
3186    }
3187
3188    pub fn project_root(&self) -> &Path {
3189        &self.project_root
3190    }
3191
3192    pub fn project_key(&self) -> &str {
3193        &self.project_key
3194    }
3195
3196    pub fn sqlite_path(&self) -> &Path {
3197        &self.sqlite_path
3198    }
3199
3200    /// The generation file named by the publication pointer when this store opened.
3201    pub(crate) fn projection_generation(&self) -> Option<&str> {
3202        self.generation.as_deref()
3203    }
3204
3205    /// Read the durable revision that changes in the same transaction as graph writes.
3206    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3207        self.refresh_read_marker()?;
3208        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3209        self.ensure_ready(&conn)?;
3210        projection_write_revision(&conn)
3211    }
3212
3213    /// Whether this store is reading from a legacy harness partition because
3214    /// the root-keyed store has not published a generation yet.
3215    pub fn is_legacy_fallback(&self) -> bool {
3216        self.legacy_fallback
3217    }
3218
3219    pub(crate) fn is_legacy_migration(&self) -> bool {
3220        self.generation.as_deref().is_some_and(|generation| {
3221            migration_generation_requires_manifest(generation)
3222                && migration_manifest_valid(&self.publication_dir, generation)
3223        })
3224    }
3225
3226    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3227        self.writer_lease.as_ref().map(|lease| lease.epoch())
3228    }
3229
3230    fn verify_writer_lease(&self) -> Result<()> {
3231        let Some(lease) = self.writer_lease.as_ref() else {
3232            return Err(CallGraphStoreError::Unavailable(
3233                "callgraph store opened read-only; write API is unavailable".to_string(),
3234            ));
3235        };
3236        verify_writer_lease(lease)
3237    }
3238
3239    fn refresh_read_marker(&self) -> Result<()> {
3240        if let Some(marker) = self.read_marker.as_ref() {
3241            marker.touch_if_due()?;
3242        }
3243        Ok(())
3244    }
3245
3246    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3247        self.write_metrics
3248            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3249    }
3250
3251    fn checkpoint_wal_truncate(&self) -> bool {
3252        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3253        checkpoint_wal_truncate(&conn)
3254    }
3255
3256    /// True if this store still reflects the currently-published generation.
3257    /// Cheap (one small pointer-file read). When false, another process (or a
3258    /// local cold rebuild) has published a newer generation and the holder
3259    /// should drop this store and reopen via the pointer to converge. A missing
3260    /// pointer keeps the current store (legacy DB still valid, or transient).
3261    pub fn is_current(&self) -> bool {
3262        let _ = self.refresh_read_marker();
3263        match (
3264            read_pointer(&self.publication_dir, &self.project_key),
3265            &self.generation,
3266        ) {
3267            // Even when both generations happen to have the same filename, the
3268            // root-keyed pointer names a different directory from the fallback.
3269            (Some(_), _) if self.legacy_fallback => false,
3270            (Some(published), Some(opened)) => &published == opened,
3271            // A generation now supersedes the legacy single-file DB we opened.
3272            (Some(_), None) => false,
3273            // No pointer: keep serving (legacy DB, or an anomalous pointer
3274            // removal where our open generation file is still valid).
3275            (None, _) => true,
3276        }
3277    }
3278
3279    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3280        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3281    }
3282
3283    /// Build in two durable passes. Discovery first commits a disk-backed file
3284    /// inventory, extraction consumes bounded batches from that inventory, and
3285    /// resolution pages through staged raw references after all symbols exist.
3286    pub fn cold_build_chunked(
3287        &self,
3288        files: &[PathBuf],
3289        chunk_size: usize,
3290    ) -> Result<ColdBuildStats> {
3291        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3292        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3293    }
3294
3295    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3296        note_cold_build_phase("enumeration");
3297        if files.is_empty() {
3298            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3299                &self.project_root,
3300            ))
3301        } else {
3302            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3303        }
3304    }
3305
3306    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3307    where
3308        I: IntoIterator<Item = PathBuf>,
3309    {
3310        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3311        self.verify_writer_lease()?;
3312        let total_changes_before = conn.total_changes();
3313        let tx = conn.transaction()?;
3314        tx.execute("DELETE FROM staging_file_inventory", [])?;
3315        tx.commit()?;
3316        self.record_commit(total_changes_before, &conn);
3317
3318        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3319        for path in paths {
3320            let path = normalize_file_path(&self.project_root, &path)?;
3321            let rel_path = relative_path(&self.project_root, &path);
3322            let size = std::fs::metadata(&path)
3323                .map(|metadata| metadata.len())
3324                .unwrap_or(0);
3325            batch.push((rel_path, size));
3326            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3327                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3328                batch.clear();
3329            }
3330        }
3331        if !batch.is_empty() {
3332            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3333        }
3334
3335        staged_corpus_fingerprint(&conn, &self.project_root)
3336    }
3337
3338    fn insert_staged_file_inventory_batch(
3339        &self,
3340        conn: &mut Connection,
3341        batch: &[(String, u64)],
3342    ) -> Result<()> {
3343        self.verify_writer_lease()?;
3344        let total_changes_before = conn.total_changes();
3345        let tx = conn.transaction()?;
3346        {
3347            let mut insert = tx.prepare(
3348                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3349            )?;
3350            for (path, size) in batch {
3351                insert.execute(params![path, *size as i64])?;
3352            }
3353        }
3354        tx.commit()?;
3355        self.record_commit(total_changes_before, conn);
3356        Ok(())
3357    }
3358
3359    fn cold_build_chunked_from_staged_inventory(
3360        &self,
3361        chunk_size: usize,
3362        corpus_fingerprint: &str,
3363    ) -> Result<ColdBuildStats> {
3364        let started = Instant::now();
3365        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3366        let workspace_root = self.project_root.display().to_string();
3367        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3368
3369        self.verify_writer_lease()?;
3370        let mut phase = staged_build_phase(&conn)?;
3371        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3372        if phase.as_deref().is_none_or(|phase| phase == "ready")
3373            || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3374        {
3375            let total_changes_before = conn.total_changes();
3376            let tx = conn.transaction()?;
3377            clear_tables(&tx)?;
3378            tx.execute("DELETE FROM staging_ref_context", [])?;
3379            insert_meta(&tx)?;
3380            drop_cold_build_secondary_indexes(&tx)?;
3381            set_meta_ready(&tx, false)?;
3382            set_staged_build_phase(&tx, "extracting")?;
3383            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3384            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3385            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3386            tx.commit()?;
3387            self.record_commit(total_changes_before, &conn);
3388            phase = Some("extracting".to_string());
3389        }
3390
3391        // A crashed extraction pass has already committed complete batches. Compare the
3392        // staged content identity with the current file before parsing so unchanged
3393        // committed files are not restarted from zero after adoption.
3394        note_cold_build_phase("extraction");
3395        if phase.as_deref() == Some("extracting") {
3396            prune_staged_files_not_in_inventory(&mut conn)?;
3397
3398            let mut after_path = String::new();
3399            loop {
3400                let Some(batch) = load_staged_file_batch(
3401                    &conn,
3402                    &self.project_root,
3403                    &after_path,
3404                    batch_files,
3405                    COLD_BUILD_EXTRACT_BATCH_BYTES,
3406                )?
3407                else {
3408                    break;
3409                };
3410                after_path = batch.last_path;
3411
3412                let mut needs_extract = Vec::with_capacity(batch.paths.len());
3413                for path in batch.paths {
3414                    if !staged_content_matches(&conn, &self.project_root, &path)? {
3415                        needs_extract.push(path);
3416                    }
3417                }
3418                if needs_extract.is_empty() {
3419                    continue;
3420                }
3421
3422                let build = build_extracts_parallel(&self.project_root, &needs_extract);
3423                self.verify_writer_lease()?;
3424                let total_changes_before = conn.total_changes();
3425                let tx = conn.transaction()?;
3426                let mut extracted_bytes = 0u64;
3427                {
3428                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3429                    for extract in &build.extracts {
3430                        delete_staged_file_rows(&tx, &extract.rel_path)?;
3431                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3432                        for raw in &extract.raw_refs {
3433                            insert_staged_ref_prepared(&mut inserts, raw)?;
3434                        }
3435                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3436                    }
3437                    for failure in &build.failures {
3438                        insert_backend_state_prepared(
3439                            &mut inserts.backend_state,
3440                            &workspace_root,
3441                            &failure.rel_path,
3442                            failure
3443                                .freshness
3444                                .as_ref()
3445                                .map(|freshness| &freshness.content_hash),
3446                            "stale",
3447                        )?;
3448                    }
3449                }
3450                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3451                note_cold_build_commit_barrier("extraction_batch_before_commit");
3452                tx.commit()?;
3453                note_cold_build_commit_barrier("extraction_batch_committed");
3454                self.record_commit(total_changes_before, &conn);
3455            }
3456
3457            let total_changes_before = conn.total_changes();
3458            let tx = conn.transaction()?;
3459            set_staged_build_phase(&tx, "indexing")?;
3460            tx.commit()?;
3461            self.record_commit(total_changes_before, &conn);
3462            phase = Some("indexing".to_string());
3463        }
3464
3465        // Secondary indexes are intentionally created only after every extract is
3466        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
3467        // corpus-wide symbol/export table.
3468        note_cold_build_phase("symbol_export_index");
3469        if phase.as_deref() == Some("indexing") {
3470            self.verify_writer_lease()?;
3471            let total_changes_before = conn.total_changes();
3472            let tx = conn.transaction()?;
3473            create_cold_build_secondary_indexes(&tx)?;
3474            set_staged_build_phase(&tx, "resolving")?;
3475            tx.commit()?;
3476            self.record_commit(total_changes_before, &conn);
3477        }
3478
3479        note_cold_build_phase("resolution");
3480        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3481        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3482        loop {
3483            let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3484            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3485                break;
3486            };
3487
3488            self.verify_writer_lease()?;
3489            let total_changes_before = conn.total_changes();
3490            let tx = conn.transaction()?;
3491            {
3492                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3493                let mut offset = 0;
3494                while offset < staged.len() {
3495                    let caller_file = staged[offset].raw.caller_file.clone();
3496                    let end = staged[offset..]
3497                        .iter()
3498                        .position(|entry| entry.raw.caller_file != caller_file)
3499                        .map(|relative| offset + relative)
3500                        .unwrap_or(staged.len());
3501                    let caller_extract = build_file_extract(
3502                        &self.project_root,
3503                        &self.project_root.join(&caller_file),
3504                    );
3505                    if let Ok(caller_extract) = caller_extract {
3506                        let index = DiskProjectIndex {
3507                            project_root: &self.project_root,
3508                            conn: &tx,
3509                            caller_file: &caller_file,
3510                            caller_data: &caller_extract.data,
3511                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3512                        };
3513                        for staged_ref in &staged[offset..end] {
3514                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3515                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3516                        }
3517                    } else {
3518                        for staged_ref in &staged[offset..end] {
3519                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3520                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3521                        }
3522                    }
3523                    offset = end;
3524                }
3525            }
3526            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3527            tx.commit()?;
3528            self.record_commit(total_changes_before, &conn);
3529            resolve_cursor = last_rowid;
3530        }
3531
3532        note_cold_build_phase("publication");
3533        self.verify_writer_lease()?;
3534        let total_changes_before = conn.total_changes();
3535        let tx = conn.transaction()?;
3536        let _supplemental_edge_count =
3537            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3538        set_meta_ready(&tx, true)?;
3539        set_staged_build_phase(&tx, "ready")?;
3540        tx.execute("DELETE FROM staging_file_inventory", [])?;
3541        tx.execute("DELETE FROM staging_ref_context", [])?;
3542        bump_projection_write_revision(&tx)?;
3543        tx.commit()?;
3544        self.record_commit(total_changes_before, &conn);
3545
3546        let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3547        let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3548        let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3549        let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3550        let failed_files = staged_failed_files(&conn)?;
3551        let elapsed_ms = started.elapsed().as_millis();
3552        crate::slog_info!(
3553            "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3554            files,
3555            nodes,
3556            refs,
3557            edges,
3558            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3559            elapsed_ms
3560        );
3561        Ok(ColdBuildStats {
3562            files,
3563            nodes,
3564            refs,
3565            edges,
3566            failed_files,
3567            elapsed_ms,
3568        })
3569    }
3570
3571    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3572        self.refresh_files_with_workspace_crate_prefix_cache(
3573            changed_files,
3574            WorkspaceCratePrefixCache::default(),
3575        )
3576    }
3577
3578    fn refresh_files_with_workspace_crate_prefix_cache(
3579        &self,
3580        changed_files: &[PathBuf],
3581        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3582    ) -> Result<IncrementalStats> {
3583        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3584            changed_files,
3585            workspace_crate_prefixes,
3586        )?;
3587        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3588            eprintln!("refresh_files phases: {}", profile.report());
3589        }
3590        Ok(stats)
3591    }
3592
3593    /// Run an incremental refresh and return phase timings for an offline store copy.
3594    #[doc(hidden)]
3595    pub fn refresh_files_profiled(
3596        &self,
3597        changed_files: &[PathBuf],
3598    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3599        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3600            changed_files,
3601            WorkspaceCratePrefixCache::default(),
3602        )
3603    }
3604
3605    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3606        &self,
3607        changed_files: &[PathBuf],
3608        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3609    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3610        let total_started = Instant::now();
3611        let mut profile = RefreshFilesProfile::default();
3612        self.verify_writer_lease()?;
3613        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3614        ensure_database_ready(&conn)?;
3615        let total_changes_before = conn.total_changes();
3616        let mut changed = Vec::new();
3617        let mut surface_changed = BTreeSet::new();
3618        let mut deleted = BTreeSet::new();
3619        let mut own_refresh = BTreeSet::new();
3620        let mut candidate_own_refresh = BTreeSet::new();
3621        let mut confirmed_fresh = BTreeSet::new();
3622        let mut unchanged_extracts = 0usize;
3623        let mut selected_ref_ids = BTreeSet::new();
3624        let mut selected_refs_by_caller = BTreeMap::new();
3625        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3626        let mut fresh_metadata = BTreeMap::new();
3627
3628        for input in changed_files {
3629            let abs_path = normalize_file_path(&self.project_root, input)?;
3630            let rel_path = relative_path(&self.project_root, &abs_path);
3631            changed.push(rel_path.clone());
3632            let old_row = load_file_row(&conn, &rel_path)?;
3633            if !abs_path.exists() {
3634                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3635                    surface_changed.insert(rel_path.clone());
3636                    let started = Instant::now();
3637                    let dependent_refs =
3638                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3639                    profile.dependency_selection += started.elapsed();
3640                    record_dependent_refs(
3641                        &mut selected_ref_ids,
3642                        &mut selected_refs_by_caller,
3643                        dependent_refs,
3644                    );
3645                }
3646                continue;
3647            }
3648
3649            if let Some(row) = &old_row {
3650                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3651                    FreshnessVerdict::HotFresh => {
3652                        // Content still matches the stored graph. A prior failed
3653                        // refresh may have left backend_file_state='stale' without
3654                        // changing bytes; skip the extract but still clear that
3655                        // leftover so dead-code projection can use this store.
3656                        confirmed_fresh.insert(rel_path.clone());
3657                        continue;
3658                    }
3659                    FreshnessVerdict::ContentFresh {
3660                        new_mtime,
3661                        new_size,
3662                    } => {
3663                        fresh_metadata.insert(
3664                            rel_path.clone(),
3665                            FileFreshness {
3666                                content_hash: row.freshness.content_hash,
3667                                mtime: new_mtime,
3668                                size: new_size,
3669                            },
3670                        );
3671                        continue;
3672                    }
3673                    FreshnessVerdict::Deleted => {
3674                        if deleted.insert(rel_path.clone()) {
3675                            surface_changed.insert(rel_path.clone());
3676                            let started = Instant::now();
3677                            let dependent_refs =
3678                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3679                            profile.dependency_selection += started.elapsed();
3680                            record_dependent_refs(
3681                                &mut selected_ref_ids,
3682                                &mut selected_refs_by_caller,
3683                                dependent_refs,
3684                            );
3685                        }
3686                        continue;
3687                    }
3688                    FreshnessVerdict::Stale => {}
3689                }
3690            }
3691
3692            let started = Instant::now();
3693            let extract = build_file_extract(&self.project_root, &abs_path)?;
3694            profile.parse += started.elapsed();
3695            let surface_is_changed = old_row
3696                .as_ref()
3697                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3698                .unwrap_or(true);
3699            if surface_is_changed {
3700                surface_changed.insert(rel_path.clone());
3701                let started = Instant::now();
3702                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3703                profile.dependency_selection += started.elapsed();
3704                record_dependent_refs(
3705                    &mut selected_ref_ids,
3706                    &mut selected_refs_by_caller,
3707                    dependent_refs,
3708                );
3709            }
3710            candidate_own_refresh.insert(rel_path.clone());
3711            changed_extracts.insert(rel_path, extract);
3712        }
3713
3714        let dependency_selected_refs = selected_ref_ids.len();
3715        let mut touched_callers: BTreeSet<String> =
3716            selected_refs_by_caller.keys().cloned().collect();
3717        touched_callers.extend(candidate_own_refresh.iter().cloned());
3718
3719        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3720        for rel_path in &touched_callers {
3721            if deleted.contains(rel_path) {
3722                continue;
3723            }
3724            if let Some(extract) = changed_extracts.get(rel_path) {
3725                caller_extracts.insert(rel_path.clone(), extract.clone());
3726                continue;
3727            }
3728            let abs_path = self.project_root.join(rel_path);
3729            if abs_path.exists() {
3730                let started = Instant::now();
3731                let extract = build_file_extract(&self.project_root, &abs_path)?;
3732                profile.dependent_parse += started.elapsed();
3733                caller_extracts.insert(rel_path.clone(), extract);
3734            }
3735        }
3736
3737        let tx = conn.transaction()?;
3738        for (rel_path, freshness) in fresh_metadata {
3739            update_file_fresh_metadata(
3740                &tx,
3741                &self.project_root,
3742                &rel_path,
3743                &freshness.content_hash,
3744                freshness.mtime,
3745                freshness.size,
3746            )?;
3747        }
3748        for rel_path in &confirmed_fresh {
3749            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3750        }
3751        for rel_path in &deleted {
3752            let started = Instant::now();
3753            delete_file_rows(&tx, rel_path)?;
3754            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3755            profile.row_deletes += started.elapsed();
3756        }
3757
3758        let started = Instant::now();
3759        let index = ProjectIndex::from_db_and_callers(
3760            &tx,
3761            &self.project_root,
3762            &caller_extracts,
3763            workspace_crate_prefixes,
3764        )?;
3765        profile.index_load += started.elapsed();
3766
3767        let workspace_root = self.project_root.display().to_string();
3768        {
3769            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3770            for rel_path in &candidate_own_refresh {
3771                let Some(extract) = changed_extracts.get(rel_path) else {
3772                    continue;
3773                };
3774                if !write_amplification_baseline_enabled()
3775                    && stored_extract_matches(&tx, rel_path, extract, &index)?
3776                {
3777                    unchanged_extracts += 1;
3778                    update_file_fresh_metadata(
3779                        &tx,
3780                        &self.project_root,
3781                        rel_path,
3782                        &extract.freshness.content_hash,
3783                        extract.freshness.mtime,
3784                        extract.freshness.size,
3785                    )?;
3786                    continue;
3787                }
3788
3789                own_refresh.insert(rel_path.clone());
3790                let started = Instant::now();
3791                delete_file_rows(&tx, rel_path)?;
3792                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3793                profile.row_deletes += started.elapsed();
3794                let started = Instant::now();
3795                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3796                profile.row_inserts += started.elapsed();
3797            }
3798
3799            let dependency_callers = touched_callers
3800                .iter()
3801                .filter(|rel_path| {
3802                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3803                })
3804                .cloned()
3805                .collect::<Vec<_>>();
3806            for rel_path in dependency_callers {
3807                let Some(extract) = caller_extracts.get(&rel_path) else {
3808                    continue;
3809                };
3810                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3811                    continue;
3812                }
3813
3814                own_refresh.insert(rel_path.clone());
3815                let started = Instant::now();
3816                delete_file_rows(&tx, &rel_path)?;
3817                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3818                profile.row_deletes += started.elapsed();
3819                let started = Instant::now();
3820                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3821                profile.row_inserts += started.elapsed();
3822            }
3823            let started = Instant::now();
3824            for rel_path in &touched_callers {
3825                if deleted.contains(rel_path) {
3826                    continue;
3827                }
3828                let Some(extract) = caller_extracts.get(rel_path) else {
3829                    continue;
3830                };
3831                if own_refresh.contains(rel_path) {
3832                    delete_refs_for_caller(&tx, rel_path)?;
3833                    for raw_ref in &extract.raw_refs {
3834                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3835                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3836                    }
3837                    continue;
3838                }
3839
3840                let selected_for_caller = selected_refs_by_caller
3841                    .get(rel_path)
3842                    .cloned()
3843                    .unwrap_or_default();
3844                delete_ref_ids(&tx, &selected_for_caller)?;
3845                for raw_ref in &extract.raw_refs {
3846                    if selected_for_caller.contains(&raw_ref.ref_id) {
3847                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3848                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3849                    }
3850                }
3851            }
3852            profile.ref_resolution += started.elapsed();
3853        }
3854
3855        let started = Instant::now();
3856        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3857        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3858        profile.method_dispatch += started.elapsed();
3859
3860        bump_projection_write_revision(&tx)?;
3861        let started = Instant::now();
3862        commit_incremental_if_current(tx)?;
3863        self.record_commit(total_changes_before, &conn);
3864        profile.commit += started.elapsed();
3865        profile.total = total_started.elapsed();
3866        Ok((
3867            IncrementalStats {
3868                changed_files: changed,
3869                surface_changed: surface_changed.into_iter().collect(),
3870                deleted_files: deleted.into_iter().collect(),
3871                dependency_selected_refs,
3872                refreshed_own_files: own_refresh.len(),
3873                unchanged_extract_files: unchanged_extracts,
3874            },
3875            profile,
3876        ))
3877    }
3878
3879    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3880        self.cold_build(current_files)
3881    }
3882
3883    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3884        self.verify_writer_lease()?;
3885        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3886        let total_changes_before = conn.total_changes();
3887        let tx = conn.transaction()?;
3888        let mut marked = Vec::new();
3889        for path in files {
3890            let abs_path = normalize_file_path(&self.project_root, path)?;
3891            let rel_path = relative_path(&self.project_root, &abs_path);
3892            let freshness = cache_freshness::collect(&abs_path).ok();
3893            mark_backend_state(
3894                &tx,
3895                &self.project_root,
3896                &rel_path,
3897                freshness.as_ref().map(|freshness| &freshness.content_hash),
3898                "stale",
3899            )?;
3900            marked.push(rel_path);
3901        }
3902        bump_projection_write_revision(&tx)?;
3903        tx.commit()?;
3904        self.record_commit(total_changes_before, &conn);
3905        marked.sort();
3906        marked.dedup();
3907        Ok(marked)
3908    }
3909
3910    pub fn stale_files(&self) -> Result<Vec<String>> {
3911        self.refresh_read_marker()?;
3912        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3913        let mut stmt = conn.prepare(
3914            "SELECT DISTINCT file_path FROM backend_file_state
3915             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3916             ORDER BY file_path",
3917        )?;
3918        let rows = stmt.query_map(
3919            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3920            |row| row.get::<_, String>(0),
3921        )?;
3922        rows.collect::<std::result::Result<Vec<_>, _>>()
3923            .map_err(Into::into)
3924    }
3925
3926    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3927        self.refresh_read_marker()?;
3928        let rel_path = relative_path(
3929            &self.project_root,
3930            &normalize_file_path(&self.project_root, file)?,
3931        );
3932        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3933        conn.query_row(
3934            "SELECT status FROM backend_file_state
3935             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3936             ORDER BY updated_at DESC LIMIT 1",
3937            params![
3938                BACKEND_TREESITTER,
3939                self.project_root.display().to_string(),
3940                rel_path
3941            ],
3942            |row| row.get(0),
3943        )
3944        .optional()
3945        .map_err(Into::into)
3946    }
3947
3948    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3949        self.refresh_read_marker()?;
3950        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3951        self.ensure_ready(&conn)?;
3952        edge_snapshot_with_conn(&conn)
3953    }
3954
3955    pub fn indexed_file_count(&self) -> Result<usize> {
3956        self.refresh_read_marker()?;
3957        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3958        self.ensure_ready(&conn)?;
3959        indexed_file_count(&conn)
3960    }
3961
3962    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3963        self.refresh_read_marker()?;
3964        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3965        let rel_path = relative_path(&self.project_root, &abs_path);
3966        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3967        self.ensure_ready(&conn)?;
3968        resolve_node_for_rel(&conn, &rel_path, symbol)
3969    }
3970
3971    /// Return all positional nodes matching a legacy symbol query in a file.
3972    ///
3973    /// Consumers that need legacy compatibility can collapse these by
3974    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3975    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3976        self.refresh_read_marker()?;
3977        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3978        let rel_path = relative_path(&self.project_root, &abs_path);
3979        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3980        self.ensure_ready(&conn)?;
3981        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3982    }
3983
3984    /// Return all positional nodes matching a symbol query anywhere in the store.
3985    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3986        self.refresh_read_marker()?;
3987        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3988        self.ensure_ready(&conn)?;
3989        nodes_matching_symbol(&conn, symbol)
3990    }
3991
3992    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3993    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3994        self.refresh_read_marker()?;
3995        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3996        let rel_path = relative_path(&self.project_root, &abs_path);
3997        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3998        self.ensure_ready(&conn)?;
3999        direct_callers_for_tuple(&conn, &rel_path, symbol)
4000    }
4001
4002    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4003    pub fn direct_callers_for_symbols(
4004        &self,
4005        targets: &[(String, String)],
4006    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4007        if targets.is_empty() {
4008            return Ok(HashMap::new());
4009        }
4010        self.refresh_read_marker()?;
4011        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4012        self.ensure_ready(&conn)?;
4013        direct_callers_for_tuples(&conn, targets)
4014    }
4015
4016    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4017    pub fn direct_caller_counts_of(
4018        &self,
4019        targets: &[(String, String)],
4020    ) -> Result<HashMap<(String, String), usize>> {
4021        if targets.is_empty() {
4022            return Ok(HashMap::new());
4023        }
4024        self.refresh_read_marker()?;
4025        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4026        self.ensure_ready(&conn)?;
4027        direct_caller_counts_for_tuples(&conn, targets)
4028    }
4029
4030    pub fn callers_of(
4031        &self,
4032        file_rel: &Path,
4033        symbol: &str,
4034        depth: usize,
4035    ) -> Result<StoreCallersResult> {
4036        let target = self.node_for(file_rel, symbol)?;
4037        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4038        self.ensure_ready(&conn)?;
4039        let effective_depth = depth.max(1);
4040        let mut visited = HashSet::new();
4041        let mut callers = Vec::new();
4042        let mut depth_limited = false;
4043        let mut truncated = 0usize;
4044        collect_callers_recursive(
4045            &conn,
4046            &target.file,
4047            &target.symbol,
4048            effective_depth,
4049            0,
4050            &mut visited,
4051            &mut callers,
4052            &mut depth_limited,
4053            &mut truncated,
4054        )?;
4055        Ok(StoreCallersResult {
4056            target,
4057            callers,
4058            scanned_files: indexed_file_count(&conn)?,
4059            depth_limited,
4060            truncated,
4061        })
4062    }
4063
4064    pub fn impact_of(
4065        &self,
4066        file_rel: &Path,
4067        symbol: &str,
4068        depth: usize,
4069    ) -> Result<StoreImpactResult> {
4070        let callers = self.callers_of(file_rel, symbol, depth)?;
4071        let target_parameters = callers
4072            .target
4073            .signature
4074            .as_deref()
4075            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4076            .unwrap_or_default();
4077        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4078        for site in &callers.callers {
4079            source_lines_by_file
4080                .entry(site.caller.file.clone())
4081                .or_insert_with(|| {
4082                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4083                });
4084        }
4085        let enriched = callers
4086            .callers
4087            .iter()
4088            .map(|site| StoreImpactCaller {
4089                site: site.clone(),
4090                signature: site.caller.signature.clone(),
4091                is_entry_point: site.caller.is_entry_point,
4092                call_expression: source_lines_by_file
4093                    .get(&site.caller.file)
4094                    .and_then(|lines| lines.as_ref())
4095                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4096                    .cloned(),
4097                parameters: site
4098                    .caller
4099                    .signature
4100                    .as_deref()
4101                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4102                    .unwrap_or_default(),
4103            })
4104            .collect();
4105        Ok(StoreImpactResult {
4106            target: callers.target,
4107            parameters: target_parameters,
4108            callers: enriched,
4109            depth_limited: callers.depth_limited,
4110            truncated: callers.truncated,
4111        })
4112    }
4113
4114    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4115        self.refresh_read_marker()?;
4116        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4117        self.ensure_ready(&conn)?;
4118        outgoing_calls_for_node(&conn, node)
4119    }
4120
4121    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4122    pub fn outgoing_calls_for_symbols(
4123        &self,
4124        sources: &[(String, String)],
4125    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4126        if sources.is_empty() {
4127            return Ok(HashMap::new());
4128        }
4129        self.refresh_read_marker()?;
4130        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4131        self.ensure_ready(&conn)?;
4132        outgoing_calls_for_symbol_tuples(&conn, sources)
4133    }
4134
4135    /// Return resolved direct self-call refs suppressed from the general edge table.
4136    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4137        self.refresh_read_marker()?;
4138        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4139        self.ensure_ready(&conn)?;
4140        resolved_self_calls_for_node(&conn, node)
4141    }
4142
4143    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4144        self.refresh_read_marker()?;
4145        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4146        self.ensure_ready(&conn)?;
4147        unresolved_calls_for_node(&conn, node)
4148    }
4149
4150    pub fn call_tree(
4151        &self,
4152        file_rel: &Path,
4153        symbol: &str,
4154        max_depth: usize,
4155    ) -> Result<callgraph::CallTreeNode> {
4156        let node = self.node_for(file_rel, symbol)?;
4157        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4158        self.ensure_ready(&conn)?;
4159        let mut visited = HashSet::new();
4160        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4161    }
4162
4163    pub fn trace_to(
4164        &self,
4165        file_rel: &Path,
4166        symbol: &str,
4167        max_depth: usize,
4168    ) -> Result<callgraph::TraceToResult> {
4169        let target = self.node_for(file_rel, symbol)?;
4170        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4171        self.ensure_ready(&conn)?;
4172        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4173
4174        #[derive(Clone)]
4175        struct PathElem {
4176            node: StoreNode,
4177        }
4178
4179        let initial = vec![PathElem {
4180            node: target.clone(),
4181        }];
4182        let mut complete_paths = Vec::new();
4183        if target.is_entry_point {
4184            complete_paths.push(initial.clone());
4185        }
4186
4187        let mut queue = vec![(initial, 0usize)];
4188        let mut max_depth_reached = false;
4189        let mut truncated_paths = 0usize;
4190
4191        while let Some((path, depth)) = queue.pop() {
4192            if depth >= effective_max {
4193                max_depth_reached = true;
4194                continue;
4195            }
4196            let Some(current) = path.last() else {
4197                continue;
4198            };
4199            let callers =
4200                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4201            if callers.is_empty() {
4202                if path.len() > 1 {
4203                    truncated_paths += 1;
4204                }
4205                continue;
4206            }
4207
4208            let mut has_new_path = false;
4209            for site in callers {
4210                if path.iter().any(|elem| {
4211                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4212                }) {
4213                    continue;
4214                }
4215                has_new_path = true;
4216                let mut new_path = path.clone();
4217                new_path.push(PathElem {
4218                    node: site.caller.clone(),
4219                });
4220                if site.caller.is_entry_point {
4221                    complete_paths.push(new_path.clone());
4222                }
4223                queue.push((new_path, depth + 1));
4224            }
4225            if !has_new_path && path.len() > 1 {
4226                truncated_paths += 1;
4227            }
4228        }
4229
4230        let mut paths: Vec<callgraph::TracePath> = complete_paths
4231            .into_iter()
4232            .map(|mut elems| {
4233                elems.reverse();
4234                let hops = elems
4235                    .iter()
4236                    .enumerate()
4237                    .map(|(index, elem)| callgraph::TraceHop {
4238                        symbol: elem.node.symbol.clone(),
4239                        file: elem.node.file.clone(),
4240                        line: elem.node.line,
4241                        signature: elem.node.signature.clone(),
4242                        is_entry_point: index == 0 && elem.node.is_entry_point,
4243                    })
4244                    .collect();
4245                callgraph::TracePath { hops }
4246            })
4247            .collect();
4248        paths.sort_by(|left, right| {
4249            let left_entry = left
4250                .hops
4251                .first()
4252                .map(|hop| hop.symbol.as_str())
4253                .unwrap_or("");
4254            let right_entry = right
4255                .hops
4256                .first()
4257                .map(|hop| hop.symbol.as_str())
4258                .unwrap_or("");
4259            left_entry
4260                .cmp(right_entry)
4261                .then(left.hops.len().cmp(&right.hops.len()))
4262        });
4263        let entry_points_found = paths
4264            .iter()
4265            .filter_map(|path| path.hops.first())
4266            .filter(|hop| hop.is_entry_point)
4267            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4268            .collect::<HashSet<_>>()
4269            .len();
4270
4271        Ok(callgraph::TraceToResult {
4272            target_symbol: target.symbol,
4273            target_file: target.file,
4274            total_paths: paths.len(),
4275            paths,
4276            entry_points_found,
4277            max_depth_reached,
4278            truncated_paths,
4279        })
4280    }
4281
4282    pub fn trace_to_symbol_candidates(
4283        &self,
4284        to_symbol: &str,
4285    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4286        self.refresh_read_marker()?;
4287        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4288        self.ensure_ready(&conn)?;
4289        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4290        for node in nodes_matching_symbol(&conn, to_symbol)? {
4291            candidates_by_file
4292                .entry(node.file)
4293                .and_modify(|line| *line = (*line).min(node.line))
4294                .or_insert(node.line);
4295        }
4296        let mut candidates: Vec<_> = candidates_by_file
4297            .into_iter()
4298            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4299            .collect();
4300        candidates
4301            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4302        Ok(candidates)
4303    }
4304
4305    pub fn trace_to_symbol(
4306        &self,
4307        file_rel: &Path,
4308        symbol: &str,
4309        to_symbol: &str,
4310        to_file: Option<&Path>,
4311        max_depth: usize,
4312    ) -> Result<callgraph::TraceToSymbolResult> {
4313        let origin = self.node_for(file_rel, symbol)?;
4314        let target_file = to_file
4315            .map(|path| normalize_file_path(&self.project_root, path))
4316            .transpose()?
4317            .map(|path| relative_path(&self.project_root, &path));
4318        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4319        self.ensure_ready(&conn)?;
4320        let effective_max = if max_depth == 0 {
4321            10
4322        } else {
4323            max_depth.min(16)
4324        };
4325
4326        let start_hop = trace_to_symbol_hop(&origin);
4327        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4328            return Ok(callgraph::TraceToSymbolResult {
4329                path: Some(vec![start_hop]),
4330                complete: true,
4331                reason: None,
4332            });
4333        }
4334
4335        let mut queue = VecDeque::new();
4336        queue.push_back((origin.clone(), vec![start_hop], 0usize));
4337        let mut visited = HashSet::new();
4338        visited.insert((origin.file.clone(), origin.symbol.clone()));
4339        let mut max_depth_exhausted = false;
4340
4341        while let Some((current, path, depth)) = queue.pop_front() {
4342            let callees = outgoing_calls_for_node(&conn, &current)?
4343                .into_iter()
4344                .filter_map(|site| site.target)
4345                .collect::<Vec<_>>();
4346
4347            if depth >= effective_max {
4348                if callees
4349                    .iter()
4350                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4351                {
4352                    max_depth_exhausted = true;
4353                }
4354                continue;
4355            }
4356
4357            for callee in callees {
4358                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4359                    continue;
4360                }
4361                let mut next_path = path.clone();
4362                next_path.push(trace_to_symbol_hop(&callee));
4363                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4364                    return Ok(callgraph::TraceToSymbolResult {
4365                        path: Some(next_path),
4366                        complete: true,
4367                        reason: None,
4368                    });
4369                }
4370                queue.push_back((callee, next_path, depth + 1));
4371            }
4372        }
4373
4374        if max_depth_exhausted {
4375            Ok(callgraph::TraceToSymbolResult {
4376                path: None,
4377                complete: false,
4378                reason: Some("max_depth_exhausted".to_string()),
4379            })
4380        } else {
4381            Ok(callgraph::TraceToSymbolResult {
4382                path: None,
4383                complete: true,
4384                reason: Some("no_path_found".to_string()),
4385            })
4386        }
4387    }
4388}
4389
4390impl ReadonlyCallGraphStore {
4391    fn from_inner(inner: CallGraphStore) -> Self {
4392        Self { inner }
4393    }
4394
4395    pub fn project_root(&self) -> &Path {
4396        self.inner.project_root()
4397    }
4398
4399    pub fn project_key(&self) -> &str {
4400        self.inner.project_key()
4401    }
4402
4403    pub fn sqlite_path(&self) -> &Path {
4404        self.inner.sqlite_path()
4405    }
4406
4407    pub fn stale_files(&self) -> Result<Vec<String>> {
4408        self.inner.stale_files()
4409    }
4410
4411    pub(crate) fn projection_generation(&self) -> Option<&str> {
4412        self.inner.projection_generation()
4413    }
4414
4415    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4416        self.inner.projection_write_revision()
4417    }
4418
4419    /// Report the open generation handle. SQLite-owned allocations are measured
4420    /// once by the process-wide SQLite allocator counters.
4421    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4422        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4423    }
4424
4425    /// Whether this reader is temporarily serving a legacy harness partition.
4426    pub fn is_legacy_fallback(&self) -> bool {
4427        self.inner.is_legacy_fallback()
4428    }
4429
4430    pub fn is_current(&self) -> bool {
4431        self.inner.is_current()
4432    }
4433
4434    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4435        self.inner.edge_snapshot()
4436    }
4437
4438    pub fn indexed_file_count(&self) -> Result<usize> {
4439        self.inner.indexed_file_count()
4440    }
4441
4442    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4443        self.inner.node_for(file_rel, symbol)
4444    }
4445
4446    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4447        self.inner.nodes_for(file_rel, symbol)
4448    }
4449
4450    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4451        self.inner.nodes_matching(symbol)
4452    }
4453
4454    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4455        self.inner.direct_callers_of(file_rel, symbol)
4456    }
4457
4458    pub fn direct_callers_for_symbols(
4459        &self,
4460        targets: &[(String, String)],
4461    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4462        self.inner.direct_callers_for_symbols(targets)
4463    }
4464
4465    pub fn direct_caller_counts_of(
4466        &self,
4467        targets: &[(String, String)],
4468    ) -> Result<HashMap<(String, String), usize>> {
4469        self.inner.direct_caller_counts_of(targets)
4470    }
4471
4472    pub fn callers_of(
4473        &self,
4474        file_rel: &Path,
4475        symbol: &str,
4476        depth: usize,
4477    ) -> Result<StoreCallersResult> {
4478        self.inner.callers_of(file_rel, symbol, depth)
4479    }
4480
4481    pub fn impact_of(
4482        &self,
4483        file_rel: &Path,
4484        symbol: &str,
4485        depth: usize,
4486    ) -> Result<StoreImpactResult> {
4487        self.inner.impact_of(file_rel, symbol, depth)
4488    }
4489
4490    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4491        self.inner.outgoing_calls_of(node)
4492    }
4493
4494    pub fn outgoing_calls_for_symbols(
4495        &self,
4496        sources: &[(String, String)],
4497    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4498        self.inner.outgoing_calls_for_symbols(sources)
4499    }
4500
4501    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4502        self.inner.resolved_self_calls_of(node)
4503    }
4504
4505    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4506        self.inner.unresolved_calls_of(node)
4507    }
4508
4509    pub fn call_tree(
4510        &self,
4511        file_rel: &Path,
4512        symbol: &str,
4513        depth: usize,
4514    ) -> Result<callgraph::CallTreeNode> {
4515        self.inner.call_tree(file_rel, symbol, depth)
4516    }
4517
4518    pub fn trace_to(
4519        &self,
4520        file_rel: &Path,
4521        symbol: &str,
4522        max_depth: usize,
4523    ) -> Result<callgraph::TraceToResult> {
4524        self.inner.trace_to(file_rel, symbol, max_depth)
4525    }
4526
4527    pub fn trace_to_symbol_candidates(
4528        &self,
4529        to_symbol: &str,
4530    ) -> Result<Vec<TraceToSymbolCandidate>> {
4531        self.inner.trace_to_symbol_candidates(to_symbol)
4532    }
4533
4534    pub fn trace_to_symbol(
4535        &self,
4536        file_rel: &Path,
4537        symbol: &str,
4538        to_symbol: &str,
4539        to_file: Option<&Path>,
4540        max_depth: usize,
4541    ) -> Result<callgraph::TraceToSymbolResult> {
4542        self.inner
4543            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4544    }
4545}
4546
4547impl CallGraphRead for CallGraphStore {
4548    fn project_root(&self) -> &Path {
4549        CallGraphStore::project_root(self)
4550    }
4551    fn project_key(&self) -> &str {
4552        CallGraphStore::project_key(self)
4553    }
4554    fn sqlite_path(&self) -> &Path {
4555        CallGraphStore::sqlite_path(self)
4556    }
4557    fn is_current(&self) -> bool {
4558        CallGraphStore::is_current(self)
4559    }
4560    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4561        CallGraphStore::edge_snapshot(self)
4562    }
4563    fn indexed_file_count(&self) -> Result<usize> {
4564        CallGraphStore::indexed_file_count(self)
4565    }
4566    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4567        CallGraphStore::node_for(self, file_rel, symbol)
4568    }
4569    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4570        CallGraphStore::nodes_for(self, file_rel, symbol)
4571    }
4572    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4573        CallGraphStore::nodes_matching(self, symbol)
4574    }
4575    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4576        CallGraphStore::direct_callers_of(self, file_rel, symbol)
4577    }
4578    fn direct_callers_for_symbols(
4579        &self,
4580        targets: &[(String, String)],
4581    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4582        CallGraphStore::direct_callers_for_symbols(self, targets)
4583    }
4584    fn direct_caller_counts_of(
4585        &self,
4586        targets: &[(String, String)],
4587    ) -> Result<HashMap<(String, String), usize>> {
4588        CallGraphStore::direct_caller_counts_of(self, targets)
4589    }
4590    fn callers_of(
4591        &self,
4592        file_rel: &Path,
4593        symbol: &str,
4594        depth: usize,
4595    ) -> Result<StoreCallersResult> {
4596        CallGraphStore::callers_of(self, file_rel, symbol, depth)
4597    }
4598    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4599        CallGraphStore::impact_of(self, file_rel, symbol, depth)
4600    }
4601    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4602        CallGraphStore::outgoing_calls_of(self, node)
4603    }
4604    fn outgoing_calls_for_symbols(
4605        &self,
4606        sources: &[(String, String)],
4607    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4608        CallGraphStore::outgoing_calls_for_symbols(self, sources)
4609    }
4610    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4611        CallGraphStore::resolved_self_calls_of(self, node)
4612    }
4613    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4614        CallGraphStore::unresolved_calls_of(self, node)
4615    }
4616    fn call_tree(
4617        &self,
4618        file_rel: &Path,
4619        symbol: &str,
4620        depth: usize,
4621    ) -> Result<callgraph::CallTreeNode> {
4622        CallGraphStore::call_tree(self, file_rel, symbol, depth)
4623    }
4624    fn trace_to(
4625        &self,
4626        file_rel: &Path,
4627        symbol: &str,
4628        max_depth: usize,
4629    ) -> Result<callgraph::TraceToResult> {
4630        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4631    }
4632    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4633        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4634    }
4635    fn trace_to_symbol(
4636        &self,
4637        file_rel: &Path,
4638        symbol: &str,
4639        to_symbol: &str,
4640        to_file: Option<&Path>,
4641        max_depth: usize,
4642    ) -> Result<callgraph::TraceToSymbolResult> {
4643        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4644    }
4645}
4646
4647impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4648    fn project_root(&self) -> &Path {
4649        (**self).project_root()
4650    }
4651    fn project_key(&self) -> &str {
4652        (**self).project_key()
4653    }
4654    fn sqlite_path(&self) -> &Path {
4655        (**self).sqlite_path()
4656    }
4657    fn is_current(&self) -> bool {
4658        (**self).is_current()
4659    }
4660    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4661        (**self).edge_snapshot()
4662    }
4663    fn indexed_file_count(&self) -> Result<usize> {
4664        (**self).indexed_file_count()
4665    }
4666    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4667        (**self).node_for(file_rel, symbol)
4668    }
4669    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4670        (**self).nodes_for(file_rel, symbol)
4671    }
4672    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4673        (**self).nodes_matching(symbol)
4674    }
4675    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4676        (**self).direct_callers_of(file_rel, symbol)
4677    }
4678    fn direct_callers_for_symbols(
4679        &self,
4680        targets: &[(String, String)],
4681    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4682        (**self).direct_callers_for_symbols(targets)
4683    }
4684    fn direct_caller_counts_of(
4685        &self,
4686        targets: &[(String, String)],
4687    ) -> Result<HashMap<(String, String), usize>> {
4688        (**self).direct_caller_counts_of(targets)
4689    }
4690    fn callers_of(
4691        &self,
4692        file_rel: &Path,
4693        symbol: &str,
4694        depth: usize,
4695    ) -> Result<StoreCallersResult> {
4696        (**self).callers_of(file_rel, symbol, depth)
4697    }
4698    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4699        (**self).impact_of(file_rel, symbol, depth)
4700    }
4701    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4702        (**self).outgoing_calls_of(node)
4703    }
4704    fn outgoing_calls_for_symbols(
4705        &self,
4706        sources: &[(String, String)],
4707    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4708        (**self).outgoing_calls_for_symbols(sources)
4709    }
4710    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4711        (**self).resolved_self_calls_of(node)
4712    }
4713    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4714        (**self).unresolved_calls_of(node)
4715    }
4716    fn call_tree(
4717        &self,
4718        file_rel: &Path,
4719        symbol: &str,
4720        depth: usize,
4721    ) -> Result<callgraph::CallTreeNode> {
4722        (**self).call_tree(file_rel, symbol, depth)
4723    }
4724    fn trace_to(
4725        &self,
4726        file_rel: &Path,
4727        symbol: &str,
4728        max_depth: usize,
4729    ) -> Result<callgraph::TraceToResult> {
4730        (**self).trace_to(file_rel, symbol, max_depth)
4731    }
4732    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4733        (**self).trace_to_symbol_candidates(to_symbol)
4734    }
4735    fn trace_to_symbol(
4736        &self,
4737        file_rel: &Path,
4738        symbol: &str,
4739        to_symbol: &str,
4740        to_file: Option<&Path>,
4741        max_depth: usize,
4742    ) -> Result<callgraph::TraceToSymbolResult> {
4743        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4744    }
4745}
4746
4747impl CallGraphRead for ReadonlyCallGraphStore {
4748    fn project_root(&self) -> &Path {
4749        self.project_root()
4750    }
4751    fn project_key(&self) -> &str {
4752        self.project_key()
4753    }
4754    fn sqlite_path(&self) -> &Path {
4755        self.sqlite_path()
4756    }
4757    fn is_current(&self) -> bool {
4758        self.is_current()
4759    }
4760    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4761        self.edge_snapshot()
4762    }
4763    fn indexed_file_count(&self) -> Result<usize> {
4764        self.indexed_file_count()
4765    }
4766    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4767        self.node_for(file_rel, symbol)
4768    }
4769    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4770        self.nodes_for(file_rel, symbol)
4771    }
4772    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4773        self.nodes_matching(symbol)
4774    }
4775    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4776        self.direct_callers_of(file_rel, symbol)
4777    }
4778    fn direct_callers_for_symbols(
4779        &self,
4780        targets: &[(String, String)],
4781    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4782        self.direct_callers_for_symbols(targets)
4783    }
4784    fn direct_caller_counts_of(
4785        &self,
4786        targets: &[(String, String)],
4787    ) -> Result<HashMap<(String, String), usize>> {
4788        self.direct_caller_counts_of(targets)
4789    }
4790    fn callers_of(
4791        &self,
4792        file_rel: &Path,
4793        symbol: &str,
4794        depth: usize,
4795    ) -> Result<StoreCallersResult> {
4796        self.callers_of(file_rel, symbol, depth)
4797    }
4798    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4799        self.impact_of(file_rel, symbol, depth)
4800    }
4801    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4802        self.outgoing_calls_of(node)
4803    }
4804    fn outgoing_calls_for_symbols(
4805        &self,
4806        sources: &[(String, String)],
4807    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4808        self.outgoing_calls_for_symbols(sources)
4809    }
4810    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4811        self.resolved_self_calls_of(node)
4812    }
4813    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4814        self.unresolved_calls_of(node)
4815    }
4816    fn call_tree(
4817        &self,
4818        file_rel: &Path,
4819        symbol: &str,
4820        depth: usize,
4821    ) -> Result<callgraph::CallTreeNode> {
4822        self.call_tree(file_rel, symbol, depth)
4823    }
4824    fn trace_to(
4825        &self,
4826        file_rel: &Path,
4827        symbol: &str,
4828        max_depth: usize,
4829    ) -> Result<callgraph::TraceToResult> {
4830        self.trace_to(file_rel, symbol, max_depth)
4831    }
4832    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4833        self.trace_to_symbol_candidates(to_symbol)
4834    }
4835    fn trace_to_symbol(
4836        &self,
4837        file_rel: &Path,
4838        symbol: &str,
4839        to_symbol: &str,
4840        to_file: Option<&Path>,
4841        max_depth: usize,
4842    ) -> Result<callgraph::TraceToSymbolResult> {
4843        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4844    }
4845}
4846
4847fn indexed_file_count(conn: &Connection) -> Result<usize> {
4848    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4849    Ok(count.max(0) as usize)
4850}
4851
4852fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4853    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4854    match candidates.as_slice() {
4855        [candidate] => Ok(candidate.clone()),
4856        [] => Err(AftError::SymbolNotFound {
4857            name: symbol.to_string(),
4858            file: rel_path.to_string(),
4859        }
4860        .into()),
4861        _ => Err(AftError::AmbiguousSymbol {
4862            name: symbol.to_string(),
4863            candidates: candidates
4864                .iter()
4865                .map(|candidate| candidate.symbol.clone())
4866                .collect(),
4867        }
4868        .into()),
4869    }
4870}
4871
4872fn nodes_for_file_matching_symbol(
4873    conn: &Connection,
4874    rel_path: &str,
4875    symbol: &str,
4876) -> Result<Vec<StoreNode>> {
4877    let qualified_query = symbol.contains("::");
4878    let sql = if qualified_query {
4879        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4880                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4881         FROM nodes n JOIN files f ON f.path = n.file_path
4882         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4883         ORDER BY n.scoped_name, n.start_line, n.start_col"
4884    } else {
4885        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4886                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4887         FROM nodes n JOIN files f ON f.path = n.file_path
4888         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4889         ORDER BY n.scoped_name, n.start_line, n.start_col"
4890    };
4891    let mut stmt = conn.prepare(sql)?;
4892    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4893    rows.collect::<std::result::Result<Vec<_>, _>>()
4894        .map_err(Into::into)
4895}
4896
4897fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4898    let qualified_query = symbol.contains("::");
4899    let sql = if qualified_query {
4900        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4901                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4902         FROM nodes n JOIN files f ON f.path = n.file_path
4903         WHERE n.scoped_name = ?1
4904         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4905    } else {
4906        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4907                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4908         FROM nodes n JOIN files f ON f.path = n.file_path
4909         WHERE n.scoped_name = ?1 OR n.name = ?1
4910         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4911    };
4912    let mut stmt = conn.prepare(sql)?;
4913    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4914    rows.collect::<std::result::Result<Vec<_>, _>>()
4915        .map_err(Into::into)
4916}
4917
4918fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4919    store_node_from_row_at(row, 0)
4920}
4921
4922fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4923    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4924    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4925    let lang_label_value: String = row.get(offset + 10)?;
4926    Ok(StoreNode {
4927        node_id: row.get(offset)?,
4928        file: row.get(offset + 1)?,
4929        symbol: row.get(offset + 2)?,
4930        name: row.get(offset + 3)?,
4931        kind: row.get(offset + 4)?,
4932        line: start_line.saturating_add(1),
4933        end_line: end_line.saturating_add(1),
4934        signature: row.get(offset + 7)?,
4935        exported: row.get::<_, i64>(offset + 8)? != 0,
4936        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4937        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4938    })
4939}
4940
4941fn optional_store_node_from_row_at(
4942    row: &rusqlite::Row<'_>,
4943    offset: usize,
4944) -> rusqlite::Result<Option<StoreNode>> {
4945    if row.get::<_, Option<String>>(offset)?.is_some() {
4946        store_node_from_row_at(row, offset).map(Some)
4947    } else {
4948        Ok(None)
4949    }
4950}
4951
4952#[allow(clippy::too_many_arguments)]
4953fn collect_callers_recursive(
4954    conn: &Connection,
4955    file: &str,
4956    symbol: &str,
4957    max_depth: usize,
4958    current_depth: usize,
4959    visited: &mut HashSet<(String, String)>,
4960    result: &mut Vec<StoreCallSite>,
4961    depth_limited: &mut bool,
4962    truncated: &mut usize,
4963) -> Result<()> {
4964    if current_depth >= max_depth {
4965        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4966        if omitted > 0 {
4967            *depth_limited = true;
4968            *truncated += omitted;
4969        }
4970        return Ok(());
4971    }
4972
4973    if !visited.insert((file.to_string(), symbol.to_string())) {
4974        return Ok(());
4975    }
4976
4977    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4978    for site in sites {
4979        result.push(site.clone());
4980        if current_depth + 1 < max_depth {
4981            collect_callers_recursive(
4982                conn,
4983                &site.caller.file,
4984                &site.caller.symbol,
4985                max_depth,
4986                current_depth + 1,
4987                visited,
4988                result,
4989                depth_limited,
4990                truncated,
4991            )?;
4992        } else {
4993            let omitted =
4994                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4995            if omitted > 0 {
4996                *depth_limited = true;
4997                *truncated += omitted;
4998            }
4999        }
5000    }
5001    Ok(())
5002}
5003
5004// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5005const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5006
5007fn direct_caller_counts_for_tuples(
5008    conn: &Connection,
5009    targets: &[(String, String)],
5010) -> Result<HashMap<(String, String), usize>> {
5011    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5012    let mut counts = unique_targets
5013        .iter()
5014        .cloned()
5015        .map(|target| (target, 0usize))
5016        .collect::<HashMap<_, _>>();
5017
5018    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5019    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5020        let requested_values = (0..chunk.len())
5021            .map(|_| "(?, ?)")
5022            .collect::<Vec<_>>()
5023            .join(", ");
5024        let sql = format!(
5025            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5026             deduped AS (
5027                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5028                 FROM requested requested
5029                 JOIN edges e
5030                   ON e.target_file = requested.target_file
5031                  AND e.target_symbol = requested.target_symbol
5032                  AND e.kind = 'call'
5033                 JOIN refs r ON r.ref_id = e.ref_id
5034                 JOIN nodes src ON src.id = e.source_node
5035                 JOIN files src_file ON src_file.path = src.file_path
5036                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5037             )
5038             SELECT target_file, target_symbol, COUNT(*)
5039             FROM deduped
5040             GROUP BY target_file, target_symbol"
5041        );
5042        let bindings = chunk
5043            .iter()
5044            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5045        let mut stmt = conn.prepare(&sql)?;
5046        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5047            Ok((
5048                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5049                row.get::<_, i64>(2)?,
5050            ))
5051        })?;
5052        for row in rows {
5053            let (target, count) = row?;
5054            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5055        }
5056    }
5057
5058    Ok(counts)
5059}
5060
5061fn direct_caller_count_for_tuple(
5062    conn: &Connection,
5063    target_file: &str,
5064    target_symbol: &str,
5065) -> Result<usize> {
5066    let count: i64 = conn.query_row(
5067        "SELECT COUNT(*)
5068         FROM edges e
5069         JOIN refs r ON r.ref_id = e.ref_id
5070         JOIN nodes src ON src.id = e.source_node
5071         JOIN files src_file ON src_file.path = src.file_path
5072         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5073        params![target_file, target_symbol],
5074        |row| row.get(0),
5075    )?;
5076    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5077}
5078
5079fn direct_callers_for_tuple(
5080    conn: &Connection,
5081    target_file: &str,
5082    target_symbol: &str,
5083) -> Result<Vec<StoreCallSite>> {
5084    let mut stmt = conn.prepare(
5085        "SELECT e.target_file, e.target_symbol, e.line,
5086                r.byte_start, r.byte_end, r.status, e.provenance,
5087                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5088                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5089                src_file.lang,
5090                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5091                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5092                tgt_file.lang
5093         FROM edges e
5094         JOIN refs r ON r.ref_id = e.ref_id
5095         JOIN nodes src ON src.id = e.source_node
5096         JOIN files src_file ON src_file.path = src.file_path
5097         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5098             ON tgt.id = e.target_node
5099         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5100         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5101    )?;
5102    let rows = stmt.query_map(
5103        params![target_file, target_symbol],
5104        direct_call_site_from_row,
5105    )?;
5106    rows.collect::<std::result::Result<Vec<_>, _>>()
5107        .map_err(Into::into)
5108}
5109
5110fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5111    let caller = store_node_from_row_at(row, 7)?;
5112    let target = optional_store_node_from_row_at(row, 18)?;
5113    Ok(StoreCallSite {
5114        caller,
5115        target_file: row.get(0)?,
5116        target_symbol: row.get(1)?,
5117        target,
5118        line: row.get::<_, i64>(2)?.max(0) as u32,
5119        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5120        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5121        resolved: row.get::<_, String>(5)? == "resolved",
5122        provenance: row.get(6)?,
5123    })
5124}
5125
5126fn direct_callers_for_tuples(
5127    conn: &Connection,
5128    targets: &[(String, String)],
5129) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5130    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5131    let mut callers_by_target = unique_targets
5132        .iter()
5133        .cloned()
5134        .map(|target| (target, Vec::new()))
5135        .collect::<HashMap<_, _>>();
5136    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5137
5138    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5139        let requested_values = (0..chunk.len())
5140            .map(|_| "(?, ?)")
5141            .collect::<Vec<_>>()
5142            .join(", ");
5143        let sql = format!(
5144            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5145             SELECT e.target_file, e.target_symbol, e.line,
5146                    r.byte_start, r.byte_end, r.status, e.provenance,
5147                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5148                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5149                    src_file.lang,
5150                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5151                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5152                    tgt_file.lang
5153             FROM requested requested
5154             JOIN edges e
5155               ON e.target_file = requested.target_file
5156              AND e.target_symbol = requested.target_symbol
5157              AND e.kind = 'call'
5158             JOIN refs r ON r.ref_id = e.ref_id
5159             JOIN nodes src ON src.id = e.source_node
5160             JOIN files src_file ON src_file.path = src.file_path
5161             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5162                 ON tgt.id = e.target_node
5163             ORDER BY e.target_file, e.target_symbol, e.source_node,
5164                      r.byte_start, r.line, r.ref_id"
5165        );
5166        let bindings = chunk
5167            .iter()
5168            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5169        let mut stmt = conn.prepare(&sql)?;
5170        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5171            let call = direct_call_site_from_row(row)?;
5172            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5173            Ok((target_key, call))
5174        })?;
5175        for row in rows {
5176            let (target, call) = row?;
5177            callers_by_target
5178                .get_mut(&target)
5179                .expect("batched caller row belongs to a requested target")
5180                .push(call);
5181        }
5182    }
5183
5184    Ok(callers_by_target)
5185}
5186
5187// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5188const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5189// Outgoing-edge batches bind one source node per parameter.
5190const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5191
5192fn outgoing_calls_for_symbol_tuples(
5193    conn: &Connection,
5194    sources: &[(String, String)],
5195) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5196    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5197    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5198    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5199    let source_nodes = unique_sources
5200        .iter()
5201        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5202        .cloned()
5203        .collect::<Vec<_>>();
5204    let source_nodes_by_id = source_nodes
5205        .iter()
5206        .cloned()
5207        .map(|node| (node.node_id.clone(), node))
5208        .collect::<HashMap<_, _>>();
5209    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5210
5211    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5212        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5213        let sql = format!(
5214            "SELECT e.source_node,
5215                    e.target_file, e.target_symbol, e.line,
5216                    r.byte_start, r.byte_end, r.status, e.provenance,
5217                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5218                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5219                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5220                    tgt_file.lang
5221             FROM edges e
5222             JOIN refs r ON r.ref_id = e.ref_id
5223             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5224             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5225             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5226             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5227        );
5228        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5229        let mut stmt = conn.prepare(&sql)?;
5230        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5231            let source_node_id = row.get::<_, String>(0)?;
5232            let caller = source_nodes_by_id
5233                .get(&source_node_id)
5234                .expect("batched outgoing row belongs to a requested source node")
5235                .clone();
5236            let target = optional_store_node_from_row_at(row, 8)?;
5237            Ok((
5238                source_node_id,
5239                StoreCallSite {
5240                    caller,
5241                    target_file: row.get(1)?,
5242                    target_symbol: row.get(2)?,
5243                    target,
5244                    line: row.get::<_, i64>(3)?.max(0) as u32,
5245                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5246                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5247                    resolved: row.get::<_, String>(6)? == "resolved",
5248                    provenance: row.get(7)?,
5249                },
5250            ))
5251        })?;
5252        for row in rows {
5253            let (source_node_id, call) = row?;
5254            calls_by_node.entry(source_node_id).or_default().push(call);
5255        }
5256    }
5257
5258    let mut calls_by_source = HashMap::new();
5259    for source in &unique_sources {
5260        let mut calls = Vec::new();
5261        if let Some(nodes) = source_nodes_by_symbol.get(source) {
5262            for node in nodes {
5263                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5264                    calls.extend(node_calls);
5265                }
5266            }
5267        }
5268        calls_by_source.insert(source.clone(), calls);
5269    }
5270
5271    // Resolve each logical target once for the whole frontier. Keeping this separate
5272    // preserves positional-symbol representatives without a correlated lookup per edge.
5273    let target_tuples = calls_by_source
5274        .values()
5275        .flatten()
5276        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5277        .collect::<Vec<_>>();
5278    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5279    for calls in calls_by_source.values_mut() {
5280        for call in calls {
5281            if let Some(target) = target_nodes
5282                .get(&(call.target_file.clone(), call.target_symbol.clone()))
5283                .and_then(|nodes| nodes.first())
5284            {
5285                call.target = Some(target.clone());
5286            }
5287        }
5288    }
5289
5290    Ok(calls_by_source)
5291}
5292
5293fn nodes_for_symbol_tuples(
5294    conn: &Connection,
5295    symbols: &[(String, String)],
5296) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5297    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5298    let mut nodes_by_symbol = unique_symbols
5299        .iter()
5300        .cloned()
5301        .map(|symbol| (symbol, Vec::new()))
5302        .collect::<HashMap<_, _>>();
5303    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5304
5305    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5306        let requested_values = (0..chunk.len())
5307            .map(|_| "(?, ?)")
5308            .collect::<Vec<_>>()
5309            .join(", ");
5310        let sql = format!(
5311            "WITH requested(file, symbol) AS (VALUES {requested_values})
5312             SELECT requested.file, requested.symbol,
5313                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
5314                    node.start_line, node.end_line, node.signature, node.exported,
5315                    node.is_callgraph_entry_point, node_file.lang
5316             FROM requested
5317             JOIN nodes node INDEXED BY idx_nodes_file
5318               ON node.file_path = requested.file
5319              AND node.scoped_name = requested.symbol
5320             JOIN files node_file ON node_file.path = node.file_path
5321             ORDER BY requested.file, requested.symbol,
5322                      node.scoped_name, node.start_line, node.end_line,
5323                      node.start_col, node.range_ordinal"
5324        );
5325        let bindings = chunk
5326            .iter()
5327            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5328        let mut stmt = conn.prepare(&sql)?;
5329        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5330            Ok((
5331                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5332                store_node_from_row_at(row, 2)?,
5333            ))
5334        })?;
5335        for row in rows {
5336            let (symbol, node) = row?;
5337            nodes_by_symbol.entry(symbol).or_default().push(node);
5338        }
5339    }
5340
5341    Ok(nodes_by_symbol)
5342}
5343
5344fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5345    let mut stmt = conn.prepare(
5346        "SELECT e.target_file, e.target_symbol, e.line,
5347                r.byte_start, r.byte_end, r.status, e.provenance,
5348                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5349                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5350                tgt_file.lang
5351         FROM edges e
5352         JOIN refs r ON r.ref_id = e.ref_id
5353         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5354             ON tgt.id = e.target_node
5355         WHERE e.kind = 'call' AND e.source_node = ?1
5356         ORDER BY r.byte_start, r.line, r.ref_id",
5357    )?;
5358    let rows = stmt.query_map(params![node.node_id], |row| {
5359        let target = optional_store_node_from_row_at(row, 7)?;
5360        Ok(StoreCallSite {
5361            caller: node.clone(),
5362            target_file: row.get(0)?,
5363            target_symbol: row.get(1)?,
5364            target,
5365            line: row.get::<_, i64>(2)?.max(0) as u32,
5366            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5367            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5368            resolved: row.get::<_, String>(5)? == "resolved",
5369            provenance: row.get(6)?,
5370        })
5371    })?;
5372    rows.collect::<std::result::Result<Vec<_>, _>>()
5373        .map_err(Into::into)
5374}
5375
5376fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5377    let mut stmt = conn.prepare(
5378        "SELECT r.target_file, r.target_symbol, r.line,
5379                r.byte_start, r.byte_end, r.status, r.provenance,
5380                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5381                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5382                tgt_file.lang
5383         FROM refs r
5384         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5385             ON tgt.id = r.target_node
5386         WHERE r.caller_node = ?1
5387           AND r.kind = 'call'
5388           AND r.status <> 'unresolved'
5389           AND r.target_file = ?2
5390           AND r.target_symbol = ?3
5391           AND r.provenance = ?4
5392           AND NOT EXISTS (
5393               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5394           )
5395         ORDER BY r.byte_start, r.line, r.ref_id",
5396    )?;
5397    let rows = stmt.query_map(
5398        params![
5399            &node.node_id,
5400            &node.file,
5401            &node.symbol,
5402            PROVENANCE_TREESITTER
5403        ],
5404        |row| {
5405            let target = optional_store_node_from_row_at(row, 7)?;
5406            Ok(StoreCallSite {
5407                caller: node.clone(),
5408                target_file: row.get(0)?,
5409                target_symbol: row.get(1)?,
5410                target,
5411                line: row.get::<_, i64>(2)?.max(0) as u32,
5412                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5413                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5414                resolved: row.get::<_, String>(5)? == "resolved",
5415                provenance: row.get(6)?,
5416            })
5417        },
5418    )?;
5419    rows.collect::<std::result::Result<Vec<_>, _>>()
5420        .map_err(Into::into)
5421}
5422
5423fn unresolved_calls_for_node(
5424    conn: &Connection,
5425    node: &StoreNode,
5426) -> Result<Vec<StoreUnresolvedCall>> {
5427    let mut stmt = conn.prepare(
5428        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5429         FROM refs
5430         WHERE caller_node = ?1
5431           AND kind = 'call'
5432           AND status = 'unresolved'
5433           AND NOT EXISTS (
5434               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5435           )
5436         ORDER BY byte_start, line, ref_id",
5437    )?;
5438    let rows = stmt.query_map(params![node.node_id], |row| {
5439        Ok(StoreUnresolvedCall {
5440            caller: node.clone(),
5441            symbol: row.get(0)?,
5442            full_ref: row.get(1)?,
5443            line: row.get::<_, i64>(2)?.max(0) as u32,
5444            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5445            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5446        })
5447    })?;
5448    rows.collect::<std::result::Result<Vec<_>, _>>()
5449        .map_err(Into::into)
5450}
5451
5452fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5453    let mut calls = Vec::new();
5454    calls.extend(
5455        outgoing_calls_for_node(conn, node)?
5456            .into_iter()
5457            .map(StoreForwardCall::Resolved),
5458    );
5459    calls.extend(
5460        unresolved_calls_for_node(conn, node)?
5461            .into_iter()
5462            .map(StoreForwardCall::Unresolved),
5463    );
5464    calls.sort_by(|left, right| {
5465        left.byte_start()
5466            .cmp(&right.byte_start())
5467            .then(left.line().cmp(&right.line()))
5468    });
5469    Ok(calls)
5470}
5471
5472fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5473    let resolved_count: i64 = conn.query_row(
5474        "SELECT COUNT(*)
5475         FROM edges e
5476         JOIN refs r ON r.ref_id = e.ref_id
5477         WHERE e.kind = 'call' AND e.source_node = ?1",
5478        params![&node.node_id],
5479        |row| row.get(0),
5480    )?;
5481    let unresolved_count: i64 = conn.query_row(
5482        "SELECT COUNT(*)
5483         FROM refs
5484         WHERE caller_node = ?1
5485           AND kind = 'call'
5486           AND status = 'unresolved'
5487           AND NOT EXISTS (
5488               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5489           )",
5490        params![&node.node_id],
5491        |row| row.get(0),
5492    )?;
5493    let total = resolved_count.saturating_add(unresolved_count);
5494    Ok(usize::try_from(total).unwrap_or(usize::MAX))
5495}
5496
5497fn call_tree_inner(
5498    conn: &Connection,
5499    node: &StoreNode,
5500    max_depth: usize,
5501    current_depth: usize,
5502    visited: &mut HashSet<(String, String)>,
5503) -> Result<callgraph::CallTreeNode> {
5504    let visit_key = (node.file.clone(), node.symbol.clone());
5505    if visited.contains(&visit_key) {
5506        return Ok(callgraph::CallTreeNode {
5507            name: node.symbol.clone(),
5508            file: node.file.clone(),
5509            line: node.line,
5510            signature: node.signature.clone(),
5511            resolved: true,
5512            children: Vec::new(),
5513            depth_limited: false,
5514            truncated: 0,
5515        });
5516    }
5517    visited.insert(visit_key.clone());
5518
5519    let mut children = Vec::new();
5520    let mut depth_limited = false;
5521    let mut truncated = 0usize;
5522
5523    if current_depth < max_depth {
5524        let calls = forward_calls_for_node(conn, node)?;
5525        for call in calls {
5526            match call {
5527                StoreForwardCall::Resolved(site) => {
5528                    if let Some(target) = site.target {
5529                        let child =
5530                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5531                        depth_limited |= child.depth_limited;
5532                        truncated += child.truncated;
5533                        children.push(child);
5534                    } else {
5535                        children.push(callgraph::CallTreeNode {
5536                            name: site.target_symbol,
5537                            file: site.target_file,
5538                            line: site.line,
5539                            signature: None,
5540                            resolved: false,
5541                            children: Vec::new(),
5542                            depth_limited: false,
5543                            truncated: 0,
5544                        });
5545                    }
5546                }
5547                StoreForwardCall::Unresolved(call) => {
5548                    children.push(callgraph::CallTreeNode {
5549                        name: call.symbol,
5550                        file: call.caller.file,
5551                        line: call.line,
5552                        signature: None,
5553                        resolved: false,
5554                        children: Vec::new(),
5555                        depth_limited: false,
5556                        truncated: 0,
5557                    });
5558                }
5559            }
5560        }
5561    } else {
5562        truncated = forward_call_count_for_node(conn, node)?;
5563        depth_limited = truncated > 0;
5564    }
5565
5566    visited.remove(&visit_key);
5567    Ok(callgraph::CallTreeNode {
5568        name: node.symbol.clone(),
5569        file: node.file.clone(),
5570        line: node.line,
5571        signature: node.signature.clone(),
5572        resolved: true,
5573        children,
5574        depth_limited,
5575        truncated,
5576    })
5577}
5578
5579fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5580    callgraph::TraceToSymbolHop {
5581        symbol: node.symbol.clone(),
5582        file: node.file.clone(),
5583        line: node.line,
5584    }
5585}
5586
5587fn trace_to_symbol_matches_target(
5588    node: &StoreNode,
5589    to_symbol: &str,
5590    to_file: Option<&str>,
5591) -> bool {
5592    if !symbol_query_matches(&node.symbol, to_symbol) {
5593        return false;
5594    }
5595    match to_file {
5596        Some(file) => node.file == file,
5597        None => true,
5598    }
5599}
5600
5601fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5602    symbol == query || unqualified_name(symbol) == query
5603}
5604
5605fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5606    let source = std::fs::read_to_string(path).ok()?;
5607    Some(source.lines().map(|line| line.trim().to_string()).collect())
5608}
5609
5610#[doc(hidden)]
5611pub fn live_callgraph_edge_snapshot(
5612    project_root: &Path,
5613    files: &[PathBuf],
5614) -> Result<BTreeSet<StoredEdge>> {
5615    let files = normalize_file_list(project_root, files)?;
5616    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5617    let mut file_data = Vec::new();
5618    for file in &files {
5619        let canon = canonicalize_path(file);
5620        let data = graph.build_file(&canon)?.clone();
5621        file_data.push((canon, data));
5622    }
5623
5624    let mut edges = BTreeSet::new();
5625    for (caller_file, data) in &file_data {
5626        for (caller_symbol, call_sites) in &data.calls_by_symbol {
5627            for call_site in call_sites {
5628                let resolution = graph.resolve_cross_file_edge(
5629                    &call_site.full_callee,
5630                    &call_site.callee_name,
5631                    caller_file,
5632                    &data.import_block,
5633                );
5634                let (target_file, target_symbol) = match resolution {
5635                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5636                    EdgeResolution::Unresolved { callee_name } => {
5637                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5638                            continue;
5639                        }
5640                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5641                            data,
5642                            caller_file,
5643                            &callee_name,
5644                        ) else {
5645                            continue;
5646                        };
5647                        (caller_file.clone(), target_symbol)
5648                    }
5649                };
5650                if target_file == *caller_file && target_symbol == *caller_symbol {
5651                    continue;
5652                }
5653                edges.insert(StoredEdge {
5654                    source_file: relative_path(project_root, caller_file),
5655                    source_symbol: caller_symbol.clone(),
5656                    target_file: relative_path(project_root, &target_file),
5657                    target_symbol,
5658                    kind: "call".to_string(),
5659                    line: call_site.line,
5660                });
5661            }
5662        }
5663    }
5664    Ok(edges)
5665}
5666
5667fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5668{
5669    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5670}
5671
5672fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5673    RebuildCooldownKey {
5674        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5675            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5676        project_key: project_key.to_string(),
5677    }
5678}
5679
5680fn rebuild_cooldown_denial(
5681    callgraph_dir: &Path,
5682    project_key: &str,
5683    project_root: &Path,
5684    now: Instant,
5685) -> Option<(PathBuf, Duration)> {
5686    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5687    let records = rebuild_cooldown_records()
5688        .lock()
5689        .unwrap_or_else(std::sync::PoisonError::into_inner);
5690    let record = records.get(&key)?;
5691    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5692        return None;
5693    }
5694    let elapsed = now.saturating_duration_since(record.published_at);
5695    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5696}
5697
5698fn record_successful_rebuild(
5699    callgraph_dir: &Path,
5700    project_key: &str,
5701    project_root: &Path,
5702    published_at: Instant,
5703) {
5704    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5705    let mut records = rebuild_cooldown_records()
5706        .lock()
5707        .unwrap_or_else(std::sync::PoisonError::into_inner);
5708    if records.len() >= 4_096 && !records.contains_key(&key) {
5709        if let Some(evict) = records.keys().next().cloned() {
5710            records.remove(&evict);
5711        }
5712    }
5713    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5714        previous.cross_root_cooldown_armed || previous.project_root != project_root
5715    });
5716    records.insert(
5717        key,
5718        RebuildCooldownRecord {
5719            project_root: project_root.to_path_buf(),
5720            published_at,
5721            cross_root_cooldown_armed,
5722        },
5723    );
5724}
5725
5726fn acquire_writer_lease(
5727    callgraph_dir: &Path,
5728    project_key: &str,
5729    project_root: &Path,
5730) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5731    crate::root_cache::WriterLease::acquire_shared(
5732        crate::root_cache::RootCacheDomain::Callgraph,
5733        callgraph_dir,
5734        project_key,
5735        project_root,
5736    )
5737    .map_err(CallGraphStoreError::from)
5738}
5739
5740fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5741    if lease.verify()? {
5742        Ok(())
5743    } else {
5744        Err(CallGraphStoreError::Unavailable(format!(
5745            "callgraph writer lease for key {} lost epoch {}; aborting write",
5746            lease.key(),
5747            lease.epoch()
5748        )))
5749    }
5750}
5751
5752fn legacy_migration_completion_line(
5753    project_key: &str,
5754    method: &str,
5755    legacy_bytes: u64,
5756    migrated_bytes: u64,
5757) -> String {
5758    format!(
5759        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5760    )
5761}
5762
5763fn log_legacy_migration_completion(
5764    project_key: &str,
5765    method: &str,
5766    legacy_bytes: u64,
5767    migrated_bytes: u64,
5768) {
5769    crate::slog_info!(
5770        "{}",
5771        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5772    );
5773}
5774
5775fn try_legacy_migration_or_fallback(
5776    callgraph_dir: &Path,
5777    project_root: &Path,
5778    project_key: &str,
5779    writer_lease: Arc<crate::root_cache::WriterLease>,
5780) -> Result<Option<CallGraphStore>> {
5781    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5782    if partitions.is_empty() {
5783        return Ok(None);
5784    }
5785
5786    for partition in &partitions {
5787        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5788            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5789                return open_legacy_fallback_store(
5790                    callgraph_dir,
5791                    project_root,
5792                    project_key,
5793                    &partitions,
5794                );
5795            }
5796            match publish_generation_copy_migration(
5797                callgraph_dir,
5798                project_key,
5799                &source,
5800                Arc::clone(&writer_lease),
5801            ) {
5802                Ok(published) => {
5803                    log_legacy_migration_completion(
5804                        project_key,
5805                        "generation_copy",
5806                        source.source_bytes,
5807                        published.migrated_bytes,
5808                    );
5809                    return CallGraphStore::open_generation(
5810                        callgraph_dir,
5811                        project_root.to_path_buf(),
5812                        project_key.to_string(),
5813                        published.generation,
5814                        writer_lease,
5815                    )
5816                    .map(Some);
5817                }
5818                Err(error) => {
5819                    crate::slog_warn!(
5820                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5821                        source.sqlite_path.display(),
5822                        error
5823                    );
5824                    return open_legacy_fallback_store(
5825                        callgraph_dir,
5826                        project_root,
5827                        project_key,
5828                        &partitions,
5829                    );
5830                }
5831            }
5832        }
5833
5834        if let Some(source) = current_legacy_generation(partition)? {
5835            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5836                return open_legacy_fallback_store(
5837                    callgraph_dir,
5838                    project_root,
5839                    project_key,
5840                    &partitions,
5841                );
5842            }
5843            match publish_backup_migration(
5844                callgraph_dir,
5845                project_key,
5846                &source,
5847                Arc::clone(&writer_lease),
5848            ) {
5849                Ok(published) => {
5850                    log_legacy_migration_completion(
5851                        project_key,
5852                        "sqlite_backup",
5853                        source.source_bytes,
5854                        published.migrated_bytes,
5855                    );
5856                    return CallGraphStore::open_generation(
5857                        callgraph_dir,
5858                        project_root.to_path_buf(),
5859                        project_key.to_string(),
5860                        published.generation,
5861                        writer_lease,
5862                    )
5863                    .map(Some);
5864                }
5865                Err(error) => {
5866                    crate::slog_warn!(
5867                        "root-keyed callgraph backup migration failed from {}: {}",
5868                        source.sqlite_path.display(),
5869                        error
5870                    );
5871                    return open_legacy_fallback_store(
5872                        callgraph_dir,
5873                        project_root,
5874                        project_key,
5875                        &partitions,
5876                    );
5877                }
5878            }
5879        }
5880    }
5881
5882    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5883}
5884
5885fn open_legacy_fallback_store(
5886    callgraph_dir: &Path,
5887    project_root: &Path,
5888    project_key: &str,
5889    partitions: &[LegacyCallgraphPartition],
5890) -> Result<Option<CallGraphStore>> {
5891    let Some(target) = first_ready_legacy_target(partitions)? else {
5892        return Ok(None);
5893    };
5894    crate::slog_warn!(
5895        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5896        target.partition.harness,
5897        target.sqlite_path.display()
5898    );
5899    let conn = open_readonly_connection(&target.sqlite_path)?;
5900    if !database_ready(&conn).unwrap_or(false) {
5901        return Ok(None);
5902    }
5903    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5904    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5905    Ok(Some(CallGraphStore::from_connection(
5906        project_root.to_path_buf(),
5907        project_key.to_string(),
5908        target.sqlite_path,
5909        callgraph_dir.to_path_buf(),
5910        true,
5911        target.generation,
5912        None,
5913        Some(read_marker),
5914        conn,
5915    )))
5916}
5917
5918fn migration_disk_floor_allows(
5919    source: &LegacyCallgraphTarget,
5920    callgraph_dir: &Path,
5921) -> Result<bool> {
5922    let available = migration_available_disk(callgraph_dir)?;
5923    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5924        source.source_bytes,
5925        available,
5926    );
5927    if decision.should_skip_copy() {
5928        crate::slog_warn!(
5929            "{}",
5930            decision.warning_message(&source.sqlite_path, callgraph_dir)
5931        );
5932        return Ok(false);
5933    }
5934    Ok(true)
5935}
5936
5937fn migration_available_disk(path: &Path) -> Result<u64> {
5938    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5939        return Ok(bytes);
5940    }
5941    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5942}
5943
5944fn legacy_callgraph_partitions(
5945    callgraph_dir: &Path,
5946    project_key: &str,
5947) -> Result<Vec<LegacyCallgraphPartition>> {
5948    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5949        return Ok(Vec::new());
5950    };
5951    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5952    let mut partitions = inventory
5953        .into_iter()
5954        .filter(|entry| {
5955            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5956                && entry.key == project_key
5957        })
5958        .map(|entry| {
5959            let dir = if entry.path.is_dir() {
5960                entry.path.clone()
5961            } else {
5962                entry
5963                    .path
5964                    .parent()
5965                    .map(Path::to_path_buf)
5966                    .unwrap_or_else(|| entry.path.clone())
5967            };
5968            LegacyCallgraphPartition {
5969                harness: entry.harness,
5970                dir,
5971                key: entry.key,
5972                bytes: entry.bytes,
5973                freshness: entry.callgraph_pointer_mtime,
5974            }
5975        })
5976        .collect::<Vec<_>>();
5977    partitions.sort_by(|left, right| {
5978        right
5979            .freshness
5980            .cmp(&left.freshness)
5981            .then_with(|| right.bytes.cmp(&left.bytes))
5982            .then_with(|| left.harness.cmp(&right.harness))
5983    });
5984    Ok(partitions)
5985}
5986
5987fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5988    let domain_dir = callgraph_dir.parent()?;
5989    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5990        return None;
5991    }
5992    domain_dir.parent().map(Path::to_path_buf)
5993}
5994
5995pub(crate) fn all_legacy_partitions_migrated_for_keys(
5996    callgraph_dir: &Path,
5997    configured_keys: &BTreeSet<String>,
5998) -> Result<bool> {
5999    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6000        return Ok(false);
6001    };
6002    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6003        .into_iter()
6004        .filter(|entry| {
6005            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6006                && configured_keys.contains(&entry.key)
6007        })
6008        .map(|entry| entry.key)
6009        .collect::<BTreeSet<_>>();
6010    if legacy_keys.is_empty() {
6011        return Ok(false);
6012    }
6013
6014    for key in legacy_keys {
6015        let migrated_dir = storage_root.join("callgraph").join(&key);
6016        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6017            return Ok(false);
6018        };
6019        if !migration_generation_requires_manifest(&generation)
6020            || !migration_manifest_valid(&migrated_dir, &generation)
6021        {
6022            return Ok(false);
6023        }
6024    }
6025    Ok(true)
6026}
6027
6028fn newest_superseded_legacy_generation(
6029    partition: &LegacyCallgraphPartition,
6030) -> Result<Option<LegacyCallgraphTarget>> {
6031    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6032        return Ok(None);
6033    };
6034    let prefix = format!("{}.g", partition.key);
6035    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6036        return Ok(None);
6037    };
6038    let mut candidates = Vec::new();
6039    for entry in entries.flatten() {
6040        let name = entry.file_name().to_string_lossy().to_string();
6041        if name == current
6042            || name.contains(".tmp.")
6043            || !name.starts_with(&prefix)
6044            || !name.ends_with(".sqlite")
6045        {
6046            continue;
6047        }
6048        let path = entry.path();
6049        if !db_path_ready(&path) {
6050            continue;
6051        }
6052        let modified = entry
6053            .metadata()
6054            .and_then(|metadata| metadata.modified())
6055            .unwrap_or(SystemTime::UNIX_EPOCH);
6056        candidates.push((modified, path, name));
6057    }
6058    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6059    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6060        return Ok(None);
6061    };
6062    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6063    Ok(Some(LegacyCallgraphTarget {
6064        partition: partition.clone(),
6065        sqlite_path,
6066        generation: Some(generation),
6067        source_bytes,
6068        source_blake3: String::new(),
6069    }))
6070}
6071
6072fn current_legacy_generation(
6073    partition: &LegacyCallgraphPartition,
6074) -> Result<Option<LegacyCallgraphTarget>> {
6075    let Some(target) = ready_legacy_target(partition)? else {
6076        return Ok(None);
6077    };
6078    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6079    if has_superseded {
6080        return Ok(None);
6081    }
6082    Ok(Some(target))
6083}
6084
6085fn freshest_legacy_fallback_target(
6086    callgraph_dir: &Path,
6087    project_key: &str,
6088) -> Result<Option<LegacyCallgraphTarget>> {
6089    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6090    first_ready_legacy_target(&partitions)
6091}
6092
6093fn first_ready_legacy_target(
6094    partitions: &[LegacyCallgraphPartition],
6095) -> Result<Option<LegacyCallgraphTarget>> {
6096    for partition in partitions {
6097        if let Some(target) = ready_legacy_target(partition)? {
6098            return Ok(Some(target));
6099        }
6100    }
6101    Ok(None)
6102}
6103
6104fn ready_legacy_target(
6105    partition: &LegacyCallgraphPartition,
6106) -> Result<Option<LegacyCallgraphTarget>> {
6107    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6108        let sqlite_path = partition.dir.join(&generation);
6109        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6110            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6111            return Ok(Some(LegacyCallgraphTarget {
6112                partition: partition.clone(),
6113                sqlite_path,
6114                generation: Some(generation),
6115                source_bytes,
6116                source_blake3: String::new(),
6117            }));
6118        }
6119    }
6120
6121    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6122    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6123        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6124        return Ok(Some(LegacyCallgraphTarget {
6125            partition: partition.clone(),
6126            sqlite_path,
6127            generation: None,
6128            source_bytes,
6129            source_blake3: String::new(),
6130        }));
6131    }
6132    Ok(None)
6133}
6134
6135fn publish_generation_copy_migration(
6136    callgraph_dir: &Path,
6137    project_key: &str,
6138    source: &LegacyCallgraphTarget,
6139    writer_lease: Arc<crate::root_cache::WriterLease>,
6140) -> Result<PublishedLegacyMigration> {
6141    let generation = migration_generation_file_name(project_key, "copy");
6142    let temp_path = migration_temp_path(callgraph_dir, &generation);
6143    remove_sqlite_file_set(&temp_path);
6144    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6145    fail_after_temp_copy_for_test()?;
6146
6147    let mut source = source.clone();
6148    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6149    source.source_blake3 = fingerprint.blake3;
6150    let generation = publish_migrated_generation(
6151        callgraph_dir,
6152        project_key,
6153        &generation,
6154        &temp_path,
6155        &source,
6156        fingerprint.bytes,
6157        writer_lease,
6158        "generation_copy",
6159    )?;
6160    Ok(PublishedLegacyMigration {
6161        generation,
6162        migrated_bytes: fingerprint.bytes,
6163    })
6164}
6165
6166fn publish_backup_migration(
6167    callgraph_dir: &Path,
6168    project_key: &str,
6169    source: &LegacyCallgraphTarget,
6170    writer_lease: Arc<crate::root_cache::WriterLease>,
6171) -> Result<PublishedLegacyMigration> {
6172    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6173        return Err(CallGraphStoreError::Unavailable(
6174            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6175        ));
6176    }
6177
6178    let generation = migration_generation_file_name(project_key, "backup");
6179    let temp_path = migration_temp_path(callgraph_dir, &generation);
6180    remove_sqlite_file_set(&temp_path);
6181
6182    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6183    let mut destination = Connection::open(&temp_path)?;
6184    destination.busy_timeout(Duration::from_secs(5))?;
6185    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6186    let started = Instant::now();
6187    let mut retries = 0;
6188    loop {
6189        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6190            rusqlite::backup::StepResult::Done => break,
6191            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6192            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6193                retries += 1;
6194                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6195                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6196                {
6197                    return Err(CallGraphStoreError::Unavailable(format!(
6198                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6199                    )));
6200                }
6201                std::thread::sleep(Duration::from_millis(20));
6202            }
6203            _ => {
6204                return Err(CallGraphStoreError::Unavailable(
6205                    "legacy callgraph backup returned an unknown step result".to_string(),
6206                ));
6207            }
6208        }
6209    }
6210    drop(backup);
6211
6212    let integrity: String =
6213        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6214    if integrity != "ok" {
6215        return Err(CallGraphStoreError::Unavailable(format!(
6216            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6217        )));
6218    }
6219    if !database_ready(&destination)? {
6220        return Err(CallGraphStoreError::Unavailable(
6221            "legacy callgraph backup produced a database without ready metadata".to_string(),
6222        ));
6223    }
6224    destination.execute_batch("PRAGMA optimize;")?;
6225    drop(destination);
6226    sync_file(&temp_path)?;
6227    fail_after_temp_copy_for_test()?;
6228
6229    let mut source = source.clone();
6230    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6231    source.source_blake3 = fingerprint.blake3;
6232    let generation = publish_migrated_generation(
6233        callgraph_dir,
6234        project_key,
6235        &generation,
6236        &temp_path,
6237        &source,
6238        fingerprint.bytes,
6239        writer_lease,
6240        "sqlite_backup",
6241    )?;
6242    Ok(PublishedLegacyMigration {
6243        generation,
6244        migrated_bytes: fingerprint.bytes,
6245    })
6246}
6247
6248fn publish_migrated_generation(
6249    callgraph_dir: &Path,
6250    project_key: &str,
6251    generation: &str,
6252    temp_path: &Path,
6253    source: &LegacyCallgraphTarget,
6254    migrated_bytes: u64,
6255    writer_lease: Arc<crate::root_cache::WriterLease>,
6256    method: &str,
6257) -> Result<String> {
6258    let gen_path = callgraph_dir.join(generation);
6259    checkpoint_sqlite_before_publication(temp_path);
6260    let publication = publish_if_current(|| {
6261        verify_writer_lease(&writer_lease)?;
6262        remove_sqlite_file_set(&gen_path);
6263        rename_sqlite_file_set(temp_path, &gen_path)?;
6264        crate::fs_lock::sync_parent(&gen_path);
6265
6266        verify_writer_lease(&writer_lease)?;
6267        publish_pointer(callgraph_dir, project_key, generation)?;
6268        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6269        Ok(generation.to_string())
6270    });
6271    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6272        remove_sqlite_file_set(temp_path);
6273    }
6274    publication
6275}
6276
6277fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6278    if let Some(parent) = destination.parent() {
6279        std::fs::create_dir_all(parent)?;
6280    }
6281    for suffix in SQLITE_FILE_SET_SUFFIXES {
6282        let source_path = sqlite_file_set_path(source, suffix);
6283        if !source_path.is_file() {
6284            continue;
6285        }
6286        let destination_path = sqlite_file_set_path(destination, suffix);
6287        std::fs::copy(&source_path, &destination_path)?;
6288        sync_file(&destination_path)?;
6289    }
6290    Ok(())
6291}
6292
6293fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6294    for suffix in SQLITE_FILE_SET_SUFFIXES {
6295        let source_path = sqlite_file_set_path(source, suffix);
6296        if !source_path.exists() {
6297            continue;
6298        }
6299        let destination_path = sqlite_file_set_path(destination, suffix);
6300        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6301            let _ = std::fs::remove_file(&source_path);
6302            return Err(error.into());
6303        }
6304    }
6305    Ok(())
6306}
6307
6308fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6309    let mut bytes = 0_u64;
6310    for suffix in SQLITE_FILE_SET_SUFFIXES {
6311        let member = sqlite_file_set_path(path, suffix);
6312        if !member.is_file() {
6313            continue;
6314        }
6315        bytes = bytes.saturating_add(member.metadata()?.len());
6316    }
6317    Ok(bytes)
6318}
6319
6320fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6321    let mut hasher = blake3::Hasher::new();
6322    let mut bytes = 0_u64;
6323    let mut buffer = [0_u8; 64 * 1024];
6324    for suffix in SQLITE_FILE_SET_SUFFIXES {
6325        let member = sqlite_file_set_path(path, suffix);
6326        if !member.is_file() {
6327            continue;
6328        }
6329        hasher.update(suffix.as_bytes());
6330        let mut file = std::fs::File::open(&member)?;
6331        loop {
6332            let read = file.read(&mut buffer)?;
6333            if read == 0 {
6334                break;
6335            }
6336            bytes = bytes.saturating_add(read as u64);
6337            hasher.update(&buffer[..read]);
6338        }
6339    }
6340    Ok(SourceFingerprint {
6341        bytes,
6342        blake3: hash_to_hex(hasher.finalize()),
6343    })
6344}
6345
6346fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6347    if suffix.is_empty() {
6348        path.to_path_buf()
6349    } else {
6350        PathBuf::from(format!("{}{suffix}", path.display()))
6351    }
6352}
6353
6354fn sync_file(path: &Path) -> Result<()> {
6355    let file = std::fs::OpenOptions::new()
6356        .read(true)
6357        .write(true)
6358        .open(path)?;
6359    file.sync_all()?;
6360    Ok(())
6361}
6362
6363fn fail_after_temp_copy_for_test() -> Result<()> {
6364    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6365        return Err(CallGraphStoreError::Unavailable(
6366            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6367        ));
6368    }
6369    Ok(())
6370}
6371
6372fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6373    format!(
6374        "{project_key}.g{}.{}{}{}.sqlite",
6375        now_nanos(),
6376        std::process::id(),
6377        MIGRATION_GENERATION_TAG,
6378        method
6379    )
6380}
6381
6382fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6383    callgraph_dir.join(format!(
6384        "{generation}.tmp.{}.{}",
6385        std::process::id(),
6386        now_nanos()
6387    ))
6388}
6389
6390fn write_migration_manifest(
6391    callgraph_dir: &Path,
6392    generation: &str,
6393    source: &LegacyCallgraphTarget,
6394    migrated_bytes: u64,
6395    method: &str,
6396) -> Result<()> {
6397    let manifest_path = migration_manifest_path(callgraph_dir, generation);
6398    let temp_path = manifest_path.with_extension(format!(
6399        "migration.json.tmp.{}.{}",
6400        std::process::id(),
6401        now_nanos()
6402    ));
6403    let manifest = serde_json::json!({
6404        "version": MIGRATION_MANIFEST_VERSION,
6405        "method": method,
6406        "target_generation": generation,
6407        "source_harness": source.partition.harness,
6408        "source_path": source.sqlite_path.display().to_string(),
6409        "source_generation": source.generation,
6410        "source_bytes": source.source_bytes,
6411        "source_blake3": source.source_blake3,
6412        "migrated_bytes": migrated_bytes,
6413    });
6414    {
6415        use std::io::Write as _;
6416        let mut file = std::fs::File::create(&temp_path)?;
6417        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6418        file.write_all(b"\n")?;
6419        file.sync_all()?;
6420    }
6421    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6422        let _ = std::fs::remove_file(&temp_path);
6423        return Err(error.into());
6424    }
6425    crate::fs_lock::sync_parent(&manifest_path);
6426    Ok(())
6427}
6428
6429fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6430    callgraph_dir.join(format!("{generation}.migration.json"))
6431}
6432
6433fn migration_generation_requires_manifest(generation: &str) -> bool {
6434    generation.contains(MIGRATION_GENERATION_TAG)
6435}
6436
6437fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6438    if !migration_generation_requires_manifest(generation) {
6439        return true;
6440    }
6441    let path = migration_manifest_path(callgraph_dir, generation);
6442    let Ok(bytes) = std::fs::read(path) else {
6443        return false;
6444    };
6445    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6446        return false;
6447    };
6448    value.get("version").and_then(serde_json::Value::as_u64)
6449        == Some(MIGRATION_MANIFEST_VERSION as u64)
6450        && value
6451            .get("target_generation")
6452            .and_then(serde_json::Value::as_str)
6453            == Some(generation)
6454        && value
6455            .get("source_bytes")
6456            .and_then(serde_json::Value::as_u64)
6457            .is_some_and(|bytes| bytes > 0)
6458        && value
6459            .get("source_blake3")
6460            .and_then(serde_json::Value::as_str)
6461            .is_some_and(|hash| hash.len() == 64)
6462}
6463
6464fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6465    let pointer_generation = read_pointer(callgraph_dir, project_key);
6466    if let Some(generation) = pointer_generation.as_deref() {
6467        if migration_generation_requires_manifest(generation)
6468            && !migration_manifest_valid(callgraph_dir, generation)
6469        {
6470            let path = callgraph_dir.join(generation);
6471            remove_sqlite_file_set(&path);
6472            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6473            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6474        }
6475    }
6476
6477    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6478        return;
6479    };
6480    for entry in entries.flatten() {
6481        let name = entry.file_name().to_string_lossy().to_string();
6482        let path = entry.path();
6483        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6484            let _ = std::fs::remove_file(path);
6485            continue;
6486        }
6487        if name.starts_with(&format!("{project_key}.g"))
6488            && name.ends_with(".sqlite")
6489            && name.contains(MIGRATION_GENERATION_TAG)
6490            && pointer_generation.as_deref() != Some(&name)
6491            && !migration_manifest_valid(callgraph_dir, &name)
6492        {
6493            remove_sqlite_file_set(&path);
6494            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6495        }
6496    }
6497    crate::fs_lock::sync_parent(callgraph_dir);
6498}
6499
6500fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6501    let mut hasher = blake3::Hasher::new();
6502    hasher.update(path.to_string_lossy().as_bytes());
6503    if let Some(generation) = generation {
6504        hasher.update(generation.as_bytes());
6505    }
6506    let digest = hash_to_hex(hasher.finalize());
6507    format!("legacy-{}", &digest[..16])
6508}
6509
6510fn open_readonly_connection(path: &Path) -> Result<Connection> {
6511    let uri = sqlite_readonly_uri(path);
6512    let conn = Connection::open_with_flags(
6513        &uri,
6514        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6515    )?;
6516    conn.pragma_update(
6517        None,
6518        "synchronous",
6519        if write_amplification_baseline_enabled() {
6520            "FULL"
6521        } else {
6522            "NORMAL"
6523        },
6524    )?;
6525    conn.busy_timeout(reader_busy_timeout())?;
6526    conn.execute_batch("PRAGMA query_only=ON;")?;
6527    Ok(conn)
6528}
6529
6530fn reader_busy_timeout() -> Duration {
6531    let jitter = (now_nanos() % 500) as u64;
6532    Duration::from_millis(250 + jitter)
6533}
6534
6535fn sqlite_readonly_uri(path: &Path) -> String {
6536    let raw = path.to_string_lossy().replace('\\', "/");
6537    let encoded = percent_encode_sqlite_uri_path(&raw);
6538    if raw.starts_with('/') {
6539        format!("file://{encoded}?mode=ro")
6540    } else if raw.as_bytes().get(1) == Some(&b':') {
6541        format!("file:///{encoded}?mode=ro")
6542    } else {
6543        format!("file:{encoded}?mode=ro")
6544    }
6545}
6546
6547fn percent_encode_sqlite_uri_path(path: &str) -> String {
6548    let mut encoded = String::with_capacity(path.len());
6549    for byte in path.bytes() {
6550        match byte {
6551            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6552                encoded.push(byte as char)
6553            }
6554            _ => encoded.push_str(&format!("%{byte:02X}")),
6555        }
6556    }
6557    encoded
6558}
6559
6560fn configure_connection(conn: &Connection) -> Result<()> {
6561    // Changing journal mode takes a database lock. Install the busy handler
6562    // first so concurrent cold-build and refresh connections wait rather than
6563    // failing immediately, especially under Windows byte-range locking.
6564    conn.busy_timeout(Duration::from_secs(5))?;
6565    conn.pragma_update(None, "journal_mode", "WAL")?;
6566    let baseline = write_amplification_baseline_enabled();
6567    conn.pragma_update(
6568        None,
6569        "synchronous",
6570        if baseline { "FULL" } else { "NORMAL" },
6571    )?;
6572    conn.pragma_update(
6573        None,
6574        "wal_autocheckpoint",
6575        if baseline {
6576            1_000
6577        } else {
6578            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6579        },
6580    )?;
6581    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6582    Ok(())
6583}
6584
6585fn configure_build_connection(conn: &Connection) -> Result<()> {
6586    // The staging database commits independently recoverable batches. WAL keeps
6587    // those commits durable without forcing a rollback journal rewrite per batch.
6588    // Set the busy handler before WAL because selecting the journal mode itself
6589    // can contend with a connection finishing an earlier staged transaction.
6590    conn.busy_timeout(Duration::from_secs(5))?;
6591    conn.pragma_update(None, "journal_mode", "WAL")?;
6592    conn.pragma_update(
6593        None,
6594        "synchronous",
6595        if write_amplification_baseline_enabled() {
6596            "FULL"
6597        } else {
6598            "NORMAL"
6599        },
6600    )?;
6601    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6602    Ok(())
6603}
6604
6605/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
6606/// private temporary copy before publishing it; a busy reader is harmless because
6607/// the next publication or cleanup pass can retry without affecting the source.
6608fn checkpoint_sqlite_before_publication(path: &Path) {
6609    let Ok(conn) = Connection::open(path) else {
6610        return;
6611    };
6612    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6613    let _ = conn.busy_timeout(Duration::from_secs(5));
6614    let _ = checkpoint_wal_truncate(&conn);
6615}
6616
6617fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6618    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6619        row.get::<_, i64>(0)
6620    }) {
6621        Ok(0) => true,
6622        Ok(_) => false,
6623        Err(rusqlite::Error::SqliteFailure(error, _))
6624            if matches!(
6625                error.code,
6626                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6627            ) =>
6628        {
6629            false
6630        }
6631        Err(error) => {
6632            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6633            false
6634        }
6635    }
6636}
6637
6638fn initialize_schema(conn: &Connection) -> Result<()> {
6639    conn.execute_batch(
6640        "CREATE TABLE IF NOT EXISTS files (
6641            path                TEXT PRIMARY KEY,
6642            content_hash        TEXT NOT NULL,
6643            mtime_ns            INTEGER NOT NULL,
6644            size                INTEGER NOT NULL,
6645            lang                TEXT NOT NULL,
6646            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
6647            is_public_api       INTEGER NOT NULL DEFAULT 0,
6648            surface_fingerprint TEXT NOT NULL,
6649            indexed_at          INTEGER NOT NULL
6650        );
6651
6652        CREATE TABLE IF NOT EXISTS nodes (
6653            id                         TEXT PRIMARY KEY,
6654            file_path                  TEXT NOT NULL,
6655            name                       TEXT NOT NULL,
6656            scoped_name                TEXT NOT NULL,
6657            kind                       TEXT NOT NULL,
6658            start_line                 INTEGER NOT NULL,
6659            start_col                  INTEGER NOT NULL,
6660            end_line                   INTEGER NOT NULL,
6661            end_col                    INTEGER NOT NULL,
6662            range_ordinal              INTEGER NOT NULL,
6663            signature                  TEXT,
6664            exported                   INTEGER NOT NULL,
6665            is_default_export          INTEGER NOT NULL,
6666            is_type_like               INTEGER NOT NULL,
6667            is_callgraph_entry_point   INTEGER NOT NULL,
6668            provenance                 TEXT NOT NULL,
6669            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6670        );
6671        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6672        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6673        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6674
6675        CREATE TABLE IF NOT EXISTS refs (
6676            ref_id          TEXT PRIMARY KEY,
6677            caller_node     TEXT,
6678            caller_file     TEXT NOT NULL,
6679            kind            TEXT NOT NULL,
6680            short_name      TEXT,
6681            full_ref        TEXT,
6682            module_path     TEXT,
6683            import_kind     TEXT,
6684            local_name      TEXT,
6685            requested_name  TEXT,
6686            namespace_alias TEXT,
6687            wildcard        INTEGER NOT NULL DEFAULT 0,
6688            line            INTEGER NOT NULL,
6689            byte_start      INTEGER NOT NULL,
6690            byte_end        INTEGER NOT NULL,
6691            status          TEXT NOT NULL,
6692            target_node     TEXT,
6693            target_file     TEXT,
6694            target_symbol   TEXT,
6695            provenance      TEXT NOT NULL
6696        );
6697        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6698        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6699        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6700        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6701        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6702
6703        CREATE TABLE IF NOT EXISTS file_dependencies (
6704            file_path   TEXT NOT NULL,
6705            dep_file    TEXT NOT NULL,
6706            PRIMARY KEY(file_path, dep_file)
6707        );
6708        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6709
6710        CREATE TABLE IF NOT EXISTS edges (
6711            edge_id       TEXT PRIMARY KEY,
6712            ref_id        TEXT NOT NULL,
6713            source_node   TEXT NOT NULL,
6714            target_node   TEXT,
6715            target_file   TEXT NOT NULL,
6716            target_symbol TEXT NOT NULL,
6717            kind          TEXT NOT NULL,
6718            line          INTEGER NOT NULL,
6719            provenance    TEXT NOT NULL
6720        );
6721        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6722        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6723        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6724        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6725
6726        CREATE TABLE IF NOT EXISTS dispatch_hints (
6727            id           TEXT PRIMARY KEY,
6728            method_name  TEXT NOT NULL,
6729            caller_node  TEXT NOT NULL,
6730            file         TEXT NOT NULL,
6731            line         INTEGER NOT NULL,
6732            byte_start   INTEGER NOT NULL,
6733            byte_end     INTEGER NOT NULL,
6734            provenance   TEXT NOT NULL
6735        );
6736        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6737        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6738
6739        CREATE TABLE IF NOT EXISTS type_ref_names (
6740            name TEXT PRIMARY KEY
6741        );
6742
6743        CREATE TABLE IF NOT EXISTS backend_file_state (
6744            backend        TEXT NOT NULL,
6745            workspace_root TEXT NOT NULL,
6746            file_path      TEXT NOT NULL,
6747            content_hash   TEXT NOT NULL,
6748            status         TEXT NOT NULL,
6749            updated_at     INTEGER NOT NULL,
6750            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6751        );
6752        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6753
6754        CREATE TABLE IF NOT EXISTS meta (
6755            k TEXT PRIMARY KEY,
6756            v TEXT NOT NULL
6757        );
6758
6759        -- The file walk is staged on disk so extraction can page through a
6760        -- deterministic inventory without retaining every source path in heap.
6761        CREATE TABLE IF NOT EXISTS staging_file_inventory (
6762            path TEXT PRIMARY KEY,
6763            size INTEGER NOT NULL
6764        ) WITHOUT ROWID;
6765
6766        -- Context needed only while a generation is staged. Raw refs live in
6767        -- `refs` with status `staged`; this table preserves the caller symbol
6768        -- needed to avoid inventing self edges during the later resolve pass.
6769        CREATE TABLE IF NOT EXISTS staging_ref_context (
6770            ref_id        TEXT PRIMARY KEY,
6771            caller_symbol TEXT
6772        );",
6773    )?;
6774    insert_meta(conn)?;
6775    Ok(())
6776}
6777
6778fn insert_meta(conn: &Connection) -> Result<()> {
6779    conn.execute(
6780        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6781        params![SCHEMA_VERSION.to_string()],
6782    )?;
6783    conn.execute(
6784        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6785        params![schema_fingerprint()],
6786    )?;
6787    conn.execute(
6788        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6789        [],
6790    )?;
6791    Ok(())
6792}
6793
6794/// Return the durable revision paired atomically with graph mutations. Stores
6795/// created by older binaries lack the revision row, so callers cannot detect
6796/// in-place graph changes and must not cache their snapshots.
6797fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6798    let revision: Option<String> = conn
6799        .query_row(
6800            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6801            [],
6802            |row| row.get(0),
6803        )
6804        .optional()?;
6805    revision
6806        .map(|revision| {
6807            revision.parse::<u64>().map_err(|error| {
6808                CallGraphStoreError::Unavailable(format!(
6809                    "callgraph projection write revision is invalid: {error}"
6810                ))
6811            })
6812        })
6813        .transpose()
6814}
6815
6816/// Advance the projection revision inside the graph mutation transaction so a
6817/// cached snapshot never survives an in-place refresh.
6818fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6819    tx.execute(
6820        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6821         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6822        [],
6823    )?;
6824    Ok(())
6825}
6826
6827fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6828    conn.execute(
6829        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6830        params![if ready { "1" } else { "0" }],
6831    )?;
6832    Ok(())
6833}
6834
6835fn database_ready(conn: &Connection) -> Result<bool> {
6836    let schema_version: Option<String> = conn
6837        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6838            row.get(0)
6839        })
6840        .optional()?;
6841    let fingerprint: Option<String> = conn
6842        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6843            row.get(0)
6844        })
6845        .optional()?;
6846    let ready: Option<String> = conn
6847        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6848        .optional()?;
6849
6850    let expected_schema = SCHEMA_VERSION.to_string();
6851    let expected_fingerprint = schema_fingerprint();
6852    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6853        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6854        && ready.as_deref() == Some("1"))
6855}
6856
6857fn ensure_database_ready(conn: &Connection) -> Result<()> {
6858    if database_ready(conn)? {
6859        Ok(())
6860    } else {
6861        Err(CallGraphStoreError::Unavailable(
6862            "database is missing, stale, or mid-build".to_string(),
6863        ))
6864    }
6865}
6866
6867fn schema_fingerprint() -> String {
6868    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6869    // edge sources, broader call extraction) even if the table SHAPE is
6870    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6871    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6872    let input =
6873        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6874    hash_to_hex(blake3::hash(input.as_bytes()))
6875}
6876
6877fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6878    tx.execute_batch(
6879        "DELETE FROM staging_ref_context;
6880         DELETE FROM edges;
6881         DELETE FROM file_dependencies;
6882         DELETE FROM refs;
6883         DELETE FROM dispatch_hints;
6884         DELETE FROM type_ref_names;
6885         DELETE FROM backend_file_state;
6886         DELETE FROM nodes;
6887         DELETE FROM files;",
6888    )?;
6889    Ok(())
6890}
6891
6892fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
6893    conn.query_row(
6894        "SELECT v FROM meta WHERE k = ?1",
6895        params![STAGED_BUILD_PHASE],
6896        |row| row.get(0),
6897    )
6898    .optional()
6899    .map_err(Into::into)
6900}
6901
6902fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
6903    let value = staged_string(conn, key)?;
6904    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
6905}
6906
6907fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
6908    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
6909        row.get::<_, String>(0)
6910    })
6911    .optional()
6912    .map_err(Into::into)
6913}
6914
6915fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
6916    tx.execute(
6917        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6918        params![STAGED_BUILD_PHASE, phase],
6919    )?;
6920    Ok(())
6921}
6922
6923fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
6924    set_staged_string(tx, key, &value.to_string())
6925}
6926
6927fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
6928    tx.execute(
6929        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6930        params![key, value],
6931    )?;
6932    Ok(())
6933}
6934
6935/// The extract rows and this counter update share a SQLite transaction. This is
6936/// intentionally not inferred from file/page growth: rollback removes both the
6937/// rows and the claimed credit, while page reuse cannot fabricate credit.
6938fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
6939    tx.execute(
6940        "INSERT INTO meta(k, v) VALUES(?1, ?2)
6941         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
6942        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
6943    )?;
6944    Ok(())
6945}
6946
6947fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
6948    let Ok(source) = std::fs::read_to_string(path) else {
6949        return Ok(false);
6950    };
6951    let Ok(freshness) = collect_source_freshness(path, &source) else {
6952        return Ok(false);
6953    };
6954    let rel_path = relative_path(project_root, path);
6955    let staged_hash = conn
6956        .query_row(
6957            "SELECT content_hash FROM files WHERE path = ?1",
6958            params![rel_path],
6959            |row| row.get::<_, String>(0),
6960        )
6961        .optional()?;
6962    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
6963}
6964
6965fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6966    tx.execute(
6967        "DELETE FROM staging_ref_context
6968         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
6969        params![rel_path],
6970    )?;
6971    delete_file_rows(tx, rel_path)
6972}
6973
6974fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
6975    loop {
6976        let removed = {
6977            let mut statement = conn.prepare(
6978                "SELECT path
6979                 FROM files
6980                 WHERE NOT EXISTS (
6981                     SELECT 1 FROM staging_file_inventory inventory
6982                     WHERE inventory.path = files.path
6983                 )
6984                 ORDER BY path
6985                 LIMIT ?1",
6986            )?;
6987            let paths = statement
6988                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
6989                    row.get::<_, String>(0)
6990                })?
6991                .collect::<std::result::Result<Vec<_>, _>>()?;
6992            paths
6993        };
6994        if removed.is_empty() {
6995            return Ok(());
6996        }
6997        let tx = conn.transaction()?;
6998        for path in removed {
6999            delete_staged_file_rows(&tx, &path)?;
7000        }
7001        tx.commit()?;
7002    }
7003}
7004
7005struct StagedFileBatch {
7006    paths: Vec<PathBuf>,
7007    last_path: String,
7008}
7009
7010fn load_staged_file_batch(
7011    conn: &Connection,
7012    project_root: &Path,
7013    after_path: &str,
7014    max_files: usize,
7015    max_bytes: u64,
7016) -> Result<Option<StagedFileBatch>> {
7017    let mut statement = conn.prepare(
7018        "SELECT path, size
7019         FROM staging_file_inventory
7020         WHERE path > ?1
7021         ORDER BY path
7022         LIMIT ?2",
7023    )?;
7024    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7025    let mut paths = Vec::with_capacity(max_files.max(1));
7026    let mut last_path = String::new();
7027    let mut batch_bytes = 0u64;
7028    while let Some(row) = rows.next()? {
7029        let rel_path = row.get::<_, String>(0)?;
7030        let size = row.get::<_, i64>(1)?.max(0) as u64;
7031        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7032            break;
7033        }
7034        batch_bytes = batch_bytes.saturating_add(size);
7035        last_path.clone_from(&rel_path);
7036        paths.push(project_root.join(rel_path));
7037    }
7038    if paths.is_empty() {
7039        Ok(None)
7040    } else {
7041        Ok(Some(StagedFileBatch { paths, last_path }))
7042    }
7043}
7044
7045fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7046    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7047    let mut rows = statement.query([])?;
7048    let mut fingerprint = CorpusFingerprint::default();
7049    while let Some(row) = rows.next()? {
7050        let rel_path = row.get::<_, String>(0)?;
7051        fingerprint.add_path(project_root, &project_root.join(rel_path));
7052    }
7053    Ok(fingerprint.finish(project_root))
7054}
7055
7056fn load_staged_ref_window(
7057    conn: &Connection,
7058    after_rowid: u64,
7059    limit: usize,
7060) -> Result<Vec<StagedRef>> {
7061    let mut statement = conn.prepare(
7062        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7063                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7064                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7065                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7066         FROM refs
7067         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7068         WHERE refs.status = 'staged' AND refs.rowid > ?1
7069         ORDER BY refs.rowid
7070         LIMIT ?2",
7071    )?;
7072    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7073        Ok(StagedRef {
7074            rowid: row.get::<_, i64>(0)? as u64,
7075            raw: RawRef {
7076                ref_id: row.get(1)?,
7077                caller_node: row.get(2)?,
7078                caller_file: row.get(3)?,
7079                kind: row.get(4)?,
7080                short_name: row.get(5)?,
7081                full_ref: row.get(6)?,
7082                module_path: row.get(7)?,
7083                import_kind: row.get(8)?,
7084                local_name: row.get(9)?,
7085                requested_name: row.get(10)?,
7086                namespace_alias: row.get(11)?,
7087                wildcard: row.get::<_, i64>(12)? != 0,
7088                line: row.get::<_, i64>(13)? as u32,
7089                byte_start: row.get::<_, i64>(14)? as usize,
7090                byte_end: row.get::<_, i64>(15)? as usize,
7091                caller_symbol: row.get(16)?,
7092                dependencies: BTreeSet::new(),
7093            },
7094        })
7095    })?;
7096    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7097    drop(statement);
7098
7099    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7100    let mut dependency_statement = conn
7101        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7102    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7103        if !dependencies.contains_key(&raw.caller_file) {
7104            let rows =
7105                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7106            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7107            dependencies.insert(raw.caller_file.clone(), values);
7108        }
7109        raw.dependencies = dependencies
7110            .get(&raw.caller_file)
7111            .cloned()
7112            .unwrap_or_default();
7113    }
7114    Ok(refs)
7115}
7116
7117fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7118    ResolvedRef {
7119        dependencies: raw.dependencies.clone(),
7120        raw,
7121        status: "unresolved".to_string(),
7122        target_node: None,
7123        target_file: None,
7124        target_symbol: None,
7125        edge: None,
7126    }
7127}
7128
7129fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7130    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7131        .map(|count| count.max(0) as u64)
7132        .map_err(Into::into)
7133}
7134
7135fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7136    let mut statement = conn.prepare(
7137        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7138    )?;
7139    let rows = statement.query_map([], |row| row.get(0))?;
7140    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7141}
7142
7143fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7144    tx.execute_batch(
7145        "DROP INDEX IF EXISTS idx_nodes_file;
7146         DROP INDEX IF EXISTS idx_nodes_name;
7147         DROP INDEX IF EXISTS idx_nodes_scoped;
7148         DROP INDEX IF EXISTS idx_refs_short_name;
7149         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7150         DROP INDEX IF EXISTS idx_refs_caller_file;
7151         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7152         DROP INDEX IF EXISTS idx_refs_target_file;
7153         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7154         DROP INDEX IF EXISTS idx_edges_source_kind;
7155         DROP INDEX IF EXISTS idx_edges_target_kind;
7156         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7157         DROP INDEX IF EXISTS idx_edges_ref_id;
7158         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7159         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7160         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7161    )?;
7162    Ok(())
7163}
7164
7165fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7166    tx.execute_batch(
7167        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7168         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7169         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7170         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7171         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7172         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7173         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7174         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7175         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7176         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7177         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7178         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7179         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7180         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7181         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7182         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7183    )?;
7184    Ok(())
7185}
7186
7187const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7188    ("files", "path"),
7189    ("nodes", "file_path"),
7190    ("refs", "caller_file"),
7191    ("refs", "target_file"),
7192    ("file_dependencies", "file_path"),
7193    ("file_dependencies", "dep_file"),
7194    ("edges", "target_file"),
7195    ("dispatch_hints", "file"),
7196    ("backend_file_state", "file_path"),
7197];
7198
7199/// Reconcile `backend_file_state.workspace_root` when the opener's project root
7200/// differs from what is stored. The store key is the git-root commit hash, so
7201/// multiple live checkouts/clones share one on-disk generation.
7202///
7203/// Cheap in-place re-root is only safe when every previously stored root path is
7204/// gone from disk (true move/rename). If any stale root still exists, another
7205/// clone is still alive and rewriting metadata would ping-pong relative rows
7206/// between trees (possibly on different branches). We then return
7207/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
7208/// opener. That can make each clone rebuild on open when they alternate — bounded
7209/// by open frequency — but each rebuild is correct for its opener, unlike silent
7210/// cross-clone corruption.
7211fn reconcile_workspace_roots(
7212    conn: &mut Connection,
7213    project_root: &Path,
7214    allow_repair: bool,
7215) -> Result<OpenRootRepair> {
7216    let roots = stored_workspace_roots(conn)?;
7217    let current_root = project_root.display().to_string();
7218    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7219        return Ok(OpenRootRepair::None);
7220    }
7221
7222    if let Some(sample) = sample_absolute_data_path(conn)? {
7223        return Ok(OpenRootRepair::NeedsRebuild {
7224            previous_roots: roots,
7225            current_root,
7226            reason: format!("absolute store data path row {sample}"),
7227        });
7228    }
7229
7230    for stored_root in roots.iter() {
7231        if stored_root == &current_root {
7232            continue;
7233        }
7234        if Path::new(stored_root).exists() {
7235            let reason = format!(
7236                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7237            );
7238            return Ok(OpenRootRepair::NeedsRebuild {
7239                previous_roots: roots,
7240                current_root,
7241                reason,
7242            });
7243        }
7244    }
7245
7246    if !allow_repair {
7247        return Ok(OpenRootRepair::NeedsRebuild {
7248            previous_roots: roots,
7249            current_root,
7250            reason: "workspace root metadata requires deferred repair".to_string(),
7251        });
7252    }
7253
7254    publish_if_current(|| {
7255        let tx = conn.transaction()?;
7256        tx.execute(
7257            "UPDATE OR IGNORE backend_file_state
7258             SET workspace_root = ?1
7259             WHERE workspace_root <> ?1",
7260            params![&current_root],
7261        )?;
7262        tx.execute(
7263            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7264            params![&current_root],
7265        )?;
7266        tx.commit()?;
7267        Ok(())
7268    })?;
7269
7270    crate::slog_info!(
7271        "callgraph store re-rooted from {} to {}",
7272        roots.join(", "),
7273        current_root
7274    );
7275    Ok(OpenRootRepair::ReRooted)
7276}
7277
7278fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7279    let mut stmt = conn.prepare(
7280        "SELECT DISTINCT workspace_root
7281         FROM backend_file_state
7282         ORDER BY workspace_root",
7283    )?;
7284    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7285    rows.collect::<std::result::Result<Vec<_>, _>>()
7286        .map_err(Into::into)
7287}
7288
7289fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7290    for (table, column) in STORE_DATA_PATH_COLUMNS {
7291        let sql = format!(
7292            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7293        );
7294        let mut stmt = conn.prepare(&sql)?;
7295        let mut rows = stmt.query([])?;
7296        while let Some(row) = rows.next()? {
7297            let value: String = row.get(0)?;
7298            if stored_path_is_absolute(&value) {
7299                return Ok(Some(format!("{table}.{column}={value}")));
7300            }
7301        }
7302    }
7303    Ok(None)
7304}
7305
7306fn stored_path_is_absolute(value: &str) -> bool {
7307    if value.is_empty() {
7308        return false;
7309    }
7310    if Path::new(value).is_absolute() || value.starts_with('/') {
7311        return true;
7312    }
7313    let bytes = value.as_bytes();
7314    if bytes.len() >= 3
7315        && bytes[1] == b':'
7316        && (bytes[2] == b'/' || bytes[2] == b'\\')
7317        && bytes[0].is_ascii_alphabetic()
7318    {
7319        return true;
7320    }
7321    value.starts_with("\\\\") || value.starts_with("//")
7322}
7323
7324fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7325    if let OpenRootRepair::NeedsRebuild {
7326        previous_roots,
7327        current_root,
7328        reason,
7329    } = repair
7330    {
7331        crate::slog_info!(
7332            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7333            previous_roots.join(", "),
7334            current_root,
7335            reason
7336        );
7337    }
7338}
7339
7340/// Nanosecond clock used to make temp/generation file names unique.
7341fn now_nanos() -> u128 {
7342    SystemTime::now()
7343        .duration_since(UNIX_EPOCH)
7344        .unwrap_or(Duration::ZERO)
7345        .as_nanos()
7346}
7347
7348/// The pointer file `<dir>/<key>.current`. Its single line names the current
7349/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
7350/// can always be atomically replaced via rename even on Windows — Rust opens
7351/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
7352fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7353    callgraph_dir.join(format!("{project_key}.current"))
7354}
7355
7356/// The legacy single-file DB path used before the generation scheme. Still read
7357/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
7358/// build publishes a generation.
7359fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7360    callgraph_dir.join(format!("{project_key}.sqlite"))
7361}
7362
7363/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
7364/// cold build writes a brand-new generation file, so publishing NEVER replaces
7365/// a file another process holds open (the root Windows fix).
7366fn generation_file_name(project_key: &str) -> String {
7367    format!(
7368        "{project_key}.g{}.{}.sqlite",
7369        now_nanos(),
7370        std::process::id()
7371    )
7372}
7373
7374/// Read the pointer; returns the generation file name if present and non-empty.
7375fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7376    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7377    let name = text.trim();
7378    if name.is_empty() {
7379        None
7380    } else {
7381        Some(name.to_string())
7382    }
7383}
7384
7385/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
7386/// `ready` flag). Uses a throwaway read-only connection.
7387fn db_path_ready(path: &Path) -> bool {
7388    (|| -> Result<bool> {
7389        let conn = open_readonly_connection(path)?;
7390        database_ready(&conn)
7391    })()
7392    .unwrap_or(false)
7393}
7394
7395/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
7396/// where `generation` is `Some(name)` for a pointer-published generation or
7397/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
7398/// published (caller treats that as "needs cold build").
7399///
7400/// Handles the GC race (the pointer names a generation that was just deleted) by
7401/// re-reading the pointer and retrying a few times.
7402fn resolve_ready_target(
7403    callgraph_dir: &Path,
7404    project_key: &str,
7405) -> Option<(PathBuf, Option<String>)> {
7406    for _ in 0..5 {
7407        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7408            let gen_path = callgraph_dir.join(&generation);
7409            if gen_path.is_file() {
7410                return (migration_manifest_valid(callgraph_dir, &generation)
7411                    && db_path_ready(&gen_path))
7412                .then_some((gen_path, Some(generation)));
7413            }
7414            // Pointer names a missing generation (a GC/publish race): re-read the
7415            // pointer and retry rather than failing the reader.
7416            std::thread::sleep(Duration::from_millis(5));
7417            continue;
7418        }
7419        // No pointer: fall back to the legacy single-file DB if it is ready.
7420        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7421        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7422    }
7423    None
7424}
7425
7426/// Atomically publish `generation` as the current store by flipping the pointer
7427/// file. Writes a temp file, fsyncs, then renames over the pointer — never
7428/// replacing an open DB file, so it succeeds cross-platform.
7429fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7430    let pointer = pointer_path(callgraph_dir, project_key);
7431    let tmp = callgraph_dir.join(format!(
7432        "{project_key}.current.tmp.{}.{}",
7433        std::process::id(),
7434        now_nanos()
7435    ));
7436    {
7437        use std::io::Write as _;
7438        let mut file = std::fs::File::create(&tmp)?;
7439        file.write_all(generation.as_bytes())?;
7440        file.write_all(b"\n")?;
7441        file.sync_all()?;
7442    }
7443    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7444        let _ = std::fs::remove_file(&tmp);
7445        return Err(error.into());
7446    }
7447    crate::fs_lock::sync_parent(&pointer);
7448    Ok(())
7449}
7450
7451#[derive(Clone, Debug)]
7452struct GenerationGcCandidate {
7453    name: String,
7454    path: PathBuf,
7455    modified: SystemTime,
7456}
7457
7458/// Best-effort GC of superseded generation files. The current pointer target and
7459/// newest previous generation are always retained. Older generations are removed
7460/// when they have no protected read marker, or after the absolute retention TTL
7461/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
7462/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
7463fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7464    let temp_grace = Duration::from_secs(60);
7465    let now = SystemTime::now();
7466    let pointer_current =
7467        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7468    let gen_prefix = format!("{project_key}.g");
7469    let tmp_prefixes = [
7470        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
7471        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
7472        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
7473    ];
7474    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7475        return;
7476    };
7477    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7478    for entry in entries.flatten() {
7479        let name = entry.file_name();
7480        let name = name.to_string_lossy().to_string();
7481        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7482        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7483
7484        // Orphaned temp files from a crashed build/publish: remove once aged out.
7485        if name.contains(".tmp.") {
7486            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7487                let _ = std::fs::remove_file(entry.path());
7488            }
7489            continue;
7490        }
7491
7492        // Superseded legacy single-file DB: best-effort delete once a generation
7493        // is published (ignored if another process still holds it open).
7494        if name == format!("{project_key}.sqlite") {
7495            remove_sqlite_file_set(&entry.path());
7496            continue;
7497        }
7498
7499        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7500            gens.push(GenerationGcCandidate {
7501                name,
7502                path: entry.path(),
7503                modified: mtime,
7504            });
7505        }
7506    }
7507
7508    let mut superseded = gens
7509        .iter()
7510        .filter(|generation| generation.name != pointer_current)
7511        .collect::<Vec<_>>();
7512    superseded.sort_by(|left, right| {
7513        right
7514            .modified
7515            .cmp(&left.modified)
7516            .then_with(|| right.name.cmp(&left.name))
7517    });
7518    let previous = superseded.first().map(|generation| generation.name.clone());
7519
7520    for generation in gens {
7521        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7522        if generation.name == pointer_current
7523            || Some(generation.name.as_str()) == previous.as_deref()
7524        {
7525            continue;
7526        }
7527
7528        let age = now
7529            .duration_since(generation.modified)
7530            .unwrap_or(Duration::ZERO);
7531        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7532            continue;
7533        }
7534
7535        remove_sqlite_file_set(&generation.path);
7536        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7537        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7538            callgraph_dir,
7539            &generation.name,
7540        ));
7541    }
7542}
7543
7544fn remove_sqlite_file_set(path: &Path) {
7545    let _ = std::fs::remove_file(path);
7546    remove_sqlite_sidecars(path);
7547}
7548
7549fn remove_sqlite_sidecars(path: &Path) {
7550    let path_text = path.to_string_lossy();
7551    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7552    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7553    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7554}
7555
7556/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
7557///
7558/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
7559/// place on success; a build that dies (process kill, crash, host restart) leaves
7560/// the temporary behind. The largest observed cold build finishes well under a
7561/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
7562/// never rename. A live build's temporary is minutes old at most.
7563///
7564/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
7565/// liveness check read false-positive on exactly the oldest files — the ones most
7566/// worth deleting: in production an orphan's embedded pid had been recycled to an
7567/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
7568/// cannot lie that way, so it is the honest orphan predicate.
7569const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7570
7571/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
7572/// same cadence as [`gc_old_generations`] (after a generation is published) but,
7573/// unlike it, is not scoped to the building root: it covers every directory in the
7574/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
7575///
7576/// That last case is the production hole this fixes. The per-root cleanup in
7577/// [`gc_old_generations`] only fires when a root actually builds, so when activity
7578/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
7579/// store's orphans become permanent — gigabytes accumulated in a legacy store
7580/// whose roots no longer built there, while the active store stayed clean. A
7581/// sibling root that still builds triggers this pass and cleans both layouts.
7582fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7583    sweep_orphaned_build_temps(callgraph_dir);
7584    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7585        return;
7586    };
7587    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7588
7589    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
7590    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
7591        for entry in entries.flatten() {
7592            if entry.path().is_dir() {
7593                sweep_orphaned_build_temps(&entry.path());
7594            }
7595        }
7596    }
7597
7598    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
7599    if let Ok(entries) = std::fs::read_dir(&storage_root) {
7600        for entry in entries.flatten() {
7601            let legacy_dir = entry.path().join(domain);
7602            if legacy_dir.is_dir() {
7603                sweep_orphaned_build_temps(&legacy_dir);
7604            }
7605        }
7606    }
7607}
7608
7609/// Sweep one callgraph directory, removing build temporaries older than
7610/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
7611fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7612    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7613}
7614
7615/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
7616/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
7617fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7618    let now = SystemTime::now();
7619    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7620        return;
7621    };
7622    let mut removed_any = false;
7623    for entry in entries.flatten() {
7624        let name = entry.file_name().to_string_lossy().to_string();
7625        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
7626        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
7627        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
7628        // without that substring — a completed `.sqlite` generation, a pointer, a
7629        // read-marker dir — is left alone: those belong to generation GC.
7630        if !name.contains(".sqlite.tmp.") {
7631            continue;
7632        }
7633        let mtime = entry
7634            .metadata()
7635            .and_then(|meta| meta.modified())
7636            .unwrap_or(now);
7637        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7638            continue;
7639        }
7640        // Deletion races a concurrent build finishing: that build renames the temp
7641        // into place, so the file is gone by the time we unlink. The 24h age makes
7642        // this overlap practically impossible, but treat a missing file as success
7643        // (the rename won) rather than an error, and never touch a path that does
7644        // not match the temporary shape above.
7645        match std::fs::remove_file(entry.path()) {
7646            Ok(()) => removed_any = true,
7647            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7648            Err(_) => {}
7649        }
7650    }
7651    if removed_any {
7652        crate::fs_lock::sync_parent(callgraph_dir);
7653    }
7654}
7655
7656/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
7657/// the global all-cores rayon pool. The store cold-build is the heaviest
7658/// background pass (parse-dominated) and runs on a separate thread off the
7659/// single-threaded request loop; left unbounded it monopolizes every core and
7660/// starves the bridge so interactive tools time out (the same starvation the
7661/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
7662/// match the main thread, since the extract walks tree-sitter ASTs.
7663fn build_pool_size() -> usize {
7664    std::thread::available_parallelism()
7665        .map(|parallelism| parallelism.get())
7666        .unwrap_or(1)
7667        .div_ceil(2)
7668        .clamp(1, 8)
7669}
7670
7671fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7672    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7673        Ok(extract) => Ok(extract),
7674        Err(error) => {
7675            let abs_path =
7676                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7677            let rel_path = relative_path(project_root, &abs_path);
7678            let freshness = cache_freshness::collect(&abs_path).ok();
7679            log::debug!(
7680                "callgraph store: skipping {} during cold build: {}",
7681                abs_path.display(),
7682                error
7683            );
7684            Err(ExtractFailure {
7685                rel_path,
7686                freshness,
7687            })
7688        }
7689    };
7690
7691    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7692        files.par_iter().map(extract_one).collect()
7693    };
7694
7695    // Run inside a dedicated bounded pool when one builds; fall back to the
7696    // global pool only if the bounded pool can't be constructed.
7697    let results = match rayon::ThreadPoolBuilder::new()
7698        .num_threads(build_pool_size())
7699        .thread_name(|index| format!("aft-callgraph-build-{index}"))
7700        .stack_size(8 * 1024 * 1024)
7701        .build()
7702    {
7703        Ok(pool) => pool.install(run),
7704        Err(error) => {
7705            log::warn!(
7706                "callgraph store: bounded build pool unavailable ({error}); using global pool"
7707            );
7708            run()
7709        }
7710    };
7711
7712    let mut extracts = Vec::new();
7713    let mut failures = Vec::new();
7714    for result in results {
7715        match result {
7716            Ok(extract) => extracts.push(extract),
7717            Err(failure) => failures.push(failure),
7718        }
7719    }
7720    BuildExtractsResult { extracts, failures }
7721}
7722
7723fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7724    let metadata = std::fs::metadata(path)?;
7725    let size = metadata.len();
7726    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7727        cache_freshness::zero_hash()
7728    } else if source.len() as u64 == size {
7729        cache_freshness::hash_bytes(source.as_bytes())
7730    } else {
7731        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7732    };
7733    Ok(FileFreshness {
7734        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7735        size,
7736        content_hash,
7737    })
7738}
7739
7740fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7741    let abs_path = normalize_file_path(project_root, path)?;
7742    let rel_path = relative_path(project_root, &abs_path);
7743    let source = std::fs::read_to_string(&abs_path)?;
7744    let freshness = collect_source_freshness(&abs_path, &source)?;
7745    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7746    let lang = data.lang;
7747    if lang == LangId::Rust {
7748        extend_rust_imports_with_nested_uses(&source, &mut data);
7749    }
7750    let mut nodes = build_node_records(&rel_path, &source, &data)?;
7751    let node_by_scoped: HashMap<String, String> = nodes
7752        .iter()
7753        .map(|node| (node.scoped_name.clone(), node.id.clone()))
7754        .collect();
7755    let import_dependencies =
7756        import_dependencies(project_root, &abs_path, &data.import_block.imports);
7757    let line_index = LineIndex::new(&source);
7758    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7759    let rust_reexports = if lang == LangId::Rust {
7760        collect_rust_pub_use_reexport_refs(
7761            project_root,
7762            &abs_path,
7763            &rel_path,
7764            &data.import_block.imports,
7765            &line_index,
7766        )
7767    } else {
7768        ReexportRefs {
7769            raw_refs: Vec::new(),
7770            surface_parts: Vec::new(),
7771        }
7772    };
7773    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7774    let mut raw_refs = Vec::new();
7775    raw_refs.extend(build_call_refs(
7776        &rel_path,
7777        &data,
7778        &node_by_scoped,
7779        &import_dependencies,
7780    ));
7781    raw_refs.extend(build_value_ref_refs(
7782        &rel_path,
7783        &data,
7784        &node_by_scoped,
7785        &import_dependencies,
7786    ));
7787    raw_refs.extend(build_import_refs(
7788        project_root,
7789        &abs_path,
7790        &rel_path,
7791        &data.import_block.imports,
7792        &line_index,
7793    ));
7794    let mut surface_parts = reexports.surface_parts;
7795    surface_parts.extend(rust_reexports.surface_parts);
7796    surface_parts.extend(source_less_exports.surface_parts);
7797    raw_refs.extend(reexports.raw_refs);
7798    raw_refs.extend(rust_reexports.raw_refs);
7799    raw_refs.extend(source_less_exports.raw_refs);
7800    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7801    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7802
7803    Ok(FileExtract {
7804        rel_path,
7805        freshness,
7806        lang,
7807        data,
7808        nodes,
7809        raw_refs,
7810        dispatch_hints,
7811        surface_fingerprint,
7812    })
7813}
7814
7815fn build_node_records(
7816    rel_path: &str,
7817    source: &str,
7818    data: &FileCallData,
7819) -> Result<Vec<NodeRecord>> {
7820    let mut records = Vec::new();
7821    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7822    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7823    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7824
7825    for (scoped_name, meta) in metadata {
7826        let name = unqualified_name(scoped_name).to_string();
7827        let range = selection_range(source, scoped_name, &name, &meta.range);
7828        let range_key = (
7829            range.start_line,
7830            range.start_col,
7831            range.end_line,
7832            range.end_col,
7833        );
7834        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
7835        let range_ordinal = *ordinal;
7836        *ordinal += 1;
7837        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
7838        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
7839        let is_default_export = data
7840            .default_export_symbol
7841            .as_deref()
7842            .map(|default| default == scoped_name || default == name)
7843            .unwrap_or(false);
7844        records.push(NodeRecord {
7845            id,
7846            file_path: rel_path.to_string(),
7847            name: name.clone(),
7848            scoped_name: scoped_name.clone(),
7849            kind: symbol_kind_label(&meta.kind).to_string(),
7850            range,
7851            range_ordinal,
7852            signature: meta.signature.clone(),
7853            exported,
7854            is_default_export,
7855            is_type_like: is_type_like(&meta.kind),
7856            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
7857                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
7858        });
7859    }
7860
7861    Ok(records)
7862}
7863
7864fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
7865    if scoped_name == TOP_LEVEL_SYMBOL {
7866        return Range {
7867            start_line: 0,
7868            start_col: 0,
7869            end_line: 0,
7870            end_col: 0,
7871        };
7872    }
7873    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
7874        return fallback.clone();
7875    };
7876    let start_col = fallback.start_col as usize;
7877    let search_start = start_col.min(line.len());
7878    if let Some(offset) = line[search_start..].find(name) {
7879        let col = search_start + offset;
7880        return Range {
7881            start_line: fallback.start_line,
7882            start_col: col as u32,
7883            end_line: fallback.start_line,
7884            end_col: (col + name.len()) as u32,
7885        };
7886    }
7887    if let Some(offset) = line.find(name) {
7888        return Range {
7889            start_line: fallback.start_line,
7890            start_col: offset as u32,
7891            end_line: fallback.start_line,
7892            end_col: (offset + name.len()) as u32,
7893        };
7894    }
7895    Range {
7896        start_line: fallback.start_line,
7897        start_col: fallback.start_col,
7898        end_line: fallback.start_line,
7899        end_col: fallback.start_col.saturating_add(name.len() as u32),
7900    }
7901}
7902
7903fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
7904    if scoped_name == TOP_LEVEL_SYMBOL {
7905        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7906    }
7907    let input = format!(
7908        "{rel_path}:{}:{}:{}:{}:{ordinal}",
7909        range.start_line, range.start_col, range.end_line, range.end_col
7910    );
7911    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7912}
7913
7914fn build_call_refs(
7915    rel_path: &str,
7916    data: &FileCallData,
7917    node_by_scoped: &HashMap<String, String>,
7918    import_dependencies: &BTreeSet<String>,
7919) -> Vec<RawRef> {
7920    build_callable_refs(
7921        rel_path,
7922        &data.calls_by_symbol,
7923        node_by_scoped,
7924        import_dependencies,
7925        "call",
7926    )
7927}
7928
7929fn build_value_ref_refs(
7930    rel_path: &str,
7931    data: &FileCallData,
7932    node_by_scoped: &HashMap<String, String>,
7933    import_dependencies: &BTreeSet<String>,
7934) -> Vec<RawRef> {
7935    build_callable_refs(
7936        rel_path,
7937        &data.value_refs_by_symbol,
7938        node_by_scoped,
7939        import_dependencies,
7940        "value_ref",
7941    )
7942}
7943
7944fn build_callable_refs(
7945    rel_path: &str,
7946    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7947    node_by_scoped: &HashMap<String, String>,
7948    import_dependencies: &BTreeSet<String>,
7949    kind: &str,
7950) -> Vec<RawRef> {
7951    let mut refs = Vec::new();
7952    let mut ordinal = 0usize;
7953    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7954    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7955    for (caller_symbol, call_sites) in symbols {
7956        let caller_node = node_by_scoped.get(caller_symbol).cloned();
7957        for call_site in call_sites {
7958            ordinal += 1;
7959            let ref_id = ref_id(&[
7960                rel_path,
7961                kind,
7962                caller_symbol,
7963                &call_site.line.to_string(),
7964                &call_site.byte_start.to_string(),
7965                &call_site.byte_end.to_string(),
7966                &call_site.full_callee,
7967                &ordinal.to_string(),
7968            ]);
7969            refs.push(RawRef {
7970                ref_id,
7971                caller_node: caller_node.clone(),
7972                caller_symbol: Some(caller_symbol.clone()),
7973                caller_file: rel_path.to_string(),
7974                kind: kind.to_string(),
7975                short_name: Some(call_site.callee_name.clone()),
7976                full_ref: Some(call_site.full_callee.clone()),
7977                module_path: None,
7978                import_kind: None,
7979                local_name: Some(call_site.callee_name.clone()),
7980                requested_name: Some(call_site.callee_name.clone()),
7981                namespace_alias: namespace_alias(&call_site.full_callee),
7982                wildcard: false,
7983                line: call_site.line,
7984                byte_start: call_site.byte_start,
7985                byte_end: call_site.byte_end,
7986                dependencies: import_dependencies.clone(),
7987            });
7988        }
7989    }
7990    refs
7991}
7992
7993fn build_import_refs(
7994    project_root: &Path,
7995    abs_path: &Path,
7996    rel_path: &str,
7997    imports: &[ImportStatement],
7998    line_index: &LineIndex,
7999) -> Vec<RawRef> {
8000    let mut refs = Vec::new();
8001    for (index, import) in imports.iter().enumerate() {
8002        let import_kind = import_kind_label(import.kind).to_string();
8003        let local_name = import_local_names(import).join(",");
8004        let requested_name = import_requested_names(import).join(",");
8005        let ref_id = ref_id(&[
8006            rel_path,
8007            "import",
8008            &import.byte_range.start.to_string(),
8009            &import.byte_range.end.to_string(),
8010            &import.module_path,
8011            &index.to_string(),
8012        ]);
8013        refs.push(RawRef {
8014            ref_id,
8015            caller_node: None,
8016            caller_symbol: None,
8017            caller_file: rel_path.to_string(),
8018            kind: "import".to_string(),
8019            short_name: None,
8020            full_ref: Some(import.raw_text.clone()),
8021            module_path: Some(import.module_path.clone()),
8022            import_kind: Some(import_kind),
8023            local_name: empty_to_none(local_name),
8024            requested_name: empty_to_none(requested_name),
8025            namespace_alias: import.namespace_import.clone(),
8026            wildcard: import_is_wildcard(import),
8027            line: line_index.byte_to_line(import.byte_range.start),
8028            byte_start: import.byte_range.start,
8029            byte_end: import.byte_range.end,
8030            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8031        });
8032    }
8033    refs
8034}
8035
8036fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8037    let grammar = grammar_for(LangId::Rust);
8038    let mut parser = Parser::new();
8039    if parser.set_language(&grammar).is_err() {
8040        return;
8041    }
8042    let Some(tree) = parser.parse(source, None) else {
8043        return;
8044    };
8045
8046    let mut seen = data
8047        .import_block
8048        .imports
8049        .iter()
8050        .map(|import| (import.byte_range.start, import.byte_range.end))
8051        .collect::<HashSet<_>>();
8052    let mut nested_imports = Vec::new();
8053    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8054    if nested_imports.is_empty() {
8055        return;
8056    }
8057
8058    data.import_block.imports.extend(nested_imports);
8059    data.import_block
8060        .imports
8061        .sort_by_key(|import| import.byte_range.start);
8062    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8063}
8064
8065fn collect_rust_use_imports(
8066    source: &str,
8067    node: Node<'_>,
8068    seen: &mut HashSet<(usize, usize)>,
8069    imports: &mut Vec<ImportStatement>,
8070) {
8071    if node.kind() == "use_declaration" {
8072        let range = node.byte_range();
8073        if seen.insert((range.start, range.end)) {
8074            if let Some(import) = rust_import_from_use_node(source, node) {
8075                imports.push(import);
8076            }
8077        }
8078    }
8079
8080    let mut cursor = node.walk();
8081    if !cursor.goto_first_child() {
8082        return;
8083    }
8084    loop {
8085        collect_rust_use_imports(source, cursor.node(), seen, imports);
8086        if !cursor.goto_next_sibling() {
8087            break;
8088        }
8089    }
8090}
8091
8092fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8093    let raw_text = source[node.byte_range()].to_string();
8094    let body = rust_use_body(&raw_text)?.to_string();
8095    let visibility = rust_use_visibility(&raw_text);
8096    let names = rust_use_list_names(&body);
8097    let group = classify_rust_import_group(&body);
8098    let byte_range = node.byte_range();
8099
8100    Some(ImportStatement {
8101        module_path: body,
8102        names: names.clone(),
8103        default_import: visibility.clone(),
8104        namespace_import: None,
8105        kind: ImportKind::Value,
8106        group,
8107        byte_range,
8108        raw_text,
8109        form: ImportForm::RustUse {
8110            visibility,
8111            named: names,
8112        },
8113    })
8114}
8115
8116fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8117    let start = imports.iter().map(|import| import.byte_range.start).min()?;
8118    let end = imports.iter().map(|import| import.byte_range.end).max()?;
8119    Some(start..end)
8120}
8121
8122fn rust_use_visibility(raw_text: &str) -> Option<String> {
8123    let use_pos = raw_text.find("use ")?;
8124    let prefix = raw_text[..use_pos].trim();
8125    if prefix.is_empty() {
8126        None
8127    } else {
8128        Some(prefix.to_string())
8129    }
8130}
8131
8132fn rust_use_body(raw_text: &str) -> Option<&str> {
8133    let use_pos = raw_text.find("use ")?;
8134    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8135}
8136
8137fn rust_use_list_names(body: &str) -> Vec<String> {
8138    let Some(open) = body.find("::{") else {
8139        return Vec::new();
8140    };
8141    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8142        return Vec::new();
8143    };
8144    body[open + 3..close]
8145        .split(',')
8146        .filter_map(|spec| {
8147            let spec = spec.trim();
8148            if spec.is_empty() {
8149                None
8150            } else {
8151                Some(spec.to_string())
8152            }
8153        })
8154        .collect()
8155}
8156
8157fn classify_rust_import_group(body: &str) -> ImportGroup {
8158    let first = body
8159        .split("::")
8160        .next()
8161        .unwrap_or(body)
8162        .split_whitespace()
8163        .next()
8164        .unwrap_or(body);
8165    match first.trim() {
8166        "std" | "core" | "alloc" => ImportGroup::Stdlib,
8167        "crate" | "self" | "super" => ImportGroup::Internal,
8168        _ => ImportGroup::External,
8169    }
8170}
8171
8172#[derive(Debug, Clone)]
8173struct ReexportRefs {
8174    raw_refs: Vec<RawRef>,
8175    surface_parts: Vec<String>,
8176}
8177
8178fn collect_reexport_refs(
8179    project_root: &Path,
8180    abs_path: &Path,
8181    rel_path: &str,
8182    source: &str,
8183) -> ReexportRefs {
8184    let mut raw_refs = Vec::new();
8185    let mut surface_parts = Vec::new();
8186    let mut search_start = 0usize;
8187    let mut ordinal = 0usize;
8188    while let Some(export_offset) = source[search_start..].find("export") {
8189        let start = search_start + export_offset;
8190        let Some(statement_end_offset) = source[start..].find(';') else {
8191            break;
8192        };
8193        let end = start + statement_end_offset + 1;
8194        let statement = &source[start..end];
8195        search_start = end;
8196        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8197            continue;
8198        }
8199        let Some(module_path) = quoted_module_path(statement) else {
8200            continue;
8201        };
8202        ordinal += 1;
8203        let wildcard = statement.contains('*');
8204        let line = source[..start]
8205            .bytes()
8206            .filter(|byte| *byte == b'\n')
8207            .count() as u32
8208            + 1;
8209        let ref_id = ref_id(&[
8210            rel_path,
8211            "reexport",
8212            &start.to_string(),
8213            &end.to_string(),
8214            &module_path,
8215            &ordinal.to_string(),
8216        ]);
8217        surface_parts.push(format!("reexport\t{statement}"));
8218        raw_refs.push(RawRef {
8219            ref_id,
8220            caller_node: None,
8221            caller_symbol: None,
8222            caller_file: rel_path.to_string(),
8223            kind: "reexport".to_string(),
8224            short_name: None,
8225            full_ref: Some(statement.to_string()),
8226            module_path: Some(module_path.clone()),
8227            import_kind: Some("reexport".to_string()),
8228            local_name: None,
8229            requested_name: None,
8230            namespace_alias: None,
8231            wildcard,
8232            line,
8233            byte_start: start,
8234            byte_end: end,
8235            dependencies: module_dependencies(project_root, abs_path, &module_path),
8236        });
8237    }
8238    ReexportRefs {
8239        raw_refs,
8240        surface_parts,
8241    }
8242}
8243
8244fn collect_rust_pub_use_reexport_refs(
8245    project_root: &Path,
8246    abs_path: &Path,
8247    rel_path: &str,
8248    imports: &[ImportStatement],
8249    line_index: &LineIndex,
8250) -> ReexportRefs {
8251    let mut raw_refs = Vec::new();
8252    let mut surface_parts = Vec::new();
8253    let mut ordinal = 0usize;
8254
8255    for import in imports {
8256        let Some(visibility) = &import.default_import else {
8257            continue;
8258        };
8259        if !visibility.starts_with("pub") {
8260            continue;
8261        }
8262        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8263            continue;
8264        };
8265        ordinal += 1;
8266        let ref_id = ref_id(&[
8267            rel_path,
8268            "rust_reexport",
8269            &import.byte_range.start.to_string(),
8270            &import.byte_range.end.to_string(),
8271            &module_path,
8272            &ordinal.to_string(),
8273        ]);
8274        surface_parts.push(format!("reexport\t{}", import.raw_text));
8275        raw_refs.push(RawRef {
8276            ref_id,
8277            caller_node: None,
8278            caller_symbol: None,
8279            caller_file: rel_path.to_string(),
8280            kind: "reexport".to_string(),
8281            short_name: None,
8282            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8283            module_path: Some(module_path.clone()),
8284            import_kind: Some("reexport".to_string()),
8285            local_name: None,
8286            requested_name: None,
8287            namespace_alias: None,
8288            wildcard,
8289            line: line_index.byte_to_line(import.byte_range.start),
8290            byte_start: import.byte_range.start,
8291            byte_end: import.byte_range.end,
8292            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8293        });
8294    }
8295
8296    ReexportRefs {
8297        raw_refs,
8298        surface_parts,
8299    }
8300}
8301
8302fn rust_pub_use_reexport_parts(
8303    import: &ImportStatement,
8304) -> Option<(String, HashMap<String, String>, bool)> {
8305    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8306    let body = body.trim();
8307    if let Some(module_path) = body.strip_suffix("::*") {
8308        return Some((module_path.trim().to_string(), HashMap::new(), true));
8309    }
8310
8311    if let Some(brace_start) = body.find("::{") {
8312        let module_path = body[..brace_start].trim().to_string();
8313        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8314        if names.is_empty() {
8315            return None;
8316        }
8317        return Some((module_path, names, false));
8318    }
8319
8320    let (module_path, spec) = body.rsplit_once("::")?;
8321    let names = rust_reexport_names_from_specs(spec);
8322    if names.is_empty() {
8323        return None;
8324    }
8325    Some((module_path.trim().to_string(), names, false))
8326}
8327
8328fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8329    let mut names = HashMap::new();
8330    for spec in specs.split(',') {
8331        let spec = spec.trim();
8332        if spec.is_empty() || spec == "self" {
8333            continue;
8334        }
8335        if let Some((source, local)) = spec.split_once(" as ") {
8336            let source = source.trim();
8337            let local = local.trim();
8338            if !source.is_empty() && !local.is_empty() && source != "self" {
8339                names.insert(local.to_string(), source.to_string());
8340            }
8341        } else {
8342            names.insert(spec.to_string(), spec.to_string());
8343        }
8344    }
8345    names
8346}
8347
8348fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8349    if named.is_empty() {
8350        return fallback.to_string();
8351    }
8352    let mut specs = named
8353        .iter()
8354        .map(|(local, source)| {
8355            if local == source {
8356                source.clone()
8357            } else {
8358                format!("{source} as {local}")
8359            }
8360        })
8361        .collect::<Vec<_>>();
8362    specs.sort();
8363    format!("pub use {{{}}};", specs.join(", "))
8364}
8365
8366fn quoted_module_path(statement: &str) -> Option<String> {
8367    let quote = match (statement.find('\''), statement.find('"')) {
8368        (Some(single), Some(double)) if single < double => '\'',
8369        (Some(_), Some(_)) => '"',
8370        (Some(_), None) => '\'',
8371        (None, Some(_)) => '"',
8372        (None, None) => return None,
8373    };
8374    let start = statement.find(quote)? + 1;
8375    let end = statement[start..].find(quote)? + start;
8376    Some(statement[start..end].to_string())
8377}
8378
8379#[derive(Debug, Clone)]
8380struct SourceLessExportRefs {
8381    raw_refs: Vec<RawRef>,
8382    surface_parts: Vec<String>,
8383}
8384
8385fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8386    let mut raw_refs = Vec::new();
8387    let mut surface_parts = Vec::new();
8388    let mut search_start = 0usize;
8389    let mut ordinal = 0usize;
8390    while let Some(export_offset) = source[search_start..].find("export") {
8391        let start = search_start + export_offset;
8392        let Some(statement_end_offset) = source[start..].find(';') else {
8393            break;
8394        };
8395        let end = start + statement_end_offset + 1;
8396        let statement = &source[start..end];
8397        search_start = end;
8398        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8399            continue;
8400        }
8401        let aliases = parse_reexport_names(statement);
8402        if aliases.is_empty() {
8403            continue;
8404        }
8405        let line = source[..start]
8406            .bytes()
8407            .filter(|byte| *byte == b'\n')
8408            .count() as u32
8409            + 1;
8410        for (exported, source_symbol) in aliases {
8411            ordinal += 1;
8412            let ref_id = ref_id(&[
8413                rel_path,
8414                "export_alias",
8415                &start.to_string(),
8416                &end.to_string(),
8417                &exported,
8418                &source_symbol,
8419                &ordinal.to_string(),
8420            ]);
8421            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8422            raw_refs.push(RawRef {
8423                ref_id,
8424                caller_node: None,
8425                caller_symbol: None,
8426                caller_file: rel_path.to_string(),
8427                kind: "export_alias".to_string(),
8428                short_name: None,
8429                full_ref: Some(statement.to_string()),
8430                module_path: None,
8431                import_kind: Some("export_alias".to_string()),
8432                local_name: Some(exported),
8433                requested_name: Some(source_symbol),
8434                namespace_alias: None,
8435                wildcard: false,
8436                line,
8437                byte_start: start,
8438                byte_end: end,
8439                dependencies: BTreeSet::new(),
8440            });
8441        }
8442    }
8443    SourceLessExportRefs {
8444        raw_refs,
8445        surface_parts,
8446    }
8447}
8448
8449fn build_dispatch_hints(
8450    rel_path: &str,
8451    data: &FileCallData,
8452    node_by_scoped: &HashMap<String, String>,
8453) -> Vec<DispatchHint> {
8454    let mut hints = Vec::new();
8455    let mut ordinal = 0usize;
8456    for (caller_symbol, call_sites) in &data.calls_by_symbol {
8457        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8458            continue;
8459        };
8460        for call_site in call_sites {
8461            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8462                continue;
8463            }
8464            ordinal += 1;
8465            hints.push(DispatchHint {
8466                id: ref_id(&[
8467                    rel_path,
8468                    "dispatch",
8469                    caller_symbol,
8470                    &call_site.line.to_string(),
8471                    &call_site.byte_start.to_string(),
8472                    &call_site.byte_end.to_string(),
8473                    &ordinal.to_string(),
8474                ]),
8475                method_name: call_site.callee_name.clone(),
8476                caller_node: caller_node.clone(),
8477                file: rel_path.to_string(),
8478                line: call_site.line,
8479                byte_start: call_site.byte_start,
8480                byte_end: call_site.byte_end,
8481            });
8482        }
8483    }
8484    hints
8485}
8486
8487fn surface_fingerprint(
8488    nodes: &mut [NodeRecord],
8489    data: &FileCallData,
8490    reexport_parts: &[String],
8491) -> String {
8492    nodes.sort_by(|left, right| {
8493        (left.file_path.as_str(), left.scoped_name.as_str())
8494            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8495    });
8496    let mut parts = Vec::new();
8497    for node in nodes.iter() {
8498        parts.push(format!(
8499            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8500            node.scoped_name,
8501            node.name,
8502            node.kind,
8503            node.exported,
8504            node.range.start_line,
8505            node.range.start_col,
8506            node.range.end_line,
8507            node.range.end_col,
8508            node.range_ordinal,
8509            node.signature.as_deref().unwrap_or("")
8510        ));
8511    }
8512    let mut exports = data.exported_symbols.clone();
8513    exports.sort();
8514    for export in exports {
8515        parts.push(format!("export\t{export}"));
8516    }
8517    if let Some(default_export) = &data.default_export_symbol {
8518        parts.push(format!("default\t{default_export}"));
8519    }
8520    let mut imports: Vec<String> = data
8521        .import_block
8522        .imports
8523        .iter()
8524        .map(|import| {
8525            format!(
8526                "import\t{}\t{:?}\t{}",
8527                import.module_path, import.form, import.raw_text
8528            )
8529        })
8530        .collect();
8531    imports.sort();
8532    parts.extend(imports);
8533    parts.extend(reexport_parts.iter().cloned());
8534    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8535}
8536
8537fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8538    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8539        return Ok(ResolvedRef {
8540            dependencies: raw.dependencies.clone(),
8541            raw,
8542            status: "unresolved".to_string(),
8543            target_node: None,
8544            target_file: None,
8545            target_symbol: None,
8546            edge: None,
8547        });
8548    }
8549
8550    let caller_file = raw.caller_file.clone();
8551    let caller_data =
8552        index
8553            .caller_data(&caller_file)
8554            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8555                file: caller_file.clone(),
8556            })?;
8557    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8558    let short_name = raw.short_name.as_deref().unwrap_or_default();
8559    let mut dependencies = raw.dependencies.clone();
8560
8561    let resolved = match index.lang_for(&caller_file) {
8562        Some(LangId::Rust) => {
8563            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8564        }
8565        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8566            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8567        }
8568        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8569    };
8570
8571    let Some((status, target_file, target_symbol)) = resolved else {
8572        return Ok(ResolvedRef {
8573            raw,
8574            status: "unresolved".to_string(),
8575            target_node: None,
8576            target_file: None,
8577            target_symbol: None,
8578            dependencies,
8579            edge: None,
8580        });
8581    };
8582
8583    dependencies.insert(target_file.clone());
8584    let target_node = index.node_for_symbol(&target_file, &target_symbol);
8585    if raw.kind == "value_ref"
8586        && !target_node
8587            .as_deref()
8588            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8589    {
8590        return Ok(ResolvedRef {
8591            raw,
8592            status: "unresolved".to_string(),
8593            target_node: None,
8594            target_file: None,
8595            target_symbol: None,
8596            dependencies,
8597            edge: None,
8598        });
8599    }
8600    let source_node = raw.caller_node.clone();
8601    let edge = if let Some(source_node) = source_node {
8602        if target_file == caller_file
8603            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8604        {
8605            None
8606        } else {
8607            Some(EdgeRecord {
8608                edge_id: ref_id(&[&raw.ref_id, "edge"]),
8609                source_node,
8610                target_node: target_node.clone(),
8611                target_file: target_file.clone(),
8612                target_symbol: target_symbol.clone(),
8613                kind: raw.kind.clone(),
8614                line: raw.line,
8615            })
8616        }
8617    } else {
8618        None
8619    };
8620
8621    Ok(ResolvedRef {
8622        raw,
8623        status,
8624        target_node,
8625        target_file: Some(target_file),
8626        target_symbol: Some(target_symbol),
8627        dependencies,
8628        edge,
8629    })
8630}
8631
8632fn resolve_js_ts_target<I: ResolverIndex>(
8633    index: &I,
8634    caller_file: &str,
8635    full_ref: &str,
8636    short_name: &str,
8637    caller_data: &FileCallData,
8638) -> Option<(String, String, String)> {
8639    if let Some((namespace, member)) = full_ref.split_once('.') {
8640        for import in &caller_data.import_block.imports {
8641            if import.namespace_import.as_deref() == Some(namespace) {
8642                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8643                    if let Some((file, symbol)) =
8644                        resolve_exported_symbol(index, &target_file, member, 0)
8645                    {
8646                        return Some(("resolved".to_string(), file, symbol));
8647                    }
8648                }
8649            }
8650        }
8651    }
8652
8653    for import in &caller_data.import_block.imports {
8654        for spec in &import.names {
8655            if crate::imports::specifier_local_name(spec) == short_name {
8656                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8657                    let requested = crate::imports::specifier_imported_name(spec);
8658                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8659                        .unwrap_or_else(|| (target_file, requested.to_string()));
8660                    return Some(("resolved".to_string(), file, symbol));
8661                }
8662            }
8663        }
8664
8665        if import.default_import.as_deref() == Some(short_name) {
8666            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8667                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8668                    .or_else(|| {
8669                        index
8670                            .default_export(&target_file)
8671                            .map(|symbol| (target_file.clone(), symbol))
8672                    })
8673                    .unwrap_or_else(|| {
8674                        let file_name = Path::new(&target_file)
8675                            .file_name()
8676                            .and_then(|name| name.to_str())
8677                            .unwrap_or("unknown")
8678                            .to_string();
8679                        (target_file, format!("<default:{file_name}>"))
8680                    });
8681                return Some(("resolved".to_string(), file, symbol));
8682            }
8683        }
8684    }
8685
8686    for import in &caller_data.import_block.imports {
8687        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8688            if index.has_export(&target_file, short_name) {
8689                return Some(("resolved".to_string(), target_file, short_name.to_string()));
8690            }
8691        }
8692    }
8693
8694    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8695}
8696
8697fn resolve_exported_symbol<I: ResolverIndex>(
8698    index: &I,
8699    file: &str,
8700    requested: &str,
8701    depth: usize,
8702) -> Option<(String, String)> {
8703    let mut visited = std::collections::HashMap::new();
8704    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8705}
8706
8707/// Re-export graphs are frequently cyclic (barrel files re-exporting each
8708/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
8709/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
8710/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
8711/// (file, symbol) pair — but only when the earlier visit had at least as much
8712/// remaining depth budget (a shallower re-visit can reach leaves the deeper
8713/// first visit had to cut off at the cap, so plain visited-set pruning would
8714/// lose resolutions the capped walk finds).
8715fn resolve_exported_symbol_inner<I: ResolverIndex>(
8716    index: &I,
8717    file: &str,
8718    requested: &str,
8719    depth: usize,
8720    visited: &mut std::collections::HashMap<(String, String), usize>,
8721) -> Option<(String, String)> {
8722    if depth > 16 {
8723        return None;
8724    }
8725    if requested != "default" {
8726        if let Some(source_symbol) = index.export_alias(file, requested) {
8727            return Some((file.to_string(), source_symbol));
8728        }
8729        if index.has_export(file, requested) {
8730            return Some((file.to_string(), requested.to_string()));
8731        }
8732    } else if let Some(default) = index.default_export(file) {
8733        return Some((file.to_string(), default));
8734    }
8735
8736    // Memo check sits after the local-export fast paths: the common direct
8737    // hit never allocates the key, and a hit through the memo would have
8738    // returned above anyway.
8739    match visited.entry((file.to_string(), requested.to_string())) {
8740        std::collections::hash_map::Entry::Occupied(mut seen) => {
8741            if *seen.get() <= depth {
8742                return None;
8743            }
8744            seen.insert(depth);
8745        }
8746        std::collections::hash_map::Entry::Vacant(slot) => {
8747            slot.insert(depth);
8748        }
8749    }
8750
8751    for reexport in index.reexports_for(file) {
8752        let mut next_requested = requested.to_string();
8753        let matches = if reexport.wildcard {
8754            true
8755        } else if let Some(source_name) = reexport.named.get(requested) {
8756            next_requested = source_name.clone();
8757            true
8758        } else {
8759            false
8760        };
8761        if !matches {
8762            continue;
8763        }
8764        if let Some(target_file) = &reexport.target_file {
8765            if let Some(target) = resolve_exported_symbol_inner(
8766                index,
8767                target_file,
8768                &next_requested,
8769                depth + 1,
8770                visited,
8771            ) {
8772                return Some(target);
8773            }
8774        }
8775    }
8776    None
8777}
8778
8779fn resolve_rust_target<I: ResolverIndex>(
8780    index: &I,
8781    caller_file: &str,
8782    full_ref: &str,
8783    short_name: &str,
8784    caller_data: &FileCallData,
8785    raw: &RawRef,
8786) -> Option<(String, String, String)> {
8787    if full_ref.contains("::") {
8788        if let Some((target_file, target_symbol)) =
8789            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8790        {
8791            return Some(("resolved".to_string(), target_file, target_symbol));
8792        }
8793    }
8794
8795    for import in &caller_data.import_block.imports {
8796        if let Some((target_file, target_symbol)) =
8797            rust_target_for_use(index, caller_file, import, short_name)
8798        {
8799            return Some(("resolved".to_string(), target_file, target_symbol));
8800        }
8801    }
8802
8803    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8804}
8805
8806fn rust_target_for_qualified<I: ResolverIndex>(
8807    index: &I,
8808    caller_file: &str,
8809    full_ref: &str,
8810    short_name: &str,
8811    caller_data: &FileCallData,
8812    raw: &RawRef,
8813) -> Option<(String, String)> {
8814    let mut segments: Vec<&str> = full_ref.split("::").collect();
8815    if segments.len() < 2 {
8816        return None;
8817    }
8818    segments.pop();
8819    let requested_symbol = rust_target_symbol(full_ref, short_name);
8820
8821    for path in rust_module_path_candidates(&segments, caller_data, raw) {
8822        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8823        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8824            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8825                return Some(rust_resolve_reexport_if_symbol_missing(
8826                    index,
8827                    target_file,
8828                    requested_symbol.clone(),
8829                ));
8830            }
8831        }
8832
8833        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
8834        if let Some(target) =
8835            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
8836        {
8837            return Some(target);
8838        }
8839        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
8840            return Some(rust_resolve_reexport_if_symbol_missing(
8841                index,
8842                target_file,
8843                requested_symbol.clone(),
8844            ));
8845        }
8846    }
8847    None
8848}
8849
8850fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
8851    full_ref
8852        .rsplit("::")
8853        .next()
8854        .filter(|name| !name.is_empty())
8855        .unwrap_or(short_name)
8856        .to_string()
8857}
8858
8859fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
8860    index: &I,
8861    target_file: String,
8862    target_symbol: String,
8863) -> (String, String) {
8864    if index
8865        .node_for_symbol(&target_file, &target_symbol)
8866        .is_some()
8867    {
8868        return (target_file, target_symbol);
8869    }
8870    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
8871        resolved
8872    } else {
8873        (target_file, target_symbol)
8874    }
8875}
8876
8877fn rust_module_path_candidates(
8878    segments: &[&str],
8879    caller_data: &FileCallData,
8880    raw: &RawRef,
8881) -> Vec<Vec<String>> {
8882    let mut candidates = Vec::new();
8883    if let Some(first) = segments.first().copied() {
8884        for import in &caller_data.import_block.imports {
8885            if !rust_import_is_visible_to_call(import, raw) {
8886                continue;
8887            }
8888            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8889                continue;
8890            };
8891            if local_name == first {
8892                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8893                rust_push_unique_path_candidate(&mut candidates, path_segments);
8894            }
8895        }
8896    }
8897    rust_push_unique_path_candidate(
8898        &mut candidates,
8899        segments
8900            .iter()
8901            .map(|segment| (*segment).to_string())
8902            .collect(),
8903    );
8904    candidates
8905}
8906
8907fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8908    if !candidates.iter().any(|existing| existing == &candidate) {
8909        candidates.push(candidate);
8910    }
8911}
8912
8913fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8914    import.byte_range.start <= raw.byte_start
8915}
8916
8917fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8918    let path = import.module_path.trim().trim_end_matches(';').trim();
8919    if path.contains("::{") || path.contains('{') || path.contains('*') {
8920        return None;
8921    }
8922    let (path_without_alias, alias) = path
8923        .split_once(" as ")
8924        .map(|(left, right)| (left.trim(), Some(right.trim())))
8925        .unwrap_or((path, None));
8926    let segments = path_without_alias
8927        .split("::")
8928        .map(str::trim)
8929        .filter(|segment| !segment.is_empty())
8930        .collect::<Vec<_>>();
8931    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8932    if local_name.chars().next().is_some_and(char::is_uppercase) {
8933        return None;
8934    }
8935    Some((
8936        local_name,
8937        segments
8938            .into_iter()
8939            .map(|segment| segment.to_string())
8940            .collect(),
8941    ))
8942}
8943
8944fn rust_inline_scoped_target<I: ResolverIndex>(
8945    index: &I,
8946    caller_file: &str,
8947    module_segments: &[String],
8948    short_name: &str,
8949) -> Option<(String, String)> {
8950    index.inline_scoped_target(caller_file, module_segments, short_name)
8951}
8952
8953fn rust_target_for_use<I: ResolverIndex>(
8954    index: &I,
8955    caller_file: &str,
8956    import: &ImportStatement,
8957    short_name: &str,
8958) -> Option<(String, String)> {
8959    let path = import.module_path.trim().trim_end_matches(';');
8960    if let Some(brace_start) = path.find("::{") {
8961        let prefix = &path[..brace_start];
8962        if import.names.iter().any(|name| name == short_name) {
8963            let prefix_segments: Vec<&str> = prefix.split("::").collect();
8964            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8965            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8966            return Some((file, short_name.to_string()));
8967        }
8968        return None;
8969    }
8970
8971    let (path_without_alias, alias) = path
8972        .split_once(" as ")
8973        .map(|(left, right)| (left.trim(), Some(right.trim())))
8974        .unwrap_or((path, None));
8975    let segments: Vec<&str> = path_without_alias.split("::").collect();
8976    let imported = alias.or_else(|| segments.last().copied())?;
8977    if imported != short_name {
8978        return None;
8979    }
8980    if segments.len() < 2 {
8981        return None;
8982    }
8983    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8984    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8985    Some((file, segments.last().unwrap_or(&short_name).to_string()))
8986}
8987
8988fn rust_workspace_file_for_segments<I: ResolverIndex>(
8989    index: &I,
8990    segments: &[&str],
8991) -> Option<String> {
8992    let crate_name = segments.first().copied()?;
8993    let src_prefix = index.crate_src_prefix(crate_name)?;
8994    let module_segments = segments[1..]
8995        .iter()
8996        .map(|segment| segment.to_string())
8997        .collect::<Vec<_>>();
8998    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8999}
9000
9001#[cfg(test)]
9002static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9003    OnceLock::new();
9004
9005#[cfg(test)]
9006fn note_workspace_crate_prefix_build(project_root: &Path) {
9007    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9008        .get_or_init(|| Mutex::new(HashMap::new()))
9009        .lock()
9010        .expect("workspace crate prefix build counts mutex poisoned");
9011    *counts.entry(project_root.to_path_buf()).or_default() += 1;
9012}
9013
9014#[cfg(not(test))]
9015fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9016
9017#[cfg(test)]
9018fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9019    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9020        .get_or_init(|| Mutex::new(HashMap::new()))
9021        .lock()
9022        .expect("workspace crate prefix build counts mutex poisoned")
9023        .remove(project_root);
9024}
9025
9026#[cfg(test)]
9027fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9028    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9029        .get_or_init(|| Mutex::new(HashMap::new()))
9030        .lock()
9031        .expect("workspace crate prefix build counts mutex poisoned")
9032        .get(project_root)
9033        .copied()
9034        .unwrap_or(0)
9035}
9036
9037/// Walk the project tree once and map every Rust crate name (package name with
9038/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
9039/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
9040/// longer re-walks the filesystem once per ref.
9041fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9042    note_workspace_crate_prefix_build(project_root);
9043    let mut prefixes = HashMap::new();
9044    let mut stack = vec![project_root.to_path_buf()];
9045    while let Some(dir) = stack.pop() {
9046        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9047        if matches!(name, "target" | "node_modules" | ".git") {
9048            continue;
9049        }
9050        let manifest = dir.join("Cargo.toml");
9051        if manifest.is_file() {
9052            let crate_names = rust_manifest_crate_names(&manifest);
9053            if !crate_names.is_empty() {
9054                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9055                for crate_name in crate_names {
9056                    prefixes
9057                        .entry(crate_name)
9058                        .or_insert_with(|| src_prefix.clone());
9059                }
9060            }
9061        }
9062        let Ok(entries) = std::fs::read_dir(&dir) else {
9063            continue;
9064        };
9065        for entry in entries.flatten() {
9066            let path = entry.path();
9067            if path.is_dir() {
9068                stack.push(path);
9069            }
9070        }
9071    }
9072    prefixes
9073}
9074
9075/// Extract the crate names a manifest defines: the normalized package name
9076/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
9077/// reachable by either spelling, matching the previous match semantics.
9078fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9079    let Ok(source) = std::fs::read_to_string(manifest) else {
9080        return Vec::new();
9081    };
9082    let mut in_lib = false;
9083    let mut package_name = None;
9084    let mut lib_name = None;
9085    for line in source.lines() {
9086        let trimmed = line.trim();
9087        if trimmed.starts_with('[') {
9088            in_lib = trimmed == "[lib]";
9089            continue;
9090        }
9091        let Some((key, value)) = trimmed.split_once('=') else {
9092            continue;
9093        };
9094        let key = key.trim();
9095        let value = value.trim().trim_matches('"');
9096        if in_lib && key == "name" {
9097            lib_name = Some(value.to_string());
9098        } else if !in_lib && key == "name" && package_name.is_none() {
9099            package_name = Some(value.to_string());
9100        }
9101    }
9102    let mut names = Vec::new();
9103    if let Some(lib) = lib_name {
9104        names.push(lib);
9105    }
9106    if let Some(package) = package_name {
9107        let normalized = package.replace('-', "_");
9108        if !names.contains(&normalized) {
9109            names.push(normalized);
9110        }
9111    }
9112    names
9113}
9114
9115fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9116    if segments.is_empty() {
9117        return Some(Vec::new());
9118    }
9119    let caller_segments = rust_module_segments_for_rel(caller_file);
9120    match segments[0] {
9121        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9122        "self" => {
9123            let mut resolved = caller_segments;
9124            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9125            Some(resolved)
9126        }
9127        "super" => {
9128            let mut resolved = caller_segments;
9129            resolved.pop();
9130            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9131            Some(resolved)
9132        }
9133        _ => {
9134            let mut resolved = caller_segments;
9135            resolved.pop();
9136            resolved.extend(segments.iter().map(|item| item.to_string()));
9137            Some(resolved)
9138        }
9139    }
9140}
9141
9142fn rust_file_for_segments<I: ResolverIndex>(
9143    index: &I,
9144    caller_file: &str,
9145    segments: &[String],
9146) -> Option<String> {
9147    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9148}
9149
9150fn rust_file_for_src_prefix<I: ResolverIndex>(
9151    index: &I,
9152    src_prefix: &str,
9153    segments: &[String],
9154) -> Option<String> {
9155    let candidate = if segments.is_empty() {
9156        [src_prefix, "lib.rs"].join("/")
9157    } else {
9158        format!("{}/{}.rs", src_prefix, segments.join("/"))
9159    };
9160    if index.contains_file(&candidate) {
9161        return Some(candidate);
9162    }
9163    if !segments.is_empty() {
9164        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9165        if index.contains_file(&mod_candidate) {
9166            return Some(mod_candidate);
9167        }
9168    }
9169    None
9170}
9171
9172fn rust_src_prefix(rel_path: &str) -> String {
9173    rel_path
9174        .split_once("/src/")
9175        .map(|(prefix, _)| format!("{prefix}/src"))
9176        .unwrap_or_else(|| "src".to_string())
9177}
9178
9179fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9180    let after_src = rel_path
9181        .split_once("/src/")
9182        .map(|(_, rest)| rest)
9183        .or_else(|| rel_path.strip_prefix("src/"))
9184        .unwrap_or(rel_path);
9185    if matches!(after_src, "lib.rs" | "main.rs") {
9186        return Vec::new();
9187    }
9188    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9189        return prefix.split('/').map(|item| item.to_string()).collect();
9190    }
9191    after_src
9192        .strip_suffix(".rs")
9193        .unwrap_or(after_src)
9194        .split('/')
9195        .map(|item| item.to_string())
9196        .collect()
9197}
9198
9199fn resolve_local_target<I: ResolverIndex>(
9200    _index: &I,
9201    caller_file: &str,
9202    full_ref: &str,
9203    short_name: &str,
9204    caller_data: &FileCallData,
9205) -> Option<(String, String, String)> {
9206    if !callgraph::is_bare_callee(full_ref, short_name) {
9207        return None;
9208    }
9209    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9210        .ok()
9211        .map(|symbol| {
9212            (
9213                "resolved_local".to_string(),
9214                caller_file.to_string(),
9215                symbol,
9216            )
9217        })
9218}
9219
9220impl<'a> ProjectIndex<'a> {
9221    fn from_parts(
9222        project_root: &Path,
9223        files: HashMap<String, DbFileIndex>,
9224        caller_data: HashMap<String, &'a FileCallData>,
9225        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9226    ) -> Self {
9227        Self {
9228            project_root: project_root.to_path_buf(),
9229            files,
9230            caller_data,
9231            workspace_crate_prefixes,
9232        }
9233    }
9234
9235    fn from_db_and_callers(
9236        tx: &Transaction<'_>,
9237        project_root: &Path,
9238        caller_extracts: &'a HashMap<String, FileExtract>,
9239        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9240    ) -> Result<Self> {
9241        let mut files = load_db_file_indexes(tx, project_root)?;
9242        let mut caller_data = HashMap::new();
9243        for (rel_path, extract) in caller_extracts {
9244            files.insert(
9245                rel_path.clone(),
9246                DbFileIndex::from_extract(project_root, extract),
9247            );
9248            caller_data.insert(rel_path.clone(), &extract.data);
9249        }
9250        Ok(Self::from_parts(
9251            project_root,
9252            files,
9253            caller_data,
9254            workspace_crate_prefixes,
9255        ))
9256    }
9257
9258    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9259        self.files.get(rel_path).and_then(|file| file.lang)
9260    }
9261
9262    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9263        self.files
9264            .get(caller_file)
9265            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9266    }
9267
9268    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9269        self.files
9270            .get(rel_path)
9271            .map(|file| file.reexports.as_slice())
9272            .unwrap_or(&[])
9273    }
9274
9275    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9276        self.files.get(rel_path).and_then(|file| {
9277            file.node_by_scoped
9278                .get(symbol)
9279                .cloned()
9280                .or_else(|| file.node_by_bare.get(symbol).cloned())
9281        })
9282    }
9283
9284    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9285        self.files
9286            .get(rel_path)
9287            .and_then(|file| file.node_kind_by_id.get(node_id))
9288            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9289    }
9290}
9291
9292impl DbFileIndex {
9293    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9294        let mut node_by_scoped = HashMap::new();
9295        let mut node_by_bare = HashMap::new();
9296        for node in &extract.nodes {
9297            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9298            node_by_bare
9299                .entry(node.name.clone())
9300                .or_insert(node.id.clone());
9301        }
9302        let node_kind_by_id = extract
9303            .nodes
9304            .iter()
9305            .map(|node| (node.id.clone(), node.kind.clone()))
9306            .collect();
9307        let mut export_aliases = HashMap::new();
9308        for raw_ref in &extract.raw_refs {
9309            if raw_ref.kind == "export_alias" {
9310                if let (Some(exported), Some(source_symbol)) =
9311                    (&raw_ref.local_name, &raw_ref.requested_name)
9312                {
9313                    export_aliases.insert(exported.clone(), source_symbol.clone());
9314                }
9315            }
9316        }
9317        let mut module_targets = HashMap::new();
9318        let mut reexports = Vec::new();
9319        for raw_ref in &extract.raw_refs {
9320            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9321                continue;
9322            }
9323            let Some(module_path) = &raw_ref.module_path else {
9324                continue;
9325            };
9326            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9327            module_targets
9328                .entry(module_path.clone())
9329                .or_insert_with(|| target_file.clone());
9330            if raw_ref.kind == "reexport" {
9331                reexports.push(reexport_index_from_raw(raw_ref, target_file));
9332            }
9333        }
9334        Self {
9335            lang: Some(extract.lang),
9336            exports: extract.data.exported_symbols.iter().cloned().collect(),
9337            default_export: extract.data.default_export_symbol.clone(),
9338            export_aliases,
9339            node_by_scoped,
9340            node_by_bare,
9341            node_kind_by_id,
9342            module_targets,
9343            reexports,
9344        }
9345    }
9346}
9347
9348fn load_db_file_indexes(
9349    tx: &Transaction<'_>,
9350    project_root: &Path,
9351) -> Result<HashMap<String, DbFileIndex>> {
9352    let mut files = HashMap::new();
9353    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9354    let rows = stmt.query_map([], |row| {
9355        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9356    })?;
9357    for row in rows {
9358        let (rel_path, lang) = row?;
9359        files.insert(
9360            rel_path.clone(),
9361            DbFileIndex {
9362                lang: lang_from_label(&lang),
9363                exports: HashSet::new(),
9364                default_export: None,
9365                export_aliases: HashMap::new(),
9366                node_by_scoped: HashMap::new(),
9367                node_by_bare: HashMap::new(),
9368                node_kind_by_id: HashMap::new(),
9369                module_targets: HashMap::new(),
9370                reexports: Vec::new(),
9371            },
9372        );
9373    }
9374
9375    let mut node_stmt = tx.prepare(
9376        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9377    )?;
9378    let nodes = node_stmt.query_map([], |row| {
9379        Ok((
9380            row.get::<_, String>(0)?,
9381            row.get::<_, String>(1)?,
9382            row.get::<_, String>(2)?,
9383            row.get::<_, String>(3)?,
9384            row.get::<_, String>(4)?,
9385            row.get::<_, i64>(5)? != 0,
9386            row.get::<_, i64>(6)? != 0,
9387        ))
9388    })?;
9389    for row in nodes {
9390        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9391        let file = files
9392            .entry(file_path.clone())
9393            .or_insert_with(|| DbFileIndex {
9394                lang: None,
9395                exports: HashSet::new(),
9396                default_export: None,
9397                export_aliases: HashMap::new(),
9398                node_by_scoped: HashMap::new(),
9399                node_by_bare: HashMap::new(),
9400                node_kind_by_id: HashMap::new(),
9401                module_targets: HashMap::new(),
9402                reexports: Vec::new(),
9403            });
9404        if exported {
9405            file.exports.insert(name.clone());
9406            file.exports.insert(scoped_name.clone());
9407        }
9408        if is_default_export {
9409            file.default_export = Some(scoped_name.clone());
9410        }
9411        file.node_by_scoped.insert(scoped_name, id.clone());
9412        file.node_by_bare.entry(name).or_insert(id.clone());
9413        file.node_kind_by_id.insert(id, kind);
9414    }
9415    let file_keys: HashSet<String> = files.keys().cloned().collect();
9416    // Persisted caller extracts supply import targets. Only reexports from other
9417    // files need dependency reconstruction, and their caller dependencies are
9418    // loaded once instead of issuing repeated SQLite queries per reference.
9419    let dependencies_by_file = load_file_dependencies_index(tx)?;
9420    let mut ref_stmt = tx.prepare(
9421        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9422         FROM refs WHERE kind IN ('reexport', 'export_alias')",
9423    )?;
9424    let ref_rows = ref_stmt.query_map([], |row| {
9425        Ok((
9426            row.get::<_, String>(0)?,
9427            row.get::<_, String>(1)?,
9428            row.get::<_, String>(2)?,
9429            row.get::<_, Option<String>>(3)?,
9430            row.get::<_, Option<String>>(4)?,
9431            row.get::<_, i64>(5)? != 0,
9432            row.get::<_, Option<String>>(6)?,
9433            row.get::<_, Option<String>>(7)?,
9434        ))
9435    })?;
9436    for row in ref_rows {
9437        let (
9438            ref_id,
9439            caller_file,
9440            kind,
9441            module_path,
9442            full_ref,
9443            wildcard,
9444            local_name,
9445            requested_name,
9446        ) = row?;
9447        if kind == "export_alias" {
9448            if let (Some(exported), Some(source_symbol), Some(file)) =
9449                (local_name, requested_name, files.get_mut(&caller_file))
9450            {
9451                file.export_aliases.insert(exported, source_symbol);
9452            }
9453            continue;
9454        }
9455        let Some(module_path) = module_path else {
9456            continue;
9457        };
9458        let file_deps = dependencies_by_file
9459            .get(&caller_file)
9460            .cloned()
9461            .unwrap_or_default();
9462        let deps = stored_dependencies_for_module(
9463            project_root,
9464            &caller_file,
9465            &module_path,
9466            &file_deps,
9467            &file_keys,
9468        );
9469        let target_file = deps
9470            .iter()
9471            .find(|dep| file_keys.contains(*dep))
9472            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9473        if let Some(file) = files.get_mut(&caller_file) {
9474            file.module_targets
9475                .entry(module_path.clone())
9476                .or_insert_with(|| target_file.clone());
9477            if kind == "reexport" {
9478                let raw = RawRef {
9479                    ref_id,
9480                    caller_node: None,
9481                    caller_symbol: None,
9482                    caller_file,
9483                    kind,
9484                    short_name: None,
9485                    full_ref,
9486                    module_path: Some(module_path),
9487                    import_kind: Some("reexport".to_string()),
9488                    local_name: None,
9489                    requested_name: None,
9490                    namespace_alias: None,
9491                    wildcard,
9492                    line: 0,
9493                    byte_start: 0,
9494                    byte_end: 0,
9495                    dependencies: deps,
9496                };
9497                file.reexports
9498                    .push(reexport_index_from_raw(&raw, target_file));
9499            }
9500        }
9501    }
9502
9503    Ok(files)
9504}
9505
9506fn stored_dependencies_for_module(
9507    project_root: &Path,
9508    caller_file: &str,
9509    module_path: &str,
9510    caller_dependencies: &BTreeSet<String>,
9511    indexed_files: &HashSet<String>,
9512) -> BTreeSet<String> {
9513    let caller_path = project_root.join(caller_file);
9514    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9515    if module_path.starts_with('.') {
9516        let caller_dir = caller_path.parent().unwrap_or(project_root);
9517        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9518            let normalized = if candidate.is_file() {
9519                canonicalize_path(&candidate)
9520            } else {
9521                candidate
9522            };
9523            candidates.insert(relative_path(project_root, &normalized));
9524        }
9525    }
9526    let exact = candidates
9527        .intersection(caller_dependencies)
9528        .filter(|dependency| indexed_files.contains(*dependency))
9529        .cloned()
9530        .collect::<BTreeSet<_>>();
9531    if !exact.is_empty() || module_path.starts_with('.') {
9532        return exact;
9533    }
9534
9535    let module_path = rust_module_path_without_alias_or_use_list(module_path)
9536        .trim_matches(|character| matches!(character, '\'' | '"'));
9537    let package_name = module_path
9538        .split('/')
9539        .next_back()
9540        .unwrap_or(module_path)
9541        .replace('_', "-");
9542    let matched = caller_dependencies
9543        .iter()
9544        .filter(|dependency| indexed_files.contains(*dependency))
9545        .filter(|dependency| {
9546            dependency.as_str() == module_path
9547                || dependency.ends_with(&format!("/{module_path}"))
9548                || Path::new(dependency).components().any(|component| {
9549                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9550                })
9551        })
9552        .cloned()
9553        .collect::<BTreeSet<_>>();
9554    if matched.len() == 1 {
9555        matched
9556    } else {
9557        BTreeSet::new()
9558    }
9559}
9560
9561fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9562    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9563    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9564    let rows = stmt.query_map([], |row| {
9565        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9566    })?;
9567    for row in rows {
9568        let (file_path, dependency) = row?;
9569        by_file.entry(file_path).or_default().insert(dependency);
9570    }
9571    Ok(by_file)
9572}
9573
9574struct ColdBuildInsertStatements<'stmt> {
9575    file: Statement<'stmt>,
9576    node: Statement<'stmt>,
9577    file_dependency: Statement<'stmt>,
9578    dispatch_hint: Statement<'stmt>,
9579    backend_state: Statement<'stmt>,
9580    reference: Statement<'stmt>,
9581    staging_ref_context: Statement<'stmt>,
9582    edge: Statement<'stmt>,
9583}
9584
9585impl<'stmt> ColdBuildInsertStatements<'stmt> {
9586    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9587        Ok(Self {
9588            file: tx.prepare(
9589                "INSERT OR REPLACE INTO files(
9590                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9591                    is_public_api, surface_fingerprint, indexed_at
9592                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9593            )?,
9594            node: tx.prepare(
9595                "INSERT OR REPLACE INTO nodes(
9596                    id, file_path, name, scoped_name, kind, start_line, start_col,
9597                    end_line, end_col, range_ordinal, signature, exported,
9598                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
9599                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9600            )?,
9601            file_dependency: tx.prepare(
9602                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9603            )?,
9604            dispatch_hint: tx.prepare(
9605                "INSERT OR REPLACE INTO dispatch_hints(
9606                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9607                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9608            )?,
9609            backend_state: tx.prepare(
9610                "INSERT OR REPLACE INTO backend_file_state(
9611                    backend, workspace_root, file_path, content_hash, status, updated_at
9612                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9613            )?,
9614            reference: tx.prepare(
9615                "INSERT OR REPLACE INTO refs(
9616                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9617                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9618                    byte_start, byte_end, status, target_node, target_file, target_symbol,
9619                    provenance
9620                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9621            )?,
9622            staging_ref_context: tx.prepare(
9623                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9624            )?,
9625            edge: tx.prepare(
9626                "INSERT OR REPLACE INTO edges(
9627                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9628                    kind, line, provenance
9629                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9630            )?,
9631        })
9632    }
9633}
9634
9635fn insert_file_extract_prepared(
9636    statements: &mut ColdBuildInsertStatements<'_>,
9637    workspace_root: &str,
9638    extract: &FileExtract,
9639) -> Result<()> {
9640    statements.file.execute(params![
9641        extract.rel_path,
9642        hash_to_hex(extract.freshness.content_hash),
9643        system_time_to_ns(extract.freshness.mtime),
9644        extract.freshness.size as i64,
9645        lang_label(extract.lang),
9646        extract.surface_fingerprint,
9647        unix_seconds_now(),
9648    ])?;
9649    for node in &extract.nodes {
9650        statements.node.execute(params![
9651            node.id,
9652            node.file_path,
9653            node.name,
9654            node.scoped_name,
9655            node.kind,
9656            node.range.start_line as i64,
9657            node.range.start_col as i64,
9658            node.range.end_line as i64,
9659            node.range.end_col as i64,
9660            node.range_ordinal as i64,
9661            node.signature,
9662            bool_int(node.exported),
9663            bool_int(node.is_default_export),
9664            bool_int(node.is_type_like),
9665            bool_int(node.is_callgraph_entry_point),
9666            PROVENANCE_TREESITTER,
9667        ])?;
9668    }
9669
9670    let mut dependencies = BTreeSet::new();
9671    for raw_ref in &extract.raw_refs {
9672        dependencies.extend(raw_ref.dependencies.iter().cloned());
9673    }
9674    for dep_file in &dependencies {
9675        statements
9676            .file_dependency
9677            .execute(params![extract.rel_path, dep_file])?;
9678    }
9679
9680    for hint in &extract.dispatch_hints {
9681        statements.dispatch_hint.execute(params![
9682            hint.id,
9683            hint.method_name,
9684            hint.caller_node,
9685            hint.file,
9686            hint.line as i64,
9687            hint.byte_start as i64,
9688            hint.byte_end as i64,
9689            PROVENANCE_TREESITTER,
9690        ])?;
9691    }
9692    insert_backend_state_prepared(
9693        &mut statements.backend_state,
9694        workspace_root,
9695        &extract.rel_path,
9696        Some(&extract.freshness.content_hash),
9697        "fresh",
9698    )?;
9699    Ok(())
9700}
9701
9702fn insert_backend_state_prepared(
9703    stmt: &mut Statement<'_>,
9704    workspace_root: &str,
9705    rel_path: &str,
9706    content_hash: Option<&blake3::Hash>,
9707    status: &str,
9708) -> Result<()> {
9709    let hash = content_hash
9710        .map(|hash| hash_to_hex(*hash))
9711        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9712    stmt.execute(params![
9713        BACKEND_TREESITTER,
9714        workspace_root,
9715        rel_path,
9716        hash,
9717        status,
9718        unix_seconds_now(),
9719    ])?;
9720    Ok(())
9721}
9722
9723fn insert_staged_ref_prepared(
9724    statements: &mut ColdBuildInsertStatements<'_>,
9725    raw: &RawRef,
9726) -> Result<()> {
9727    statements.reference.execute(params![
9728        raw.ref_id,
9729        raw.caller_node,
9730        raw.caller_file,
9731        raw.kind,
9732        raw.short_name,
9733        raw.full_ref,
9734        raw.module_path,
9735        raw.import_kind,
9736        raw.local_name,
9737        raw.requested_name,
9738        raw.namespace_alias,
9739        bool_int(raw.wildcard),
9740        raw.line as i64,
9741        raw.byte_start as i64,
9742        raw.byte_end as i64,
9743        "staged",
9744        Option::<String>::None,
9745        Option::<String>::None,
9746        Option::<String>::None,
9747        ref_provenance(raw),
9748    ])?;
9749    statements
9750        .staging_ref_context
9751        .execute(params![raw.ref_id, raw.caller_symbol])?;
9752    Ok(())
9753}
9754
9755fn insert_resolved_ref_prepared(
9756    statements: &mut ColdBuildInsertStatements<'_>,
9757    resolved: &ResolvedRef,
9758) -> Result<()> {
9759    let raw = &resolved.raw;
9760    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9761    statements.reference.execute(params![
9762        raw.ref_id,
9763        raw.caller_node,
9764        raw.caller_file,
9765        raw.kind,
9766        raw.short_name,
9767        raw.full_ref,
9768        raw.module_path,
9769        raw.import_kind,
9770        raw.local_name,
9771        raw.requested_name,
9772        raw.namespace_alias,
9773        bool_int(raw.wildcard),
9774        raw.line as i64,
9775        raw.byte_start as i64,
9776        raw.byte_end as i64,
9777        resolved.status,
9778        resolved.target_node,
9779        resolved.target_file,
9780        resolved.target_symbol,
9781        ref_provenance(raw),
9782    ])?;
9783    if let Some(edge) = &resolved.edge {
9784        statements.edge.execute(params![
9785            edge.edge_id,
9786            raw.ref_id,
9787            edge.source_node,
9788            edge.target_node,
9789            edge.target_file,
9790            edge.target_symbol,
9791            edge.kind,
9792            edge.line as i64,
9793            ref_provenance(raw),
9794        ])?;
9795    }
9796    Ok(())
9797}
9798
9799#[cfg(test)]
9800fn insert_file_extract(
9801    tx: &Transaction<'_>,
9802    project_root: &Path,
9803    extract: &FileExtract,
9804) -> Result<()> {
9805    tx.execute(
9806        "INSERT OR REPLACE INTO files(
9807            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9808            is_public_api, surface_fingerprint, indexed_at
9809        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9810        params![
9811            extract.rel_path,
9812            hash_to_hex(extract.freshness.content_hash),
9813            system_time_to_ns(extract.freshness.mtime),
9814            extract.freshness.size as i64,
9815            lang_label(extract.lang),
9816            extract.surface_fingerprint,
9817            unix_seconds_now(),
9818        ],
9819    )?;
9820    for node in &extract.nodes {
9821        tx.execute(
9822            "INSERT OR REPLACE INTO nodes(
9823                id, file_path, name, scoped_name, kind, start_line, start_col,
9824                end_line, end_col, range_ordinal, signature, exported,
9825                is_default_export, is_type_like, is_callgraph_entry_point, provenance
9826            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9827            params![
9828                node.id,
9829                node.file_path,
9830                node.name,
9831                node.scoped_name,
9832                node.kind,
9833                node.range.start_line as i64,
9834                node.range.start_col as i64,
9835                node.range.end_line as i64,
9836                node.range.end_col as i64,
9837                node.range_ordinal as i64,
9838                node.signature,
9839                bool_int(node.exported),
9840                bool_int(node.is_default_export),
9841                bool_int(node.is_type_like),
9842                bool_int(node.is_callgraph_entry_point),
9843                PROVENANCE_TREESITTER,
9844            ],
9845        )?;
9846    }
9847    let mut dependencies = BTreeSet::new();
9848    for raw_ref in &extract.raw_refs {
9849        dependencies.extend(raw_ref.dependencies.iter().cloned());
9850    }
9851    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
9852
9853    for hint in &extract.dispatch_hints {
9854        tx.execute(
9855            "INSERT OR REPLACE INTO dispatch_hints(
9856                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9857            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9858            params![
9859                hint.id,
9860                hint.method_name,
9861                hint.caller_node,
9862                hint.file,
9863                hint.line as i64,
9864                hint.byte_start as i64,
9865                hint.byte_end as i64,
9866                PROVENANCE_TREESITTER,
9867            ],
9868        )?;
9869    }
9870    mark_backend_state(
9871        tx,
9872        project_root,
9873        &extract.rel_path,
9874        Some(&extract.freshness.content_hash),
9875        "fresh",
9876    )?;
9877    Ok(())
9878}
9879
9880#[cfg(test)]
9881fn insert_file_dependencies(
9882    tx: &Transaction<'_>,
9883    file_path: &str,
9884    dependencies: &BTreeSet<String>,
9885) -> Result<()> {
9886    for dep_file in dependencies {
9887        tx.execute(
9888            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9889            params![file_path, dep_file],
9890        )?;
9891    }
9892    Ok(())
9893}
9894
9895fn ref_provenance(raw: &RawRef) -> &'static str {
9896    if raw.kind == "value_ref" {
9897        PROVENANCE_VALUE_REF
9898    } else {
9899        PROVENANCE_TREESITTER
9900    }
9901}
9902
9903#[cfg(test)]
9904fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9905    let raw = &resolved.raw;
9906    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9907    tx.execute(
9908        "INSERT OR REPLACE INTO refs(
9909            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9910            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9911            byte_start, byte_end, status, target_node, target_file, target_symbol,
9912            provenance
9913        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9914        params![
9915            raw.ref_id,
9916            raw.caller_node,
9917            raw.caller_file,
9918            raw.kind,
9919            raw.short_name,
9920            raw.full_ref,
9921            raw.module_path,
9922            raw.import_kind,
9923            raw.local_name,
9924            raw.requested_name,
9925            raw.namespace_alias,
9926            bool_int(raw.wildcard),
9927            raw.line as i64,
9928            raw.byte_start as i64,
9929            raw.byte_end as i64,
9930            resolved.status,
9931            resolved.target_node,
9932            resolved.target_file,
9933            resolved.target_symbol,
9934            ref_provenance(raw),
9935        ],
9936    )?;
9937    if let Some(edge) = &resolved.edge {
9938        tx.execute(
9939            "INSERT OR REPLACE INTO edges(
9940                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9941                kind, line, provenance
9942            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9943            params![
9944                edge.edge_id,
9945                raw.ref_id,
9946                edge.source_node,
9947                edge.target_node,
9948                edge.target_file,
9949                edge.target_symbol,
9950                edge.kind,
9951                edge.line as i64,
9952                ref_provenance(raw),
9953            ],
9954        )?;
9955    }
9956    Ok(())
9957}
9958
9959fn insert_method_dispatch_edges(
9960    tx: &Transaction<'_>,
9961    project_root: &Path,
9962    caller_files: Option<&BTreeSet<String>>,
9963) -> Result<usize> {
9964    let references = load_name_match_refs(tx, caller_files)?;
9965    if references.is_empty() {
9966        return Ok(0);
9967    }
9968
9969    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9970    let mut source_cache: DispatchSourceCache = HashMap::new();
9971    let mut inserted = 0usize;
9972    for reference in references {
9973        let key = (reference.method_name.clone(), reference.lang.clone());
9974        let candidates = match candidates_by_name.entry(key) {
9975            Entry::Occupied(entry) => entry.into_mut(),
9976            Entry::Vacant(entry) => {
9977                let candidates =
9978                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9979                entry.insert(candidates)
9980            }
9981        };
9982
9983        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9984            ReceiverTypeInference::Known(receiver_type) => {
9985                let Some(candidate) =
9986                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9987                else {
9988                    continue;
9989                };
9990                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9991                inserted += 1;
9992                continue;
9993            }
9994            ReceiverTypeInference::RustDirectSelfField {
9995                receiver_type,
9996                declaration_file,
9997                module_scope,
9998            } => {
9999                let Some(candidate) = select_rust_direct_self_field_candidate(
10000                    project_root,
10001                    &reference,
10002                    candidates.as_slice(),
10003                    &receiver_type,
10004                    &declaration_file,
10005                    &module_scope,
10006                    &mut source_cache,
10007                ) else {
10008                    continue;
10009                };
10010                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10011                inserted += 1;
10012                continue;
10013            }
10014            ReceiverTypeInference::KnownButUnresolved => continue,
10015            ReceiverTypeInference::Unknown => {}
10016        }
10017
10018        if method_name_match_denylisted(&reference.method_name) {
10019            continue;
10020        }
10021
10022        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
10023            continue;
10024        };
10025        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
10026        inserted += 1;
10027    }
10028    Ok(inserted)
10029}
10030
10031fn insert_method_dispatch_edges_chunked(
10032    tx: &Transaction<'_>,
10033    project_root: &Path,
10034    chunk_size: usize,
10035) -> Result<usize> {
10036    let mut inserted = 0usize;
10037    let mut after_file = String::new();
10038    loop {
10039        let caller_files = {
10040            let mut statement = tx.prepare(
10041                "SELECT DISTINCT caller_file
10042                 FROM refs
10043                 WHERE caller_file > ?1
10044                 ORDER BY caller_file
10045                 LIMIT ?2",
10046            )?;
10047            let rows = statement
10048                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10049                    row.get::<_, String>(0)
10050                })?;
10051            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10052        };
10053        let Some(last_file) = caller_files.last().cloned() else {
10054            break;
10055        };
10056        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10057        after_file = last_file;
10058    }
10059    Ok(inserted)
10060}
10061
10062fn insert_method_dispatch_edge(
10063    tx: &Transaction<'_>,
10064    reference: &NameMatchRef,
10065    candidate: &NameMatchCandidate,
10066    provenance: &str,
10067) -> Result<()> {
10068    tx.execute(
10069        "INSERT OR REPLACE INTO edges(
10070            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10071            kind, line, provenance
10072        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10073        params![
10074            ref_id(&[&reference.ref_id, provenance, "edge"]),
10075            &reference.ref_id,
10076            &reference.caller_node,
10077            &candidate.node_id,
10078            &candidate.file_path,
10079            &candidate.scoped_name,
10080            reference.line as i64,
10081            provenance,
10082        ],
10083    )?;
10084    Ok(())
10085}
10086
10087fn delete_method_dispatch_edges_for_callers(
10088    tx: &Transaction<'_>,
10089    caller_files: &BTreeSet<String>,
10090) -> Result<()> {
10091    if caller_files.is_empty() {
10092        return Ok(());
10093    }
10094
10095    let mut stmt = tx.prepare(
10096        "DELETE FROM edges
10097         WHERE provenance IN (?1, ?2)
10098           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10099    )?;
10100    for caller_file in caller_files {
10101        stmt.execute(params![
10102            PROVENANCE_NAME_MATCH,
10103            PROVENANCE_TYPE_MATCH,
10104            caller_file
10105        ])?;
10106    }
10107    Ok(())
10108}
10109
10110fn load_name_match_refs(
10111    tx: &Transaction<'_>,
10112    caller_files: Option<&BTreeSet<String>>,
10113) -> Result<Vec<NameMatchRef>> {
10114    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10115                           n.signature, r.short_name, r.full_ref, r.line, f.lang
10116                    FROM refs r
10117                    JOIN files f ON f.path = r.caller_file
10118                    JOIN nodes n ON n.id = r.caller_node
10119                    WHERE r.kind = 'call'
10120                      AND r.status = 'unresolved'
10121                      AND r.caller_node IS NOT NULL
10122                      AND r.full_ref IS NOT NULL
10123                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10124                      AND NOT EXISTS (
10125                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10126                      )";
10127    let mut references = Vec::new();
10128
10129    if let Some(caller_files) = caller_files {
10130        if caller_files.is_empty() {
10131            return Ok(references);
10132        }
10133        let sql = format!(
10134            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10135        );
10136        let mut stmt = tx.prepare(&sql)?;
10137        for caller_file in caller_files {
10138            let rows = stmt.query_map(params![caller_file], |row| {
10139                Ok((
10140                    row.get::<_, String>(0)?,
10141                    row.get::<_, Option<String>>(1)?,
10142                    row.get::<_, String>(2)?,
10143                    row.get::<_, String>(3)?,
10144                    row.get::<_, Option<String>>(4)?,
10145                    row.get::<_, Option<String>>(5)?,
10146                    row.get::<_, Option<String>>(6)?,
10147                    row.get::<_, i64>(7)?,
10148                    row.get::<_, String>(8)?,
10149                ))
10150            })?;
10151            for row in rows {
10152                let (
10153                    ref_id,
10154                    caller_node,
10155                    caller_file,
10156                    caller_symbol,
10157                    caller_signature,
10158                    short_name,
10159                    full_ref,
10160                    line,
10161                    lang,
10162                ) = row?;
10163                if let Some(reference) = name_match_ref_from_parts(
10164                    ref_id,
10165                    caller_node,
10166                    caller_file,
10167                    caller_symbol,
10168                    caller_signature,
10169                    short_name,
10170                    full_ref,
10171                    line,
10172                    lang,
10173                ) {
10174                    references.push(reference);
10175                }
10176            }
10177        }
10178        return Ok(references);
10179    }
10180
10181    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10182    let mut stmt = tx.prepare(&sql)?;
10183    let rows = stmt.query_map([], |row| {
10184        Ok((
10185            row.get::<_, String>(0)?,
10186            row.get::<_, Option<String>>(1)?,
10187            row.get::<_, String>(2)?,
10188            row.get::<_, String>(3)?,
10189            row.get::<_, Option<String>>(4)?,
10190            row.get::<_, Option<String>>(5)?,
10191            row.get::<_, Option<String>>(6)?,
10192            row.get::<_, i64>(7)?,
10193            row.get::<_, String>(8)?,
10194        ))
10195    })?;
10196    for row in rows {
10197        let (
10198            ref_id,
10199            caller_node,
10200            caller_file,
10201            caller_symbol,
10202            caller_signature,
10203            short_name,
10204            full_ref,
10205            line,
10206            lang,
10207        ) = row?;
10208        if let Some(reference) = name_match_ref_from_parts(
10209            ref_id,
10210            caller_node,
10211            caller_file,
10212            caller_symbol,
10213            caller_signature,
10214            short_name,
10215            full_ref,
10216            line,
10217            lang,
10218        ) {
10219            references.push(reference);
10220        }
10221    }
10222    Ok(references)
10223}
10224
10225#[allow(clippy::too_many_arguments)]
10226fn name_match_ref_from_parts(
10227    ref_id: String,
10228    caller_node: Option<String>,
10229    caller_file: String,
10230    caller_symbol: String,
10231    caller_signature: Option<String>,
10232    short_name: Option<String>,
10233    full_ref: Option<String>,
10234    line: i64,
10235    lang: String,
10236) -> Option<NameMatchRef> {
10237    let caller_node = caller_node?;
10238    let full_ref = full_ref?;
10239    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10240    let method_name = if member.is_empty() {
10241        short_name.as_deref()?.to_string()
10242    } else {
10243        member
10244    };
10245    Some(NameMatchRef {
10246        ref_id,
10247        caller_node,
10248        caller_file,
10249        caller_symbol,
10250        caller_signature,
10251        receiver_expression,
10252        receiver,
10253        method_name,
10254        colon_dispatch,
10255        line: line.max(0) as u32,
10256        lang,
10257    })
10258}
10259
10260fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10261    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10262    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10263    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10264    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10265        .into_iter()
10266        .flatten()
10267        .max_by_key(|(index, _, _)| *index)?;
10268    if delimiter == 0 {
10269        return None;
10270    }
10271    let member_start = delimiter + delimiter_len;
10272    if member_start >= full_ref.len() {
10273        return None;
10274    }
10275    let receiver_expression = full_ref[..delimiter].trim();
10276    let receiver = last_name_segment(receiver_expression).trim();
10277    let member = &full_ref[member_start..];
10278    if receiver.is_empty() || member.is_empty() {
10279        return None;
10280    }
10281    Some((
10282        receiver_expression.to_string(),
10283        receiver.to_string(),
10284        member.to_string(),
10285        colon_dispatch,
10286    ))
10287}
10288
10289fn last_name_segment(value: &str) -> &str {
10290    value
10291        .rsplit(['.', ':', '/', '\\', '-', '>'])
10292        .find(|segment| !segment.is_empty())
10293        .unwrap_or(value)
10294}
10295
10296fn load_name_match_candidates(
10297    tx: &Transaction<'_>,
10298    method_name: &str,
10299    lang: &str,
10300) -> Result<Vec<NameMatchCandidate>> {
10301    let mut stmt = tx.prepare(
10302        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10303         FROM nodes n JOIN files f ON f.path = n.file_path
10304         WHERE n.name = ?1
10305           AND f.lang = ?2
10306           AND n.kind IN ('method', 'function')
10307         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10308    )?;
10309    let rows = stmt.query_map(params![method_name, lang], |row| {
10310        Ok(NameMatchCandidate {
10311            node_id: row.get(0)?,
10312            file_path: row.get(1)?,
10313            scoped_name: row.get(2)?,
10314            kind: row.get(3)?,
10315            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10316        })
10317    })?;
10318    rows.collect::<std::result::Result<Vec<_>, _>>()
10319        .map_err(Into::into)
10320}
10321
10322struct ParsedDispatchSource {
10323    source: String,
10324    tree: tree_sitter::Tree,
10325}
10326
10327type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10328
10329#[derive(Debug, Clone, PartialEq, Eq)]
10330enum ReceiverTypeInference {
10331    Unknown,
10332    Known(String),
10333    RustDirectSelfField {
10334        receiver_type: String,
10335        declaration_file: String,
10336        module_scope: Vec<(usize, usize)>,
10337    },
10338    KnownButUnresolved,
10339}
10340
10341#[cfg(test)]
10342fn infer_receiver_type(
10343    project_root: &Path,
10344    reference: &NameMatchRef,
10345    source_cache: &mut DispatchSourceCache,
10346) -> Option<String> {
10347    match infer_receiver_type_state(project_root, reference, source_cache) {
10348        ReceiverTypeInference::Known(receiver_type)
10349        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10350        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10351    }
10352}
10353
10354fn infer_receiver_type_state(
10355    project_root: &Path,
10356    reference: &NameMatchRef,
10357    source_cache: &mut DispatchSourceCache,
10358) -> ReceiverTypeInference {
10359    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10360    match reference.lang.as_str() {
10361        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10362        "java" => {
10363            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10364                .map(known)
10365                .unwrap_or(ReceiverTypeInference::Unknown)
10366        }
10367        "kotlin" => {
10368            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10369                .map(known)
10370                .unwrap_or(ReceiverTypeInference::Unknown)
10371        }
10372        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10373            .map(known)
10374            .unwrap_or(ReceiverTypeInference::Unknown),
10375        _ => ReceiverTypeInference::Unknown,
10376    }
10377}
10378
10379fn parse_dispatch_source(
10380    project_root: &Path,
10381    caller_file: &str,
10382    lang: LangId,
10383) -> Option<ParsedDispatchSource> {
10384    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10385    let grammar = crate::parser::grammar_for(lang);
10386    let mut parser = tree_sitter::Parser::new();
10387    parser.set_language(&grammar).ok()?;
10388    let tree = parser.parse(&source, None)?;
10389    Some(ParsedDispatchSource { source, tree })
10390}
10391
10392fn parsed_dispatch_source<'a>(
10393    project_root: &Path,
10394    reference: &NameMatchRef,
10395    lang: LangId,
10396    source_cache: &'a mut DispatchSourceCache,
10397) -> Option<&'a ParsedDispatchSource> {
10398    parsed_dispatch_source_for_file(
10399        project_root,
10400        &reference.caller_file,
10401        &reference.lang,
10402        lang,
10403        source_cache,
10404    )
10405}
10406
10407fn parsed_dispatch_source_for_file<'a>(
10408    project_root: &Path,
10409    file_path: &str,
10410    lang_label: &str,
10411    lang: LangId,
10412    source_cache: &'a mut DispatchSourceCache,
10413) -> Option<&'a ParsedDispatchSource> {
10414    let key = (file_path.to_string(), lang_label.to_string());
10415    source_cache
10416        .entry(key)
10417        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10418        .as_ref()
10419}
10420
10421fn infer_java_like_receiver_type(
10422    project_root: &Path,
10423    reference: &NameMatchRef,
10424    lang: LangId,
10425    source_cache: &mut DispatchSourceCache,
10426) -> Option<String> {
10427    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10428        return None;
10429    }
10430
10431    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10432    let root = parsed.tree.root_node();
10433    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10434
10435    let callable_scope = type_node
10436        .and_then(|node| {
10437            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10438        })
10439        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10440
10441    if let Some(callable_scope) = callable_scope {
10442        if let Some(receiver_type) = infer_java_like_local_receiver_type(
10443            callable_scope,
10444            &parsed.source,
10445            &reference.receiver,
10446            reference.line.max(1),
10447            lang,
10448        ) {
10449            return Some(receiver_type);
10450        }
10451    }
10452
10453    type_node.and_then(|node| {
10454        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10455    })
10456}
10457
10458fn infer_cpp_receiver_type(
10459    project_root: &Path,
10460    reference: &NameMatchRef,
10461    source_cache: &mut DispatchSourceCache,
10462) -> Option<String> {
10463    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10464        return None;
10465    }
10466
10467    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10468    let root = parsed.tree.root_node();
10469    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10470    infer_cpp_receiver_type_from_scope(
10471        scope,
10472        &parsed.source,
10473        &reference.receiver,
10474        reference.line.max(1),
10475    )
10476}
10477
10478fn find_enclosing_java_like_type_node<'tree>(
10479    root: tree_sitter::Node<'tree>,
10480    source: &str,
10481    reference: &NameMatchRef,
10482    lang: LangId,
10483) -> Option<tree_sitter::Node<'tree>> {
10484    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10485        .and_then(|name| simple_type_name(&name));
10486    let line = reference.line.max(1);
10487    let mut best = None;
10488    let mut stack = vec![root];
10489    while let Some(node) = stack.pop() {
10490        if !node_contains_line(node, line) {
10491            continue;
10492        }
10493        if is_java_like_type_kind(node.kind(), lang) {
10494            let name = declaration_name(node, source);
10495            if expected_type
10496                .as_deref()
10497                .is_none_or(|expected| name == Some(expected))
10498            {
10499                best = tighter_node(best, node);
10500            }
10501        }
10502        push_named_children(node, &mut stack);
10503    }
10504    best
10505}
10506
10507fn find_enclosing_java_like_callable_node<'tree>(
10508    root: tree_sitter::Node<'tree>,
10509    source: &str,
10510    reference: &NameMatchRef,
10511    lang: LangId,
10512) -> Option<tree_sitter::Node<'tree>> {
10513    let expected_name = reference.caller_symbol.rsplit("::").next();
10514    let line = reference.line.max(1);
10515    let mut best = None;
10516    let mut stack = vec![root];
10517    while let Some(node) = stack.pop() {
10518        if !node_contains_line(node, line) {
10519            continue;
10520        }
10521        if is_java_like_callable_kind(node.kind(), lang) {
10522            let name = declaration_name(node, source);
10523            if expected_name.is_none_or(|expected| name == Some(expected)) {
10524                best = tighter_node(best, node);
10525            }
10526        }
10527        push_named_children(node, &mut stack);
10528    }
10529    best
10530}
10531
10532fn find_enclosing_cpp_callable_node<'tree>(
10533    root: tree_sitter::Node<'tree>,
10534    _source: &str,
10535    reference: &NameMatchRef,
10536) -> Option<tree_sitter::Node<'tree>> {
10537    let line = reference.line.max(1);
10538    let mut best = None;
10539    let mut stack = vec![root];
10540    while let Some(node) = stack.pop() {
10541        if !node_contains_line(node, line) {
10542            continue;
10543        }
10544        if node.kind() == "function_definition" {
10545            best = tighter_node(best, node);
10546        }
10547        push_named_children(node, &mut stack);
10548    }
10549    best
10550}
10551
10552fn tighter_node<'tree>(
10553    current: Option<tree_sitter::Node<'tree>>,
10554    candidate: tree_sitter::Node<'tree>,
10555) -> Option<tree_sitter::Node<'tree>> {
10556    match current {
10557        Some(current)
10558            if current.start_byte() > candidate.start_byte()
10559                || (current.start_byte() == candidate.start_byte()
10560                    && current.end_byte() <= candidate.end_byte()) =>
10561        {
10562            Some(current)
10563        }
10564        _ => Some(candidate),
10565    }
10566}
10567
10568fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10569    let start = node.start_position().row as u32 + 1;
10570    let end = node.end_position().row as u32 + 1;
10571    start <= line && line <= end
10572}
10573
10574fn push_named_children<'tree>(
10575    node: tree_sitter::Node<'tree>,
10576    stack: &mut Vec<tree_sitter::Node<'tree>>,
10577) {
10578    for index in 0..node.named_child_count() {
10579        if let Some(child) = node.named_child(index as u32) {
10580            stack.push(child);
10581        }
10582    }
10583}
10584
10585fn declaration_name<'source>(
10586    node: tree_sitter::Node<'_>,
10587    source: &'source str,
10588) -> Option<&'source str> {
10589    node.child_by_field_name("name")
10590        .map(|name| node_text(name, source))
10591        .or_else(|| {
10592            first_named_child_text(
10593                node,
10594                source,
10595                &["identifier", "type_identifier", "simple_identifier"],
10596            )
10597        })
10598}
10599
10600fn first_named_child_text<'source>(
10601    node: tree_sitter::Node<'_>,
10602    source: &'source str,
10603    kinds: &[&str],
10604) -> Option<&'source str> {
10605    for index in 0..node.named_child_count() {
10606        let child = node.named_child(index as u32)?;
10607        if kinds.contains(&child.kind()) {
10608            return Some(node_text(child, source));
10609        }
10610    }
10611    None
10612}
10613
10614fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10615    &source[node.byte_range()]
10616}
10617
10618fn infer_java_like_field_receiver_type(
10619    type_node: tree_sitter::Node<'_>,
10620    source: &str,
10621    receiver: &str,
10622    lang: LangId,
10623) -> Option<String> {
10624    let mut stack = Vec::new();
10625    push_named_children(type_node, &mut stack);
10626    while let Some(node) = stack.pop() {
10627        if is_java_like_field_kind(node.kind(), lang) {
10628            if let Some(receiver_type) =
10629                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10630            {
10631                return Some(receiver_type);
10632            }
10633        }
10634        if is_java_like_type_kind(node.kind(), lang)
10635            || is_java_like_callable_kind(node.kind(), lang)
10636        {
10637            continue;
10638        }
10639        push_named_children(node, &mut stack);
10640    }
10641    None
10642}
10643
10644fn infer_java_like_local_receiver_type(
10645    callable_node: tree_sitter::Node<'_>,
10646    source: &str,
10647    receiver: &str,
10648    call_line: u32,
10649    lang: LangId,
10650) -> Option<String> {
10651    let mut best: Option<(u32, String)> = None;
10652    let mut stack = Vec::new();
10653    push_named_children(callable_node, &mut stack);
10654    while let Some(node) = stack.pop() {
10655        let start_line = node.start_position().row as u32 + 1;
10656        if start_line > call_line {
10657            continue;
10658        }
10659        if is_java_like_local_kind(node.kind(), lang) {
10660            if let Some(receiver_type) =
10661                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10662            {
10663                if best
10664                    .as_ref()
10665                    .is_none_or(|(best_line, _)| start_line >= *best_line)
10666                {
10667                    best = Some((start_line, receiver_type));
10668                }
10669            }
10670        }
10671        if is_java_like_type_kind(node.kind(), lang)
10672            || is_java_like_callable_kind(node.kind(), lang)
10673        {
10674            continue;
10675        }
10676        push_named_children(node, &mut stack);
10677    }
10678    best.map(|(_, receiver_type)| receiver_type)
10679}
10680
10681fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10682    match lang {
10683        LangId::Java => matches!(
10684            kind,
10685            "class_declaration"
10686                | "interface_declaration"
10687                | "enum_declaration"
10688                | "record_declaration"
10689                | "annotation_type_declaration"
10690        ),
10691        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10692        _ => false,
10693    }
10694}
10695
10696fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10697    match lang {
10698        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10699        LangId::Kotlin => kind == "function_declaration",
10700        _ => false,
10701    }
10702}
10703
10704fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10705    match lang {
10706        LangId::Java => kind == "field_declaration",
10707        LangId::Kotlin => kind == "property_declaration",
10708        _ => false,
10709    }
10710}
10711
10712fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10713    match lang {
10714        LangId::Java => kind == "local_variable_declaration",
10715        LangId::Kotlin => kind == "property_declaration",
10716        _ => false,
10717    }
10718}
10719
10720fn extract_java_like_declared_type(
10721    declaration: &str,
10722    receiver: &str,
10723    lang: LangId,
10724) -> Option<String> {
10725    match lang {
10726        LangId::Java => extract_java_declared_type(declaration, receiver),
10727        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10728        _ => None,
10729    }
10730}
10731
10732fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10733    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10734    let after = declaration[receiver_start + receiver.len()..].trim_start();
10735    if after
10736        .chars()
10737        .next()
10738        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10739    {
10740        return None;
10741    }
10742
10743    let before = declaration[..receiver_start].trim_end();
10744    if before.contains(',') {
10745        return None;
10746    }
10747    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10748}
10749
10750fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10751    loop {
10752        value = value.trim_start();
10753        if let Some(stripped) = strip_leading_java_annotation(value) {
10754            value = stripped;
10755            continue;
10756        }
10757        if let Some(stripped) = strip_leading_java_modifier(value) {
10758            value = stripped;
10759            continue;
10760        }
10761        return value.trim();
10762    }
10763}
10764
10765fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10766    let value = value.trim_start();
10767    let mut chars = value.char_indices();
10768    let (_, first) = chars.next()?;
10769    if first != '@' {
10770        return None;
10771    }
10772    let mut end = first.len_utf8();
10773    for (index, ch) in chars {
10774        if !(is_code_ident_char(ch) || ch == '.') {
10775            end = index;
10776            break;
10777        }
10778        end = index + ch.len_utf8();
10779    }
10780    let rest = value[end..].trim_start();
10781    if let Some(stripped) = rest.strip_prefix('(') {
10782        let mut depth = 1usize;
10783        for (index, ch) in stripped.char_indices() {
10784            match ch {
10785                '(' => depth += 1,
10786                ')' => {
10787                    depth = depth.saturating_sub(1);
10788                    if depth == 0 {
10789                        return Some(stripped[index + ch.len_utf8()..].trim_start());
10790                    }
10791                }
10792                _ => {}
10793            }
10794        }
10795        return Some("");
10796    }
10797    Some(rest)
10798}
10799
10800fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10801    const MODIFIERS: &[&str] = &[
10802        "public",
10803        "protected",
10804        "private",
10805        "abstract",
10806        "static",
10807        "final",
10808        "transient",
10809        "volatile",
10810        "synchronized",
10811        "native",
10812        "strictfp",
10813    ];
10814    MODIFIERS
10815        .iter()
10816        .find_map(|modifier| strip_leading_word(value, modifier))
10817}
10818
10819fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10820    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10821    let before = &declaration[..receiver_start];
10822    if find_identifier_occurrence(before, "val").is_none()
10823        && find_identifier_occurrence(before, "var").is_none()
10824    {
10825        return None;
10826    }
10827
10828    let after = declaration[receiver_start + receiver.len()..].trim_start();
10829    if let Some(type_text) = after.strip_prefix(':') {
10830        return normalize_receiver_type_name(read_type_prefix(type_text));
10831    }
10832    after
10833        .strip_prefix('=')
10834        .and_then(infer_kotlin_constructor_type)
10835}
10836
10837fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
10838    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
10839    if rest.trim_start().starts_with('(') {
10840        normalize_receiver_type_name(head)
10841    } else {
10842        None
10843    }
10844}
10845
10846fn read_type_prefix(value: &str) -> &str {
10847    let mut angle_depth = 0usize;
10848    for (index, ch) in value.char_indices() {
10849        match ch {
10850            '<' => angle_depth += 1,
10851            '>' => angle_depth = angle_depth.saturating_sub(1),
10852            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
10853                return value[..index].trim();
10854            }
10855            _ => {}
10856        }
10857    }
10858    value.trim()
10859}
10860
10861fn infer_cpp_receiver_type_from_scope(
10862    scope: tree_sitter::Node<'_>,
10863    source: &str,
10864    receiver: &str,
10865    call_line: u32,
10866) -> Option<String> {
10867    let lines = source.lines().collect::<Vec<_>>();
10868    if lines.is_empty() {
10869        return None;
10870    }
10871    let scope_start = scope.start_position().row as usize;
10872    let call_index = (call_line as usize)
10873        .saturating_sub(1)
10874        .min(lines.len().saturating_sub(1));
10875    for index in (scope_start..=call_index).rev() {
10876        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
10877            return Some(receiver_type);
10878        }
10879    }
10880    None
10881}
10882
10883fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10884    for receiver_start in identifier_occurrences(line, receiver) {
10885        let after = line[receiver_start + receiver.len()..].trim_start();
10886        if after
10887            .chars()
10888            .next()
10889            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10890        {
10891            continue;
10892        }
10893        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10894        let normalized = normalize_cpp_type_name(type_text)?;
10895        if normalized == "auto" {
10896            if let Some(rhs) = after.strip_prefix('=') {
10897                return infer_cpp_auto_receiver_type(rhs);
10898            }
10899            continue;
10900        }
10901        return Some(normalized);
10902    }
10903    None
10904}
10905
10906fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10907    let candidate = prefix
10908        .rsplit([';', '{', '}', '('])
10909        .next()
10910        .unwrap_or(prefix)
10911        .trim();
10912    if candidate.is_empty() || candidate.ends_with(',') {
10913        None
10914    } else {
10915        Some(candidate)
10916    }
10917}
10918
10919fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10920    let without_templates = strip_angle_groups(type_text);
10921    let mut cleaned = String::with_capacity(without_templates.len());
10922    for token in without_templates.split_whitespace() {
10923        if matches!(
10924            token,
10925            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10926        ) {
10927            continue;
10928        }
10929        if !cleaned.is_empty() {
10930            cleaned.push(' ');
10931        }
10932        cleaned.push_str(token);
10933    }
10934    let token = cleaned
10935        .split_whitespace()
10936        .last()
10937        .unwrap_or(cleaned.trim())
10938        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10939        .trim_matches(['*', '&']);
10940    let simple = token.rsplit("::").next().unwrap_or(token).trim();
10941    if simple.is_empty() || cpp_non_type_token(simple) {
10942        None
10943    } else {
10944        Some(simple.to_string())
10945    }
10946}
10947
10948fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10949    let rhs = rhs.trim_start();
10950    if let Some(after_new) = rhs.strip_prefix("new ") {
10951        return infer_cpp_constructor_type(after_new);
10952    }
10953    infer_cpp_make_template_type(rhs)
10954        .or_else(|| infer_cpp_constructor_type(rhs))
10955        .or_else(|| infer_cpp_factory_type(rhs))
10956}
10957
10958fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10959    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10960    let normalized = normalize_cpp_type_name(head)?;
10961    if !normalized
10962        .chars()
10963        .next()
10964        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10965    {
10966        return None;
10967    }
10968    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10969        Some(normalized)
10970    } else {
10971        None
10972    }
10973}
10974
10975fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10976    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10977    if !rest.trim_start().starts_with('(') {
10978        return None;
10979    }
10980    let base = head.split('<').next().unwrap_or(head);
10981    let base_simple = base.rsplit("::").next().unwrap_or(base);
10982    if !matches!(base_simple, "make_unique" | "make_shared") {
10983        return None;
10984    }
10985    first_angle_arg(head).and_then(normalize_cpp_type_name)
10986}
10987
10988fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10989    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10990    if !rest.trim_start().starts_with('(') {
10991        return None;
10992    }
10993    let simple = head
10994        .split('<')
10995        .next()
10996        .unwrap_or(head)
10997        .rsplit("::")
10998        .next()
10999        .unwrap_or(head);
11000    for prefix in ["make", "create", "build"] {
11001        if let Some(suffix) = simple.strip_prefix(prefix) {
11002            if suffix
11003                .chars()
11004                .next()
11005                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11006            {
11007                return normalize_cpp_type_name(suffix);
11008            }
11009        }
11010    }
11011    None
11012}
11013
11014#[derive(Debug, Clone, Copy)]
11015enum JavaLikeInvocation {
11016    Kotlin,
11017    Cpp,
11018}
11019
11020fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
11021    let value = value.trim_start();
11022    let mut end = 0usize;
11023    for (index, ch) in value.char_indices() {
11024        let allowed_separator = match flavor {
11025            JavaLikeInvocation::Kotlin => ch == '.',
11026            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
11027        };
11028        if is_code_ident_char(ch) || allowed_separator {
11029            end = index + ch.len_utf8();
11030            continue;
11031        }
11032        break;
11033    }
11034    if end == 0 {
11035        return None;
11036    }
11037    let mut rest = &value[end..];
11038    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
11039        let skipped = skip_balanced_angle(stripped)?;
11040        let rest_start = rest.len() - rest.trim_start().len();
11041        let angle_len = 1 + skipped;
11042        end += rest_start + angle_len;
11043        rest = &value[end..];
11044    }
11045    Some((value[..end].trim(), rest))
11046}
11047
11048fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11049    let mut depth = 1usize;
11050    for (index, ch) in value_after_open.char_indices() {
11051        match ch {
11052            '<' => depth += 1,
11053            '>' => {
11054                depth = depth.saturating_sub(1);
11055                if depth == 0 {
11056                    return Some(index + ch.len_utf8());
11057                }
11058            }
11059            _ => {}
11060        }
11061    }
11062    None
11063}
11064
11065fn first_angle_arg(value: &str) -> Option<&str> {
11066    let open = value.find('<')?;
11067    let inner_len = skip_balanced_angle(&value[open + 1..])?;
11068    let inner = &value[open + 1..open + inner_len];
11069    split_top_level_commas(inner).into_iter().next()
11070}
11071
11072fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11073    let without_generics = strip_angle_groups(type_text);
11074    let cleaned = without_generics
11075        .replace("[]", " ")
11076        .replace("...", " ")
11077        .replace(['?', '&', '*'], " ");
11078    let token = cleaned
11079        .split_whitespace()
11080        .last()
11081        .unwrap_or(cleaned.trim())
11082        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11083    let token = token.rsplit("::").next().unwrap_or(token);
11084    let simple = token.rsplit('.').next().unwrap_or(token).trim();
11085    if simple.is_empty()
11086        || java_like_primitive_type(simple)
11087        || !simple
11088            .chars()
11089            .next()
11090            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11091    {
11092        None
11093    } else {
11094        Some(simple.to_string())
11095    }
11096}
11097
11098fn simple_type_name(scoped_name: &str) -> Option<String> {
11099    scoped_name
11100        .rsplit("::")
11101        .find(|segment| !segment.is_empty())
11102        .and_then(normalize_receiver_type_name)
11103}
11104
11105fn strip_angle_groups(value: &str) -> String {
11106    let mut output = String::with_capacity(value.len());
11107    let mut depth = 0usize;
11108    for ch in value.chars() {
11109        match ch {
11110            '<' => {
11111                if depth == 0 {
11112                    output.push(' ');
11113                }
11114                depth += 1;
11115            }
11116            '>' => depth = depth.saturating_sub(1),
11117            _ if depth == 0 => output.push(ch),
11118            _ => {}
11119        }
11120    }
11121    output
11122}
11123
11124fn java_like_primitive_type(value: &str) -> bool {
11125    matches!(
11126        value,
11127        "boolean"
11128            | "byte"
11129            | "char"
11130            | "double"
11131            | "float"
11132            | "int"
11133            | "long"
11134            | "short"
11135            | "void"
11136            | "Boolean"
11137            | "Byte"
11138            | "Char"
11139            | "Double"
11140            | "Float"
11141            | "Int"
11142            | "Long"
11143            | "Short"
11144            | "Unit"
11145    )
11146}
11147
11148fn cpp_non_type_token(value: &str) -> bool {
11149    matches!(
11150        value,
11151        "return"
11152            | "if"
11153            | "else"
11154            | "for"
11155            | "while"
11156            | "do"
11157            | "switch"
11158            | "case"
11159            | "default"
11160            | "break"
11161            | "continue"
11162            | "goto"
11163            | "throw"
11164            | "new"
11165            | "delete"
11166            | "co_await"
11167            | "co_yield"
11168            | "co_return"
11169            | "static_cast"
11170            | "const_cast"
11171            | "dynamic_cast"
11172            | "reinterpret_cast"
11173            | "sizeof"
11174            | "alignof"
11175            | "typeid"
11176            | "and"
11177            | "or"
11178            | "not"
11179            | "xor"
11180    )
11181}
11182
11183fn receiver_is_bare_identifier(value: &str) -> bool {
11184    let mut chars = value.chars();
11185    let Some(first) = chars.next() else {
11186        return false;
11187    };
11188    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11189}
11190
11191fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11192    identifier_occurrences(value, needle).into_iter().next()
11193}
11194
11195fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11196    value
11197        .match_indices(needle)
11198        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11199        .collect()
11200}
11201
11202fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11203    let before = value[..start].chars().next_back();
11204    let after = value[start + len..].chars().next();
11205    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11206}
11207
11208fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11209    let stripped = value.strip_prefix(word)?;
11210    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11211        Some(stripped.trim_start())
11212    } else {
11213        None
11214    }
11215}
11216
11217fn is_code_ident_char(ch: char) -> bool {
11218    ch == '_' || ch.is_ascii_alphanumeric()
11219}
11220
11221fn infer_rust_receiver_type(
11222    project_root: &Path,
11223    reference: &NameMatchRef,
11224    source_cache: &mut DispatchSourceCache,
11225) -> ReceiverTypeInference {
11226    if matches!(reference.receiver.as_str(), "self" | "Self") {
11227        return enclosing_type_from_scoped_name(&reference.caller_symbol)
11228            .map(ReceiverTypeInference::Known)
11229            .unwrap_or(ReceiverTypeInference::Unknown);
11230    }
11231
11232    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11233        return ReceiverTypeInference::Known(reference.receiver.clone());
11234    }
11235
11236    if let Some(receiver_type) = reference
11237        .caller_signature
11238        .as_deref()
11239        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11240    {
11241        return ReceiverTypeInference::Known(receiver_type);
11242    }
11243
11244    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11245}
11246
11247fn infer_rust_direct_self_field_receiver_type(
11248    project_root: &Path,
11249    reference: &NameMatchRef,
11250    source_cache: &mut DispatchSourceCache,
11251) -> ReceiverTypeInference {
11252    if reference.colon_dispatch {
11253        return ReceiverTypeInference::Unknown;
11254    }
11255    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11256        return ReceiverTypeInference::Unknown;
11257    };
11258    if field_name != reference.receiver {
11259        return ReceiverTypeInference::Unknown;
11260    }
11261
11262    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11263        return ReceiverTypeInference::Unknown;
11264    };
11265    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11266        return ReceiverTypeInference::KnownButUnresolved;
11267    };
11268    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11269    else {
11270        return ReceiverTypeInference::Unknown;
11271    };
11272    let Some(impl_node) =
11273        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11274    else {
11275        return ReceiverTypeInference::Unknown;
11276    };
11277    if impl_node.child_by_field_name("trait").is_some()
11278        || impl_node.child_by_field_name("type_parameters").is_some()
11279    {
11280        return ReceiverTypeInference::KnownButUnresolved;
11281    }
11282    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11283        return ReceiverTypeInference::KnownButUnresolved;
11284    };
11285    if impl_target.kind() != "type_identifier"
11286        || node_text(impl_target, &parsed.source) != impl_type
11287    {
11288        return ReceiverTypeInference::KnownButUnresolved;
11289    }
11290
11291    let module_scope = rust_module_scope(impl_node);
11292    let Some(struct_node) = find_unique_rust_struct(
11293        parsed.tree.root_node(),
11294        &parsed.source,
11295        struct_name,
11296        &module_scope,
11297    ) else {
11298        return ReceiverTypeInference::KnownButUnresolved;
11299    };
11300    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11301    else {
11302        return ReceiverTypeInference::KnownButUnresolved;
11303    };
11304    if field_type.kind() != "type_identifier" {
11305        return ReceiverTypeInference::KnownButUnresolved;
11306    }
11307    let field_type_name = node_text(field_type, &parsed.source);
11308    if find_unique_rust_struct(
11309        parsed.tree.root_node(),
11310        &parsed.source,
11311        field_type_name,
11312        &module_scope,
11313    )
11314    .is_none()
11315    {
11316        return ReceiverTypeInference::KnownButUnresolved;
11317    }
11318
11319    ReceiverTypeInference::RustDirectSelfField {
11320        receiver_type: field_type_name.to_string(),
11321        declaration_file: reference.caller_file.clone(),
11322        module_scope,
11323    }
11324}
11325
11326fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11327    let (base, field) = receiver_expression.split_once('.')?;
11328    let base = base.trim();
11329    let field = field.trim();
11330    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11331}
11332
11333fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11334    let name = value.rsplit("::").next()?.trim();
11335    (!name.is_empty()
11336        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11337        && name.chars().all(is_rust_ident_char))
11338    .then_some(name)
11339}
11340
11341fn find_enclosing_rust_impl_node<'tree>(
11342    root: tree_sitter::Node<'tree>,
11343    line: u32,
11344) -> Option<tree_sitter::Node<'tree>> {
11345    let mut best = None;
11346    let mut stack = vec![root];
11347    while let Some(node) = stack.pop() {
11348        if !node_contains_line(node, line) {
11349            continue;
11350        }
11351        if node.kind() == "impl_item" {
11352            best = tighter_node(best, node);
11353        }
11354        push_named_children(node, &mut stack);
11355    }
11356    best
11357}
11358
11359fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11360    let mut scope = Vec::new();
11361    let mut current = node.parent();
11362    while let Some(parent) = current {
11363        if parent.kind() == "mod_item" {
11364            scope.push((parent.start_byte(), parent.end_byte()));
11365        }
11366        current = parent.parent();
11367    }
11368    scope.reverse();
11369    scope
11370}
11371
11372fn find_unique_rust_struct<'tree>(
11373    root: tree_sitter::Node<'tree>,
11374    source: &str,
11375    expected_name: &str,
11376    module_scope: &[(usize, usize)],
11377) -> Option<tree_sitter::Node<'tree>> {
11378    let mut found = None;
11379    let mut stack = vec![root];
11380    while let Some(node) = stack.pop() {
11381        if node.kind() == "struct_item"
11382            && rust_module_scope(node) == module_scope
11383            && node.child_by_field_name("type_parameters").is_none()
11384            && declaration_name(node, source) == Some(expected_name)
11385        {
11386            if found.is_some() {
11387                return None;
11388            }
11389            found = Some(node);
11390        }
11391        push_named_children(node, &mut stack);
11392    }
11393    found
11394}
11395
11396fn rust_struct_field_type_node<'tree>(
11397    struct_node: tree_sitter::Node<'tree>,
11398    source: &str,
11399    field_name: &str,
11400) -> Option<tree_sitter::Node<'tree>> {
11401    let fields = struct_node.child_by_field_name("body")?;
11402    if fields.kind() != "field_declaration_list" {
11403        return None;
11404    }
11405    for index in 0..fields.named_child_count() {
11406        let field = fields.named_child(index as u32)?;
11407        if field.kind() != "field_declaration"
11408            || declaration_name(field, source) != Some(field_name)
11409        {
11410            continue;
11411        }
11412        return field.child_by_field_name("type");
11413    }
11414    None
11415}
11416
11417fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11418    receiver
11419        .chars()
11420        .next()
11421        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11422}
11423
11424fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11425    scoped_name
11426        .rsplit_once("::")
11427        .map(|(enclosing, _)| enclosing)
11428        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11429        .map(ToString::to_string)
11430}
11431
11432fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11433    let params = signature_parameter_text(signature)?;
11434    for param in split_top_level_commas(params) {
11435        let Some((pattern, type_text)) = param.split_once(':') else {
11436            continue;
11437        };
11438        let Some(name) = rust_parameter_name(pattern) else {
11439            continue;
11440        };
11441        if name == receiver {
11442            return normalize_rust_receiver_type(type_text);
11443        }
11444    }
11445    None
11446}
11447
11448fn signature_parameter_text(signature: &str) -> Option<&str> {
11449    let open = signature.find('(')?;
11450    let mut depth = 0usize;
11451    for (offset, ch) in signature[open..].char_indices() {
11452        match ch {
11453            '(' => depth += 1,
11454            ')' => {
11455                depth = depth.saturating_sub(1);
11456                if depth == 0 {
11457                    return Some(&signature[open + 1..open + offset]);
11458                }
11459            }
11460            _ => {}
11461        }
11462    }
11463    None
11464}
11465
11466fn split_top_level_commas(value: &str) -> Vec<&str> {
11467    let mut parts = Vec::new();
11468    let mut start = 0usize;
11469    let mut angle_depth = 0usize;
11470    let mut paren_depth = 0usize;
11471    let mut bracket_depth = 0usize;
11472    for (index, ch) in value.char_indices() {
11473        match ch {
11474            '<' => angle_depth += 1,
11475            '>' => angle_depth = angle_depth.saturating_sub(1),
11476            '(' => paren_depth += 1,
11477            ')' => paren_depth = paren_depth.saturating_sub(1),
11478            '[' => bracket_depth += 1,
11479            ']' => bracket_depth = bracket_depth.saturating_sub(1),
11480            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11481                let part = value[start..index].trim();
11482                if !part.is_empty() {
11483                    parts.push(part);
11484                }
11485                start = index + ch.len_utf8();
11486            }
11487            _ => {}
11488        }
11489    }
11490    let part = value[start..].trim();
11491    if !part.is_empty() {
11492        parts.push(part);
11493    }
11494    parts
11495}
11496
11497fn rust_parameter_name(pattern: &str) -> Option<&str> {
11498    let mut pattern = pattern.trim();
11499    if let Some(stripped) = pattern.strip_prefix("mut ") {
11500        pattern = stripped.trim_start();
11501    }
11502    pattern
11503        .rsplit(|ch: char| !is_rust_ident_char(ch))
11504        .find(|part| !part.is_empty())
11505}
11506
11507fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11508    let mut ty = strip_leading_rust_type_modifiers(type_text);
11509    let owned_inner;
11510    if let Some(inner) = single_outer_generic_arg(ty) {
11511        owned_inner = inner.trim().to_string();
11512        ty = strip_leading_rust_type_modifiers(&owned_inner);
11513    }
11514    rust_base_type_ident(ty)
11515}
11516
11517fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11518    loop {
11519        ty = ty.trim_start();
11520        if let Some(stripped) = ty.strip_prefix('&') {
11521            ty = stripped.trim_start();
11522            if let Some(stripped) = strip_leading_lifetime(ty) {
11523                ty = stripped.trim_start();
11524            }
11525            if let Some(stripped) = ty.strip_prefix("mut ") {
11526                ty = stripped.trim_start();
11527            }
11528            continue;
11529        }
11530        if let Some(stripped) = ty.strip_prefix("mut ") {
11531            ty = stripped.trim_start();
11532            continue;
11533        }
11534        if let Some(stripped) = ty.strip_prefix("dyn ") {
11535            ty = stripped.trim_start();
11536            continue;
11537        }
11538        if let Some(stripped) = ty.strip_prefix("impl ") {
11539            ty = stripped.trim_start();
11540            continue;
11541        }
11542        break ty.trim();
11543    }
11544}
11545
11546fn strip_leading_lifetime(value: &str) -> Option<&str> {
11547    let mut chars = value.char_indices();
11548    let (_, first) = chars.next()?;
11549    if first != '\'' {
11550        return None;
11551    }
11552    for (index, ch) in chars {
11553        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11554            return Some(&value[index..]);
11555        }
11556    }
11557    Some("")
11558}
11559
11560fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11561    let ty = ty.trim();
11562    let open = ty.find('<')?;
11563    let mut depth = 0usize;
11564    let mut close = None;
11565    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11566        match ch {
11567            '<' => depth += 1,
11568            '>' => {
11569                depth = depth.saturating_sub(1);
11570                if depth == 0 {
11571                    close = Some(index);
11572                    break;
11573                }
11574            }
11575            _ => {}
11576        }
11577    }
11578    let close = close?;
11579    if !ty[close + 1..].trim().is_empty() {
11580        return None;
11581    }
11582    let inner = &ty[open + 1..close];
11583    let args = split_top_level_commas(inner);
11584    match args.as_slice() {
11585        [arg] => Some(*arg),
11586        _ => None,
11587    }
11588}
11589
11590fn rust_base_type_ident(ty: &str) -> Option<String> {
11591    let ty = ty.trim();
11592    let head = ty
11593        .split([' ', '+', '='])
11594        .find(|part| !part.is_empty())
11595        .unwrap_or(ty);
11596    let head = head.split('<').next().unwrap_or(head).trim();
11597    let ident = head
11598        .rsplit("::")
11599        .next()
11600        .unwrap_or(head)
11601        .trim_matches(|ch: char| !is_rust_ident_char(ch));
11602    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11603        None
11604    } else {
11605        Some(ident.to_string())
11606    }
11607}
11608
11609fn is_rust_ident_char(ch: char) -> bool {
11610    ch == '_' || ch.is_ascii_alphanumeric()
11611}
11612
11613fn select_rust_direct_self_field_candidate(
11614    project_root: &Path,
11615    reference: &NameMatchRef,
11616    candidates: &[NameMatchCandidate],
11617    receiver_type: &str,
11618    declaration_file: &str,
11619    declaration_scope: &[(usize, usize)],
11620    source_cache: &mut DispatchSourceCache,
11621) -> Option<NameMatchCandidate> {
11622    let eligible = candidates
11623        .iter()
11624        .filter(|candidate| candidate.node_id != reference.caller_node)
11625        .filter(|candidate| {
11626            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11627        })
11628        .filter(|candidate| {
11629            rust_direct_self_field_candidate_matches_scope(
11630                project_root,
11631                candidate,
11632                receiver_type,
11633                declaration_file,
11634                declaration_scope,
11635                source_cache,
11636            )
11637        })
11638        .collect::<Vec<_>>();
11639    match eligible.as_slice() {
11640        [candidate] => Some((**candidate).clone()),
11641        _ => None,
11642    }
11643}
11644
11645fn rust_direct_self_field_candidate_matches_scope(
11646    project_root: &Path,
11647    candidate: &NameMatchCandidate,
11648    receiver_type: &str,
11649    declaration_file: &str,
11650    declaration_scope: &[(usize, usize)],
11651    source_cache: &mut DispatchSourceCache,
11652) -> bool {
11653    if candidate.file_path != declaration_file {
11654        return false;
11655    }
11656    let Some(parsed) = parsed_dispatch_source_for_file(
11657        project_root,
11658        &candidate.file_path,
11659        "rust",
11660        LangId::Rust,
11661        source_cache,
11662    ) else {
11663        return false;
11664    };
11665    let Some(impl_node) =
11666        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11667    else {
11668        return false;
11669    };
11670    if impl_node.child_by_field_name("trait").is_some()
11671        || impl_node.child_by_field_name("type_parameters").is_some()
11672    {
11673        return false;
11674    }
11675    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11676        return false;
11677    };
11678    impl_target.kind() == "type_identifier"
11679        && node_text(impl_target, &parsed.source) == receiver_type
11680        && rust_module_scope(impl_node) == declaration_scope
11681}
11682
11683fn select_type_match_candidate(
11684    reference: &NameMatchRef,
11685    candidates: &[NameMatchCandidate],
11686    receiver_type: &str,
11687) -> Option<NameMatchCandidate> {
11688    let candidates = candidates
11689        .iter()
11690        .filter(|candidate| candidate.node_id != reference.caller_node)
11691        .filter(|candidate| {
11692            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11693        })
11694        .collect::<Vec<_>>();
11695    match candidates.as_slice() {
11696        [candidate] => Some((**candidate).clone()),
11697        _ => None,
11698    }
11699}
11700
11701fn type_candidate_matches(
11702    candidate: &NameMatchCandidate,
11703    receiver_type: &str,
11704    method_name: &str,
11705) -> bool {
11706    let normalized_type = receiver_type.replace('.', "::");
11707    let suffix = format!("{normalized_type}::{method_name}");
11708    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11709}
11710
11711fn select_name_match_candidate(
11712    reference: &NameMatchRef,
11713    candidates: &[NameMatchCandidate],
11714) -> Option<NameMatchCandidate> {
11715    let candidates = candidates
11716        .iter()
11717        .filter(|candidate| candidate.node_id != reference.caller_node)
11718        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11719        .collect::<Vec<_>>();
11720    match candidates.as_slice() {
11721        [] => None,
11722        [candidate] => Some((**candidate).clone()),
11723        _ => select_scored_name_match_candidate(reference, &candidates),
11724    }
11725}
11726
11727fn candidate_allowed_for_reference(
11728    reference: &NameMatchRef,
11729    candidate: &NameMatchCandidate,
11730) -> bool {
11731    if !reference.colon_dispatch {
11732        return true;
11733    }
11734
11735    candidate.kind == "method"
11736        && candidate
11737            .scoped_name
11738            .split("::")
11739            .any(|segment| segment == reference.receiver)
11740}
11741
11742fn select_scored_name_match_candidate(
11743    reference: &NameMatchRef,
11744    candidates: &[&NameMatchCandidate],
11745) -> Option<NameMatchCandidate> {
11746    let receiver_words = split_camel_case(&reference.receiver);
11747    if receiver_words.is_empty() {
11748        return None;
11749    }
11750
11751    let mut best: Option<(&NameMatchCandidate, f64)> = None;
11752    let mut tied_best = false;
11753    for candidate in candidates {
11754        let candidate_words = split_camel_case(&candidate.scoped_name);
11755        let overlap = receiver_words
11756            .iter()
11757            .filter(|receiver_word| {
11758                candidate_words
11759                    .iter()
11760                    .any(|candidate_word| candidate_word == *receiver_word)
11761            })
11762            .count() as f64;
11763        let score =
11764            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11765        match best {
11766            None => {
11767                best = Some((*candidate, score));
11768                tied_best = false;
11769            }
11770            Some((_, best_score)) if score > best_score => {
11771                best = Some((*candidate, score));
11772                tied_best = false;
11773            }
11774            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11775                tied_best = true;
11776            }
11777            _ => {}
11778        }
11779    }
11780
11781    let (candidate, score) = best?;
11782    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11783        Some(candidate.clone())
11784    } else {
11785        None
11786    }
11787}
11788
11789fn method_name_match_denylisted(method_name: &str) -> bool {
11790    matches!(
11791        method_name,
11792        "and_then"
11793            | "as_bytes"
11794            | "as_deref"
11795            | "as_mut"
11796            | "as_ref"
11797            | "as_str"
11798            | "borrow"
11799            | "borrow_mut"
11800            | "clear"
11801            | "clone"
11802            | "collect"
11803            | "contains"
11804            | "contains_key"
11805            | "count"
11806            | "dedup"
11807            | "default"
11808            | "drain"
11809            | "ends_with"
11810            | "entry"
11811            | "err"
11812            | "expect"
11813            | "extend"
11814            | "filter"
11815            | "filter_map"
11816            | "find"
11817            | "from"
11818            | "get"
11819            | "get_mut"
11820            | "insert"
11821            | "into"
11822            | "into_iter"
11823            | "is_empty"
11824            | "is_err"
11825            | "is_none"
11826            | "is_ok"
11827            | "is_some"
11828            | "iter"
11829            | "iter_mut"
11830            | "join"
11831            | "len"
11832            | "lock"
11833            | "map"
11834            | "map_err"
11835            | "max"
11836            | "min"
11837            | "new"
11838            | "next"
11839            | "ok"
11840            | "or_default"
11841            | "or_else"
11842            | "or_insert"
11843            | "or_insert_with"
11844            | "parse"
11845            | "pop"
11846            | "position"
11847            | "push"
11848            | "read"
11849            | "recv"
11850            | "remove"
11851            | "replace"
11852            | "retain"
11853            | "send"
11854            | "sort"
11855            | "sort_by"
11856            | "split"
11857            | "starts_with"
11858            | "sum"
11859            | "take"
11860            | "to_owned"
11861            | "to_string"
11862            | "trim"
11863            | "try_from"
11864            | "try_into"
11865            | "unwrap"
11866            | "unwrap_or"
11867            | "unwrap_or_default"
11868            | "unwrap_or_else"
11869            | "with_capacity"
11870            | "write"
11871    )
11872}
11873
11874fn split_camel_case(value: &str) -> Vec<String> {
11875    let chars = value.chars().collect::<Vec<_>>();
11876    let mut normalized = String::with_capacity(value.len() + 8);
11877    for (index, ch) in chars.iter().enumerate() {
11878        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
11879        let next = chars.get(index + 1);
11880        let is_separator = ch.is_whitespace()
11881            || matches!(
11882                ch,
11883                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11884            );
11885        if is_separator {
11886            normalized.push(' ');
11887            continue;
11888        }
11889        let camel_boundary = previous.is_some_and(|prev| {
11890            (prev.is_lowercase() && ch.is_uppercase())
11891                || (prev.is_ascii_digit() && ch.is_alphabetic())
11892                || (prev.is_uppercase()
11893                    && ch.is_uppercase()
11894                    && next.is_some_and(|next| next.is_lowercase()))
11895        });
11896        if camel_boundary {
11897            normalized.push(' ');
11898        }
11899        normalized.push(*ch);
11900    }
11901
11902    normalized
11903        .split_whitespace()
11904        .filter(|word| word.len() > 1)
11905        .map(|word| word.to_ascii_lowercase())
11906        .collect()
11907}
11908
11909fn compute_path_proximity(left: &str, right: &str) -> f64 {
11910    let left_dirs = left
11911        .rsplit_once('/')
11912        .map(|(dir, _)| dir)
11913        .unwrap_or_default()
11914        .split('/')
11915        .filter(|part| !part.is_empty());
11916    let right_dirs = right
11917        .rsplit_once('/')
11918        .map(|(dir, _)| dir)
11919        .unwrap_or_default()
11920        .split('/')
11921        .filter(|part| !part.is_empty());
11922
11923    let shared = left_dirs
11924        .zip(right_dirs)
11925        .take_while(|(left, right)| left == right)
11926        .count();
11927    ((shared as f64) * 0.05).min(0.5)
11928}
11929
11930fn mark_backend_state(
11931    tx: &Transaction<'_>,
11932    project_root: &Path,
11933    rel_path: &str,
11934    content_hash: Option<&blake3::Hash>,
11935    status: &str,
11936) -> Result<()> {
11937    clear_backend_state_for_file(tx, project_root, rel_path)?;
11938    let hash = content_hash
11939        .map(|hash| hash_to_hex(*hash))
11940        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11941    tx.execute(
11942        "INSERT OR REPLACE INTO backend_file_state(
11943            backend, workspace_root, file_path, content_hash, status, updated_at
11944        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11945        params![
11946            BACKEND_TREESITTER,
11947            project_root.display().to_string(),
11948            rel_path,
11949            hash,
11950            status,
11951            unix_seconds_now(),
11952        ],
11953    )?;
11954    Ok(())
11955}
11956
11957fn clear_backend_state_for_file(
11958    tx: &Transaction<'_>,
11959    project_root: &Path,
11960    rel_path: &str,
11961) -> Result<()> {
11962    tx.execute(
11963        "DELETE FROM backend_file_state
11964         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11965        params![
11966            BACKEND_TREESITTER,
11967            project_root.display().to_string(),
11968            rel_path
11969        ],
11970    )?;
11971    Ok(())
11972}
11973
11974/// Mark a file whose graph bytes were just confirmed current as fresh.
11975///
11976/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
11977/// leftover `status='stale'` row from a failed refresh would keep blocking
11978/// dead-code projection even though the graph still matches disk.
11979fn clear_stale_backend_status_for_file(
11980    tx: &Transaction<'_>,
11981    project_root: &Path,
11982    rel_path: &str,
11983) -> Result<()> {
11984    tx.execute(
11985        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
11986         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
11987        params![
11988            BACKEND_TREESITTER,
11989            project_root.display().to_string(),
11990            rel_path,
11991            unix_seconds_now(),
11992        ],
11993    )?;
11994    Ok(())
11995}
11996
11997fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11998    conn.query_row(
11999        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
12000        params![rel_path],
12001        |row| {
12002            let hash_text: String = row.get(1)?;
12003            Ok(FileRow {
12004                surface_fingerprint: row.get(0)?,
12005                freshness: FileFreshness {
12006                    content_hash: hash_from_hex(&hash_text)
12007                        .unwrap_or_else(cache_freshness::zero_hash),
12008                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
12009                    size: row.get::<_, i64>(3)? as u64,
12010                },
12011            })
12012        },
12013    )
12014    .optional()
12015    .map_err(CallGraphStoreError::from)
12016}
12017
12018fn stored_node_ids_match_extract(
12019    tx: &Transaction<'_>,
12020    rel_path: &str,
12021    extract: &FileExtract,
12022) -> Result<bool> {
12023    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
12024    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12025    let mut stored = BTreeSet::new();
12026    for row in rows {
12027        stored.insert(row?);
12028    }
12029    let extracted = extract
12030        .nodes
12031        .iter()
12032        .map(|node| node.id.clone())
12033        .collect::<BTreeSet<_>>();
12034    Ok(stored == extracted)
12035}
12036
12037/// Compare every persisted graph row that comes from this file before rewriting it.
12038/// Ranges and reference byte offsets are part of the key because queries expose
12039/// source locations; equal names and edges are not enough after a body shift.
12040fn stored_extract_matches(
12041    tx: &Transaction<'_>,
12042    rel_path: &str,
12043    extract: &FileExtract,
12044    index: &ProjectIndex<'_>,
12045) -> Result<bool> {
12046    let stored_file = tx
12047        .query_row(
12048            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12049            params![rel_path],
12050            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12051        )
12052        .optional()?;
12053    if stored_file
12054        != Some((
12055            lang_label(extract.lang).to_string(),
12056            extract.surface_fingerprint.clone(),
12057        ))
12058    {
12059        return Ok(false);
12060    }
12061
12062    let mut stored_nodes_stmt = tx.prepare(
12063        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12064                end_line, end_col, range_ordinal, signature, exported,
12065                is_default_export, is_type_like, is_callgraph_entry_point, provenance
12066         FROM nodes WHERE file_path = ?1",
12067    )?;
12068    let stored_nodes = stored_nodes_stmt
12069        .query_map(params![rel_path], |row| {
12070            Ok(serde_json::json!([
12071                row.get::<_, String>(0)?,
12072                row.get::<_, String>(1)?,
12073                row.get::<_, String>(2)?,
12074                row.get::<_, String>(3)?,
12075                row.get::<_, String>(4)?,
12076                row.get::<_, i64>(5)?,
12077                row.get::<_, i64>(6)?,
12078                row.get::<_, i64>(7)?,
12079                row.get::<_, i64>(8)?,
12080                row.get::<_, i64>(9)?,
12081                row.get::<_, Option<String>>(10)?,
12082                row.get::<_, i64>(11)?,
12083                row.get::<_, i64>(12)?,
12084                row.get::<_, i64>(13)?,
12085                row.get::<_, i64>(14)?,
12086                row.get::<_, String>(15)?,
12087            ])
12088            .to_string())
12089        })?
12090        .collect::<rusqlite::Result<Vec<_>>>()?;
12091    let expected_nodes = extract
12092        .nodes
12093        .iter()
12094        .map(|node| {
12095            serde_json::json!([
12096                node.id,
12097                node.file_path,
12098                node.name,
12099                node.scoped_name,
12100                node.kind,
12101                node.range.start_line,
12102                node.range.start_col,
12103                node.range.end_line,
12104                node.range.end_col,
12105                node.range_ordinal,
12106                node.signature,
12107                bool_int(node.exported),
12108                bool_int(node.is_default_export),
12109                bool_int(node.is_type_like),
12110                bool_int(node.is_callgraph_entry_point),
12111                PROVENANCE_TREESITTER,
12112            ])
12113            .to_string()
12114        })
12115        .collect::<Vec<_>>();
12116    let mut stored_nodes = stored_nodes;
12117    let mut expected_nodes = expected_nodes;
12118    stored_nodes.sort();
12119    expected_nodes.sort();
12120    if stored_nodes != expected_nodes {
12121        return Ok(false);
12122    }
12123
12124    let resolved_refs = extract
12125        .raw_refs
12126        .iter()
12127        .cloned()
12128        .map(|raw| resolve_ref(raw, index))
12129        .collect::<Result<Vec<_>>>()?;
12130    let mut stored_refs_stmt = tx.prepare(
12131        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12132                module_path, import_kind, local_name, requested_name, namespace_alias,
12133                wildcard, line, byte_start, byte_end, status, target_node,
12134                target_file, target_symbol, provenance
12135         FROM refs WHERE caller_file = ?1",
12136    )?;
12137    let stored_refs = stored_refs_stmt
12138        .query_map(params![rel_path], |row| {
12139            Ok(serde_json::json!([
12140                row.get::<_, String>(0)?,
12141                row.get::<_, Option<String>>(1)?,
12142                row.get::<_, String>(2)?,
12143                row.get::<_, String>(3)?,
12144                row.get::<_, Option<String>>(4)?,
12145                row.get::<_, Option<String>>(5)?,
12146                row.get::<_, Option<String>>(6)?,
12147                row.get::<_, Option<String>>(7)?,
12148                row.get::<_, Option<String>>(8)?,
12149                row.get::<_, Option<String>>(9)?,
12150                row.get::<_, Option<String>>(10)?,
12151                row.get::<_, i64>(11)?,
12152                row.get::<_, i64>(12)?,
12153                row.get::<_, i64>(13)?,
12154                row.get::<_, i64>(14)?,
12155                row.get::<_, String>(15)?,
12156                row.get::<_, Option<String>>(16)?,
12157                row.get::<_, Option<String>>(17)?,
12158                row.get::<_, Option<String>>(18)?,
12159                row.get::<_, String>(19)?,
12160            ])
12161            .to_string())
12162        })?
12163        .collect::<rusqlite::Result<Vec<_>>>()?;
12164    let expected_refs = resolved_refs
12165        .iter()
12166        .map(|resolved| {
12167            let raw = &resolved.raw;
12168            serde_json::json!([
12169                raw.ref_id,
12170                raw.caller_node,
12171                raw.caller_file,
12172                raw.kind,
12173                raw.short_name,
12174                raw.full_ref,
12175                raw.module_path,
12176                raw.import_kind,
12177                raw.local_name,
12178                raw.requested_name,
12179                raw.namespace_alias,
12180                bool_int(raw.wildcard),
12181                raw.line,
12182                raw.byte_start,
12183                raw.byte_end,
12184                resolved.status,
12185                resolved.target_node,
12186                resolved.target_file,
12187                resolved.target_symbol,
12188                PROVENANCE_TREESITTER,
12189            ])
12190            .to_string()
12191        })
12192        .collect::<Vec<_>>();
12193    let mut stored_refs = stored_refs;
12194    let mut expected_refs = expected_refs;
12195    stored_refs.sort();
12196    expected_refs.sort();
12197    if stored_refs != expected_refs {
12198        return Ok(false);
12199    }
12200
12201    let mut stored_edges_stmt = tx.prepare(
12202        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12203                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12204         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12205         WHERE r.caller_file = ?1 AND e.provenance = ?2",
12206    )?;
12207    let stored_edges = stored_edges_stmt
12208        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12209            Ok(serde_json::json!([
12210                row.get::<_, String>(0)?,
12211                row.get::<_, String>(1)?,
12212                row.get::<_, String>(2)?,
12213                row.get::<_, Option<String>>(3)?,
12214                row.get::<_, String>(4)?,
12215                row.get::<_, String>(5)?,
12216                row.get::<_, String>(6)?,
12217                row.get::<_, i64>(7)?,
12218                row.get::<_, String>(8)?,
12219            ])
12220            .to_string())
12221        })?
12222        .collect::<rusqlite::Result<Vec<_>>>()?;
12223    let expected_edges = resolved_refs
12224        .iter()
12225        .filter_map(|resolved| {
12226            resolved.edge.as_ref().map(|edge| {
12227                serde_json::json!([
12228                    edge.edge_id,
12229                    resolved.raw.ref_id,
12230                    edge.source_node,
12231                    edge.target_node,
12232                    edge.target_file,
12233                    edge.target_symbol,
12234                    edge.kind,
12235                    edge.line,
12236                    PROVENANCE_TREESITTER,
12237                ])
12238                .to_string()
12239            })
12240        })
12241        .collect::<Vec<_>>();
12242    let mut stored_edges = stored_edges;
12243    let mut expected_edges = expected_edges;
12244    stored_edges.sort();
12245    expected_edges.sort();
12246    if stored_edges != expected_edges {
12247        return Ok(false);
12248    }
12249
12250    let mut stored_dependencies_stmt =
12251        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12252    let stored_dependencies = stored_dependencies_stmt
12253        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12254        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12255    let expected_dependencies = extract
12256        .raw_refs
12257        .iter()
12258        .flat_map(|raw| raw.dependencies.iter().cloned())
12259        .collect::<BTreeSet<_>>();
12260    if stored_dependencies != expected_dependencies {
12261        return Ok(false);
12262    }
12263
12264    let mut stored_hints_stmt = tx.prepare(
12265        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12266         FROM dispatch_hints WHERE file = ?1",
12267    )?;
12268    let stored_hints = stored_hints_stmt
12269        .query_map(params![rel_path], |row| {
12270            Ok(serde_json::json!([
12271                row.get::<_, String>(0)?,
12272                row.get::<_, String>(1)?,
12273                row.get::<_, String>(2)?,
12274                row.get::<_, String>(3)?,
12275                row.get::<_, i64>(4)?,
12276                row.get::<_, i64>(5)?,
12277                row.get::<_, i64>(6)?,
12278                row.get::<_, String>(7)?,
12279            ])
12280            .to_string())
12281        })?
12282        .collect::<rusqlite::Result<Vec<_>>>()?;
12283    let expected_hints = extract
12284        .dispatch_hints
12285        .iter()
12286        .map(|hint| {
12287            serde_json::json!([
12288                hint.id,
12289                hint.method_name,
12290                hint.caller_node,
12291                hint.file,
12292                hint.line,
12293                hint.byte_start,
12294                hint.byte_end,
12295                PROVENANCE_TREESITTER,
12296            ])
12297            .to_string()
12298        })
12299        .collect::<Vec<_>>();
12300    let mut stored_hints = stored_hints;
12301    let mut expected_hints = expected_hints;
12302    stored_hints.sort();
12303    expected_hints.sort();
12304    Ok(stored_hints == expected_hints)
12305}
12306
12307fn update_file_fresh_metadata(
12308    tx: &Transaction<'_>,
12309    project_root: &Path,
12310    rel_path: &str,
12311    hash: &blake3::Hash,
12312    mtime: SystemTime,
12313    size: u64,
12314) -> Result<()> {
12315    tx.execute(
12316        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12317         WHERE path = ?1",
12318        params![
12319            rel_path,
12320            hash_to_hex(*hash),
12321            system_time_to_ns(mtime),
12322            size as i64,
12323            unix_seconds_now()
12324        ],
12325    )?;
12326    tx.execute(
12327        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12328         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12329        params![
12330            BACKEND_TREESITTER,
12331            rel_path,
12332            hash_to_hex(*hash),
12333            project_root.display().to_string(),
12334            unix_seconds_now(),
12335        ],
12336    )?;
12337    Ok(())
12338}
12339
12340#[derive(Debug, Clone, PartialEq, Eq)]
12341struct DependentRefSelection {
12342    ref_id: String,
12343    caller_file: String,
12344}
12345
12346fn ref_ids_depending_on(
12347    conn: &Connection,
12348    project_root: &Path,
12349    rel_path: &str,
12350) -> Result<Vec<DependentRefSelection>> {
12351    let mut stmt = conn.prepare(
12352        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12353         FROM refs r
12354         WHERE r.caller_file IN (
12355             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12356         )
12357            OR r.target_file = ?1
12358         ORDER BY r.ref_id",
12359    )?;
12360    let rows = stmt.query_map(params![rel_path], |row| {
12361        Ok(RefDependencyRow {
12362            ref_id: row.get(0)?,
12363            kind: row.get(1)?,
12364            caller_file: row.get(2)?,
12365            module_path: row.get(3)?,
12366            target_file: row.get(4)?,
12367        })
12368    })?;
12369    let mut ids = Vec::new();
12370    for row in rows {
12371        let row = row?;
12372        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12373            ids.push(DependentRefSelection {
12374                ref_id: row.ref_id,
12375                caller_file: row.caller_file,
12376            });
12377        }
12378    }
12379    Ok(ids)
12380}
12381
12382fn record_dependent_refs(
12383    selected_ref_ids: &mut BTreeSet<String>,
12384    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12385    dependent_refs: Vec<DependentRefSelection>,
12386) {
12387    for dependent_ref in dependent_refs {
12388        let DependentRefSelection {
12389            ref_id,
12390            caller_file,
12391        } = dependent_ref;
12392        selected_ref_ids.insert(ref_id.clone());
12393        selected_refs_by_caller
12394            .entry(caller_file)
12395            .or_default()
12396            .insert(ref_id);
12397    }
12398}
12399
12400#[cfg(test)]
12401fn refs_by_caller_for_ref_ids(
12402    tx: &Transaction<'_>,
12403    ref_ids: &BTreeSet<String>,
12404) -> Result<BTreeMap<String, BTreeSet<String>>> {
12405    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12406    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12407    for ref_id in ref_ids {
12408        if let Some(caller) = stmt
12409            .query_row(params![ref_id], |row| row.get::<_, String>(0))
12410            .optional()?
12411        {
12412            by_caller.entry(caller).or_default().insert(ref_id.clone());
12413        }
12414    }
12415    Ok(by_caller)
12416}
12417
12418fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12419    tx.execute(
12420        "DELETE FROM file_dependencies WHERE file_path = ?1",
12421        params![rel_path],
12422    )?;
12423    delete_refs_for_caller(tx, rel_path)?;
12424    tx.execute(
12425        "DELETE FROM dispatch_hints WHERE file = ?1",
12426        params![rel_path],
12427    )?;
12428    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12429    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12430    Ok(())
12431}
12432
12433fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12434    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12435    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12436    let mut ids = BTreeSet::new();
12437    for row in rows {
12438        ids.insert(row?);
12439    }
12440    delete_ref_ids(tx, &ids)
12441}
12442
12443fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12444    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12445    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12446    for ref_id in ref_ids {
12447        delete_edges.execute(params![ref_id])?;
12448        delete_refs.execute(params![ref_id])?;
12449    }
12450    Ok(())
12451}
12452
12453fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12454    let mut stmt = conn.prepare(
12455        "SELECT source.file_path, source.scoped_name, edges.target_file,
12456                edges.target_symbol, edges.kind, edges.line
12457         FROM edges
12458         JOIN nodes AS source ON source.id = edges.source_node
12459         ORDER BY source.file_path, source.scoped_name, edges.target_file,
12460                  edges.target_symbol, edges.kind, edges.line",
12461    )?;
12462    let rows = stmt.query_map([], |row| {
12463        Ok(StoredEdge {
12464            source_file: row.get(0)?,
12465            source_symbol: row.get(1)?,
12466            target_file: row.get(2)?,
12467            target_symbol: row.get(3)?,
12468            kind: row.get(4)?,
12469            line: row.get::<_, i64>(5)? as u32,
12470        })
12471    })?;
12472    let mut edges = BTreeSet::new();
12473    for row in rows {
12474        edges.insert(row?);
12475    }
12476    Ok(edges)
12477}
12478
12479fn module_target_from_dependencies(
12480    project_root: &Path,
12481    dependencies: &BTreeSet<String>,
12482) -> Option<String> {
12483    dependencies.iter().find_map(|dep| {
12484        let path = project_root.join(dep);
12485        if path.is_file() {
12486            Some(relative_path(project_root, &canonicalize_path(&path)))
12487        } else {
12488            None
12489        }
12490    })
12491}
12492
12493fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12494    let mut named = HashMap::new();
12495    if let Some(full_ref) = &raw_ref.full_ref {
12496        named = parse_reexport_names(full_ref);
12497    }
12498    ReexportIndex {
12499        target_file,
12500        named,
12501        wildcard: raw_ref.wildcard,
12502    }
12503}
12504
12505fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12506    let mut names = HashMap::new();
12507    let Some(open) = statement.find('{') else {
12508        return names;
12509    };
12510    let Some(close) = statement[open + 1..]
12511        .find('}')
12512        .map(|offset| open + 1 + offset)
12513    else {
12514        return names;
12515    };
12516    for spec in statement[open + 1..close].split(',') {
12517        let spec = spec.trim();
12518        if spec.is_empty() {
12519            continue;
12520        }
12521        if let Some((source, local)) = spec.split_once(" as ") {
12522            names.insert(local.trim().to_string(), source.trim().to_string());
12523        } else {
12524            names.insert(spec.to_string(), spec.to_string());
12525        }
12526    }
12527    names
12528}
12529
12530#[derive(Debug)]
12531struct RefDependencyRow {
12532    ref_id: String,
12533    kind: String,
12534    caller_file: String,
12535    module_path: Option<String>,
12536    target_file: Option<String>,
12537}
12538
12539fn ref_dependency_row_depends_on(
12540    project_root: &Path,
12541    row: &RefDependencyRow,
12542    rel_path: &str,
12543) -> bool {
12544    if row.target_file.as_deref() == Some(rel_path) {
12545        return true;
12546    }
12547
12548    match row.kind.as_str() {
12549        "call" => true,
12550        "import" | "reexport" => row
12551            .module_path
12552            .as_deref()
12553            .map(|module_path| {
12554                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12555                    .contains(rel_path)
12556            })
12557            .unwrap_or(false),
12558        "export_alias" => false,
12559        _ => false,
12560    }
12561}
12562
12563fn module_dependencies_for_ref(
12564    project_root: &Path,
12565    caller_file: &str,
12566    module_path: &str,
12567) -> BTreeSet<String> {
12568    module_dependencies(project_root, &project_root.join(caller_file), module_path)
12569}
12570
12571fn import_dependencies(
12572    project_root: &Path,
12573    abs_path: &Path,
12574    imports: &[ImportStatement],
12575) -> BTreeSet<String> {
12576    let mut deps = BTreeSet::new();
12577    for import in imports {
12578        deps.extend(module_dependencies(
12579            project_root,
12580            abs_path,
12581            &import.module_path,
12582        ));
12583    }
12584    deps
12585}
12586
12587fn module_dependencies(
12588    project_root: &Path,
12589    abs_path: &Path,
12590    module_path: &str,
12591) -> BTreeSet<String> {
12592    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12593    let caller_dir = abs_path.parent().unwrap_or(project_root);
12594    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12595        deps.insert(relative_path(project_root, &resolved));
12596    }
12597    if module_path.starts_with('.') {
12598        let base = caller_dir.join(module_path);
12599        for candidate in relative_module_candidates(&base) {
12600            deps.insert(relative_path(project_root, &candidate));
12601        }
12602    }
12603    deps
12604}
12605
12606fn rust_module_dependencies(
12607    project_root: &Path,
12608    abs_path: &Path,
12609    module_path: &str,
12610) -> BTreeSet<String> {
12611    let mut deps = BTreeSet::new();
12612    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12613    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12614        return deps;
12615    };
12616    let src_prefix = rust_src_prefix(&rel_path);
12617    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12618    if !path_segments.is_empty() {
12619        rust_push_module_dependency_candidate(
12620            project_root,
12621            &mut deps,
12622            &src_prefix,
12623            &path_segments[..path_segments.len() - 1],
12624        );
12625    }
12626    deps
12627}
12628
12629fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12630    let path = rust_module_path_without_alias_or_use_list(module_path);
12631    let segments = path
12632        .split("::")
12633        .map(str::trim)
12634        .filter(|segment| !segment.is_empty())
12635        .collect::<Vec<_>>();
12636    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12637        return None;
12638    }
12639    rust_resolve_segments(rel_path, &segments)
12640}
12641
12642fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12643    let path = module_path
12644        .trim()
12645        .trim_end_matches(';')
12646        .split_once(" as ")
12647        .map(|(left, _)| left.trim())
12648        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12649    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12650}
12651
12652fn rust_push_module_dependency_candidate(
12653    project_root: &Path,
12654    deps: &mut BTreeSet<String>,
12655    src_prefix: &str,
12656    segments: &[String],
12657) {
12658    let candidates = if segments.is_empty() {
12659        vec![
12660            format!("{src_prefix}/lib.rs"),
12661            format!("{src_prefix}/main.rs"),
12662        ]
12663    } else {
12664        vec![
12665            format!("{}/{}.rs", src_prefix, segments.join("/")),
12666            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12667        ]
12668    };
12669    for candidate in candidates {
12670        if project_root.join(&candidate).is_file() {
12671            deps.insert(candidate);
12672        }
12673    }
12674}
12675
12676fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12677    let mut candidates = Vec::new();
12678    if base.extension().is_some() {
12679        candidates.push(base.to_path_buf());
12680        return candidates;
12681    }
12682    for ext in JS_TS_EXTENSIONS {
12683        candidates.push(base.with_extension(ext));
12684    }
12685    for ext in JS_TS_EXTENSIONS {
12686        candidates.push(base.join(format!("index.{ext}")));
12687    }
12688    candidates
12689}
12690
12691fn import_local_names(import: &ImportStatement) -> Vec<String> {
12692    let mut names = Vec::new();
12693    if let Some(default) = &import.default_import {
12694        names.push(default.clone());
12695    }
12696    if let Some(namespace) = &import.namespace_import {
12697        names.push(namespace.clone());
12698    }
12699    for name in &import.names {
12700        names.push(crate::imports::specifier_local_name(name).to_string());
12701    }
12702    names
12703}
12704
12705fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12706    import
12707        .names
12708        .iter()
12709        .map(|name| crate::imports::specifier_imported_name(name).to_string())
12710        .collect()
12711}
12712
12713fn import_is_wildcard(import: &ImportStatement) -> bool {
12714    import.namespace_import.is_some() || import.raw_text.contains('*')
12715}
12716
12717fn namespace_alias(full_ref: &str) -> Option<String> {
12718    full_ref
12719        .split_once('.')
12720        .map(|(namespace, _)| namespace.to_string())
12721}
12722
12723fn import_kind_label(kind: ImportKind) -> &'static str {
12724    match kind {
12725        ImportKind::Value => "value",
12726        ImportKind::Type => "type",
12727        ImportKind::SideEffect => "side_effect",
12728    }
12729}
12730
12731fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12732    match kind {
12733        SymbolKind::Function => "function",
12734        SymbolKind::Class => "class",
12735        SymbolKind::Method => "method",
12736        SymbolKind::Struct => "struct",
12737        SymbolKind::Interface => "interface",
12738        SymbolKind::Enum => "enum",
12739        SymbolKind::TypeAlias => "type_alias",
12740        SymbolKind::Variable => "variable",
12741        SymbolKind::Heading => "heading",
12742        SymbolKind::FileSummary => "file_summary",
12743    }
12744}
12745
12746fn is_type_like(kind: &SymbolKind) -> bool {
12747    matches!(
12748        kind,
12749        SymbolKind::Class
12750            | SymbolKind::Struct
12751            | SymbolKind::Interface
12752            | SymbolKind::Enum
12753            | SymbolKind::TypeAlias
12754    )
12755}
12756
12757fn lang_label(lang: LangId) -> &'static str {
12758    match lang {
12759        LangId::TypeScript => "typescript",
12760        LangId::Tsx => "tsx",
12761        LangId::JavaScript => "javascript",
12762        LangId::Python => "python",
12763        LangId::Rust => "rust",
12764        LangId::Go => "go",
12765        LangId::C => "c",
12766        LangId::Cpp => "cpp",
12767        LangId::Zig => "zig",
12768        LangId::CSharp => "csharp",
12769        LangId::Bash => "bash",
12770        LangId::Html => "html",
12771        LangId::Markdown => "markdown",
12772        LangId::Solidity => "solidity",
12773        LangId::Scss => "scss",
12774        LangId::Vue => "vue",
12775        LangId::Json => "json",
12776        LangId::Scala => "scala",
12777        LangId::Java => "java",
12778        LangId::Ruby => "ruby",
12779        LangId::Kotlin => "kotlin",
12780        LangId::Swift => "swift",
12781        LangId::Php => "php",
12782        LangId::Lua => "lua",
12783        LangId::Perl => "perl",
12784        LangId::Yaml => "yaml",
12785        LangId::Pascal => "pascal",
12786        LangId::R => "r",
12787        LangId::Groovy => "groovy",
12788        LangId::ObjC => "objc",
12789    }
12790}
12791
12792fn lang_from_label(label: &str) -> Option<LangId> {
12793    match label {
12794        "typescript" => Some(LangId::TypeScript),
12795        "tsx" => Some(LangId::Tsx),
12796        "javascript" => Some(LangId::JavaScript),
12797        "python" => Some(LangId::Python),
12798        "rust" => Some(LangId::Rust),
12799        "go" => Some(LangId::Go),
12800        "c" => Some(LangId::C),
12801        "cpp" => Some(LangId::Cpp),
12802        "zig" => Some(LangId::Zig),
12803        "csharp" => Some(LangId::CSharp),
12804        "bash" => Some(LangId::Bash),
12805        "html" => Some(LangId::Html),
12806        "markdown" => Some(LangId::Markdown),
12807        "solidity" => Some(LangId::Solidity),
12808        "scss" => Some(LangId::Scss),
12809        "vue" => Some(LangId::Vue),
12810        "json" => Some(LangId::Json),
12811        "scala" => Some(LangId::Scala),
12812        "java" => Some(LangId::Java),
12813        "ruby" => Some(LangId::Ruby),
12814        "kotlin" => Some(LangId::Kotlin),
12815        "swift" => Some(LangId::Swift),
12816        "php" => Some(LangId::Php),
12817        "lua" => Some(LangId::Lua),
12818        "perl" => Some(LangId::Perl),
12819        "yaml" => Some(LangId::Yaml),
12820        "pascal" => Some(LangId::Pascal),
12821        "r" => Some(LangId::R),
12822        "groovy" => Some(LangId::Groovy),
12823        "objc" => Some(LangId::ObjC),
12824        _ => None,
12825    }
12826}
12827
12828fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12829    let mut normalized = if files.is_empty() {
12830        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
12831    } else {
12832        files
12833            .iter()
12834            .map(|path| normalize_file_path(project_root, path))
12835            .collect::<Result<Vec<_>>>()?
12836    };
12837    normalized.sort();
12838    normalized.dedup();
12839    Ok(normalized)
12840}
12841
12842fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
12843    let full_path = if path.is_relative() {
12844        project_root.join(path)
12845    } else {
12846        path.to_path_buf()
12847    };
12848    Ok(canonicalize_path(&full_path))
12849}
12850
12851fn canonicalize_path(path: &Path) -> PathBuf {
12852    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
12853}
12854
12855fn relative_path(project_root: &Path, path: &Path) -> String {
12856    if let Ok(stripped) = path.strip_prefix(project_root) {
12857        return stripped.to_string_lossy().replace('\\', "/");
12858    }
12859    let canon_root = canonicalize_path(project_root);
12860    let canon_path = canonicalize_path(path);
12861    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
12862        return stripped.to_string_lossy().replace('\\', "/");
12863    }
12864    canon_path.to_string_lossy().replace('\\', "/")
12865}
12866
12867fn unqualified_name(scoped: &str) -> &str {
12868    if scoped == TOP_LEVEL_SYMBOL {
12869        return scoped;
12870    }
12871    scoped
12872        .rsplit("::")
12873        .next()
12874        .unwrap_or(scoped)
12875        .rsplit('.')
12876        .next()
12877        .unwrap_or(scoped)
12878        .rsplit('#')
12879        .next()
12880        .unwrap_or(scoped)
12881}
12882
12883fn ref_id(parts: &[&str]) -> String {
12884    let joined = parts.join("\0");
12885    hash_to_hex(blake3::hash(joined.as_bytes()))
12886}
12887
12888fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
12889    let mut fingerprint = CorpusFingerprint::default();
12890    for path in callgraph::walk_project_files(project_root) {
12891        fingerprint.add_path(project_root, &path);
12892    }
12893    Ok(fingerprint.finish(project_root))
12894}
12895
12896/// Pre-admission fingerprint over the same source set the staging inventory
12897/// will consume: the walk when no explicit list is supplied, the list
12898/// otherwise. Streaming accumulator - no staging writes, bounded memory.
12899fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
12900    if files.is_empty() {
12901        return callgraph_corpus_fingerprint(project_root);
12902    }
12903    let mut fingerprint = CorpusFingerprint::default();
12904    for path in files {
12905        fingerprint.add_path(project_root, path);
12906    }
12907    Ok(fingerprint.finish(project_root))
12908}
12909
12910#[derive(Default)]
12911struct CorpusFingerprint {
12912    xor: [u8; 32],
12913    sums: [u64; 4],
12914    files: u64,
12915}
12916
12917impl CorpusFingerprint {
12918    fn add_path(&mut self, project_root: &Path, path: &Path) {
12919        let mut record = blake3::Hasher::new();
12920        record.update(relative_path(project_root, path).as_bytes());
12921        record.update(&[0]);
12922        match hash_file_bounded(path) {
12923            Ok(content_hash) => record.update(content_hash.as_bytes()),
12924            // Encoding a missing file as a distinct record changes the corpus
12925            // fingerprint, so breaker state keyed to the previous corpus is not reused.
12926            Err(error) => record.update(format!("missing:{error}").as_bytes()),
12927        };
12928        record.update(&[0]);
12929        let record = record.finalize();
12930        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
12931            self.xor[index] ^= byte;
12932        }
12933        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
12934            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
12935            self.sums[index] = self.sums[index].wrapping_add(value);
12936        }
12937        self.files = self.files.saturating_add(1);
12938    }
12939
12940    fn finish(self, project_root: &Path) -> String {
12941        // Combining both xor and modular sums keeps the digest independent of
12942        // walk order while retaining duplicate sensitivity for generic callers.
12943        let mut hasher = blake3::Hasher::new();
12944        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
12945        hasher.update(&self.files.to_le_bytes());
12946        hasher.update(&self.xor);
12947        for sum in self.sums {
12948            hasher.update(&sum.to_le_bytes());
12949        }
12950        let ignore_rules = project_root.join(".gitignore");
12951        if let Ok(contents) = std::fs::read(ignore_rules) {
12952            hasher.update(b".gitignore\0");
12953            hasher.update(blake3::hash(&contents).as_bytes());
12954        }
12955        hash_to_hex(hasher.finalize())
12956    }
12957}
12958
12959fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
12960    let mut file = std::fs::File::open(path)?;
12961    let mut hasher = blake3::Hasher::new();
12962    let mut buffer = [0u8; 64 * 1024];
12963    loop {
12964        let read = file.read(&mut buffer)?;
12965        if read == 0 {
12966            break;
12967        }
12968        hasher.update(&buffer[..read]);
12969    }
12970    Ok(hasher.finalize())
12971}
12972
12973#[cfg(test)]
12974pub(crate) fn callgraph_corpus_fingerprint_for_test(
12975    project_root: &Path,
12976    _files: &[PathBuf],
12977) -> Result<String> {
12978    // The streaming fingerprint walks the corpus itself (order-independent
12979    // accumulator, no resident file list); the test seam keeps its historical
12980    // signature so callers need not thread a walk of their own.
12981    callgraph_corpus_fingerprint(project_root)
12982}
12983
12984fn hash_to_hex(hash: blake3::Hash) -> String {
12985    hash.to_hex().to_string()
12986}
12987
12988fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
12989    let bytes = hex_to_bytes(value)?;
12990    Some(blake3::Hash::from_bytes(bytes))
12991}
12992
12993fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
12994    if value.len() != 64 {
12995        return None;
12996    }
12997    let mut bytes = [0u8; 32];
12998    for (index, slot) in bytes.iter_mut().enumerate() {
12999        let start = index * 2;
13000        let end = start + 2;
13001        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
13002    }
13003    Some(bytes)
13004}
13005
13006#[derive(Debug, Clone)]
13007struct LineIndex {
13008    newline_offsets: Vec<usize>,
13009    source_len: usize,
13010}
13011
13012impl LineIndex {
13013    fn new(source: &str) -> Self {
13014        Self {
13015            newline_offsets: source
13016                .bytes()
13017                .enumerate()
13018                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
13019                .collect(),
13020            source_len: source.len(),
13021        }
13022    }
13023
13024    fn byte_to_line(&self, byte_offset: usize) -> u32 {
13025        let byte_offset = byte_offset.min(self.source_len);
13026        self.newline_offsets
13027            .partition_point(|offset| *offset < byte_offset) as u32
13028            + 1
13029    }
13030}
13031
13032fn empty_to_none(value: String) -> Option<String> {
13033    if value.is_empty() {
13034        None
13035    } else {
13036        Some(value)
13037    }
13038}
13039
13040fn bool_int(value: bool) -> i64 {
13041    if value {
13042        1
13043    } else {
13044        0
13045    }
13046}
13047
13048fn system_time_to_ns(time: SystemTime) -> i64 {
13049    time.duration_since(UNIX_EPOCH)
13050        .unwrap_or_default()
13051        .as_nanos()
13052        .min(i64::MAX as u128) as i64
13053}
13054
13055fn ns_to_system_time(value: i64) -> SystemTime {
13056    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13057}
13058
13059pub(crate) fn unix_millis_now() -> u64 {
13060    SystemTime::now()
13061        .duration_since(UNIX_EPOCH)
13062        .unwrap_or_default()
13063        .as_millis()
13064        .min(u128::from(u64::MAX)) as u64
13065}
13066
13067fn unix_seconds_now() -> i64 {
13068    SystemTime::now()
13069        .duration_since(UNIX_EPOCH)
13070        .unwrap_or_default()
13071        .as_secs() as i64
13072}
13073
13074/// Serializes every test that drives the process-wide refresh worker
13075/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
13076/// worker down between another test's enqueue and its flush, deferring the
13077/// batch and zeroing that test's seam counts).
13078#[cfg(test)]
13079pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13080
13081#[cfg(test)]
13082mod refresh_worker_tests {
13083    use super::*;
13084    use std::fs;
13085    use tempfile::tempdir;
13086
13087    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13088        let temp = tempdir().unwrap();
13089        let root = temp.path().join("root");
13090        fs::create_dir_all(&root).unwrap();
13091        let artifact_key = crate::search_index::artifact_cache_key(&root);
13092        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13093        let callgraph_dir = temp
13094            .path()
13095            .join("storage")
13096            .join("callgraph")
13097            .join(artifact_key);
13098        let source = root.join("main.rs");
13099        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13100        let (store, _) = CallGraphStore::cold_build_with_lease(
13101            callgraph_dir.clone(),
13102            root.clone(),
13103            std::slice::from_ref(&source),
13104        )
13105        .unwrap();
13106        drop(store);
13107        (temp, root, callgraph_dir, source)
13108    }
13109
13110    fn pending_paths() -> PendingCallGraphStorePaths {
13111        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13112    }
13113
13114    fn wait_for_refresh_calls(root: &Path, expected: usize) {
13115        let deadline = Instant::now() + Duration::from_secs(12);
13116        while callgraph_refresh_worker_test_counts(root).0 < expected {
13117            assert!(
13118                Instant::now() < deadline,
13119                "timed out waiting for {expected} callgraph refresh worker call(s)"
13120            );
13121            std::thread::sleep(Duration::from_millis(5));
13122        }
13123    }
13124
13125    fn wait_for_refresh_worker_idle() {
13126        let deadline = Instant::now() + Duration::from_secs(12);
13127        loop {
13128            let worker = CALLGRAPH_REFRESH_WORKER
13129                .get_or_init(|| Mutex::new(None))
13130                .lock()
13131                .expect("callgraph refresh worker mutex poisoned")
13132                .clone();
13133            let idle = worker.is_none_or(|worker| {
13134                let queue = worker
13135                    .shared
13136                    .queue
13137                    .lock()
13138                    .expect("callgraph refresh queue mutex poisoned");
13139                queue.active.is_none() && queue.order.is_empty()
13140            });
13141            if idle {
13142                return;
13143            }
13144            assert!(
13145                Instant::now() < deadline,
13146                "timed out waiting for callgraph refresh worker to become idle"
13147            );
13148            std::thread::sleep(Duration::from_millis(5));
13149        }
13150    }
13151
13152    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13153        let temp = tempdir().unwrap();
13154        let root = temp.path().join("workspace");
13155        fs::create_dir_all(root.join("app/src")).unwrap();
13156        let artifact_key = crate::search_index::artifact_cache_key(&root);
13157        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13158        let callgraph_dir = temp
13159            .path()
13160            .join("storage")
13161            .join("callgraph")
13162            .join(artifact_key);
13163        fs::write(
13164            root.join("Cargo.toml"),
13165            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13166        )
13167        .unwrap();
13168        fs::write(
13169            root.join("app/Cargo.toml"),
13170            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13171        )
13172        .unwrap();
13173        let caller = root.join("app/src/lib.rs");
13174        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13175        let (store, _) = CallGraphStore::cold_build_with_lease(
13176            callgraph_dir.clone(),
13177            root.clone(),
13178            std::slice::from_ref(&caller),
13179        )
13180        .unwrap();
13181        drop(store);
13182        (temp, root, callgraph_dir, caller)
13183    }
13184
13185    #[test]
13186    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13187        let _guard = REFRESH_WORKER_TEST_LOCK
13188            .lock()
13189            .unwrap_or_else(std::sync::PoisonError::into_inner);
13190        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13191        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13192        reset_workspace_crate_prefix_build_count(&root);
13193        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13194
13195        for revision in ["first", "second"] {
13196            fs::write(
13197                &caller,
13198                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13199            )
13200            .unwrap();
13201            enqueue_callgraph_store_refresh(
13202                callgraph_dir.clone(),
13203                root.clone(),
13204                vec![caller.clone()],
13205                pending_paths(),
13206            );
13207            wait_for_refresh_worker_idle();
13208        }
13209
13210        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13211        assert!(flush_callgraph_store_refreshes_with_budget(
13212            Duration::from_secs(5)
13213        ));
13214        clear_callgraph_refresh_worker_test_seam(&root);
13215    }
13216
13217    #[test]
13218    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13219        let _guard = REFRESH_WORKER_TEST_LOCK
13220            .lock()
13221            .unwrap_or_else(std::sync::PoisonError::into_inner);
13222        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13223        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13224        reset_workspace_crate_prefix_build_count(&root);
13225        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13226
13227        fs::write(
13228            &caller,
13229            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13230        )
13231        .unwrap();
13232        enqueue_callgraph_store_refresh(
13233            callgraph_dir.clone(),
13234            root.clone(),
13235            vec![caller.clone()],
13236            pending_paths(),
13237        );
13238        wait_for_refresh_worker_idle();
13239        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13240
13241        let added_manifest = root.join("added/Cargo.toml");
13242        let added_source = root.join("added/src/lib.rs");
13243        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13244        fs::write(
13245            root.join("Cargo.toml"),
13246            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13247        )
13248        .unwrap();
13249        fs::write(
13250            &added_manifest,
13251            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13252        )
13253        .unwrap();
13254        fs::write(&added_source, "pub fn target() {}\n").unwrap();
13255        fs::write(
13256            &caller,
13257            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13258        )
13259        .unwrap();
13260
13261        enqueue_callgraph_store_refresh(
13262            callgraph_dir.clone(),
13263            root.clone(),
13264            vec![
13265                root.join("Cargo.toml"),
13266                added_manifest,
13267                added_source,
13268                caller,
13269            ],
13270            pending_paths(),
13271        );
13272        assert!(flush_callgraph_store_refreshes_with_budget(
13273            Duration::from_secs(12)
13274        ));
13275
13276        // This is the negative control for a permanently-static cache: without
13277        // manifest invalidation the build count stays at one and the call remains
13278        // unresolved because `added_crate` was absent when the map was primed.
13279        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13280        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13281            .unwrap()
13282            .expect("refreshed workspace store");
13283        let tree = store
13284            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13285            .unwrap();
13286        assert_eq!(tree.children.len(), 1);
13287        assert_eq!(tree.children[0].file, "added/src/lib.rs");
13288        assert_eq!(tree.children[0].name, "target");
13289        assert!(tree.children[0].resolved);
13290        clear_callgraph_refresh_worker_test_seam(&root);
13291    }
13292
13293    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13294        let temp = tempdir().unwrap();
13295        let main = temp.path().join("main");
13296        let worktree = temp.path().join("worktree");
13297        fs::create_dir_all(&main).unwrap();
13298        let mut git = std::process::Command::new("git");
13299        assert!(
13300            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13301                .status()
13302                .unwrap()
13303                .success()
13304        );
13305        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13306        for args in [
13307            vec![
13308                "-C",
13309                main.to_str().unwrap(),
13310                "config",
13311                "user.email",
13312                "test@example.com",
13313            ],
13314            vec![
13315                "-C",
13316                main.to_str().unwrap(),
13317                "config",
13318                "user.name",
13319                "AFT Test",
13320            ],
13321            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13322            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13323        ] {
13324            let mut command = std::process::Command::new("git");
13325            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13326                .status()
13327                .unwrap()
13328                .success());
13329        }
13330        let mut add_worktree = std::process::Command::new("git");
13331        assert!(crate::test_env::apply_hermetic_git_env(
13332            add_worktree
13333                .arg("-C")
13334                .arg(&main)
13335                .args(["worktree", "add", "--detach"])
13336                .arg(&worktree),
13337        )
13338        .status()
13339        .unwrap()
13340        .success());
13341        let main = fs::canonicalize(main).unwrap();
13342        let worktree = fs::canonicalize(worktree).unwrap();
13343        let project_key = crate::search_index::artifact_cache_key(&main);
13344        assert_eq!(
13345            crate::search_index::artifact_cache_key(&worktree),
13346            project_key
13347        );
13348        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13349        (temp, main, worktree, project_key, callgraph_dir)
13350    }
13351
13352    #[test]
13353    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13354        let _git_env = crate::test_env::hermetic_git_env_guard();
13355        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13356        crate::root_cache::configure_artifact_access(&root, &project_key, true);
13357        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13358        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13359        let publications_for_observer = Arc::clone(&publications);
13360        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13361            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13362        })));
13363        let source = root.join("lib.rs");
13364
13365        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13366            .expect_err("borrow-only writable open must remain unavailable");
13367        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13368        assert!(
13369            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13370                .unwrap()
13371                .is_none()
13372        );
13373        assert!(
13374            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13375                .unwrap()
13376                .is_none()
13377        );
13378        assert!(matches!(
13379            CallGraphStore::cold_build_with_lease(
13380                callgraph_dir.clone(),
13381                root.clone(),
13382                std::slice::from_ref(&source),
13383            ),
13384            Err(CallGraphStoreError::Unavailable(_))
13385        ));
13386        assert!(matches!(
13387            CallGraphStore::ensure_built_with_lease(
13388                callgraph_dir.clone(),
13389                root.clone(),
13390                std::slice::from_ref(&source),
13391            ),
13392            Err(CallGraphStoreError::Unavailable(_))
13393        ));
13394        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13395            callgraph_dir.clone(),
13396            root.clone(),
13397            &[source],
13398            1,
13399        )
13400        .expect_err("borrow-only forced rebuild must remain unsatisfied");
13401        set_cold_build_swap_observer(None);
13402
13403        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13404        assert_eq!(
13405            crate::root_cache::writer_lease_acquisition_count_for_test(
13406                crate::root_cache::RootCacheDomain::Callgraph,
13407                &project_key,
13408                &root,
13409            ),
13410            0
13411        );
13412        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13413        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13414    }
13415
13416    #[test]
13417    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13418        let _git_env = crate::test_env::hermetic_git_env_guard();
13419        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13420        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13421        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13422        let source = owner.join("lib.rs");
13423        let (store, _) = CallGraphStore::cold_build_with_lease(
13424            callgraph_dir.clone(),
13425            owner.clone(),
13426            std::slice::from_ref(&source),
13427        )
13428        .unwrap();
13429        let sqlite_path = store.sqlite_path().to_path_buf();
13430        drop(store);
13431
13432        let conn = Connection::open(&sqlite_path).unwrap();
13433        conn.execute(
13434            "UPDATE backend_file_state SET workspace_root = ?1",
13435            [worktree.display().to_string()],
13436        )
13437        .unwrap();
13438        drop(conn);
13439
13440        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13441        let publications_for_observer = Arc::clone(&publications);
13442        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13443            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13444        })));
13445        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13446
13447        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13448            .unwrap()
13449            .expect("owner should purge the storm-era worktree root");
13450        drop(repaired);
13451        for _ in 0..3 {
13452            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13453                .unwrap()
13454                .expect("linked worktree should borrow the owner generation");
13455            drop(borrower);
13456            assert!(
13457                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13458                    .unwrap()
13459                    .is_none()
13460            );
13461            let owner_store =
13462                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13463                    .unwrap()
13464                    .expect("owner generation should remain ready");
13465            drop(owner_store);
13466        }
13467        set_cold_build_swap_observer(None);
13468
13469        assert_eq!(
13470            publications.load(AtomicOrdering::SeqCst),
13471            1,
13472            "the owner performs one expected post-storm purge and alternation stays read-only"
13473        );
13474        assert_eq!(
13475            crate::root_cache::writer_lease_acquisition_count_for_test(
13476                crate::root_cache::RootCacheDomain::Callgraph,
13477                &project_key,
13478                &worktree,
13479            ),
13480            0
13481        );
13482    }
13483
13484    #[test]
13485    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13486        let temp = tempdir().unwrap();
13487        let root = temp.path().join("owner");
13488        let other_root = temp.path().join("other");
13489        fs::create_dir_all(&root).unwrap();
13490        fs::create_dir_all(&other_root).unwrap();
13491        let source = root.join("lib.rs");
13492        fs::write(&source, "pub fn marker() {}\n").unwrap();
13493        let project_key = crate::search_index::artifact_cache_key(&root);
13494        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13495        crate::root_cache::configure_artifact_access(&root, &project_key, false);
13496        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13497        rebuild_cooldown_records()
13498            .lock()
13499            .unwrap_or_else(std::sync::PoisonError::into_inner)
13500            .remove(&cooldown_key);
13501        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13502        let stale_epoch = epoch.current();
13503        epoch.next();
13504
13505        let failed = with_publish_epoch(epoch, stale_epoch, || {
13506            CallGraphStore::cold_build_with_lease(
13507                callgraph_dir.clone(),
13508                root.clone(),
13509                std::slice::from_ref(&source),
13510            )
13511        });
13512        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13513        assert!(
13514            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13515                .is_none()
13516        );
13517
13518        let (store, _) = CallGraphStore::cold_build_with_lease(
13519            callgraph_dir.clone(),
13520            root.clone(),
13521            std::slice::from_ref(&source),
13522        )
13523        .unwrap();
13524        drop(store);
13525        assert!(
13526            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13527                .is_none()
13528        );
13529
13530        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13531        assert!(
13532            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13533        );
13534    }
13535
13536    #[test]
13537    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13538        let _guard = REFRESH_WORKER_TEST_LOCK
13539            .lock()
13540            .unwrap_or_else(std::sync::PoisonError::into_inner);
13541        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13542        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13543        let pending = pending_paths();
13544        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13545
13546        let lifecycle = SubcLifecycleAdmission::default();
13547        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13548        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13549        let ticket = CallgraphRefreshTicket::new(
13550            lifecycle,
13551            Arc::clone(&generation),
13552            7,
13553            publish_epoch.clone(),
13554            publish_epoch.current(),
13555        );
13556        // Supersede before the worker runs: the batch must defer, not commit.
13557        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13558        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13559            .unwrap()
13560            .expect("ready store snapshot");
13561        let refresh_state = CallgraphRefreshState::new(
13562            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13563            Arc::new(AtomicBool::new(true)),
13564        );
13565
13566        enqueue_callgraph_store_refresh_fenced_with_state(
13567            callgraph_dir,
13568            root.clone(),
13569            vec![source.clone()],
13570            Arc::clone(&pending),
13571            refresh_state,
13572            ticket,
13573        );
13574        assert!(flush_callgraph_store_refreshes_with_budget(
13575            Duration::from_secs(5)
13576        ));
13577        assert_eq!(
13578            callgraph_refresh_worker_test_counts(&root).0,
13579            0,
13580            "superseded batch must not reach refresh_files or self-replay"
13581        );
13582        assert!(
13583            pending.lock().contains(&source),
13584            "superseded batch must defer its paths to the pending sink"
13585        );
13586        clear_callgraph_refresh_worker_test_seam(&root);
13587    }
13588
13589    #[test]
13590    fn superseded_open_failure_defers_without_self_replay() {
13591        let _guard = REFRESH_WORKER_TEST_LOCK
13592            .lock()
13593            .unwrap_or_else(std::sync::PoisonError::into_inner);
13594        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13595        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13596        let pending = pending_paths();
13597        let installed = Arc::new(
13598            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13599                .unwrap()
13600                .expect("ready store snapshot"),
13601        );
13602        let refresh_state = CallgraphRefreshState::new(
13603            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13604            Arc::new(AtomicBool::new(true)),
13605        );
13606        assert!(!installed.is_legacy_fallback());
13607        assert!(installed.is_current());
13608        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13609        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13610        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13611        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13612
13613        let lifecycle = SubcLifecycleAdmission::default();
13614        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13615        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13616        let ticket = CallgraphRefreshTicket::new(
13617            lifecycle,
13618            Arc::clone(&generation),
13619            7,
13620            publish_epoch.clone(),
13621            publish_epoch.current(),
13622        );
13623        enqueue_callgraph_store_refresh_fenced_with_state(
13624            callgraph_dir,
13625            root.clone(),
13626            vec![source.clone()],
13627            Arc::clone(&pending),
13628            refresh_state,
13629            ticket,
13630        );
13631        held_rx
13632            .recv_timeout(Duration::from_secs(12))
13633            .expect("refresh worker must hold after injected open failure");
13634
13635        // Mark the refresh request obsolete after the injected open failure,
13636        // then unblock the worker before its deferred retry can run.
13637        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13638        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13639        release_tx
13640            .send(())
13641            .expect("release superseded refresh worker");
13642        wait_for_refresh_worker_idle();
13643
13644        assert_eq!(
13645            callgraph_refresh_worker_test_counts(&root).0,
13646            1,
13647            "superseded open-failure batch must not self-replay"
13648        );
13649        assert_eq!(
13650            callgraph_refresh_worker_test_worker_calls(&root),
13651            1,
13652            "superseded open-failure batch must not create another worker call"
13653        );
13654        assert!(
13655            pending.lock().contains(&source),
13656            "superseded open-failure paths must remain in the pending sink"
13657        );
13658        let tree = installed
13659            .call_tree(Path::new("main.rs"), "entry", 1)
13660            .unwrap();
13661        assert_eq!(
13662            tree.children[0].name, "old_leaf",
13663            "superseded open-failure batch must not converge the store"
13664        );
13665        clear_callgraph_refresh_worker_test_seam(&root);
13666    }
13667
13668    #[test]
13669    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13670        let _guard = REFRESH_WORKER_TEST_LOCK
13671            .lock()
13672            .unwrap_or_else(std::sync::PoisonError::into_inner);
13673        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13674        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13675        let pending = pending_paths();
13676        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13677
13678        let lifecycle = SubcLifecycleAdmission::default();
13679        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13680        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13681        let expected_epoch = publish_epoch.current();
13682        let ticket = CallgraphRefreshTicket::new(
13683            lifecycle,
13684            generation,
13685            3,
13686            publish_epoch.clone(),
13687            expected_epoch,
13688        );
13689        // A cold build published a replacement generation after enqueue.
13690        publish_epoch.next();
13691
13692        enqueue_callgraph_store_refresh_fenced(
13693            callgraph_dir,
13694            root.clone(),
13695            vec![source.clone()],
13696            Arc::clone(&pending),
13697            ticket,
13698        );
13699        assert!(flush_callgraph_store_refreshes_with_budget(
13700            Duration::from_secs(5)
13701        ));
13702        assert_eq!(
13703            callgraph_refresh_worker_test_counts(&root).0,
13704            0,
13705            "epoch-superseded batch must not reach refresh_files"
13706        );
13707        assert!(
13708            pending.lock().contains(&source),
13709            "epoch-superseded batch must defer its paths to the pending sink"
13710        );
13711        clear_callgraph_refresh_worker_test_seam(&root);
13712    }
13713
13714    #[test]
13715    fn fenced_refresh_with_current_ticket_commits_normally() {
13716        let _guard = REFRESH_WORKER_TEST_LOCK
13717            .lock()
13718            .unwrap_or_else(std::sync::PoisonError::into_inner);
13719        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13720        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13721        let pending = pending_paths();
13722        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13723
13724        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13725
13726        let lifecycle = SubcLifecycleAdmission::default();
13727        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13728        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13729        let ticket = CallgraphRefreshTicket::new(
13730            lifecycle,
13731            generation,
13732            5,
13733            publish_epoch.clone(),
13734            publish_epoch.current(),
13735        );
13736
13737        enqueue_callgraph_store_refresh_fenced(
13738            callgraph_dir.clone(),
13739            root.clone(),
13740            vec![source.clone()],
13741            Arc::clone(&pending),
13742            ticket,
13743        );
13744        assert!(flush_callgraph_store_refreshes_with_budget(
13745            Duration::from_secs(5)
13746        ));
13747        assert_eq!(
13748            callgraph_refresh_worker_test_counts(&root).0,
13749            1,
13750            "current ticket must run the refresh"
13751        );
13752        assert!(
13753            pending.lock().is_empty(),
13754            "committed batch must not defer paths"
13755        );
13756
13757        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13758            .unwrap()
13759            .expect("published generation must remain readable");
13760        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13761        assert_eq!(
13762            tree.children[0].name, "new_leaf",
13763            "fenced commit must actually persist the refreshed content"
13764        );
13765        clear_callgraph_refresh_worker_test_seam(&root);
13766    }
13767
13768    #[test]
13769    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
13770        let _guard = REFRESH_WORKER_TEST_LOCK
13771            .lock()
13772            .unwrap_or_else(std::sync::PoisonError::into_inner);
13773        // Generous pre-drain: the refresh worker is process-wide, so a prior
13774        // test's still-running batch (slow Windows CI) must fully settle
13775        // before this test enqueues, or its wait deadline absorbs the
13776        // leftover work. Idle workers return immediately.
13777        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13778        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13779        let pending = pending_paths();
13780        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
13781
13782        enqueue_callgraph_store_refresh(
13783            callgraph_dir.clone(),
13784            root.clone(),
13785            vec![source.clone()],
13786            Arc::clone(&pending),
13787        );
13788        wait_for_refresh_calls(&root, 1);
13789        for _ in 0..3 {
13790            enqueue_callgraph_store_refresh(
13791                callgraph_dir.clone(),
13792                root.clone(),
13793                vec![source.clone()],
13794                Arc::clone(&pending),
13795            );
13796        }
13797
13798        assert!(flush_callgraph_store_refreshes_with_budget(
13799            Duration::from_secs(2)
13800        ));
13801        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
13802        assert!(pending.lock().is_empty());
13803        clear_callgraph_refresh_worker_test_seam(&root);
13804    }
13805
13806    #[test]
13807    fn queued_refresh_opens_generation_published_after_enqueue() {
13808        let _guard = REFRESH_WORKER_TEST_LOCK
13809            .lock()
13810            .unwrap_or_else(std::sync::PoisonError::into_inner);
13811        // Generous pre-drain: the refresh worker is process-wide, so a prior
13812        // test's still-running batch (slow Windows CI) must fully settle
13813        // before this test enqueues, or its wait deadline absorbs the
13814        // leftover work. Idle workers return immediately.
13815        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13816        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13817        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
13818        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
13819        let (active_held_rx, active_release_tx) =
13820            install_callgraph_refresh_worker_test_gate(active_root.clone());
13821        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
13822        enqueue_callgraph_store_refresh(
13823            active_dir,
13824            active_root.clone(),
13825            vec![active_source],
13826            pending_paths(),
13827        );
13828        active_held_rx
13829            .recv_timeout(Duration::from_secs(12))
13830            .expect("active refresh worker holds the queue");
13831
13832        fs::write(
13833            &target_source,
13834            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13835        )
13836        .unwrap();
13837        enqueue_callgraph_store_refresh(
13838            target_dir.clone(),
13839            target_root.clone(),
13840            vec![target_source.clone()],
13841            pending_paths(),
13842        );
13843        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
13844            target_dir.clone(),
13845            target_root.clone(),
13846            std::slice::from_ref(&target_source),
13847        )
13848        .unwrap();
13849        fs::write(
13850            &target_source,
13851            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13852        )
13853        .unwrap();
13854        drop(new_generation);
13855
13856        active_release_tx
13857            .send(())
13858            .expect("release active refresh worker");
13859        wait_for_refresh_calls(&target_root, 1);
13860        assert!(flush_callgraph_store_refreshes_with_budget(
13861            Duration::from_secs(12)
13862        ));
13863        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
13864            .unwrap()
13865            .expect("current callgraph generation");
13866        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13867        assert_eq!(tree.children[0].name, "worker_leaf");
13868        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
13869        clear_callgraph_refresh_worker_test_seam(&active_root);
13870        clear_callgraph_refresh_worker_test_seam(&target_root);
13871    }
13872
13873    #[test]
13874    fn refresh_failure_marks_files_stale() {
13875        let _guard = REFRESH_WORKER_TEST_LOCK
13876            .lock()
13877            .unwrap_or_else(std::sync::PoisonError::into_inner);
13878        // Generous pre-drain: the refresh worker is process-wide, so a prior
13879        // test's still-running batch (slow Windows CI) must fully settle
13880        // before this test enqueues, or its wait deadline absorbs the
13881        // leftover work. Idle workers return immediately.
13882        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13883        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13884        let pending = pending_paths();
13885        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
13886
13887        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
13888        assert!(flush_callgraph_store_refreshes_with_budget(
13889            Duration::from_secs(2)
13890        ));
13891
13892        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
13893        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
13894            .unwrap()
13895            .expect("ready callgraph store");
13896        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
13897        clear_callgraph_refresh_worker_test_seam(&root);
13898    }
13899
13900    #[test]
13901    fn idle_refresh_truncates_wal() {
13902        let _guard = REFRESH_WORKER_TEST_LOCK
13903            .lock()
13904            .unwrap_or_else(std::sync::PoisonError::into_inner);
13905        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13906        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13907        let generation = read_pointer(
13908            &callgraph_dir,
13909            &crate::search_index::artifact_cache_key(&root),
13910        )
13911        .expect("fixture publishes a generation");
13912        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
13913        let pending = pending_paths();
13914        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13915
13916        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
13917        enqueue_callgraph_store_refresh(
13918            callgraph_dir.clone(),
13919            root.clone(),
13920            vec![source.clone()],
13921            Arc::clone(&pending),
13922        );
13923        wait_for_refresh_calls(&root, 1);
13924        wait_for_refresh_worker_idle();
13925        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
13926        while fs::metadata(&wal_path)
13927            .map(|metadata| metadata.len())
13928            .unwrap_or(0)
13929            != 0
13930        {
13931            assert!(
13932                Instant::now() < checkpoint_deadline,
13933                "idle checkpoint did not truncate WAL"
13934            );
13935            std::thread::sleep(Duration::from_millis(5));
13936        }
13937        assert_eq!(
13938            fs::metadata(&wal_path)
13939                .map(|metadata| metadata.len())
13940                .unwrap_or(0),
13941            0,
13942            "idle transition truncates the refresh WAL"
13943        );
13944
13945        clear_callgraph_refresh_worker_test_seam(&root);
13946    }
13947
13948    #[test]
13949    fn bounded_shutdown_defers_unprocessed_batches() {
13950        let _guard = REFRESH_WORKER_TEST_LOCK
13951            .lock()
13952            .unwrap_or_else(std::sync::PoisonError::into_inner);
13953        // Generous pre-drain: the refresh worker is process-wide, so a prior
13954        // test's still-running batch (slow Windows CI) must fully settle
13955        // before this test enqueues, or its wait deadline absorbs the
13956        // leftover work. Idle workers return immediately.
13957        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13958        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13959        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
13960        let active_pending = pending_paths();
13961        let queued_pending = pending_paths();
13962        set_callgraph_refresh_worker_test_seam(
13963            active_root.clone(),
13964            Duration::from_millis(300),
13965            false,
13966        );
13967
13968        enqueue_callgraph_store_refresh(
13969            active_dir,
13970            active_root.clone(),
13971            vec![active_source.clone()],
13972            Arc::clone(&active_pending),
13973        );
13974        wait_for_refresh_calls(&active_root, 1);
13975        enqueue_callgraph_store_refresh(
13976            queued_dir,
13977            queued_root.clone(),
13978            vec![queued_source.clone()],
13979            Arc::clone(&queued_pending),
13980        );
13981
13982        assert!(!flush_callgraph_store_refreshes_with_budget(
13983            Duration::from_millis(20)
13984        ));
13985        assert!(active_pending.lock().contains(&active_source));
13986        assert!(queued_pending.lock().contains(&queued_source));
13987        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
13988        clear_callgraph_refresh_worker_test_seam(&active_root);
13989    }
13990}
13991
13992#[cfg(test)]
13993mod cold_build_insert_tests {
13994    use super::*;
13995    use crate::imports::ImportBlock;
13996    use std::cell::Cell;
13997    use std::fs;
13998    use std::path::{Path, PathBuf};
13999    use tempfile::tempdir;
14000
14001    thread_local! {
14002        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
14003        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
14004        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
14005    }
14006
14007    fn count_caller_traversal_selects(sql: &str) {
14008        let sql = sql.trim_start();
14009        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
14010            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
14011        }
14012        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
14013            && sql.contains("e.target_file =")
14014        {
14015            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
14016        }
14017        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
14018            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
14019        }
14020    }
14021
14022    #[test]
14023    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
14024        let dir = tempdir().unwrap();
14025        let previous_root = dir.path().join("previous-root");
14026        let current_root = dir.path().join("current-root");
14027        fs::create_dir_all(&previous_root).unwrap();
14028        fs::create_dir_all(&current_root).unwrap();
14029        fs::remove_dir(&previous_root).unwrap();
14030        let mut conn = Connection::open_in_memory().unwrap();
14031        initialize_schema(&conn).unwrap();
14032        conn.execute(
14033            "INSERT INTO backend_file_state(
14034                backend, workspace_root, file_path, content_hash, status, updated_at
14035             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
14036            params![previous_root.display().to_string()],
14037        )
14038        .unwrap();
14039
14040        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
14041
14042        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
14043        assert_eq!(
14044            stored_workspace_roots(&conn).unwrap(),
14045            vec![previous_root.display().to_string()]
14046        );
14047    }
14048
14049    #[test]
14050    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14051        assert_eq!(
14052            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14053            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14054        );
14055    }
14056
14057    #[test]
14058    fn legacy_migration_completion_log_has_operator_fields() {
14059        assert_eq!(
14060            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14061            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14062        );
14063    }
14064
14065    fn write_generation_with_age(
14066        dir: &Path,
14067        project_key: &str,
14068        ordinal: u64,
14069        age: Duration,
14070    ) -> String {
14071        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14072        let path = dir.join(&generation);
14073        fs::write(&path, b"sqlite placeholder").unwrap();
14074        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14075        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14076        generation
14077    }
14078
14079    #[test]
14080    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14081        let dir = tempfile::tempdir().unwrap();
14082        let project_key = "project";
14083        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14084        let previous =
14085            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14086        let pinned =
14087            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14088        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14089
14090        gc_old_generations(dir.path(), project_key, &current);
14091
14092        assert!(dir.path().join(&previous).is_file());
14093        assert!(dir.path().join(&pinned).is_file());
14094
14095        drop(marker);
14096        gc_old_generations(dir.path(), project_key, &current);
14097
14098        assert!(dir.path().join(&previous).is_file());
14099        assert!(!dir.path().join(&pinned).exists());
14100    }
14101
14102    #[test]
14103    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14104        let dir = tempfile::tempdir().unwrap();
14105        let project_key = "project";
14106        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14107        let _previous =
14108            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14109        let pinned =
14110            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14111        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14112        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14113
14114        gc_old_generations(dir.path(), project_key, &current);
14115
14116        assert!(dir.path().join(&pinned).is_file());
14117    }
14118
14119    #[test]
14120    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14121        let dir = tempfile::tempdir().unwrap();
14122        let project_key = "project";
14123        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14124        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14125        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14126        let old = write_generation_with_age(
14127            dir.path(),
14128            project_key,
14129            200,
14130            expired + Duration::from_secs(60),
14131        );
14132        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14133
14134        gc_old_generations(dir.path(), project_key, &current);
14135
14136        assert!(dir.path().join(&current).is_file());
14137        assert!(dir.path().join(&previous).is_file());
14138        assert!(!dir.path().join(&old).exists());
14139    }
14140
14141    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14142        let path = dir.join(name);
14143        fs::write(&path, b"temp placeholder").unwrap();
14144        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14145        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14146        path
14147    }
14148
14149    #[test]
14150    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14151        let dir = tempdir().unwrap();
14152        // One directory holds both an aged orphan (with its journal sidecar) and a
14153        // fresh temporary, so this proves the sweep SELECTS by age rather than
14154        // deleting everything in the directory.
14155        let aged = "project.g100.1.sqlite.tmp.1.200";
14156        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14157        let fresh = "project.g300.1.sqlite.tmp.1.400";
14158        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14159        write_build_temp_with_age(dir.path(), aged, aged_age);
14160        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14161        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14162
14163        sweep_orphaned_build_temps(dir.path());
14164
14165        assert!(
14166            !dir.path().join(aged).exists(),
14167            "aged orphan must be removed"
14168        );
14169        assert!(
14170            !dir.path().join(aged_journal).exists(),
14171            "aged journal sidecar must be removed"
14172        );
14173        assert!(
14174            dir.path().join(fresh).is_file(),
14175            "fresh temporary must survive"
14176        );
14177    }
14178
14179    #[test]
14180    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14181        let storage = tempdir().unwrap();
14182        let storage_root = storage.path();
14183        // The production shape: a legacy per-harness store whose root no longer
14184        // builds there — no `.current` pointer, no running build — so the per-root
14185        // cleanup never fires for it. A sibling root still building in the
14186        // root-keyed store triggers the store-wide sweep, which must reach into the
14187        // legacy directory and reclaim the orphan.
14188        let legacy_dir = storage_root.join("opencode").join("callgraph");
14189        fs::create_dir_all(&legacy_dir).unwrap();
14190        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14191        write_build_temp_with_age(
14192            &legacy_dir,
14193            orphan,
14194            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14195        );
14196        assert!(
14197            !legacy_dir.join("deadbeef.current").exists(),
14198            "the dead root has no current pointer"
14199        );
14200
14201        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14202        fs::create_dir_all(&root_keyed_dir).unwrap();
14203
14204        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14205
14206        assert!(
14207            !legacy_dir.join(orphan).exists(),
14208            "legacy orphan must be reclaimed by the store-wide sweep"
14209        );
14210    }
14211
14212    #[test]
14213    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14214        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
14215        // everything (min_age = 0) removes the fresh temporary that the real 24h
14216        // threshold spares in the test above. If a mutation to the age check leaves
14217        // the fresh file in place here, the predicate is no longer doing the
14218        // selection work the fresh-survives assertion relies on.
14219        let dir = tempdir().unwrap();
14220        let fresh = "project.g300.1.sqlite.tmp.1.400";
14221        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14222
14223        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14224
14225        assert!(
14226            !dir.path().join(fresh).exists(),
14227            "with the age predicate forced open, the fresh temporary is removed"
14228        );
14229    }
14230
14231    #[test]
14232    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14233        let dir = tempdir().unwrap();
14234        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
14235        // to be swept, plus a live read marker, is generation GC's jurisdiction.
14236        // The orphan sweep must not intersect it.
14237        let generation = write_generation_with_age(
14238            dir.path(),
14239            "project",
14240            400,
14241            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14242        );
14243        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14244
14245        sweep_orphaned_build_temps(dir.path());
14246
14247        assert!(
14248            dir.path().join(&generation).is_file(),
14249            "completed generation must survive the orphan sweep"
14250        );
14251        assert!(
14252            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14253            "read marker must survive the orphan sweep"
14254        );
14255    }
14256
14257    #[test]
14258    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14259        let dir = tempfile::tempdir().unwrap();
14260        let project_key = "project".to_string();
14261        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14262        let sqlite_path = dir.path().join(&generation);
14263        fs::remove_file(&sqlite_path).unwrap();
14264        let conn = Connection::open(&sqlite_path).unwrap();
14265        let store = CallGraphStore::from_connection(
14266            dir.path().to_path_buf(),
14267            project_key,
14268            sqlite_path,
14269            dir.path().to_path_buf(),
14270            false,
14271            Some(generation.clone()),
14272            None,
14273            None,
14274            conn,
14275        );
14276
14277        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14278        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14279
14280        drop(marker);
14281        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14282    }
14283
14284    #[test]
14285    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14286        let dir = tempdir().expect("temp dir");
14287        let file = dir.path().join("main.ts");
14288        fs::write(&file, "export function main() {}\n").expect("write fixture");
14289        let store = CallGraphStore::open(
14290            dir.path().join(".store-readiness-cache"),
14291            dir.path().to_path_buf(),
14292        )
14293        .expect("open store");
14294        {
14295            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14296            conn.trace(Some(count_caller_traversal_selects));
14297        }
14298
14299        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14300        assert!(store.indexed_file_count().is_err());
14301        assert!(store.indexed_file_count().is_err());
14302        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14303
14304        store
14305            .cold_build(std::slice::from_ref(&file))
14306            .expect("cold build");
14307        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14308        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14309        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14310        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14311
14312        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14313        conn.trace(None);
14314    }
14315
14316    #[test]
14317    fn direct_caller_frontier_chunks_sqlite_selects() {
14318        let dir = tempdir().expect("temp dir");
14319        let file = dir.path().join("main.ts");
14320        fs::write(
14321            &file,
14322            "export function caller() { target(); }\nexport function target() {}\n",
14323        )
14324        .expect("write fixture");
14325        let store = CallGraphStore::open(
14326            dir.path().join(".store-caller-frontier-query"),
14327            dir.path().to_path_buf(),
14328        )
14329        .expect("open store");
14330        store
14331            .cold_build(std::slice::from_ref(&file))
14332            .expect("cold build");
14333        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14334        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14335
14336        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14337        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14338        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14339        {
14340            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14341            conn.trace(Some(count_caller_traversal_selects));
14342        }
14343        let callers = store
14344            .direct_callers_for_symbols(&targets)
14345            .expect("batched callers");
14346        {
14347            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14348            conn.trace(None);
14349        }
14350
14351        assert_eq!(callers.len(), 1_000);
14352        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14353        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14354        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14355        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14356    }
14357
14358    #[test]
14359    fn callers_depth_boundary_batches_sqlite_counts() {
14360        const CALLER_COUNT: usize = 1_000;
14361
14362        let dir = tempdir().expect("temp dir");
14363        let file = dir.path().join("main.ts");
14364        let mut source = String::from("export function sharedHotHelper() {}\n");
14365        for index in 0..CALLER_COUNT {
14366            source.push_str(&format!(
14367                "export function caller{index}() {{ sharedHotHelper(); }}\n"
14368            ));
14369        }
14370        fs::write(&file, source).expect("write fixture");
14371
14372        let store = CallGraphStore::open(
14373            dir.path().join(".store-callers-query-fanout"),
14374            dir.path().to_path_buf(),
14375        )
14376        .expect("open store");
14377        store
14378            .cold_build(std::slice::from_ref(&file))
14379            .expect("cold build");
14380
14381        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14382        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14383        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14384        {
14385            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14386            conn.trace(Some(count_caller_traversal_selects));
14387        }
14388
14389        let started = Instant::now();
14390        let result = crate::commands::callgraph_store_adapter::callers_result(
14391            &store,
14392            Path::new("main.ts"),
14393            "sharedHotHelper",
14394            1,
14395            true,
14396        )
14397        .expect("callers result");
14398        let elapsed = started.elapsed();
14399
14400        {
14401            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14402            conn.trace(None);
14403        }
14404        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14405        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14406        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14407        eprintln!(
14408            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14409            result.total_callers,
14410            caller_queries,
14411            boundary_queries,
14412            total_selects,
14413            elapsed.as_secs_f64() * 1_000.0
14414        );
14415
14416        assert_eq!(result.total_callers, CALLER_COUNT);
14417        assert_eq!(caller_queries, 1);
14418        assert_eq!(boundary_queries, 3);
14419        assert_eq!(total_selects, 9);
14420    }
14421
14422    #[test]
14423    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14424        let dir = tempdir().expect("temp dir");
14425        let file = dir.path().join("main.ts");
14426        fs::write(
14427            &file,
14428            r#"export function topA() {
14429  root();
14430}
14431
14432export function topB() {
14433  root();
14434}
14435
14436export function root() {
14437  leaf();
14438  missing();
14439}
14440
14441export function leaf() {}
14442"#,
14443        )
14444        .expect("write fixture");
14445
14446        let store = CallGraphStore::open(
14447            dir.path().join(".store-depth-boundary-counts"),
14448            dir.path().to_path_buf(),
14449        )
14450        .expect("open store");
14451        store
14452            .cold_build(std::slice::from_ref(&file))
14453            .expect("cold build");
14454
14455        let root = store
14456            .node_for(Path::new("main.ts"), "root")
14457            .expect("root node");
14458        let leaf = store
14459            .node_for(Path::new("main.ts"), "leaf")
14460            .expect("leaf node");
14461
14462        let (full_forward_len, full_direct_len) = {
14463            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14464            conn.execute(
14465                "INSERT INTO edges (
14466                    edge_id, ref_id, source_node, target_node, target_file,
14467                    target_symbol, kind, line, provenance
14468                 ) VALUES (
14469                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14470                    ?2, ?3, 'call', 98, ?4
14471                 )",
14472                rusqlite::params![
14473                    &root.node_id,
14474                    &leaf.file,
14475                    &leaf.symbol,
14476                    PROVENANCE_TREESITTER
14477                ],
14478            )
14479            .expect("insert dangling forward edge");
14480            conn.execute(
14481                "INSERT INTO edges (
14482                    edge_id, ref_id, source_node, target_node, target_file,
14483                    target_symbol, kind, line, provenance
14484                 ) VALUES (
14485                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14486                    ?1, ?2, ?3, 'call', 99, ?4
14487                 )",
14488                rusqlite::params![
14489                    &root.node_id,
14490                    &root.file,
14491                    &root.symbol,
14492                    PROVENANCE_TREESITTER
14493                ],
14494            )
14495            .expect("insert dangling direct-caller edge");
14496
14497            let full_forward_len = forward_calls_for_node(&conn, &root)
14498                .expect("full forward calls")
14499                .len();
14500            let counted_forward_len =
14501                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14502            assert_eq!(
14503                counted_forward_len, full_forward_len,
14504                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14505            );
14506
14507            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14508                .expect("full direct callers");
14509            let full_direct_len = full_direct.len();
14510            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14511                .expect("counted direct callers");
14512            assert_eq!(
14513                counted_direct_len, full_direct_len,
14514                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14515            );
14516
14517            let distinct_direct_len = full_direct
14518                .iter()
14519                .map(|site| {
14520                    (
14521                        site.caller.file.clone(),
14522                        site.line,
14523                        site.target_file.clone(),
14524                        site.target_symbol.clone(),
14525                    )
14526                })
14527                .collect::<BTreeSet<_>>()
14528                .len();
14529            let batch_counts = direct_caller_counts_for_tuples(
14530                &conn,
14531                &[
14532                    (root.file.clone(), root.symbol.clone()),
14533                    (root.file.clone(), root.symbol.clone()),
14534                    (leaf.file.clone(), leaf.symbol.clone()),
14535                ],
14536            )
14537            .expect("batched direct-caller counts");
14538            assert_eq!(batch_counts.len(), 2);
14539            assert_eq!(
14540                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14541                Some(&distinct_direct_len)
14542            );
14543
14544            (full_forward_len, full_direct_len)
14545        };
14546
14547        assert_eq!(
14548            full_forward_len, 2,
14549            "fixture root should have one resolved and one unresolved outgoing call"
14550        );
14551        assert_eq!(
14552            full_direct_len, 2,
14553            "fixture root should have two real direct callers"
14554        );
14555
14556        let tree = store
14557            .call_tree(Path::new("main.ts"), "root", 0)
14558            .expect("call tree");
14559        assert!(tree.depth_limited);
14560        assert_eq!(tree.children.len(), 0);
14561        assert_eq!(
14562            tree.truncated, full_forward_len,
14563            "call_tree depth boundary must report the full forward-call list length"
14564        );
14565
14566        let callers = store
14567            .callers_of(Path::new("main.ts"), "leaf", 0)
14568            .expect("callers");
14569        assert!(callers.depth_limited);
14570        assert_eq!(callers.callers.len(), 1);
14571        assert_eq!(callers.callers[0].caller.symbol, "root");
14572        assert_eq!(
14573            callers.truncated, full_direct_len,
14574            "callers depth boundary must report the full direct-caller list length"
14575        );
14576    }
14577
14578    #[test]
14579    fn source_freshness_matches_cache_collect_for_same_bytes() {
14580        let dir = tempdir().expect("temp dir");
14581        let path = dir.path().join("fixture.ts");
14582        let source = "export function main() { return helper(); }\n";
14583        fs::write(&path, source).expect("write fixture");
14584
14585        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14586        let actual =
14587            collect_source_freshness(&path, source).expect("collect freshness from source");
14588
14589        assert_eq!(actual, expected);
14590    }
14591
14592    #[test]
14593    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14594        let root = tempfile::tempdir().unwrap();
14595        let callgraph_dir = tempfile::tempdir().unwrap();
14596        let source_dir = root.path().join("src");
14597        std::fs::create_dir_all(&source_dir).unwrap();
14598        let source = source_dir.join("lib.rs");
14599        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14600        let files = vec![source.clone()];
14601        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14602        let old_epoch = epoch.next();
14603        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14604        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14605        let old_epoch_flag = epoch.clone();
14606        let old_dir = callgraph_dir.path().to_path_buf();
14607        let old_root = root.path().to_path_buf();
14608        let old_files = files.clone();
14609        let old = std::thread::spawn(move || {
14610            set_cold_build_before_publish_observer(Some(Arc::new(move || {
14611                reached_tx.send(()).unwrap();
14612                release_rx.recv().unwrap();
14613            })));
14614            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14615                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14616            });
14617            set_cold_build_before_publish_observer(None);
14618            result
14619        });
14620        // Positive wait: the older build runs a real cold build (git probe +
14621        // SQLite schema init) before the barrier, which can exceed 5s on a
14622        // contended Windows CI runner. Only negative waits stay short.
14623        reached_rx
14624            .recv_timeout(Duration::from_secs(30))
14625            .expect("older build did not reach its publication barrier");
14626
14627        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14628        let new_epoch = epoch.next();
14629        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14630            CallGraphStore::cold_build_with_lease(
14631                callgraph_dir.path().to_path_buf(),
14632                root.path().to_path_buf(),
14633                &files,
14634            )
14635        })
14636        .expect("newer build should publish");
14637        drop(new_store);
14638
14639        release_tx.send(()).unwrap();
14640        assert!(matches!(
14641            old.join().unwrap(),
14642            Err(CallGraphStoreError::Superseded)
14643        ));
14644
14645        let current = CallGraphStore::open_readonly(
14646            callgraph_dir.path().to_path_buf(),
14647            root.path().to_path_buf(),
14648        )
14649        .unwrap()
14650        .expect("current callgraph generation");
14651        assert_eq!(
14652            current
14653                .nodes_matching("new_generation_marker")
14654                .unwrap()
14655                .len(),
14656            1
14657        );
14658        assert!(current
14659            .nodes_matching("old_generation_marker")
14660            .unwrap()
14661            .is_empty());
14662    }
14663
14664    #[test]
14665    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14666        let dir = tempdir().expect("temp dir");
14667        let project_root = dir.path();
14668        let extract = fixture_extract(project_root);
14669        let resolved = fixture_resolved(&extract);
14670
14671        let reference = build_reference_connection(project_root, &extract, &resolved);
14672        let optimized = build_optimized_connection(project_root, &extract, &resolved);
14673
14674        for table in [
14675            "files",
14676            "nodes",
14677            "file_dependencies",
14678            "dispatch_hints",
14679            "refs",
14680            "edges",
14681        ] {
14682            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
14683            // the reference and optimized builds run sequentially and can straddle a
14684            // one-second tick under load, so it is legitimately allowed to differ.
14685            // This mirrors the existing exclusions of `backend_file_state.updated_at`
14686            // and the chunked-vs-unchunked sibling test. The check is for structural
14687            // row equivalence of the optimized bulk insert, not wall-clock equality.
14688            let excluded: &[&str] = if table == "files" {
14689                &["indexed_at"]
14690            } else {
14691                &[]
14692            };
14693            assert_eq!(
14694                table_rows_without(&reference, table, excluded),
14695                table_rows_without(&optimized, table, excluded),
14696                "table `{table}` rows must match apart from wall-clock columns"
14697            );
14698        }
14699        assert_eq!(
14700            backend_state_rows(&reference),
14701            backend_state_rows(&optimized),
14702            "backend freshness rows must match apart from updated_at"
14703        );
14704        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14705    }
14706
14707    #[test]
14708    fn cold_build_chunked_matches_unchunked_logical_rows() {
14709        let dir = tempdir().expect("temp dir");
14710        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14711        write_chunked_equivalence_fixture(&project_root);
14712        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14713        assert!(
14714            files.len() > 6,
14715            "fixture should be large enough to split into multiple chunks"
14716        );
14717
14718        let unchunked = CallGraphStore::open(
14719            project_root.join(".store-unchunked"),
14720            project_root.to_path_buf(),
14721        )
14722        .expect("open unchunked store");
14723        let unchunked_stats = unchunked
14724            .cold_build_chunked(&files, 0)
14725            .expect("unchunked cold build");
14726
14727        let chunked = CallGraphStore::open(
14728            project_root.join(".store-chunked"),
14729            project_root.to_path_buf(),
14730        )
14731        .expect("open chunked store");
14732        let chunked_stats = chunked
14733            .cold_build_chunked(&files, 3)
14734            .expect("chunked cold build");
14735
14736        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14737        assert_eq!(
14738            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14739            chunked.edge_snapshot().expect("chunked edge snapshot"),
14740            "public edge snapshots must match"
14741        );
14742
14743        let dispatch_edges = {
14744            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14745            conn.query_row(
14746                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14747                [],
14748                |row| row.get::<_, i64>(0),
14749            )
14750            .expect("count dispatch edges")
14751        };
14752        assert!(
14753            dispatch_edges > 0,
14754            "fixture must exercise method-dispatch edge insertion"
14755        );
14756
14757        for table in [
14758            "edges",
14759            "refs",
14760            "nodes",
14761            "file_dependencies",
14762            "dispatch_hints",
14763        ] {
14764            assert_eq!(
14765                graph_table_rows(&unchunked, table),
14766                graph_table_rows(&chunked, table),
14767                "chunked cold build must match unchunked rows for {table}"
14768            );
14769        }
14770        assert_eq!(
14771            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
14772            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
14773            "files rows must match apart from indexed_at"
14774        );
14775        assert_eq!(
14776            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
14777            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
14778            "backend freshness rows must match apart from updated_at"
14779        );
14780
14781        let published_dir = project_root.join(".store-published");
14782        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
14783            published_dir.clone(),
14784            project_root.to_path_buf(),
14785            &files,
14786            0,
14787        )
14788        .expect("published unchunked cold build");
14789        assert!(
14790            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
14791                .expect("needs_cold_build after publish"),
14792            "published store should be ready"
14793        );
14794        drop(_published);
14795        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
14796            published_dir,
14797            project_root.to_path_buf(),
14798            &files,
14799            3,
14800        )
14801        .expect("ensure with a different chunk size");
14802        assert!(
14803            rebuild_stats.is_none(),
14804            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
14805        );
14806    }
14807
14808    // Perf A/B bench (not a gate): measures cold_build wall time at a given
14809    // chunk size against a real repo. Driven by env so the same binary can A/B
14810    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
14811    // memory work. Run:
14812    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
14813    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
14814    #[test]
14815    #[ignore]
14816    fn bench_cold_build_chunk() {
14817        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
14818        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
14819            .expect("AFT_PERF_CHUNK")
14820            .parse()
14821            .expect("AFT_PERF_CHUNK must be a non-negative integer");
14822        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
14823        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14824        let dir = tempdir().expect("temp dir");
14825        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
14826            .expect("open store");
14827        let started = Instant::now();
14828        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
14829        let ms = started.elapsed().as_millis();
14830        println!(
14831            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
14832            stats.files, stats.nodes, stats.refs, stats.edges
14833        );
14834    }
14835
14836    #[test]
14837    fn persisted_workspace_reexport_selects_its_package_dependency() {
14838        let root = tempdir().expect("temp dir");
14839        let dependencies = BTreeSet::from([
14840            "packages/aft-bridge/src/index.ts".to_string(),
14841            "packages/opencode-plugin/src/types.ts".to_string(),
14842        ]);
14843        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
14844
14845        assert_eq!(
14846            stored_dependencies_for_module(
14847                root.path(),
14848                "packages/opencode-plugin/src/shared/bash-hints.ts",
14849                "@cortexkit/aft-bridge",
14850                &dependencies,
14851                &indexed_files,
14852            ),
14853            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
14854        );
14855    }
14856
14857    #[test]
14858    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
14859        let dir = tempdir().expect("temp dir");
14860        let project_root = dir.path();
14861        let files =
14862            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
14863        let index_path = project_root.join("src/index.ts");
14864
14865        let store = CallGraphStore::open(
14866            project_root.join(".store-incremental-barrel"),
14867            project_root.to_path_buf(),
14868        )
14869        .expect("open incremental store");
14870        store.cold_build(&files).expect("initial cold build");
14871
14872        {
14873            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14874            let tx = conn.transaction().expect("dependency transaction");
14875            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
14876                .expect("dependent refs for barrel");
14877            let selected_ref_ids = dependent_refs
14878                .iter()
14879                .map(|dependent_ref| dependent_ref.ref_id.clone())
14880                .collect::<BTreeSet<_>>();
14881            let mut threaded_ref_ids = BTreeSet::new();
14882            let mut threaded_by_caller = BTreeMap::new();
14883            record_dependent_refs(
14884                &mut threaded_ref_ids,
14885                &mut threaded_by_caller,
14886                dependent_refs,
14887            );
14888            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
14889                .expect("old per-ref caller lookup");
14890
14891            assert_eq!(threaded_ref_ids, selected_ref_ids);
14892            assert_eq!(threaded_by_caller, old_by_caller);
14893            for consumer in [
14894                "src/consumer_a.ts",
14895                "src/consumer_b.ts",
14896                "src/consumer_c.ts",
14897            ] {
14898                assert!(
14899                    threaded_by_caller.contains_key(consumer),
14900                    "barrel edit should select dependent refs from {consumer}"
14901                );
14902            }
14903        }
14904
14905        fs::write(
14906            &index_path,
14907            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
14908        )
14909        .expect("edit barrel");
14910        let stats = store
14911            .refresh_files(std::slice::from_ref(&index_path))
14912            .expect("incremental refresh");
14913        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
14914        assert!(
14915            stats.dependency_selected_refs > 0,
14916            "barrel surface edit should select dependent refs"
14917        );
14918
14919        let cold_store = CallGraphStore::open(
14920            project_root.join(".store-cold-barrel"),
14921            project_root.to_path_buf(),
14922        )
14923        .expect("open cold rebuild store");
14924        cold_store
14925            .cold_build(&files)
14926            .expect("comparison cold build");
14927
14928        for table in [
14929            "nodes",
14930            "refs",
14931            "file_dependencies",
14932            "edges",
14933            "dispatch_hints",
14934        ] {
14935            assert_eq!(
14936                graph_table_rows(&store, table),
14937                graph_table_rows(&cold_store, table),
14938                "incremental refresh {table} rows must match cold rebuild"
14939            );
14940        }
14941
14942        let consumer_path = project_root.join("src/consumer_a.ts");
14943        fs::write(
14944            &consumer_path,
14945            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
14946        )
14947        .expect("edit barrel consumer");
14948        store
14949            .refresh_files(std::slice::from_ref(&consumer_path))
14950            .expect("refresh consumer through unchanged barrel");
14951        cold_store
14952            .cold_build(&files)
14953            .expect("comparison cold rebuild after consumer refresh");
14954        for table in [
14955            "nodes",
14956            "refs",
14957            "file_dependencies",
14958            "edges",
14959            "dispatch_hints",
14960        ] {
14961            assert_eq!(
14962                graph_table_rows(&store, table),
14963                graph_table_rows(&cold_store, table),
14964                "refresh through a persisted barrel must preserve cold-build {table} rows"
14965            );
14966        }
14967    }
14968
14969    fn build_reference_connection(
14970        project_root: &Path,
14971        extract: &FileExtract,
14972        resolved: &ResolvedRef,
14973    ) -> Connection {
14974        let mut conn = Connection::open_in_memory().expect("open reference db");
14975        configure_build_connection(&conn).expect("configure reference db");
14976        initialize_schema(&conn).expect("initialize reference schema");
14977        {
14978            let tx = conn.transaction().expect("reference transaction");
14979            clear_tables(&tx).expect("reference clear");
14980            insert_meta(&tx).expect("reference meta");
14981            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
14982            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
14983            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14984                .expect("reference dispatch edges");
14985            assert_eq!(supplemental, 0);
14986            tx.commit().expect("reference commit");
14987        }
14988        conn
14989    }
14990
14991    fn build_optimized_connection(
14992        project_root: &Path,
14993        extract: &FileExtract,
14994        resolved: &ResolvedRef,
14995    ) -> Connection {
14996        let mut conn = Connection::open_in_memory().expect("open optimized db");
14997        configure_build_connection(&conn).expect("configure optimized db");
14998        initialize_schema(&conn).expect("initialize optimized schema");
14999        {
15000            let tx = conn.transaction().expect("optimized transaction");
15001            clear_tables(&tx).expect("optimized clear");
15002            insert_meta(&tx).expect("optimized meta");
15003            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
15004            {
15005                let workspace_root = project_root.display().to_string();
15006                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
15007                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
15008                    .expect("optimized file extract");
15009                insert_resolved_ref_prepared(&mut inserts, resolved)
15010                    .expect("optimized resolved ref");
15011            }
15012            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
15013            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15014                .expect("optimized dispatch edges");
15015            assert_eq!(supplemental, 0);
15016            tx.commit().expect("optimized commit");
15017        }
15018        conn
15019    }
15020
15021    fn fixture_extract(_project_root: &Path) -> FileExtract {
15022        let rel_path = "src/main.ts".to_string();
15023        let target_path = "src/helper.ts".to_string();
15024        let node = NodeRecord {
15025            id: "node-main".to_string(),
15026            file_path: rel_path.clone(),
15027            name: "main".to_string(),
15028            scoped_name: "main".to_string(),
15029            kind: "function".to_string(),
15030            range: Range {
15031                start_line: 0,
15032                start_col: 0,
15033                end_line: 0,
15034                end_col: 32,
15035            },
15036            range_ordinal: 0,
15037            signature: Some("export function main()".to_string()),
15038            exported: true,
15039            is_default_export: false,
15040            is_type_like: false,
15041            is_callgraph_entry_point: true,
15042        };
15043        let mut dependencies = BTreeSet::new();
15044        dependencies.insert(target_path.clone());
15045        let raw_ref = RawRef {
15046            ref_id: "ref-main-helper".to_string(),
15047            caller_node: Some(node.id.clone()),
15048            caller_symbol: Some(node.scoped_name.clone()),
15049            caller_file: rel_path.clone(),
15050            kind: "call".to_string(),
15051            short_name: Some("helper".to_string()),
15052            full_ref: Some("helper".to_string()),
15053            module_path: None,
15054            import_kind: None,
15055            local_name: Some("helper".to_string()),
15056            requested_name: Some("helper".to_string()),
15057            namespace_alias: None,
15058            wildcard: false,
15059            line: 1,
15060            byte_start: 24,
15061            byte_end: 32,
15062            dependencies,
15063        };
15064        FileExtract {
15065            rel_path,
15066            freshness: FileFreshness {
15067                mtime: UNIX_EPOCH + Duration::from_secs(123),
15068                size: 40,
15069                content_hash: cache_freshness::hash_bytes(b"fixture source"),
15070            },
15071            lang: LangId::TypeScript,
15072            data: FileCallData {
15073                calls_by_symbol: HashMap::new(),
15074                value_refs_by_symbol: HashMap::new(),
15075                exported_symbols: Vec::new(),
15076                symbol_metadata: HashMap::new(),
15077                default_export_symbol: None,
15078                import_block: ImportBlock::empty(),
15079                lang: LangId::TypeScript,
15080            },
15081            nodes: vec![node.clone()],
15082            raw_refs: vec![raw_ref],
15083            dispatch_hints: vec![DispatchHint {
15084                id: "dispatch-main-helper".to_string(),
15085                method_name: "helper".to_string(),
15086                caller_node: node.id,
15087                file: "src/main.ts".to_string(),
15088                line: 1,
15089                byte_start: 24,
15090                byte_end: 32,
15091            }],
15092            surface_fingerprint: "surface".to_string(),
15093        }
15094    }
15095
15096    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15097        let raw = extract.raw_refs[0].clone();
15098        let mut dependencies = raw.dependencies.clone();
15099        dependencies.insert("src/helper.ts".to_string());
15100        ResolvedRef {
15101            edge: Some(EdgeRecord {
15102                edge_id: "edge-main-helper".to_string(),
15103                source_node: raw.caller_node.clone().expect("caller node"),
15104                target_node: Some("node-helper".to_string()),
15105                target_file: "src/helper.ts".to_string(),
15106                target_symbol: "helper".to_string(),
15107                kind: "call".to_string(),
15108                line: raw.line,
15109            }),
15110            raw,
15111            status: "resolved".to_string(),
15112            target_node: Some("node-helper".to_string()),
15113            target_file: Some("src/helper.ts".to_string()),
15114            target_symbol: Some("helper".to_string()),
15115            dependencies,
15116        }
15117    }
15118
15119    fn write_chunked_equivalence_fixture(project_root: &Path) {
15120        let ts_dir = project_root.join("ts");
15121        fs::create_dir_all(&ts_dir).expect("create ts dir");
15122        fs::write(
15123            ts_dir.join("leaf.ts"),
15124            "export function leaf(value: number) {\n  return value + 1;\n}\n",
15125        )
15126        .expect("write ts leaf");
15127        fs::write(
15128            ts_dir.join("mid.ts"),
15129            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
15130        )
15131        .expect("write ts mid");
15132        fs::write(
15133            ts_dir.join("entry.ts"),
15134            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
15135        )
15136        .expect("write ts entry");
15137        fs::write(
15138            ts_dir.join("worker.ts"),
15139            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
15140        )
15141        .expect("write ts worker");
15142        for idx in 0..4 {
15143            fs::write(
15144                ts_dir.join(format!("extra_{idx}.ts")),
15145                format!(
15146                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
15147                ),
15148            )
15149            .expect("write ts extra");
15150        }
15151
15152        let rust_dir = project_root.join("src");
15153        let commands_dir = rust_dir.join("commands");
15154        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15155        fs::write(
15156            rust_dir.join("context.rs"),
15157            r#"pub struct AppContext;
15158
15159impl AppContext {
15160    pub fn callgraph_store_for_ops(&self) -> usize {
15161        1
15162    }
15163}
15164"#,
15165        )
15166        .expect("write rust context");
15167        fs::write(
15168            rust_dir.join("lib.rs"),
15169            "pub mod context;\npub mod commands;\n",
15170        )
15171        .expect("write rust lib");
15172        fs::write(
15173            commands_dir.join("mod.rs"),
15174            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15175        )
15176        .expect("write rust commands mod");
15177        for name in ["callers", "impact", "trace_to"] {
15178            fs::write(
15179                commands_dir.join(format!("{name}.rs")),
15180                format!(
15181                    r#"use crate::context::AppContext;
15182
15183pub fn handle_{name}(ctx: &AppContext) -> usize {{
15184    ctx.callgraph_store_for_ops()
15185}}
15186"#
15187                ),
15188            )
15189            .expect("write rust command");
15190        }
15191    }
15192
15193    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15194        let src_dir = project_root.join("src");
15195        fs::create_dir_all(&src_dir).expect("create src dir");
15196
15197        let target_path = src_dir.join("target.ts");
15198        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
15199            .expect("write target");
15200
15201        let index_path = src_dir.join("index.ts");
15202        fs::write(&index_path, barrel_source).expect("write barrel");
15203
15204        let mut files = vec![target_path, index_path];
15205        for (file_name, function_name) in [
15206            ("consumer_a.ts", "consumerA"),
15207            ("consumer_b.ts", "consumerB"),
15208            ("consumer_c.ts", "consumerC"),
15209        ] {
15210            let path = src_dir.join(file_name);
15211            fs::write(
15212                &path,
15213                format!(
15214                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
15215                ),
15216            )
15217            .expect("write consumer");
15218            files.push(path);
15219        }
15220        files
15221    }
15222
15223    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15224        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15225        table_rows(&conn, table)
15226    }
15227
15228    fn graph_table_rows_without(
15229        store: &CallGraphStore,
15230        table: &str,
15231        excluded_columns: &[&str],
15232    ) -> Vec<String> {
15233        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15234        table_rows_without(&conn, table, excluded_columns)
15235    }
15236
15237    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15238        table_rows_without(conn, table, &[])
15239    }
15240
15241    fn table_rows_without(
15242        conn: &Connection,
15243        table: &str,
15244        excluded_columns: &[&str],
15245    ) -> Vec<String> {
15246        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15247        let columns: Vec<String> = conn
15248            .prepare(&format!("PRAGMA table_info({table})"))
15249            .expect("prepare table_info")
15250            .query_map([], |row| row.get::<_, String>(1))
15251            .expect("query table_info")
15252            .collect::<std::result::Result<Vec<String>, _>>()
15253            .expect("collect columns")
15254            .into_iter()
15255            .filter(|column| !excluded_columns.contains(column.as_str()))
15256            .collect();
15257        let sql = format!(
15258            "SELECT {} FROM {table} ORDER BY {}",
15259            columns.join(", "),
15260            columns.join(", ")
15261        );
15262        conn.prepare(&sql)
15263            .expect("prepare table rows")
15264            .query_map([], |row| row_to_strings(row, columns.len()))
15265            .expect("query table rows")
15266            .collect::<std::result::Result<_, _>>()
15267            .expect("collect table rows")
15268    }
15269
15270    fn assert_cold_build_stats_match_except_elapsed(
15271        expected: &ColdBuildStats,
15272        actual: &ColdBuildStats,
15273    ) {
15274        assert_eq!(actual.files, expected.files, "file counts must match");
15275        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15276        assert_eq!(actual.refs, expected.refs, "ref counts must match");
15277        assert_eq!(actual.edges, expected.edges, "edge counts must match");
15278        assert_eq!(
15279            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15280            expected
15281                .failed_files
15282                .iter()
15283                .cloned()
15284                .collect::<BTreeSet<_>>(),
15285            "failed file sets must match"
15286        );
15287    }
15288
15289    fn backend_state_rows(conn: &Connection) -> Vec<String> {
15290        conn.prepare(
15291            "SELECT backend, workspace_root, file_path, content_hash, status
15292             FROM backend_file_state
15293             ORDER BY backend, workspace_root, file_path, content_hash, status",
15294        )
15295        .expect("prepare backend rows")
15296        .query_map([], |row| row_to_strings(row, 5))
15297        .expect("query backend rows")
15298        .collect::<std::result::Result<_, _>>()
15299        .expect("collect backend rows")
15300    }
15301
15302    fn secondary_indexes(conn: &Connection) -> Vec<String> {
15303        let mut indexes = Vec::new();
15304        for table in [
15305            "files",
15306            "nodes",
15307            "refs",
15308            "file_dependencies",
15309            "edges",
15310            "dispatch_hints",
15311            "type_ref_names",
15312            "backend_file_state",
15313            "meta",
15314        ] {
15315            let sql = format!("PRAGMA index_list({table})");
15316            let mut stmt = conn.prepare(&sql).expect("prepare index list");
15317            let rows = stmt
15318                .query_map([], |row| row.get::<_, String>(1))
15319                .expect("query index list");
15320            for name in rows {
15321                let name = name.expect("index name");
15322                if name.starts_with("idx_") {
15323                    indexes.push(format!("{table}:{name}"));
15324                }
15325            }
15326        }
15327        indexes.sort();
15328        indexes
15329    }
15330
15331    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15332        let mut values = Vec::with_capacity(len);
15333        for index in 0..len {
15334            let value = row.get_ref(index)?;
15335            values.push(match value {
15336                rusqlite::types::ValueRef::Null => "NULL".to_string(),
15337                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15338                rusqlite::types::ValueRef::Real(value) => value.to_string(),
15339                rusqlite::types::ValueRef::Text(value) => {
15340                    String::from_utf8_lossy(value).into_owned()
15341                }
15342                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15343            });
15344        }
15345        Ok(values.join("\u{1f}"))
15346    }
15347}
15348
15349#[cfg(test)]
15350mod rust_resolution_tests {
15351    use super::*;
15352    use crate::inspect::job::CallgraphSnapshot;
15353    use std::fs;
15354    use tempfile::tempdir;
15355
15356    #[test]
15357    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15358        let dir = tempdir().expect("tempdir");
15359        let root = dir.path();
15360        write_rust_manifest(root, "scoped-alias-fixture");
15361        write_file(
15362            root,
15363            "src/lib.rs",
15364            r#"pub mod finalization_contract;
15365
15366pub fn run_alias() {
15367    use crate::finalization_contract as fc;
15368    fc::check_mason_contract();
15369}
15370"#,
15371        );
15372        write_file(
15373            root,
15374            "src/finalization_contract.rs",
15375            r#"pub fn check_mason_contract() {}
15376fn planted_dead() {}
15377"#,
15378        );
15379
15380        let (store, snapshot) = cold_build_twice(root);
15381        assert_direct_caller(
15382            &store,
15383            "src/finalization_contract.rs",
15384            "check_mason_contract",
15385            "src/lib.rs",
15386            "run_alias",
15387        );
15388        assert_projected_call(
15389            root,
15390            &snapshot,
15391            "src/finalization_contract.rs",
15392            "check_mason_contract",
15393        );
15394        assert_no_projected_call(
15395            root,
15396            &snapshot,
15397            "src/finalization_contract.rs",
15398            "planted_dead",
15399        );
15400        assert!(
15401            store
15402                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15403                .expect("planted dead callers")
15404                .is_empty(),
15405            "planted-dead guard should stay without callers"
15406        );
15407    }
15408
15409    #[test]
15410    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15411        let dir = tempdir().expect("tempdir");
15412        let root = dir.path();
15413        write_rust_manifest(root, "inline-module-fixture");
15414        write_file(
15415            root,
15416            "src/lib.rs",
15417            r#"mod work_graph { fn operations() {} }
15418mod manifest { fn operations() {} }
15419mod audit { fn operations() {} }
15420mod dispatch { fn operations() {} }
15421mod finalization { fn operations() {} }
15422
15423pub fn run_inline_operations() {
15424    work_graph::operations();
15425    manifest::operations();
15426    audit::operations();
15427    dispatch::operations();
15428    finalization::operations();
15429}
15430
15431fn planted_dead() {}
15432"#,
15433        );
15434
15435        let (store, snapshot) = cold_build_twice(root);
15436        for module in [
15437            "work_graph",
15438            "manifest",
15439            "audit",
15440            "dispatch",
15441            "finalization",
15442        ] {
15443            assert_direct_caller(
15444                &store,
15445                "src/lib.rs",
15446                &format!("{module}::operations"),
15447                "src/lib.rs",
15448                "run_inline_operations",
15449            );
15450        }
15451        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15452        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15453    }
15454
15455    #[test]
15456    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15457        let dir = tempdir().expect("tempdir");
15458        let root = dir.path();
15459        fs::write(
15460            root.join("Cargo.toml"),
15461            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15462        )
15463        .expect("write workspace manifest");
15464        write_file(
15465            root,
15466            "crates/but-action/Cargo.toml",
15467            r#"[package]
15468name = "but-action"
15469version = "0.1.0"
15470edition = "2021"
15471"#,
15472        );
15473        write_file(
15474            root,
15475            "crates/but-action/src/lib.rs",
15476            "mod action;\npub use action::{list_actions};\n",
15477        );
15478        write_file(
15479            root,
15480            "crates/but-action/src/action.rs",
15481            "pub fn list_actions() {}\nfn planted_dead() {}\n",
15482        );
15483        write_file(
15484            root,
15485            "crates/app/Cargo.toml",
15486            r#"[package]
15487name = "app"
15488version = "0.1.0"
15489edition = "2021"
15490"#,
15491        );
15492        write_file(
15493            root,
15494            "crates/app/src/lib.rs",
15495            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
15496        );
15497
15498        let (store, snapshot) = cold_build_twice(root);
15499        assert_direct_caller(
15500            &store,
15501            "crates/but-action/src/action.rs",
15502            "list_actions",
15503            "crates/app/src/lib.rs",
15504            "run_actions",
15505        );
15506        assert!(
15507            store
15508                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15509                .expect("lib reexport callers")
15510                .is_empty(),
15511            "call should target the reexported source function, not lib.rs"
15512        );
15513        assert_projected_call(
15514            root,
15515            &snapshot,
15516            "crates/but-action/src/action.rs",
15517            "list_actions",
15518        );
15519        assert_no_projected_call(
15520            root,
15521            &snapshot,
15522            "crates/but-action/src/action.rs",
15523            "planted_dead",
15524        );
15525    }
15526
15527    #[test]
15528    fn rust_generic_self_turbofish_method_dispatch_resolves() {
15529        let dir = tempdir().expect("tempdir");
15530        let root = dir.path();
15531        write_rust_manifest(root, "generic-self-fixture");
15532        write_file(
15533            root,
15534            "src/lib.rs",
15535            r#"pub struct Matcher;
15536
15537impl Matcher {
15538    pub fn run(&self) -> bool {
15539        self.fuzzy_match_optimal::<usize>("needle")
15540    }
15541
15542    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15543        let _ = std::marker::PhantomData::<T>;
15544        true
15545    }
15546
15547    fn planted_dead(&self) {}
15548}
15549
15550pub fn entry() -> bool {
15551    let matcher = Matcher;
15552    matcher.run()
15553}
15554"#,
15555        );
15556
15557        let (store, snapshot) = cold_build_twice(root);
15558        assert_direct_caller(
15559            &store,
15560            "src/lib.rs",
15561            "Matcher::fuzzy_match_optimal",
15562            "src/lib.rs",
15563            "Matcher::run",
15564        );
15565        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15566        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15567    }
15568
15569    #[test]
15570    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15571        let dir = tempdir().expect("tempdir");
15572        let root = dir.path();
15573        write_rust_manifest(root, "manifest-operations-fixture");
15574        write_file(
15575            root,
15576            "src/main.rs",
15577            r#"mod dispatch;
15578use dispatch::{manifest_operations};
15579
15580fn main() {
15581    manifest_operations();
15582}
15583"#,
15584        );
15585        write_file(
15586            root,
15587            "src/dispatch.rs",
15588            r#"mod work_graph { fn operations() {} }
15589mod manifest { fn operations() {} }
15590mod audit { fn operations() {} }
15591mod descriptor { fn operations() {} }
15592mod writer { fn operations() {} }
15593
15594pub fn manifest_operations() {
15595    manifest::operations();
15596}
15597
15598pub fn work_graph_operations() {
15599    work_graph::operations();
15600}
15601
15602pub fn audit_operations() {
15603    audit::operations();
15604}
15605
15606pub fn descriptor_operations() {
15607    descriptor::operations();
15608}
15609
15610pub fn writer_operations() {
15611    writer::operations();
15612}
15613
15614fn planted_dead() {}
15615"#,
15616        );
15617
15618        let (store, snapshot) = cold_build_twice(root);
15619        assert_direct_caller(
15620            &store,
15621            "src/dispatch.rs",
15622            "manifest_operations",
15623            "src/main.rs",
15624            "main",
15625        );
15626        assert_direct_caller(
15627            &store,
15628            "src/dispatch.rs",
15629            "manifest::operations",
15630            "src/dispatch.rs",
15631            "manifest_operations",
15632        );
15633        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15634        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15635        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15636    }
15637
15638    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15639        let files = rust_files(root);
15640        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15641            .expect("open first store");
15642        first.cold_build(&files).expect("first cold build");
15643        let first_snapshot =
15644            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15645
15646        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15647            .expect("open second store");
15648        second.cold_build(&files).expect("second cold build");
15649        let second_snapshot =
15650            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15651
15652        assert_eq!(
15653            projection_rows(&first_snapshot),
15654            projection_rows(&second_snapshot),
15655            "cold-build projection should be deterministic"
15656        );
15657        (first, first_snapshot)
15658    }
15659
15660    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15661        let mut rows = Vec::new();
15662        for export in &snapshot.exported_symbols {
15663            rows.push(format!(
15664                "export\t{}\t{}\t{}\t{}",
15665                export.file.display(),
15666                export.symbol,
15667                export.kind,
15668                export.line
15669            ));
15670        }
15671        for call in &snapshot.outbound_calls {
15672            rows.push(format!(
15673                "call\t{}\t{}\t{}\t{}\t{}",
15674                call.caller_file.display(),
15675                call.caller_symbol,
15676                call.target,
15677                call.line,
15678                call.provenance
15679            ));
15680        }
15681        for file in &snapshot.entry_points {
15682            rows.push(format!("entry_file\t{}", file.display()));
15683        }
15684        for (file, symbols) in &snapshot.entry_point_symbols {
15685            for symbol in symbols {
15686                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15687            }
15688        }
15689        rows.sort();
15690        rows
15691    }
15692
15693    fn assert_direct_caller(
15694        store: &CallGraphStore,
15695        target_rel: &str,
15696        target_symbol: &str,
15697        caller_rel: &str,
15698        caller_symbol: &str,
15699    ) {
15700        let callers = store
15701            .direct_callers_of(Path::new(target_rel), target_symbol)
15702            .unwrap_or_else(|error| {
15703                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15704            });
15705        assert!(
15706            callers.iter().any(|site| {
15707                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15708            }),
15709            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15710        );
15711    }
15712
15713    fn assert_projected_call(
15714        root: &Path,
15715        snapshot: &CallgraphSnapshot,
15716        target_rel: &str,
15717        symbol: &str,
15718    ) {
15719        let target = projected_target(root, target_rel, symbol);
15720        assert!(
15721            snapshot.outbound_calls.iter().any(|call| {
15722                call.target == target
15723                    || call.target.starts_with(&format!(
15724                        "{target}{}",
15725                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15726                    ))
15727            }),
15728            "expected projected call to {target}; calls: {:#?}",
15729            snapshot.outbound_calls
15730        );
15731    }
15732
15733    fn assert_no_projected_call(
15734        root: &Path,
15735        snapshot: &CallgraphSnapshot,
15736        target_rel: &str,
15737        symbol: &str,
15738    ) {
15739        let target = projected_target(root, target_rel, symbol);
15740        assert!(
15741            snapshot.outbound_calls.iter().all(|call| {
15742                call.target != target
15743                    && !call.target.starts_with(&format!(
15744                        "{target}{}",
15745                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15746                    ))
15747            }),
15748            "did not expect projected call to {target}; calls: {:#?}",
15749            snapshot.outbound_calls
15750        );
15751    }
15752
15753    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15754        // Projection targets carry the normalized (verbatim-stripped)
15755        // canonical form; bare fs::canonicalize diverges on Windows.
15756        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15757        format!("{}::{symbol}", path.display())
15758    }
15759
15760    fn write_rust_manifest(root: &Path, name: &str) {
15761        write_file(
15762            root,
15763            "Cargo.toml",
15764            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15765        );
15766    }
15767
15768    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15769        let path = root.join(rel_path);
15770        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
15771        fs::write(&path, source).expect("write fixture file");
15772        path
15773    }
15774
15775    fn rust_files(root: &Path) -> Vec<PathBuf> {
15776        let mut files = Vec::new();
15777        collect_rust_files(root, &mut files);
15778        files.sort();
15779        files
15780    }
15781
15782    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
15783        for entry in fs::read_dir(dir).expect("read fixture dir") {
15784            let entry = entry.expect("read fixture entry");
15785            let path = entry.path();
15786            if path.is_dir() {
15787                let name = path
15788                    .file_name()
15789                    .and_then(|name| name.to_str())
15790                    .unwrap_or("");
15791                if !name.starts_with(".store") {
15792                    collect_rust_files(&path, files);
15793                }
15794            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
15795                files.push(path);
15796            }
15797        }
15798    }
15799}
15800
15801#[cfg(test)]
15802mod build_pool_tests {
15803    use super::build_pool_size;
15804
15805    #[test]
15806    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
15807        let size = build_pool_size();
15808        // Never zero, never the full core count, never above the 8 cap — this is
15809        // the starvation guard for the cold-build's all-cores tree-sitter pass.
15810        assert!(size >= 1, "pool size must be at least 1");
15811        assert!(size <= 8, "pool size must be capped at 8, got {size}");
15812
15813        let cores = std::thread::available_parallelism()
15814            .map(|p| p.get())
15815            .unwrap_or(1);
15816        let expected = cores.div_ceil(2).clamp(1, 8);
15817        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
15818    }
15819}
15820
15821#[cfg(test)]
15822mod reexport_resolution_tests {
15823    use super::*;
15824
15825    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
15826        ProjectIndex {
15827            project_root: PathBuf::from("/fixture"),
15828            files: files.into_iter().collect(),
15829            caller_data: HashMap::new(),
15830            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
15831        }
15832    }
15833
15834    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
15835        DbFileIndex {
15836            lang: None,
15837            exports: HashSet::new(),
15838            default_export: None,
15839            export_aliases: HashMap::new(),
15840            node_by_scoped: HashMap::new(),
15841            node_by_bare: HashMap::new(),
15842            node_kind_by_id: HashMap::new(),
15843            module_targets: HashMap::new(),
15844            reexports: reexport_targets
15845                .iter()
15846                .map(|target| ReexportIndex {
15847                    target_file: Some((*target).to_string()),
15848                    named: HashMap::new(),
15849                    wildcard: true,
15850                })
15851                .collect(),
15852        }
15853    }
15854
15855    /// A dense wildcard re-export cycle (barrel files re-exporting each
15856    /// other) must resolve in O(files), not O(branching^depth). Without the
15857    /// resolver's memoization, resolving a MISSING symbol through this
15858    /// 12-file complete digraph explores ~11^16 paths and this test never
15859    /// finishes: the depth cap bounds path length, not path count, and one
15860    /// such resolution can pin a worker thread at 100% CPU indefinitely.
15861    #[test]
15862    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
15863        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
15864        let files = names
15865            .iter()
15866            .map(|name| {
15867                let targets: Vec<&str> = names
15868                    .iter()
15869                    .filter(|other| *other != name)
15870                    .map(String::as_str)
15871                    .collect();
15872                (name.clone(), barrel_file(&targets))
15873            })
15874            .collect();
15875        let index = barrel_index(files);
15876
15877        assert_eq!(
15878            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
15879            None
15880        );
15881    }
15882
15883    /// Depth-dominance counterexample: the walk first reaches `shared` down a
15884    /// 16-hop chain (no budget left for its leaf), then reaches it again
15885    /// directly at depth 1. Plain visited-set pruning would skip the second
15886    /// visit and lose a resolution the capped resolver finds; the
15887    /// depth-dominance memo revisits because the second arrival is shallower.
15888    #[test]
15889    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
15890        let mut leaf = barrel_file(&[]);
15891        leaf.exports.insert("deep_symbol".to_string());
15892        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
15893        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
15894        // entry's SECOND reexport goes straight to shared.
15895        files.push((
15896            "src/entry.ts".to_string(),
15897            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
15898        ));
15899        for i in 0..15 {
15900            let next = if i == 14 {
15901                "src/shared.ts".to_string()
15902            } else {
15903                format!("src/chain{}.ts", i + 1)
15904            };
15905            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
15906        }
15907        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
15908        files.push(("src/leaf.ts".to_string(), leaf));
15909        let index = barrel_index(files);
15910
15911        assert_eq!(
15912            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
15913            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
15914            "a shallower re-visit must not be pruned by a deeper capped visit"
15915        );
15916    }
15917
15918    #[test]
15919    fn symbol_reachable_through_reexport_cycle_still_resolves() {
15920        let mut leaf = barrel_file(&[]);
15921        leaf.exports.insert("real_symbol".to_string());
15922        let index = barrel_index(vec![
15923            (
15924                "src/a.ts".to_string(),
15925                barrel_file(&["src/b.ts", "src/a.ts"]),
15926            ),
15927            (
15928                "src/b.ts".to_string(),
15929                barrel_file(&["src/a.ts", "src/leaf.ts"]),
15930            ),
15931            ("src/leaf.ts".to_string(), leaf),
15932        ]);
15933
15934        assert_eq!(
15935            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
15936            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
15937        );
15938    }
15939}
15940
15941#[cfg(test)]
15942mod method_dispatch_inference_tests {
15943    use super::*;
15944    use std::fs;
15945    use tempfile::tempdir;
15946
15947    #[test]
15948    fn java_field_receiver_type_selects_declared_class_method() {
15949        let source = r#"class EntryPoint {
15950    private UserService userService;
15951
15952    void handle() {
15953        userService.find();
15954    }
15955}
15956
15957class UserService {
15958    void find() {}
15959}
15960
15961class AuditService {
15962    void find() {}
15963}
15964"#;
15965        let dir = tempdir().expect("temp dir");
15966        let root = dir.path();
15967        write_fixture(root, "src/EntryPoint.java", source);
15968        let reference = reference(
15969            "java",
15970            "src/EntryPoint.java",
15971            "EntryPoint::handle",
15972            "userService",
15973            "find",
15974            line_of(source, "userService.find()"),
15975        );
15976        let mut cache = DispatchSourceCache::new();
15977
15978        let receiver_type =
15979            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
15980        assert_eq!(receiver_type, "UserService");
15981
15982        let candidates = vec![
15983            method_candidate("audit", "AuditService::find"),
15984            method_candidate("user", "UserService::find"),
15985        ];
15986        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
15987            .expect("type candidate");
15988        assert_eq!(selected.scoped_name, "UserService::find");
15989
15990        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
15991        assert!(
15992            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
15993        );
15994    }
15995
15996    #[test]
15997    fn kotlin_property_and_local_value_types_are_inferred() {
15998        let source = r#"class Handler {
15999    private val auditService: AuditService = AuditService()
16000
16001    fun handle() {
16002        auditService.find()
16003        val userService: UserService = UserService()
16004        userService.find()
16005        val billingService = BillingService()
16006        billingService.find()
16007    }
16008}
16009
16010class UserService { fun find() {} }
16011class AuditService { fun find() {} }
16012class BillingService { fun find() {} }
16013"#;
16014        let dir = tempdir().expect("temp dir");
16015        let root = dir.path();
16016        write_fixture(root, "src/Handler.kt", source);
16017        let mut cache = DispatchSourceCache::new();
16018
16019        let audit_ref = reference(
16020            "kotlin",
16021            "src/Handler.kt",
16022            "Handler::handle",
16023            "auditService",
16024            "find",
16025            line_of(source, "auditService.find()"),
16026        );
16027        assert_eq!(
16028            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
16029            Some("AuditService")
16030        );
16031
16032        let user_ref = reference(
16033            "kotlin",
16034            "src/Handler.kt",
16035            "Handler::handle",
16036            "userService",
16037            "find",
16038            line_of(source, "userService.find()"),
16039        );
16040        assert_eq!(
16041            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
16042            Some("UserService")
16043        );
16044
16045        let billing_ref = reference(
16046            "kotlin",
16047            "src/Handler.kt",
16048            "Handler::handle",
16049            "billingService",
16050            "find",
16051            line_of(source, "billingService.find()"),
16052        );
16053        assert_eq!(
16054            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16055            Some("BillingService")
16056        );
16057    }
16058
16059    #[test]
16060    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16061        let source = r#"struct Foo { void run(); };
16062struct PointerFoo { void run(); };
16063struct FactoryFoo { void run(); };
16064FactoryFoo makeFactoryFoo();
16065
16066void handle() {
16067    Foo foo;
16068    foo.run();
16069    PointerFoo* pointerFoo = nullptr;
16070    pointerFoo->run();
16071    auto factoryFoo = makeFactoryFoo();
16072    factoryFoo.run();
16073}
16074"#;
16075        let dir = tempdir().expect("temp dir");
16076        let root = dir.path();
16077        write_fixture(root, "src/fixture.cpp", source);
16078        let mut cache = DispatchSourceCache::new();
16079
16080        let foo_ref = reference(
16081            "cpp",
16082            "src/fixture.cpp",
16083            "handle",
16084            "foo",
16085            "run",
16086            line_of(source, "foo.run()"),
16087        );
16088        assert_eq!(
16089            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16090            Some("Foo")
16091        );
16092
16093        let pointer_ref = reference(
16094            "cpp",
16095            "src/fixture.cpp",
16096            "handle",
16097            "pointerFoo",
16098            "run",
16099            line_of(source, "pointerFoo->run()"),
16100        );
16101        assert_eq!(
16102            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16103            Some("PointerFoo")
16104        );
16105
16106        let factory_ref = reference(
16107            "cpp",
16108            "src/fixture.cpp",
16109            "handle",
16110            "factoryFoo",
16111            "run",
16112            line_of(source, "factoryFoo.run()"),
16113        );
16114        assert_eq!(
16115            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16116            Some("FactoryFoo")
16117        );
16118    }
16119
16120    #[test]
16121    fn rust_direct_self_field_name_trims_separator_whitespace() {
16122        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16123            assert_eq!(
16124                rust_direct_self_field_name(receiver_expression),
16125                Some("engine")
16126            );
16127        }
16128    }
16129
16130    #[test]
16131    fn rust_direct_self_field_receiver_type_is_conservative() {
16132        let source = r#"struct Engine;
16133
16134struct Car {
16135    engine: Engine,
16136}
16137
16138impl Car {
16139    fn run(&self) {
16140        self.engine.start();
16141    }
16142}
16143
16144struct NestedCar {
16145    engine: Engine,
16146}
16147
16148impl NestedCar {
16149    fn run(&self) {
16150        self.inner.engine.start();
16151    }
16152}
16153
16154struct WrappedCar {
16155    engine: Option<Engine>,
16156}
16157
16158impl WrappedCar {
16159    fn run(&self) {
16160        self.engine.start(); // wrapped
16161    }
16162}
16163
16164struct GenericCar<T> {
16165    engine: T,
16166}
16167
16168impl<T> GenericCar<T> {
16169    fn run(&self) {
16170        self.engine.start(); // generic
16171    }
16172}
16173
16174type EngineAlias = Engine;
16175
16176struct AliasCar {
16177    engine: EngineAlias,
16178}
16179
16180impl AliasCar {
16181    fn run(&self) {
16182        self.engine.start(); // alias
16183    }
16184}
16185"#;
16186        let dir = tempdir().expect("temp dir");
16187        let root = dir.path();
16188        write_fixture(root, "src/lib.rs", source);
16189        let mut cache = DispatchSourceCache::new();
16190
16191        let mut direct = reference(
16192            "rust",
16193            "src/lib.rs",
16194            "Car::run",
16195            "engine",
16196            "start",
16197            line_of(source, "self.engine.start()"),
16198        );
16199        direct.receiver_expression = "self.engine".to_string();
16200        assert_eq!(
16201            infer_receiver_type(root, &direct, &mut cache).as_deref(),
16202            Some("Engine")
16203        );
16204
16205        let mut mismatched_impl_target = direct.clone();
16206        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16207        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16208
16209        let mut nested = reference(
16210            "rust",
16211            "src/lib.rs",
16212            "NestedCar::run",
16213            "engine",
16214            "start",
16215            line_of(source, "self.inner.engine.start()"),
16216        );
16217        nested.receiver_expression = "self.inner.engine".to_string();
16218        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16219
16220        let mut wrapped = reference(
16221            "rust",
16222            "src/lib.rs",
16223            "WrappedCar::run",
16224            "engine",
16225            "start",
16226            line_of(source, "self.engine.start(); // wrapped"),
16227        );
16228        wrapped.receiver_expression = "self.engine".to_string();
16229        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16230
16231        let mut generic = reference(
16232            "rust",
16233            "src/lib.rs",
16234            "GenericCar::run",
16235            "engine",
16236            "start",
16237            line_of(source, "self.engine.start(); // generic"),
16238        );
16239        generic.receiver_expression = "self.engine".to_string();
16240        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16241
16242        let mut alias = reference(
16243            "rust",
16244            "src/lib.rs",
16245            "AliasCar::run",
16246            "engine",
16247            "start",
16248            line_of(source, "self.engine.start(); // alias"),
16249        );
16250        alias.receiver_expression = "self.engine".to_string();
16251        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16252    }
16253
16254    #[test]
16255    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16256        let source = r#"struct Engine;
16257
16258struct Car {
16259    engine: &'static Engine,
16260}
16261
16262impl Car {
16263    fn run(&self) {
16264        self.engine.start();
16265    }
16266}
16267"#;
16268        let dir = tempdir().expect("temp dir");
16269        let root = dir.path();
16270        write_fixture(root, "src/lib.rs", source);
16271        let mut cache = DispatchSourceCache::new();
16272        let mut reference = reference(
16273            "rust",
16274            "src/lib.rs",
16275            "Car::run",
16276            "engine",
16277            "start",
16278            line_of(source, "self.engine.start()"),
16279        );
16280        reference.receiver_expression = "self.engine".to_string();
16281
16282        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16283    }
16284
16285    #[test]
16286    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16287        let source = r#"trait Drive {
16288    fn run(&self);
16289}
16290
16291struct Engine;
16292
16293struct Car {
16294    engine: Engine,
16295}
16296
16297impl Drive for Car {
16298    fn run(&self) {
16299        self.engine.start();
16300    }
16301}
16302"#;
16303        let dir = tempdir().expect("temp dir");
16304        let root = dir.path();
16305        write_fixture(root, "src/lib.rs", source);
16306        let mut cache = DispatchSourceCache::new();
16307        let mut reference = reference(
16308            "rust",
16309            "src/lib.rs",
16310            "Car::run",
16311            "engine",
16312            "start",
16313            line_of(source, "self.engine.start()"),
16314        );
16315        reference.receiver_expression = "self.engine".to_string();
16316
16317        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16318    }
16319
16320    #[test]
16321    fn rust_self_field_does_not_bind_struct_from_another_module() {
16322        let source = r#"struct Engine;
16323
16324mod unrelated {
16325    struct Car {
16326        engine: Engine,
16327    }
16328}
16329
16330impl Car {
16331    fn run(&self) {
16332        self.engine.start();
16333    }
16334}
16335"#;
16336        let dir = tempdir().expect("temp dir");
16337        let root = dir.path();
16338        write_fixture(root, "src/lib.rs", source);
16339        let mut cache = DispatchSourceCache::new();
16340        let mut reference = reference(
16341            "rust",
16342            "src/lib.rs",
16343            "Car::run",
16344            "engine",
16345            "start",
16346            line_of(source, "self.engine.start()"),
16347        );
16348        reference.receiver_expression = "self.engine".to_string();
16349
16350        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16351    }
16352
16353    #[test]
16354    fn unknown_java_receiver_still_uses_name_match_fallback() {
16355        let source = r#"class EntryPoint {
16356    void handle() {
16357        service.runSpecial();
16358    }
16359}
16360
16361class OnlyService {
16362    void runSpecial() {}
16363}
16364"#;
16365        let dir = tempdir().expect("temp dir");
16366        let root = dir.path();
16367        write_fixture(root, "src/EntryPoint.java", source);
16368        let reference = reference(
16369            "java",
16370            "src/EntryPoint.java",
16371            "EntryPoint::handle",
16372            "service",
16373            "runSpecial",
16374            line_of(source, "service.runSpecial()"),
16375        );
16376        let mut cache = DispatchSourceCache::new();
16377
16378        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16379        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16380        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16381        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16382    }
16383
16384    fn reference(
16385        lang: &str,
16386        caller_file: &str,
16387        caller_symbol: &str,
16388        receiver: &str,
16389        method_name: &str,
16390        line: u32,
16391    ) -> NameMatchRef {
16392        NameMatchRef {
16393            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16394            caller_node: format!("{caller_symbol}:node"),
16395            caller_file: caller_file.to_string(),
16396            caller_symbol: caller_symbol.to_string(),
16397            caller_signature: None,
16398            receiver_expression: receiver.to_string(),
16399            receiver: receiver.to_string(),
16400            method_name: method_name.to_string(),
16401            colon_dispatch: false,
16402            line,
16403            lang: lang.to_string(),
16404        }
16405    }
16406
16407    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16408        NameMatchCandidate {
16409            node_id: node_id.to_string(),
16410            file_path: "src/targets.fixture".to_string(),
16411            scoped_name: scoped_name.to_string(),
16412            kind: "method".to_string(),
16413            start_line: 1,
16414        }
16415    }
16416
16417    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16418        let path = root.join(rel_path);
16419        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16420        fs::write(path, source).expect("write fixture");
16421    }
16422
16423    fn line_of(source: &str, needle: &str) -> u32 {
16424        source
16425            .lines()
16426            .position(|line| line.contains(needle))
16427            .map(|index| index as u32 + 1)
16428            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16429    }
16430}
16431
16432#[cfg(test)]
16433mod bounded_build_breaker_tests {
16434    use super::*;
16435    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16436    use tempfile::tempdir;
16437
16438    #[test]
16439    fn staged_inventory_drives_ordered_bounded_file_batches() {
16440        let temp = tempdir().unwrap();
16441        let root = temp.path().join("root");
16442        std::fs::create_dir_all(&root).unwrap();
16443        let first = root.join("a.ts");
16444        let second = root.join("b.ts");
16445        let third = root.join("c.ts");
16446        for path in [&first, &second, &third] {
16447            std::fs::write(path, "export function item() {}\n").unwrap();
16448        }
16449        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16450            .unwrap()
16451            .expect("test root may write its private staging database");
16452        let store = CallGraphStore::open_at_path(
16453            root.clone(),
16454            "inventory-key".to_string(),
16455            temp.path().join("inventory.sqlite"),
16456            None,
16457            true,
16458            Some(writer_lease),
16459            None,
16460        )
16461        .unwrap()
16462        .store;
16463        let fingerprint = store
16464            .stage_cold_build_file_inventory(&[
16465                third.clone(),
16466                first.clone(),
16467                second.clone(),
16468                first.clone(),
16469            ])
16470            .unwrap();
16471
16472        let conn = store.conn.lock().unwrap();
16473        assert_eq!(
16474            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16475            3,
16476            "the primary key deduplicates caller-supplied paths on disk"
16477        );
16478        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16479            .unwrap()
16480            .expect("first batch");
16481        assert_eq!(first_batch.paths, vec![first.clone(), second]);
16482        let second_batch =
16483            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16484                .unwrap()
16485                .expect("second batch");
16486        assert_eq!(second_batch.paths, vec![third]);
16487        assert_eq!(
16488            fingerprint,
16489            callgraph_corpus_fingerprint(&root).unwrap(),
16490            "staged and direct streaming fingerprints agree without walk-order dependence"
16491        );
16492    }
16493
16494    #[test]
16495    fn resumed_stage_preserves_committed_batch_and_counter() {
16496        let temp = tempdir().unwrap();
16497        let root = temp.path().join("root");
16498        std::fs::create_dir_all(&root).unwrap();
16499        let first = root.join("first.ts");
16500        let second = root.join("second.ts");
16501        std::fs::write(&first, "export function first() {}\n").unwrap();
16502        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16503        let staging = temp.path().join("stage.sqlite");
16504        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16505            .unwrap()
16506            .expect("test root may write its private staging database");
16507        let store = CallGraphStore::open_at_path(
16508            root.clone(),
16509            "test-key".to_string(),
16510            staging,
16511            None,
16512            true,
16513            Some(writer_lease),
16514            None,
16515        )
16516        .unwrap()
16517        .store;
16518        let corpus_fingerprint = store
16519            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16520            .unwrap();
16521        let first_extract = build_file_extract(&root, &first).unwrap();
16522        let first_bytes = first_extract.freshness.size;
16523        {
16524            let mut conn = store.conn.lock().unwrap();
16525            let tx = conn.transaction().unwrap();
16526            clear_tables(&tx).unwrap();
16527            insert_meta(&tx).unwrap();
16528            drop_cold_build_secondary_indexes(&tx).unwrap();
16529            set_meta_ready(&tx, false).unwrap();
16530            set_staged_build_phase(&tx, "extracting").unwrap();
16531            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16532            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16533            {
16534                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16535                insert_file_extract_prepared(
16536                    &mut inserts,
16537                    &root.display().to_string(),
16538                    &first_extract,
16539                )
16540                .unwrap();
16541                for raw in &first_extract.raw_refs {
16542                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16543                }
16544            }
16545            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16546            tx.commit().unwrap();
16547        }
16548
16549        store
16550            .cold_build_chunked(&[first.clone(), second.clone()], 1)
16551            .unwrap();
16552        let conn = store.conn.lock().unwrap();
16553        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16554        assert_eq!(
16555            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16556            first_bytes + std::fs::metadata(second).unwrap().len(),
16557            "the already committed batch and its credit survive adoption; only the new batch increments credit"
16558        );
16559        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16560    }
16561
16562    const SPECIMEN_CHILD_TEST: &str =
16563        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16564    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16565    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16566    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16567    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16568
16569    fn wait_for_child_barrier(path: &Path) {
16570        let deadline = Instant::now() + Duration::from_secs(10);
16571        while !path.exists() {
16572            assert!(
16573                Instant::now() < deadline,
16574                "callgraph child did not reach barrier {}",
16575                path.display()
16576            );
16577            std::thread::sleep(Duration::from_millis(5));
16578        }
16579    }
16580
16581    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16582        let signal = store.join("specimen-child.reached");
16583        let _ = std::fs::remove_file(&signal);
16584        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16585        command
16586            .arg("--exact")
16587            .arg(SPECIMEN_CHILD_TEST)
16588            .arg("--nocapture")
16589            .arg("--test-threads=1")
16590            .env(SPECIMEN_CHILD_ROOT, root)
16591            .env(SPECIMEN_CHILD_STORE, store)
16592            .env(SPECIMEN_CHILD_SIGNAL, &signal)
16593            .stdout(std::process::Stdio::null())
16594            .stderr(std::process::Stdio::null());
16595        if let Some(phase) = phase {
16596            command.env(SPECIMEN_CHILD_PHASE, phase);
16597        }
16598        command.spawn().unwrap()
16599    }
16600
16601    fn staging_path(root: &Path, store: &Path) -> PathBuf {
16602        let project_key = crate::search_index::artifact_cache_key(root);
16603        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16604    }
16605
16606    fn durable_staging_state(path: &Path) -> (u64, u64) {
16607        if !path.exists() {
16608            return (0, 0);
16609        }
16610        let conn = Connection::open(path).unwrap();
16611        (
16612            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16613            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16614        )
16615    }
16616
16617    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16618        wait_for_child_barrier(signal);
16619        child.kill().unwrap();
16620        let _ = child.wait().unwrap();
16621    }
16622
16623    #[test]
16624    fn respawn_loop_build_child() {
16625        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16626            return;
16627        };
16628        let root = PathBuf::from(root);
16629        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16630        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16631            let phase = phase.to_string_lossy().into_owned();
16632            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16633            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16634                if observed == phase {
16635                    std::fs::write(&signal, observed.as_bytes()).unwrap();
16636                    std::thread::sleep(Duration::from_secs(30));
16637                }
16638            })));
16639        }
16640        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16641        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16642    }
16643
16644    #[test]
16645    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16646        let temp = tempdir().unwrap();
16647        let root = temp.path().join("resumable-root");
16648        let store = temp.path().join("resumable-store");
16649        std::fs::create_dir_all(&root).unwrap();
16650        std::fs::create_dir_all(&store).unwrap();
16651        for index in 0..3 {
16652            std::fs::write(
16653                root.join(format!("file-{index}.ts")),
16654                format!("export function specimen{index}() {{ return {index}; }}\n"),
16655            )
16656            .unwrap();
16657        }
16658        let stage = staging_path(&root, &store);
16659        let signal = store.join("specimen-child.reached");
16660
16661        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16662        kill_barrier_child(&mut first, &signal);
16663        let (first_rows, first_bytes) = durable_staging_state(&stage);
16664        assert_eq!(first_rows, 1);
16665        assert!(first_bytes > 0);
16666
16667        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16668        kill_barrier_child(&mut second, &signal);
16669        let (second_rows, second_bytes) = durable_staging_state(&stage);
16670        assert_eq!(second_rows, 2);
16671        assert!(
16672            second_bytes > first_bytes,
16673            "a replacement process must adopt committed bytes instead of restarting from zero"
16674        );
16675
16676        let status = spawn_build_child(&root, &store, None).wait().unwrap();
16677        assert!(status.success(), "uninterrupted replacement build failed");
16678        assert!(!stage.exists(), "published staging file must be renamed");
16679        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16680            .unwrap()
16681            .expect("replacement attempts must converge to a published graph");
16682        assert_eq!(ready.indexed_file_count().unwrap(), 3);
16683
16684        let fast_root = temp.path().join("zero-credit-root");
16685        let fast_store = temp.path().join("zero-credit-store");
16686        std::fs::create_dir_all(&fast_root).unwrap();
16687        std::fs::create_dir_all(&fast_store).unwrap();
16688        std::fs::write(
16689            fast_root.join("main.ts"),
16690            "export function neverCommitted() {}\n",
16691        )
16692        .unwrap();
16693        let fast_stage = staging_path(&fast_root, &fast_store);
16694        let fast_signal = fast_store.join("specimen-child.reached");
16695        let breaker_path = fast_store.join("build-breaker.sqlite");
16696        let now = unix_millis_now();
16697
16698        for death in 0..3 {
16699            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16700            wait_for_child_barrier(&fast_signal);
16701            let attempt_id = Connection::open(&breaker_path)
16702                .unwrap()
16703                .query_row(
16704                    "SELECT attempt_id FROM breaker_attempts
16705                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16706                    [],
16707                    |row| row.get::<_, String>(0),
16708                )
16709                .unwrap();
16710            let (_, committed_bytes) = durable_staging_state(&fast_stage);
16711            assert_eq!(
16712                committed_bytes, 0,
16713                "the fast-kill schedule must not cross an extraction commit"
16714            );
16715            child.kill().unwrap();
16716            let _ = child.wait().unwrap();
16717
16718            let key = BreakerKey::new(
16719                fast_root.display().to_string(),
16720                BuildDomain::CallgraphCold,
16721                callgraph_corpus_fingerprint(&fast_root).unwrap(),
16722            );
16723            BuildDeathBreaker::open(&breaker_path)
16724                .unwrap()
16725                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16726                .unwrap();
16727        }
16728
16729        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16730        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16731            .unwrap()
16732            .expect("three zero-credit process deaths must suspend the root");
16733        assert_eq!(suspension.reason, "zero_credit_death_limit");
16734        assert_eq!(suspension.death_count, 3);
16735        let response = crate::commands::callgraph_store_adapter::suspended_response(
16736            "specimen",
16737            "callers",
16738            &suspension,
16739        );
16740        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16741        let message = response.data["message"].as_str().unwrap();
16742        assert!(
16743            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16744        );
16745        assert!(message.ends_with(
16746            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16747        ));
16748        let refused =
16749            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16750                .expect_err("a suspended root must not report a perpetually building worker");
16751        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16752    }
16753
16754    #[test]
16755    fn published_callgraph_build_respects_durable_domain_suspension() {
16756        let temp = tempdir().unwrap();
16757        let root = temp.path().join("root");
16758        let store_dir = temp.path().join("store");
16759        std::fs::create_dir_all(&root).unwrap();
16760        let source = root.join("main.ts");
16761        std::fs::write(&source, "export function marker() {}\n").unwrap();
16762        let files = vec![source];
16763        let key = BreakerKey::new(
16764            root.display().to_string(),
16765            BuildDomain::CallgraphCold,
16766            callgraph_corpus_fingerprint(&root).unwrap(),
16767        );
16768        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16769        for _ in 0..3 {
16770            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
16771                panic!("unexpected early suspension");
16772            };
16773            breaker
16774                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
16775                .unwrap();
16776        }
16777
16778        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
16779            .expect_err("durably tripped callgraph domain must refuse a new cold build");
16780        assert!(matches!(
16781            error,
16782            CallGraphStoreError::Suspended(ref suspension)
16783                if suspension.domain == BuildDomain::CallgraphCold
16784                    && suspension.death_count == 3
16785        ));
16786    }
16787}