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_and_reader_use_bounded_normal_pragmas() {
380        let temp = tempdir().unwrap();
381        let root = temp.path().join("root");
382        fs::create_dir_all(&root).unwrap();
383        let source = root.join("main.ts");
384        fs::write(&source, "export function main() {}\n").unwrap();
385        let store_dir = temp.path().join("store");
386        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
387
388        let conn = store.conn.lock().unwrap();
389        let synchronous: i64 = conn
390            .pragma_query_value(None, "synchronous", |row| row.get(0))
391            .unwrap();
392        let autocheckpoint: i64 = conn
393            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
394            .unwrap();
395        let cache_size: i64 = conn
396            .pragma_query_value(None, "cache_size", |row| row.get(0))
397            .unwrap();
398        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
399        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
400        assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
401        drop(conn);
402        store.cold_build(std::slice::from_ref(&source)).unwrap();
403        drop(store);
404
405        let readonly = CallGraphStore::open_readonly(store_dir, root)
406            .unwrap()
407            .expect("writer-created empty schema should be readable");
408        let conn = readonly.inner.conn.lock().unwrap();
409        let synchronous: i64 = conn
410            .pragma_query_value(None, "synchronous", |row| row.get(0))
411            .unwrap();
412        assert_eq!(synchronous, 1);
413    }
414
415    #[test]
416    fn own_refresh_skips_identical_extract_but_not_position_shift() {
417        let temp = tempdir().unwrap();
418        let root = temp.path().join("root");
419        fs::create_dir_all(&root).unwrap();
420        let source = root.join("main.ts");
421        fs::write(&source, "export function main() { return 1; }\n").unwrap();
422        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
423        store.cold_build(std::slice::from_ref(&source)).unwrap();
424        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
425        assert!(write_metrics.commits_60s > 0);
426        assert!(write_metrics.pages_or_bytes_written_60s > 0);
427
428        let before = store.conn.lock().unwrap().total_changes();
429        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
430        let (stats, _) = store
431            .refresh_files_profiled(std::slice::from_ref(&source))
432            .unwrap();
433        let after = store.conn.lock().unwrap().total_changes();
434        assert_eq!(stats.unchanged_extract_files, 1);
435        assert_eq!(stats.refreshed_own_files, 0);
436        assert_eq!(
437            after - before,
438            3,
439            "files, backend freshness, and the durable projection revision update"
440        );
441
442        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
443        let (shifted_stats, _) = store
444            .refresh_files_profiled(std::slice::from_ref(&source))
445            .unwrap();
446        assert_eq!(shifted_stats.unchanged_extract_files, 0);
447        assert_eq!(shifted_stats.refreshed_own_files, 1);
448    }
449
450    #[test]
451    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
452        let now = Instant::now();
453        assert!(idle_checkpoint_due(None, now));
454        assert!(!idle_checkpoint_due(
455            Some(now),
456            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
457        ));
458        assert!(idle_checkpoint_due(
459            Some(now),
460            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
461        ));
462    }
463
464    #[test]
465    fn write_metrics_decay_after_the_sixty_second_window() {
466        let key = format!("metrics-test-{}", now_nanos());
467        let metrics = callgraph_write_metrics_for_key(&key);
468        metrics.record_commit(17);
469        assert_eq!(metrics.snapshot().commits_60s, 1);
470        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
471        metrics.window_start_ms.store(
472            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
473            AtomicOrdering::Release,
474        );
475        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
476    }
477}
478
479#[cfg(test)]
480type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
481// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
482// thread running the cold build, and the only caller (a test) installs and
483// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
484// across parallel tests — one test's installed observer fired during ANOTHER
485// test's `cold_build_with_lease`, asserting against the wrong build's edges
486// (flaked on Windows CI under parallel scheduling). Production never sets it.
487thread_local! {
488    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
489        const { std::cell::RefCell::new(None) };
490    #[cfg(test)]
491    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
492        const { std::cell::RefCell::new(None) };
493    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
494        const { std::cell::RefCell::new(None) };
495    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
496    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
497        const { std::cell::Cell::new(false) };
498    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
499        const { std::cell::RefCell::new(None) };
500    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
501        const { std::cell::RefCell::new(None) };
502}
503
504mod dead_code_projection;
505pub use dead_code_projection::project_dead_code_snapshot;
506pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
507#[cfg(test)]
508pub(crate) use dead_code_projection::set_projection_before_open_observer;
509
510#[doc(hidden)]
511pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
512    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
513}
514
515#[cfg(test)]
516fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
517    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
518}
519
520#[cfg(test)]
521fn notify_cold_build_before_publish_observer() {
522    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
523    if let Some(observer) = observer {
524        observer();
525    }
526}
527
528#[cfg(not(test))]
529fn notify_cold_build_before_publish_observer() {}
530
531#[doc(hidden)]
532pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
533    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
534}
535
536#[doc(hidden)]
537pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
538    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
539}
540
541#[doc(hidden)]
542pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
543    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
544}
545
546struct PublishAdmissionGuard {
547    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
548}
549
550impl Drop for PublishAdmissionGuard {
551    fn drop(&mut self) {
552        PUBLISH_ADMISSION.with(|slot| {
553            *slot.borrow_mut() = self.previous.take();
554        });
555    }
556}
557
558pub(crate) fn with_publish_epoch<R>(
559    epoch: crate::root_cache::ArtifactPublishEpoch,
560    expected: u64,
561    run: impl FnOnce() -> R,
562) -> R {
563    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
564    let _guard = PublishAdmissionGuard { previous };
565    run()
566}
567
568fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
569    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
570    match admission {
571        Some((epoch, expected)) => epoch
572            .run_if_current(expected, publish)
573            .unwrap_or(Err(CallGraphStoreError::Superseded)),
574        None => publish(),
575    }
576}
577
578struct RefreshCommitAdmissionGuard {
579    previous: Option<(
580        SubcLifecycleAdmission,
581        Arc<std::sync::atomic::AtomicU64>,
582        u64,
583    )>,
584}
585
586impl Drop for RefreshCommitAdmissionGuard {
587    fn drop(&mut self) {
588        REFRESH_COMMIT_ADMISSION.with(|slot| {
589            *slot.borrow_mut() = self.previous.take();
590        });
591    }
592}
593
594fn with_refresh_commit_admission<R>(
595    lifecycle: SubcLifecycleAdmission,
596    generation_flag: Arc<std::sync::atomic::AtomicU64>,
597    expected_generation: u64,
598    run: impl FnOnce() -> R,
599) -> R {
600    let previous = REFRESH_COMMIT_ADMISSION
601        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
602    let _guard = RefreshCommitAdmissionGuard { previous };
603    run()
604}
605
606fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
607    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
608    let commit = || {
609        publish_if_current(|| {
610            tx.commit()?;
611            Ok(())
612        })
613    };
614    match admission {
615        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
616            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
617            .unwrap_or(Err(CallGraphStoreError::Superseded)),
618        None => commit(),
619    }
620}
621
622fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
623    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
624    if let Some(observer) = observer {
625        observer(temp_path, target_path);
626    }
627}
628
629#[derive(Debug)]
630pub enum CallGraphStoreError {
631    Io(std::io::Error),
632    Sqlite(rusqlite::Error),
633    Json(serde_json::Error),
634    Aft(AftError),
635    Lock(crate::fs_lock::AcquireError),
636    MissingCallerData { file: String },
637    Unavailable(String),
638    Suspended(crate::build_breaker::BuildSuspension),
639    Superseded,
640    StaleFiles(Vec<String>),
641}
642
643impl fmt::Display for CallGraphStoreError {
644    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
645        match self {
646            Self::Io(error) => write!(formatter, "I/O error: {error}"),
647            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
648            Self::Json(error) => write!(formatter, "json error: {error}"),
649            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
650            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
651            Self::MissingCallerData { file } => {
652                write!(formatter, "missing extracted caller data for {file}")
653            }
654            Self::Unavailable(message) => {
655                write!(formatter, "callgraph store unavailable: {message}")
656            }
657            Self::Suspended(suspension) => write!(
658                formatter,
659                "callgraph build suspended for {} after {} deaths ({})",
660                suspension.domain.as_str(),
661                suspension.death_count,
662                suspension.reason
663            ),
664            Self::Superseded => {
665                write!(formatter, "callgraph store build superseded before publish")
666            }
667            Self::StaleFiles(files) => {
668                write!(
669                    formatter,
670                    "callgraph store has stale files: {}",
671                    files.join(", ")
672                )
673            }
674        }
675    }
676}
677
678impl std::error::Error for CallGraphStoreError {}
679
680impl From<std::io::Error> for CallGraphStoreError {
681    fn from(error: std::io::Error) -> Self {
682        Self::Io(error)
683    }
684}
685
686impl From<rusqlite::Error> for CallGraphStoreError {
687    fn from(error: rusqlite::Error) -> Self {
688        Self::Sqlite(error)
689    }
690}
691
692impl From<serde_json::Error> for CallGraphStoreError {
693    fn from(error: serde_json::Error) -> Self {
694        Self::Json(error)
695    }
696}
697
698impl From<AftError> for CallGraphStoreError {
699    fn from(error: AftError) -> Self {
700        Self::Aft(error)
701    }
702}
703
704impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
705    fn from(error: crate::fs_lock::AcquireError) -> Self {
706        Self::Lock(error)
707    }
708}
709
710pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
711
712/// Config flag name gating whether the store is opened (default on). Production
713/// commands open it through `open_if_enabled` so the substrate can be disabled
714/// without code changes.
715pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
716
717#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
718pub struct CallGraphStoreOptions {
719    pub enabled: bool,
720}
721
722pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
723
724/// Shared context state that lets the refresh worker observe a store installed
725/// after its batch was opened. The worker clones the installed store Arc before
726/// checking it, so no context lock guard crosses the check or enqueue call.
727#[derive(Clone)]
728pub(crate) struct CallgraphRefreshState {
729    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
730    heavy_root_work_allowed: Arc<AtomicBool>,
731}
732
733impl CallgraphRefreshState {
734    pub(crate) fn new(
735        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
736        heavy_root_work_allowed: Arc<AtomicBool>,
737    ) -> Self {
738        Self {
739            store,
740            heavy_root_work_allowed,
741        }
742    }
743
744    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
745        self.store
746            .read()
747            .unwrap_or_else(std::sync::PoisonError::into_inner)
748            .as_ref()
749            .map(Arc::clone)
750    }
751}
752
753type WorkspaceCratePrefixes = HashMap<String, String>;
754
755#[derive(Clone, Debug, Default)]
756struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
757
758const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
759
760pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
761    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
762}
763
764#[derive(Clone, Debug, Hash, PartialEq, Eq)]
765struct RefreshRoot {
766    callgraph_dir: PathBuf,
767    project_root: PathBuf,
768}
769
770#[derive(Clone)]
771pub(crate) struct CallgraphRefreshTicket {
772    lifecycle: SubcLifecycleAdmission,
773    generation_flag: Arc<std::sync::atomic::AtomicU64>,
774    expected_generation: u64,
775    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
776    expected_publish_epoch: u64,
777}
778
779impl CallgraphRefreshTicket {
780    pub(crate) fn new(
781        lifecycle: SubcLifecycleAdmission,
782        generation_flag: Arc<std::sync::atomic::AtomicU64>,
783        expected_generation: u64,
784        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
785        expected_publish_epoch: u64,
786    ) -> Self {
787        Self {
788            lifecycle,
789            generation_flag,
790            expected_generation,
791            publish_epoch,
792            expected_publish_epoch,
793        }
794    }
795
796    fn is_current(&self) -> bool {
797        self.lifecycle
798            .is_current(self.generation_flag.as_ref(), self.expected_generation)
799            && self.publish_epoch.current() == self.expected_publish_epoch
800    }
801}
802
803#[derive(Clone)]
804struct RefreshBatch {
805    root: RefreshRoot,
806    paths: BTreeSet<PathBuf>,
807    pending_sinks: Vec<PendingCallGraphStorePaths>,
808    refresh_states: Vec<CallgraphRefreshState>,
809    ticket: Option<CallgraphRefreshTicket>,
810}
811
812impl RefreshBatch {
813    fn defer(&self) {
814        for sink in &self.pending_sinks {
815            sink.lock().extend(self.paths.iter().cloned());
816        }
817    }
818
819    fn defer_after_open_failure(&self) {
820        self.defer();
821        if self
822            .ticket
823            .as_ref()
824            .is_some_and(|ticket| !ticket.is_current())
825            || !self
826                .refresh_states
827                .iter()
828                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
829        {
830            return;
831        }
832
833        let ready_store_installed = self.refresh_states.iter().any(|state| {
834            let store = state.installed_store_snapshot();
835            store.is_some_and(|store| {
836                store.project_root() == self.root.project_root
837                    && !store.is_legacy_fallback()
838                    && store.is_current()
839            })
840        });
841        if !ready_store_installed {
842            return;
843        }
844
845        // This re-check and the ready-store install's pending-sink take form a
846        // check-then-act handoff: after this defer, exactly one site observes
847        // the parked paths with a ready current store, so no polling is needed.
848        for sink in &self.pending_sinks {
849            let paths = {
850                let mut pending = sink.lock();
851                self.paths
852                    .iter()
853                    .filter(|path| pending.remove(*path))
854                    .cloned()
855                    .collect::<Vec<_>>()
856            };
857            if paths.is_empty() {
858                continue;
859            }
860            let _ = enqueue_callgraph_store_refresh_inner(
861                self.root.callgraph_dir.clone(),
862                self.root.project_root.clone(),
863                paths,
864                Arc::clone(sink),
865                self.refresh_states.clone(),
866                self.ticket.clone(),
867            );
868        }
869    }
870
871    fn merge(
872        &mut self,
873        paths: impl IntoIterator<Item = PathBuf>,
874        sink: PendingCallGraphStorePaths,
875        refresh_states: Vec<CallgraphRefreshState>,
876        ticket: Option<CallgraphRefreshTicket>,
877    ) {
878        self.paths.extend(paths);
879        if ticket.is_some() {
880            self.ticket = ticket;
881        }
882        if !self
883            .pending_sinks
884            .iter()
885            .any(|existing| Arc::ptr_eq(existing, &sink))
886        {
887            self.pending_sinks.push(sink);
888        }
889        for refresh_state in refresh_states {
890            if !self.refresh_states.iter().any(|existing| {
891                Arc::ptr_eq(&existing.store, &refresh_state.store)
892                    && Arc::ptr_eq(
893                        &existing.heavy_root_work_allowed,
894                        &refresh_state.heavy_root_work_allowed,
895                    )
896            }) {
897                self.refresh_states.push(refresh_state);
898            }
899        }
900    }
901}
902
903#[derive(Default)]
904struct RefreshQueue {
905    order: VecDeque<RefreshRoot>,
906    queued: HashMap<RefreshRoot, RefreshBatch>,
907    active: Option<RefreshBatch>,
908    shutdown_requested: bool,
909}
910
911struct RefreshWorkerShared {
912    queue: Mutex<RefreshQueue>,
913    wake: Condvar,
914}
915
916struct RefreshWorker {
917    shared: Arc<RefreshWorkerShared>,
918    thread: Mutex<Option<JoinHandle<()>>>,
919}
920
921struct RefreshWorkerWatchdog {
922    first_path: PathBuf,
923    batch_len: usize,
924    started: Instant,
925}
926
927impl RefreshWorkerWatchdog {
928    fn start(paths: &[PathBuf]) -> Self {
929        Self {
930            first_path: paths
931                .first()
932                .expect("non-empty callgraph refresh batch has a first path")
933                .clone(),
934            batch_len: paths.len(),
935            started: Instant::now(),
936        }
937    }
938}
939
940impl Drop for RefreshWorkerWatchdog {
941    fn drop(&mut self) {
942        let elapsed = self.started.elapsed();
943        if elapsed < REFRESH_WORKER_WARN_AFTER {
944            return;
945        }
946        let path = if self.batch_len == 1 {
947            self.first_path.display().to_string()
948        } else {
949            format!(
950                "{} (+{} paths)",
951                self.first_path.display(),
952                self.batch_len - 1
953            )
954        };
955        log::warn!(
956            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
957            path,
958            elapsed.as_millis()
959        );
960        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
961            log::warn!(
962                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
963                path,
964                elapsed.as_millis()
965            );
966        }
967    }
968}
969
970impl RefreshWorker {
971    fn spawn() -> Arc<Self> {
972        let shared = Arc::new(RefreshWorkerShared {
973            queue: Mutex::new(RefreshQueue::default()),
974            wake: Condvar::new(),
975        });
976        let thread_shared = Arc::clone(&shared);
977        let thread = std::thread::Builder::new()
978            .name("aft-callgraph-refresh".to_string())
979            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
980            .expect("failed to spawn callgraph refresh worker");
981        Arc::new(Self {
982            shared,
983            thread: Mutex::new(Some(thread)),
984        })
985    }
986
987    fn enqueue(
988        &self,
989        root: RefreshRoot,
990        paths: Vec<PathBuf>,
991        pending_sink: PendingCallGraphStorePaths,
992        refresh_states: Vec<CallgraphRefreshState>,
993        ticket: Option<CallgraphRefreshTicket>,
994    ) -> bool {
995        let mut queue = self
996            .shared
997            .queue
998            .lock()
999            .expect("callgraph refresh queue mutex poisoned");
1000        if queue.shutdown_requested {
1001            pending_sink.lock().extend(paths);
1002            return false;
1003        }
1004        if let Some(batch) = queue.queued.get_mut(&root) {
1005            batch.merge(paths, pending_sink, refresh_states, ticket);
1006        } else {
1007            queue.order.push_back(root.clone());
1008            queue.queued.insert(
1009                root.clone(),
1010                RefreshBatch {
1011                    root,
1012                    paths: paths.into_iter().collect(),
1013                    pending_sinks: vec![pending_sink],
1014                    refresh_states,
1015                    ticket,
1016                },
1017            );
1018        }
1019        self.shared.wake.notify_one();
1020        true
1021    }
1022
1023    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1024        let deadline = Instant::now() + budget;
1025        let mut queue = self
1026            .shared
1027            .queue
1028            .lock()
1029            .expect("callgraph refresh queue mutex poisoned");
1030        queue.shutdown_requested = true;
1031        self.shared.wake.notify_one();
1032        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1033            let remaining = deadline.saturating_duration_since(Instant::now());
1034            let (next, _) = self
1035                .shared
1036                .wake
1037                .wait_timeout(queue, remaining)
1038                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1039            queue = next;
1040        }
1041        let drained = queue.active.is_none() && queue.order.is_empty();
1042        if !drained {
1043            if let Some(active) = queue.active.as_ref() {
1044                active.defer();
1045            }
1046            for batch in queue.queued.values() {
1047                batch.defer();
1048            }
1049            queue.order.clear();
1050            queue.queued.clear();
1051        }
1052        drop(queue);
1053
1054        if drained {
1055            if let Some(thread) = self
1056                .thread
1057                .lock()
1058                .expect("callgraph refresh worker thread mutex poisoned")
1059                .take()
1060            {
1061                let _ = thread.join();
1062            }
1063        }
1064        drained
1065    }
1066}
1067
1068static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1069
1070pub fn enqueue_callgraph_store_refresh(
1071    callgraph_dir: PathBuf,
1072    project_root: PathBuf,
1073    paths: Vec<PathBuf>,
1074    pending_sink: PendingCallGraphStorePaths,
1075) -> bool {
1076    enqueue_callgraph_store_refresh_inner(
1077        callgraph_dir,
1078        project_root,
1079        paths,
1080        pending_sink,
1081        Vec::new(),
1082        None,
1083    )
1084}
1085
1086#[cfg(test)]
1087pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1088    callgraph_dir: PathBuf,
1089    project_root: PathBuf,
1090    paths: Vec<PathBuf>,
1091    pending_sink: PendingCallGraphStorePaths,
1092    ticket: CallgraphRefreshTicket,
1093) -> bool {
1094    enqueue_callgraph_store_refresh_inner(
1095        callgraph_dir,
1096        project_root,
1097        paths,
1098        pending_sink,
1099        Vec::new(),
1100        Some(ticket),
1101    )
1102}
1103
1104pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1105    callgraph_dir: PathBuf,
1106    project_root: PathBuf,
1107    paths: Vec<PathBuf>,
1108    pending_sink: PendingCallGraphStorePaths,
1109    refresh_state: CallgraphRefreshState,
1110    ticket: CallgraphRefreshTicket,
1111) -> bool {
1112    enqueue_callgraph_store_refresh_inner(
1113        callgraph_dir,
1114        project_root,
1115        paths,
1116        pending_sink,
1117        vec![refresh_state],
1118        Some(ticket),
1119    )
1120}
1121
1122fn enqueue_callgraph_store_refresh_inner(
1123    callgraph_dir: PathBuf,
1124    project_root: PathBuf,
1125    paths: Vec<PathBuf>,
1126    pending_sink: PendingCallGraphStorePaths,
1127    refresh_states: Vec<CallgraphRefreshState>,
1128    ticket: Option<CallgraphRefreshTicket>,
1129) -> bool {
1130    if paths.is_empty() {
1131        return true;
1132    }
1133    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1134    let worker = {
1135        let mut worker = slot
1136            .lock()
1137            .expect("callgraph refresh worker mutex poisoned");
1138        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1139    };
1140    worker.enqueue(
1141        RefreshRoot {
1142            callgraph_dir,
1143            project_root,
1144        },
1145        paths,
1146        pending_sink,
1147        refresh_states,
1148        ticket,
1149    )
1150}
1151
1152pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1153    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1154}
1155
1156#[doc(hidden)]
1157pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1158    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1159    let worker = slot
1160        .lock()
1161        .expect("callgraph refresh worker mutex poisoned")
1162        .clone();
1163    let Some(worker) = worker else {
1164        return true;
1165    };
1166    let drained = worker.shutdown_with_budget(budget);
1167    if drained {
1168        let mut current = slot
1169            .lock()
1170            .expect("callgraph refresh worker mutex poisoned");
1171        if current
1172            .as_ref()
1173            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1174        {
1175            *current = None;
1176        }
1177    }
1178    drained
1179}
1180
1181fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1182    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1183}
1184
1185fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1186    // The worker owns these caches so maps are shared only by refreshes for the
1187    // same canonical root and disappear when the worker shuts down.
1188    let mut workspace_crate_prefixes = HashMap::new();
1189    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1190    loop {
1191        let batch = {
1192            let mut queue = shared
1193                .queue
1194                .lock()
1195                .expect("callgraph refresh queue mutex poisoned");
1196            loop {
1197                if let Some(root) = queue.order.pop_front() {
1198                    let batch = queue
1199                        .queued
1200                        .remove(&root)
1201                        .expect("queued callgraph refresh root has a batch");
1202                    queue.active = Some(batch.clone());
1203                    break batch;
1204                }
1205                if queue.shutdown_requested {
1206                    return;
1207                }
1208                queue = shared
1209                    .wake
1210                    .wait(queue)
1211                    .expect("callgraph refresh queue mutex poisoned while waiting");
1212            }
1213        };
1214
1215        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1216
1217        let mut queue = shared
1218            .queue
1219            .lock()
1220            .expect("callgraph refresh queue mutex poisoned");
1221        queue.active = None;
1222        let became_idle = queue.order.is_empty();
1223        shared.wake.notify_all();
1224        drop(queue);
1225
1226        if became_idle {
1227            let checkpoint_due = idle_checkpoint_due(
1228                last_idle_checkpoints.get(&batch.root).copied(),
1229                Instant::now(),
1230            );
1231            if checkpoint_due {
1232                if let Some(store) = store {
1233                    if store.checkpoint_wal_truncate() {
1234                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1235                    }
1236                }
1237            }
1238        }
1239    }
1240}
1241
1242fn process_callgraph_refresh_batch(
1243    batch: &RefreshBatch,
1244    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1245) -> Option<CallGraphStore> {
1246    // A manifest event is an invalidation signal, not a source file to parse.
1247    // Drop the root's map even for a superseded batch: the filesystem changed,
1248    // and a later configure must never inherit crate membership from before it.
1249    if batch
1250        .paths
1251        .iter()
1252        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1253    {
1254        workspace_crate_prefixes.remove(&batch.root);
1255    }
1256
1257    let paths = batch
1258        .paths
1259        .iter()
1260        .filter(|path| crate::parser::detect_language(path).is_some())
1261        .cloned()
1262        .collect::<Vec<_>>();
1263    if paths.is_empty() {
1264        return None;
1265    }
1266    note_refresh_worker_batch_for_test(&batch.root.project_root);
1267    if batch
1268        .ticket
1269        .as_ref()
1270        .is_some_and(|ticket| !ticket.is_current())
1271    {
1272        // Superseded before starting: park the paths so the next configure's
1273        // pending replay (or unbind cleanup) decides their fate.
1274        batch.defer();
1275        return None;
1276    }
1277    let workspace_crate_prefix_cache =
1278        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1279    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1280    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1281    note_refresh_worker_call_for_test(&batch.root.project_root);
1282    let opened = if test_seam.fail_open {
1283        Ok(None)
1284    } else {
1285        CallGraphStore::open_ready(
1286            batch.root.callgraph_dir.clone(),
1287            batch.root.project_root.clone(),
1288        )
1289    };
1290    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1291        // The gate is deliberately after open_ready so tests can hold a failed
1292        // open between its result and the defer that parks the batch.
1293        let _ = gate.held_tx.send(());
1294        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1295    }
1296    let store = match opened {
1297        Ok(Some(store)) => store,
1298        Ok(None) => {
1299            batch.defer_after_open_failure();
1300            return None;
1301        }
1302        Err(error) => {
1303            batch.defer_after_open_failure();
1304            crate::slog_warn!(
1305                "callgraph store writer open failed during refresh; deferred paths: {}",
1306                error
1307            );
1308            return None;
1309        }
1310    };
1311    if !test_seam.delay.is_zero() {
1312        std::thread::sleep(test_seam.delay);
1313    }
1314    if batch
1315        .ticket
1316        .as_ref()
1317        .is_some_and(|ticket| !ticket.is_current())
1318    {
1319        // This is a superseded-ticket defer, not an open-failure defer: leave
1320        // the paths for the replacement configure instead of self-replaying.
1321        batch.defer();
1322        return Some(store);
1323    }
1324    let refresh_result = if test_seam.fail_refresh {
1325        Err(CallGraphStoreError::Unavailable(
1326            "injected refresh worker failure".to_string(),
1327        ))
1328    } else if let Some(ticket) = &batch.ticket {
1329        with_publish_epoch(
1330            ticket.publish_epoch.clone(),
1331            ticket.expected_publish_epoch,
1332            || {
1333                with_refresh_commit_admission(
1334                    ticket.lifecycle.clone(),
1335                    Arc::clone(&ticket.generation_flag),
1336                    ticket.expected_generation,
1337                    || {
1338                        store
1339                            .refresh_files_with_workspace_crate_prefix_cache(
1340                                &paths,
1341                                workspace_crate_prefix_cache.clone(),
1342                            )
1343                            .map(|_| ())
1344                    },
1345                )
1346            },
1347        )
1348    } else {
1349        store
1350            .refresh_files_with_workspace_crate_prefix_cache(
1351                &paths,
1352                workspace_crate_prefix_cache.clone(),
1353            )
1354            .map(|_| ())
1355    };
1356    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1357        // The commit lost the fence race: a newer configure or publication
1358        // owns the store now. Defer instead of stale-marking — the paths were
1359        // never committed, and the replacement generation re-indexes them.
1360        batch.defer();
1361        return Some(store);
1362    }
1363    if let Err(error) = refresh_result {
1364        crate::slog_warn!("callgraph store refresh failed: {}", error);
1365        match store.mark_files_stale(&paths) {
1366            Ok(marked) => {
1367                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1368                crate::slog_warn!(
1369                    "marked {} callgraph store file(s) stale after refresh failure",
1370                    marked.len()
1371                );
1372            }
1373            Err(mark_error) => crate::slog_warn!(
1374                "failed to mark callgraph store files stale after refresh failure: {}",
1375                mark_error
1376            ),
1377        }
1378    } else {
1379        crate::logging::note_callgraph_invalidations(paths.len());
1380    }
1381    Some(store)
1382}
1383
1384fn workspace_crate_prefix_cache_for_root(
1385    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1386    root: &RefreshRoot,
1387) -> WorkspaceCratePrefixCache {
1388    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1389        // Eviction only costs a future rebuild; it cannot make resolution stale.
1390        if let Some(evicted) = caches.keys().next().cloned() {
1391            caches.remove(&evicted);
1392        }
1393    }
1394    caches.entry(root.clone()).or_default().clone()
1395}
1396
1397#[derive(Clone, Copy, Default)]
1398struct RefreshWorkerTestSeam {
1399    delay: Duration,
1400    fail_refresh: bool,
1401    fail_open: bool,
1402    refresh_calls: usize,
1403    worker_calls: usize,
1404    stale_marks: usize,
1405}
1406
1407static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1408    OnceLock::new();
1409
1410struct RefreshWorkerTestGate {
1411    held_tx: crossbeam_channel::Sender<()>,
1412    release_rx: crossbeam_channel::Receiver<()>,
1413}
1414
1415static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1416    OnceLock::new();
1417
1418#[doc(hidden)]
1419pub fn install_callgraph_refresh_worker_test_gate(
1420    project_root: PathBuf,
1421) -> (
1422    crossbeam_channel::Receiver<()>,
1423    crossbeam_channel::Sender<()>,
1424) {
1425    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1426    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1427    REFRESH_WORKER_TEST_GATES
1428        .get_or_init(|| Mutex::new(HashMap::new()))
1429        .lock()
1430        .expect("callgraph refresh test gate mutex poisoned")
1431        .insert(
1432            project_root,
1433            RefreshWorkerTestGate {
1434                held_tx,
1435                release_rx,
1436            },
1437        );
1438    (held_rx, release_tx)
1439}
1440
1441fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1442    REFRESH_WORKER_TEST_GATES
1443        .get_or_init(|| Mutex::new(HashMap::new()))
1444        .lock()
1445        .expect("callgraph refresh test gate mutex poisoned")
1446        .remove(project_root)
1447}
1448
1449fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1450    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1451        return RefreshWorkerTestSeam::default();
1452    };
1453    seams
1454        .lock()
1455        .expect("callgraph refresh test seam mutex poisoned")
1456        .get(project_root)
1457        .copied()
1458        .unwrap_or_default()
1459}
1460
1461fn note_refresh_worker_batch_for_test(project_root: &Path) {
1462    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1463        if let Some(seam) = seams
1464            .lock()
1465            .expect("callgraph refresh test seam mutex poisoned")
1466            .get_mut(project_root)
1467        {
1468            seam.worker_calls += 1;
1469        }
1470    }
1471}
1472
1473fn note_refresh_worker_call_for_test(project_root: &Path) {
1474    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1475        if let Some(seam) = seams
1476            .lock()
1477            .expect("callgraph refresh test seam mutex poisoned")
1478            .get_mut(project_root)
1479        {
1480            seam.refresh_calls += 1;
1481        }
1482    }
1483}
1484
1485fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1486    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1487        if let Some(seam) = seams
1488            .lock()
1489            .expect("callgraph refresh test seam mutex poisoned")
1490            .get_mut(project_root)
1491        {
1492            seam.stale_marks += 1;
1493        }
1494    }
1495}
1496
1497#[doc(hidden)]
1498pub fn set_callgraph_refresh_worker_test_seam(
1499    project_root: PathBuf,
1500    delay: Duration,
1501    fail_refresh: bool,
1502) {
1503    REFRESH_WORKER_TEST_SEAMS
1504        .get_or_init(|| Mutex::new(HashMap::new()))
1505        .lock()
1506        .expect("callgraph refresh test seam mutex poisoned")
1507        .insert(
1508            project_root,
1509            RefreshWorkerTestSeam {
1510                delay,
1511                fail_refresh,
1512                ..RefreshWorkerTestSeam::default()
1513            },
1514        );
1515}
1516
1517#[doc(hidden)]
1518pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1519    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1520        if let Some(seam) = seams
1521            .lock()
1522            .expect("callgraph refresh test seam mutex poisoned")
1523            .get_mut(&project_root)
1524        {
1525            seam.fail_open = enabled;
1526        }
1527    }
1528}
1529
1530#[doc(hidden)]
1531pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1532    let seam = refresh_worker_test_seam(project_root);
1533    (seam.refresh_calls, seam.stale_marks)
1534}
1535
1536#[doc(hidden)]
1537pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1538    refresh_worker_test_seam(project_root).worker_calls
1539}
1540
1541#[doc(hidden)]
1542pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1543    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1544        seams
1545            .lock()
1546            .expect("callgraph refresh test seam mutex poisoned")
1547            .remove(project_root);
1548    }
1549}
1550
1551#[derive(Debug)]
1552pub struct CallGraphStore {
1553    project_root: PathBuf,
1554    project_key: String,
1555    /// The concrete on-disk DB file this store opened. With the generation
1556    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1557    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1558    sqlite_path: PathBuf,
1559    /// Root-keyed directory whose pointer controls this store. For a legacy
1560    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1561    /// newly published root-keyed generation invalidates the fallback reader.
1562    publication_dir: PathBuf,
1563    /// True only when the root-keyed read path opened data from a legacy
1564    /// harness partition. Writer-capable callers use this to schedule migration
1565    /// without making read-only/worktree callers acquire a writer lease.
1566    legacy_fallback: bool,
1567    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1568    /// or `None` when it opened the legacy single-file DB. Used to detect when
1569    /// another process has published a newer generation so this process can
1570    /// drop its connection and reopen (see `current_generation`).
1571    generation: Option<String>,
1572    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1573    read_marker: Option<crate::root_cache::ReadMarker>,
1574    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1575    // Failed validations are not cached, so a later successful build remains visible.
1576    database_ready: AtomicBool,
1577    write_metrics: Arc<CallgraphWriteMetrics>,
1578    conn: Mutex<Connection>,
1579}
1580
1581#[derive(Debug)]
1582pub struct ReadonlyCallGraphStore {
1583    inner: CallGraphStore,
1584}
1585
1586pub trait CallGraphRead {
1587    fn project_root(&self) -> &Path;
1588    fn project_key(&self) -> &str;
1589    fn sqlite_path(&self) -> &Path;
1590    fn is_current(&self) -> bool;
1591    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1592    fn indexed_file_count(&self) -> Result<usize>;
1593    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1594    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1595    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1596    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1597    fn direct_callers_for_symbols(
1598        &self,
1599        targets: &[(String, String)],
1600    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1601        targets
1602            .iter()
1603            .cloned()
1604            .map(|target| {
1605                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1606                Ok((target, callers))
1607            })
1608            .collect()
1609    }
1610    fn direct_caller_counts_of(
1611        &self,
1612        targets: &[(String, String)],
1613    ) -> Result<HashMap<(String, String), usize>>;
1614    fn outgoing_calls_for_symbols(
1615        &self,
1616        sources: &[(String, String)],
1617    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1618    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1619        -> Result<StoreCallersResult>;
1620    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1621    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1622    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1623    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1624    fn call_tree(
1625        &self,
1626        file_rel: &Path,
1627        symbol: &str,
1628        depth: usize,
1629    ) -> Result<callgraph::CallTreeNode>;
1630    fn trace_to(
1631        &self,
1632        file_rel: &Path,
1633        symbol: &str,
1634        max_depth: usize,
1635    ) -> Result<callgraph::TraceToResult>;
1636    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1637    fn trace_to_symbol(
1638        &self,
1639        file_rel: &Path,
1640        symbol: &str,
1641        to_symbol: &str,
1642        to_file: Option<&Path>,
1643        max_depth: usize,
1644    ) -> Result<callgraph::TraceToSymbolResult>;
1645}
1646
1647#[derive(Debug, Clone, PartialEq, Eq)]
1648enum OpenRootRepair {
1649    None,
1650    ReRooted,
1651    NeedsRebuild {
1652        previous_roots: Vec<String>,
1653        current_root: String,
1654        reason: String,
1655    },
1656}
1657
1658struct OpenedStore {
1659    store: CallGraphStore,
1660    root_repair: OpenRootRepair,
1661}
1662
1663#[derive(Clone, Debug)]
1664struct LegacyCallgraphPartition {
1665    harness: String,
1666    dir: PathBuf,
1667    key: String,
1668    bytes: u64,
1669    freshness: Option<SystemTime>,
1670}
1671
1672#[derive(Clone, Debug)]
1673struct LegacyCallgraphTarget {
1674    partition: LegacyCallgraphPartition,
1675    sqlite_path: PathBuf,
1676    generation: Option<String>,
1677    source_bytes: u64,
1678    source_blake3: String,
1679}
1680
1681#[derive(Clone, Debug)]
1682struct SourceFingerprint {
1683    bytes: u64,
1684    blake3: String,
1685}
1686
1687#[derive(Clone, Debug)]
1688struct PublishedLegacyMigration {
1689    generation: String,
1690    migrated_bytes: u64,
1691}
1692
1693#[derive(Debug, Clone)]
1694pub struct ColdBuildStats {
1695    pub files: usize,
1696    pub nodes: usize,
1697    pub refs: usize,
1698    pub edges: usize,
1699    pub failed_files: Vec<String>,
1700    pub elapsed_ms: u128,
1701}
1702
1703#[derive(Debug, Clone)]
1704pub struct IncrementalStats {
1705    pub changed_files: Vec<String>,
1706    pub surface_changed: Vec<String>,
1707    pub deleted_files: Vec<String>,
1708    pub dependency_selected_refs: usize,
1709    pub refreshed_own_files: usize,
1710    pub unchanged_extract_files: usize,
1711}
1712
1713/// Phase timings for the copy-based incremental refresh benchmark.
1714#[doc(hidden)]
1715#[derive(Debug, Clone, Default, PartialEq, Eq)]
1716pub struct RefreshFilesProfile {
1717    pub parse: Duration,
1718    pub dependency_selection: Duration,
1719    pub row_deletes: Duration,
1720    pub row_inserts: Duration,
1721    pub dependent_parse: Duration,
1722    pub index_load: Duration,
1723    pub ref_resolution: Duration,
1724    pub method_dispatch: Duration,
1725    pub commit: Duration,
1726    pub total: Duration,
1727}
1728
1729impl RefreshFilesProfile {
1730    pub fn report(&self) -> String {
1731        format!(
1732            "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",
1733            self.parse.as_millis(),
1734            self.dependency_selection.as_millis(),
1735            self.row_deletes.as_millis(),
1736            self.row_inserts.as_millis(),
1737            self.dependent_parse.as_millis(),
1738            self.index_load.as_millis(),
1739            self.ref_resolution.as_millis(),
1740            self.method_dispatch.as_millis(),
1741            self.commit.as_millis(),
1742            self.total.as_millis(),
1743        )
1744    }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1748pub struct StoredEdge {
1749    pub source_file: String,
1750    pub source_symbol: String,
1751    pub target_file: String,
1752    pub target_symbol: String,
1753    pub kind: String,
1754    pub line: u32,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq)]
1758pub struct StoreNode {
1759    node_id: String,
1760    pub file: String,
1761    pub symbol: String,
1762    pub name: String,
1763    pub kind: String,
1764    pub line: u32,
1765    pub end_line: u32,
1766    pub signature: Option<String>,
1767    pub exported: bool,
1768    pub is_entry_point: bool,
1769    pub lang: LangId,
1770}
1771
1772#[cfg(test)]
1773impl StoreNode {
1774    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1775        Self {
1776            node_id: format!("{file}:{symbol}"),
1777            file: file.to_string(),
1778            symbol: symbol.to_string(),
1779            name: symbol.to_string(),
1780            kind: "function".to_string(),
1781            line: 1,
1782            end_line: 1,
1783            signature: None,
1784            exported: is_entry_point,
1785            is_entry_point,
1786            lang: LangId::TypeScript,
1787        }
1788    }
1789}
1790
1791#[derive(Debug, Clone, PartialEq, Eq)]
1792pub struct StoreCallSite {
1793    pub caller: StoreNode,
1794    pub target_file: String,
1795    pub target_symbol: String,
1796    pub target: Option<StoreNode>,
1797    pub line: u32,
1798    pub byte_start: usize,
1799    pub byte_end: usize,
1800    pub resolved: bool,
1801    pub provenance: String,
1802}
1803
1804impl StoreCallSite {
1805    pub fn approximate(&self) -> bool {
1806        self.provenance == PROVENANCE_NAME_MATCH
1807    }
1808
1809    pub fn resolved_by(&self) -> &str {
1810        &self.provenance
1811    }
1812
1813    pub fn supplemental_resolution(&self) -> Option<&str> {
1814        match self.provenance.as_str() {
1815            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1816            _ => None,
1817        }
1818    }
1819}
1820
1821#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct StoreUnresolvedCall {
1823    pub caller: StoreNode,
1824    pub symbol: String,
1825    pub full_ref: Option<String>,
1826    pub line: u32,
1827    pub byte_start: usize,
1828    pub byte_end: usize,
1829}
1830
1831#[derive(Debug, Clone, PartialEq, Eq)]
1832pub struct StoreCallersResult {
1833    pub target: StoreNode,
1834    pub callers: Vec<StoreCallSite>,
1835    pub scanned_files: usize,
1836    pub depth_limited: bool,
1837    pub truncated: usize,
1838}
1839
1840#[derive(Debug, Clone, PartialEq, Eq)]
1841pub struct StoreImpactCaller {
1842    pub site: StoreCallSite,
1843    pub signature: Option<String>,
1844    pub is_entry_point: bool,
1845    pub call_expression: Option<String>,
1846    pub parameters: Vec<String>,
1847}
1848
1849#[derive(Debug, Clone, PartialEq, Eq)]
1850pub struct StoreImpactResult {
1851    pub target: StoreNode,
1852    pub parameters: Vec<String>,
1853    pub callers: Vec<StoreImpactCaller>,
1854    pub depth_limited: bool,
1855    pub truncated: usize,
1856}
1857
1858#[derive(Debug, Clone)]
1859struct ExtractFailure {
1860    rel_path: String,
1861    freshness: Option<FileFreshness>,
1862}
1863
1864#[derive(Debug, Clone)]
1865struct BuildExtractsResult {
1866    extracts: Vec<FileExtract>,
1867    failures: Vec<ExtractFailure>,
1868}
1869
1870#[derive(Debug, Clone)]
1871enum StoreForwardCall {
1872    Resolved(StoreCallSite),
1873    Unresolved(StoreUnresolvedCall),
1874}
1875
1876impl StoreForwardCall {
1877    fn byte_start(&self) -> usize {
1878        match self {
1879            Self::Resolved(site) => site.byte_start,
1880            Self::Unresolved(call) => call.byte_start,
1881        }
1882    }
1883
1884    fn line(&self) -> u32 {
1885        match self {
1886            Self::Resolved(site) => site.line,
1887            Self::Unresolved(call) => call.line,
1888        }
1889    }
1890}
1891
1892#[derive(Debug, Clone)]
1893struct FileExtract {
1894    rel_path: String,
1895    freshness: FileFreshness,
1896    lang: LangId,
1897    data: FileCallData,
1898    nodes: Vec<NodeRecord>,
1899    raw_refs: Vec<RawRef>,
1900    dispatch_hints: Vec<DispatchHint>,
1901    surface_fingerprint: String,
1902}
1903
1904#[derive(Debug, Clone)]
1905struct NodeRecord {
1906    id: String,
1907    file_path: String,
1908    name: String,
1909    scoped_name: String,
1910    kind: String,
1911    range: Range,
1912    range_ordinal: u32,
1913    signature: Option<String>,
1914    exported: bool,
1915    is_default_export: bool,
1916    is_type_like: bool,
1917    is_callgraph_entry_point: bool,
1918}
1919
1920#[derive(Debug, Clone)]
1921struct RawRef {
1922    ref_id: String,
1923    caller_node: Option<String>,
1924    caller_symbol: Option<String>,
1925    caller_file: String,
1926    kind: String,
1927    short_name: Option<String>,
1928    full_ref: Option<String>,
1929    module_path: Option<String>,
1930    import_kind: Option<String>,
1931    local_name: Option<String>,
1932    requested_name: Option<String>,
1933    namespace_alias: Option<String>,
1934    wildcard: bool,
1935    line: u32,
1936    byte_start: usize,
1937    byte_end: usize,
1938    dependencies: BTreeSet<String>,
1939}
1940
1941/// A raw reference read from the durable staging table with its SQLite ordering
1942/// key. The ordering key is advanced only in the same transaction that writes
1943/// the resolved result, so a crash resumes at a committed window boundary.
1944#[derive(Debug)]
1945struct StagedRef {
1946    rowid: u64,
1947    raw: RawRef,
1948}
1949
1950#[derive(Debug, Clone)]
1951struct ResolvedRef {
1952    raw: RawRef,
1953    status: String,
1954    target_node: Option<String>,
1955    target_file: Option<String>,
1956    target_symbol: Option<String>,
1957    dependencies: BTreeSet<String>,
1958    edge: Option<EdgeRecord>,
1959}
1960
1961#[derive(Debug, Clone)]
1962struct EdgeRecord {
1963    edge_id: String,
1964    source_node: String,
1965    target_node: Option<String>,
1966    target_file: String,
1967    target_symbol: String,
1968    kind: String,
1969    line: u32,
1970}
1971
1972#[derive(Debug, Clone)]
1973struct DispatchHint {
1974    id: String,
1975    method_name: String,
1976    caller_node: String,
1977    file: String,
1978    line: u32,
1979    byte_start: usize,
1980    byte_end: usize,
1981}
1982
1983#[derive(Debug, Clone)]
1984struct NameMatchRef {
1985    ref_id: String,
1986    caller_node: String,
1987    caller_file: String,
1988    caller_symbol: String,
1989    caller_signature: Option<String>,
1990    receiver_expression: String,
1991    receiver: String,
1992    method_name: String,
1993    colon_dispatch: bool,
1994    line: u32,
1995    lang: String,
1996}
1997
1998#[derive(Debug, Clone)]
1999struct NameMatchCandidate {
2000    node_id: String,
2001    file_path: String,
2002    scoped_name: String,
2003    kind: String,
2004    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2005    start_line: u32,
2006}
2007
2008#[derive(Debug, Clone)]
2009struct FileRow {
2010    surface_fingerprint: String,
2011    freshness: FileFreshness,
2012}
2013
2014#[derive(Debug, Clone)]
2015struct DbFileIndex {
2016    lang: Option<LangId>,
2017    exports: HashSet<String>,
2018    default_export: Option<String>,
2019    export_aliases: HashMap<String, String>,
2020    node_by_scoped: HashMap<String, String>,
2021    node_by_bare: HashMap<String, String>,
2022    node_kind_by_id: HashMap<String, String>,
2023    module_targets: HashMap<String, Option<String>>,
2024    reexports: Vec<ReexportIndex>,
2025}
2026
2027#[derive(Debug, Clone)]
2028struct ReexportIndex {
2029    target_file: Option<String>,
2030    named: HashMap<String, String>,
2031    wildcard: bool,
2032}
2033
2034#[derive(Debug, Clone)]
2035struct ProjectIndex<'a> {
2036    project_root: PathBuf,
2037    files: HashMap<String, DbFileIndex>,
2038    caller_data: HashMap<String, &'a FileCallData>,
2039    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2040    /// watcher events replace the cache before another batch can resolve refs.
2041    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2042    /// its own workspace mapping.
2043    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2044}
2045
2046/// Resolution reads symbols and exports through one interface. Incremental
2047/// refreshes use the in-memory index, while cold builds query only the rows
2048/// needed by the active caller from SQLite.
2049trait ResolverIndex {
2050    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2051    fn lang_for(&self, file: &str) -> Option<LangId>;
2052    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2053    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2054    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2055    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2056    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2057    fn has_export(&self, file: &str, symbol: &str) -> bool;
2058    fn default_export(&self, file: &str) -> Option<String>;
2059    fn contains_file(&self, file: &str) -> bool;
2060    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2061    fn inline_scoped_target(
2062        &self,
2063        caller_file: &str,
2064        module_segments: &[String],
2065        short_name: &str,
2066    ) -> Option<(String, String)>;
2067}
2068
2069impl ResolverIndex for ProjectIndex<'_> {
2070    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2071        self.caller_data.get(file).copied()
2072    }
2073
2074    fn lang_for(&self, file: &str) -> Option<LangId> {
2075        self.lang_for(file)
2076    }
2077
2078    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2079        self.module_target(caller_file, module_path)
2080    }
2081
2082    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2083        self.reexports_for(file).to_vec()
2084    }
2085
2086    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2087        self.node_for_symbol(file, symbol)
2088    }
2089
2090    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2091        self.node_is_callable(file, node_id)
2092    }
2093
2094    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2095        self.files
2096            .get(file)
2097            .and_then(|item| item.export_aliases.get(symbol))
2098            .cloned()
2099    }
2100
2101    fn has_export(&self, file: &str, symbol: &str) -> bool {
2102        self.files
2103            .get(file)
2104            .is_some_and(|item| item.exports.contains(symbol))
2105    }
2106
2107    fn default_export(&self, file: &str) -> Option<String> {
2108        self.files
2109            .get(file)
2110            .and_then(|item| item.default_export.clone())
2111    }
2112
2113    fn contains_file(&self, file: &str) -> bool {
2114        self.files.contains_key(file)
2115    }
2116
2117    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2118        self.workspace_crate_prefixes
2119            .0
2120            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2121            .get(crate_name)
2122            .cloned()
2123    }
2124
2125    fn inline_scoped_target(
2126        &self,
2127        caller_file: &str,
2128        module_segments: &[String],
2129        short_name: &str,
2130    ) -> Option<(String, String)> {
2131        let src_prefix = rust_src_prefix(caller_file);
2132        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2133        file_paths.sort();
2134        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2135            let caller = file_paths.remove(position);
2136            file_paths.insert(0, caller);
2137        }
2138        for file_path in file_paths {
2139            if self.lang_for(&file_path) != Some(LangId::Rust)
2140                || rust_src_prefix(&file_path) != src_prefix
2141            {
2142                continue;
2143            }
2144            let file_module_segments = rust_module_segments_for_rel(&file_path);
2145            if !module_segments.starts_with(&file_module_segments) {
2146                continue;
2147            }
2148            let scoped_segments = &module_segments[file_module_segments.len()..];
2149            if scoped_segments.is_empty() {
2150                continue;
2151            }
2152            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2153            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2154                return Some((file_path, scoped_symbol));
2155            }
2156        }
2157        None
2158    }
2159}
2160
2161/// A cold-build resolver view that loads one file's index at a time. Keeping the
2162/// complete staged corpus in SQLite makes the heap proportional to the active
2163/// reference window rather than to the number of project files.
2164struct DiskProjectIndex<'a> {
2165    project_root: &'a Path,
2166    conn: &'a Connection,
2167    caller_file: &'a str,
2168    caller_data: &'a FileCallData,
2169    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2170}
2171
2172impl DiskProjectIndex<'_> {
2173    fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2174        let lang: String = self
2175            .conn
2176            .query_row(
2177                "SELECT lang FROM files WHERE path = ?1",
2178                params![rel_path],
2179                |row| row.get(0),
2180            )
2181            .optional()
2182            .ok()??;
2183        let mut index = DbFileIndex {
2184            lang: lang_from_label(&lang),
2185            exports: HashSet::new(),
2186            default_export: None,
2187            export_aliases: HashMap::new(),
2188            node_by_scoped: HashMap::new(),
2189            node_by_bare: HashMap::new(),
2190            node_kind_by_id: HashMap::new(),
2191            module_targets: HashMap::new(),
2192            reexports: Vec::new(),
2193        };
2194        let mut nodes = self
2195            .conn
2196            .prepare(
2197                "SELECT id, name, scoped_name, kind, exported, is_default_export
2198                 FROM nodes WHERE file_path = ?1",
2199            )
2200            .ok()?;
2201        let rows = nodes
2202            .query_map(params![rel_path], |row| {
2203                Ok((
2204                    row.get::<_, String>(0)?,
2205                    row.get::<_, String>(1)?,
2206                    row.get::<_, String>(2)?,
2207                    row.get::<_, String>(3)?,
2208                    row.get::<_, i64>(4)? != 0,
2209                    row.get::<_, i64>(5)? != 0,
2210                ))
2211            })
2212            .ok()?
2213            .collect::<std::result::Result<Vec<_>, _>>()
2214            .ok()?;
2215        drop(nodes);
2216        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2217            if exported {
2218                index.exports.insert(name.clone());
2219                index.exports.insert(scoped_name.clone());
2220            }
2221            if is_default_export {
2222                index.default_export = Some(scoped_name.clone());
2223            }
2224            index.node_by_scoped.insert(scoped_name, id.clone());
2225            index.node_by_bare.entry(name).or_insert(id.clone());
2226            index.node_kind_by_id.insert(id, kind);
2227        }
2228
2229        let mut refs = self
2230            .conn
2231            .prepare(
2232                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2233                 FROM refs
2234                 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2235            )
2236            .ok()?;
2237        let rows = refs
2238            .query_map(params![rel_path], |row| {
2239                Ok((
2240                    row.get::<_, String>(0)?,
2241                    row.get::<_, String>(1)?,
2242                    row.get::<_, Option<String>>(2)?,
2243                    row.get::<_, Option<String>>(3)?,
2244                    row.get::<_, i64>(4)? != 0,
2245                    row.get::<_, Option<String>>(5)?,
2246                    row.get::<_, Option<String>>(6)?,
2247                ))
2248            })
2249            .ok()?
2250            .collect::<std::result::Result<Vec<_>, _>>()
2251            .ok()?;
2252        drop(refs);
2253        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2254            if kind == "export_alias" {
2255                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2256                    index.export_aliases.insert(exported, source);
2257                }
2258                continue;
2259            }
2260            let Some(module_path) = module_path else {
2261                continue;
2262            };
2263            let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2264                self.conn
2265                    .query_row(
2266                        "SELECT d.dep_file
2267                         FROM file_dependencies d
2268                         JOIN files f ON f.path = d.dep_file
2269                         WHERE d.file_path = ?1
2270                         ORDER BY d.dep_file
2271                         LIMIT 1",
2272                        params![rel_path],
2273                        |row| row.get::<_, String>(0),
2274                    )
2275                    .optional()
2276                    .ok()
2277                    .flatten()
2278            });
2279            index
2280                .module_targets
2281                .entry(module_path.clone())
2282                .or_insert_with(|| target_file.clone());
2283            if kind == "reexport" {
2284                let raw = RawRef {
2285                    ref_id,
2286                    caller_node: None,
2287                    caller_symbol: None,
2288                    caller_file: rel_path.to_string(),
2289                    kind,
2290                    short_name: None,
2291                    full_ref,
2292                    module_path: Some(module_path),
2293                    import_kind: Some("reexport".to_string()),
2294                    local_name: None,
2295                    requested_name: None,
2296                    namespace_alias: None,
2297                    wildcard,
2298                    line: 0,
2299                    byte_start: 0,
2300                    byte_end: 0,
2301                    dependencies: BTreeSet::new(),
2302                };
2303                index
2304                    .reexports
2305                    .push(reexport_index_from_raw(&raw, target_file));
2306            }
2307        }
2308        Some(index)
2309    }
2310
2311    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2312        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2313        let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2314        let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2315        self.contains_file(&rel_path).then_some(rel_path)
2316    }
2317}
2318
2319impl ResolverIndex for DiskProjectIndex<'_> {
2320    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2321        (file == self.caller_file).then_some(self.caller_data)
2322    }
2323
2324    fn lang_for(&self, file: &str) -> Option<LangId> {
2325        self.file_index(file).and_then(|index| index.lang)
2326    }
2327
2328    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2329        self.file_index(caller_file)
2330            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2331    }
2332
2333    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2334        self.file_index(file)
2335            .map(|index| index.reexports)
2336            .unwrap_or_default()
2337    }
2338
2339    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2340        self.file_index(file).and_then(|index| {
2341            index
2342                .node_by_scoped
2343                .get(symbol)
2344                .cloned()
2345                .or_else(|| index.node_by_bare.get(symbol).cloned())
2346        })
2347    }
2348
2349    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2350        self.file_index(file)
2351            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2352            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2353    }
2354
2355    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2356        self.file_index(file)
2357            .and_then(|index| index.export_aliases.get(symbol).cloned())
2358    }
2359
2360    fn has_export(&self, file: &str, symbol: &str) -> bool {
2361        self.file_index(file)
2362            .is_some_and(|index| index.exports.contains(symbol))
2363    }
2364
2365    fn default_export(&self, file: &str) -> Option<String> {
2366        self.file_index(file).and_then(|index| index.default_export)
2367    }
2368
2369    fn contains_file(&self, file: &str) -> bool {
2370        self.conn
2371            .query_row(
2372                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2373                params![file],
2374                |_| Ok(()),
2375            )
2376            .is_ok()
2377    }
2378
2379    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2380        self.workspace_crate_prefixes
2381            .0
2382            .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2383            .get(crate_name)
2384            .cloned()
2385    }
2386
2387    fn inline_scoped_target(
2388        &self,
2389        caller_file: &str,
2390        module_segments: &[String],
2391        short_name: &str,
2392    ) -> Option<(String, String)> {
2393        let src_prefix = rust_src_prefix(caller_file);
2394        let check = |file_path: String| {
2395            let file_module_segments = rust_module_segments_for_rel(&file_path);
2396            if rust_src_prefix(&file_path) != src_prefix
2397                || !module_segments.starts_with(&file_module_segments)
2398            {
2399                return None;
2400            }
2401            let scoped_segments = &module_segments[file_module_segments.len()..];
2402            if scoped_segments.is_empty() {
2403                return None;
2404            }
2405            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2406            self.node_for_symbol(&file_path, &scoped_symbol)
2407                .map(|_| (file_path, scoped_symbol))
2408        };
2409        if let Some(target) = check(caller_file.to_string()) {
2410            return Some(target);
2411        }
2412        let mut statement = self
2413            .conn
2414            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2415            .ok()?;
2416        let rows = statement
2417            .query_map(params![caller_file], |row| row.get::<_, String>(0))
2418            .ok()?;
2419        for path in rows.flatten() {
2420            if let Some(target) = check(path) {
2421                return Some(target);
2422            }
2423        }
2424        None
2425    }
2426}
2427
2428impl CallGraphStore {
2429    pub fn open_if_enabled(
2430        options: CallGraphStoreOptions,
2431        callgraph_dir: PathBuf,
2432        project_root: PathBuf,
2433    ) -> Result<Option<Self>> {
2434        if !options.enabled {
2435            return Ok(None);
2436        }
2437        Self::open(callgraph_dir, project_root).map(Some)
2438    }
2439
2440    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2441        let project_key = crate::search_index::artifact_cache_key(&project_root);
2442        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2443        else {
2444            return Err(CallGraphStoreError::Unavailable(
2445                "writer capability denied; use the read-only callgraph opener".to_string(),
2446            ));
2447        };
2448        std::fs::create_dir_all(&callgraph_dir)?;
2449        // Resolve the current generation via the pointer (falling back to the
2450        // legacy single-file DB). If nothing is published yet, open the legacy
2451        // path so a brand-new store still gets a writable DB + schema.
2452        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2453            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2454        let OpenedStore { store, root_repair } = Self::open_at_path(
2455            project_root.clone(),
2456            project_key,
2457            sqlite_path,
2458            generation,
2459            true,
2460            Some(Arc::clone(&writer_lease)),
2461            None,
2462        )?;
2463        match root_repair {
2464            OpenRootRepair::NeedsRebuild { .. } => {
2465                log_root_repair_rebuild(&root_repair);
2466                drop(store);
2467                drop(writer_lease);
2468                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2469                let (store, _stats) =
2470                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2471                Ok(store)
2472            }
2473            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2474        }
2475    }
2476
2477    pub fn open_readonly(
2478        callgraph_dir: PathBuf,
2479        project_root: PathBuf,
2480    ) -> Result<Option<ReadonlyCallGraphStore>> {
2481        let project_key = crate::search_index::artifact_cache_key(&project_root);
2482        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2483        {
2484            let conn = open_readonly_connection(&sqlite_path)?;
2485            if !database_ready(&conn).unwrap_or(false) {
2486                return Ok(None);
2487            }
2488            let marker_label = generation.as_deref().unwrap_or("legacy");
2489            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2490            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2491                Self::from_connection(
2492                    project_root,
2493                    project_key,
2494                    sqlite_path,
2495                    callgraph_dir,
2496                    false,
2497                    generation,
2498                    None,
2499                    Some(read_marker),
2500                    conn,
2501                ),
2502            )));
2503        }
2504
2505        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2506            return Ok(None);
2507        };
2508        crate::slog_warn!(
2509            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2510            target.partition.harness,
2511            target.sqlite_path.display()
2512        );
2513        let conn = open_readonly_connection(&target.sqlite_path)?;
2514        if !database_ready(&conn).unwrap_or(false) {
2515            return Ok(None);
2516        }
2517        let marker_label =
2518            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2519        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2520        Ok(Some(ReadonlyCallGraphStore::from_inner(
2521            Self::from_connection(
2522                project_root,
2523                project_key,
2524                target.sqlite_path,
2525                callgraph_dir,
2526                true,
2527                target.generation,
2528                None,
2529                Some(read_marker),
2530                conn,
2531            ),
2532        )))
2533    }
2534
2535    /// Open the currently-published ready store with write access so moved-root
2536    /// metadata can be repaired before projection readers consume it. Unlike
2537    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2538    /// ready generation exists, it returns `Ok(None)` instead of creating an
2539    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2540    pub fn open_ready_repairing(
2541        callgraph_dir: PathBuf,
2542        project_root: PathBuf,
2543    ) -> Result<Option<Self>> {
2544        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2545    }
2546
2547    /// Open a ready store for bounded maintenance work without repairing root
2548    /// metadata or starting a cold rebuild. A store that needs either action is
2549    /// reported as unavailable so a background build can own that work.
2550    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2551        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2552    }
2553
2554    pub fn open_ready_no_rebuild(
2555        callgraph_dir: PathBuf,
2556        project_root: PathBuf,
2557    ) -> Result<Option<Self>> {
2558        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2559    }
2560
2561    fn open_ready_with_rebuild_policy(
2562        callgraph_dir: PathBuf,
2563        project_root: PathBuf,
2564        allow_cold_build: bool,
2565        allow_root_repair: bool,
2566    ) -> Result<Option<Self>> {
2567        let project_key = crate::search_index::artifact_cache_key(&project_root);
2568        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2569        else {
2570            return Ok(None);
2571        };
2572        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2573        else {
2574            return Ok(None);
2575        };
2576        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2577            project_root.clone(),
2578            project_key.clone(),
2579            sqlite_path,
2580            generation,
2581            true,
2582            Some(Arc::clone(&writer_lease)),
2583            None,
2584            allow_root_repair,
2585        )?;
2586        match root_repair {
2587            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2588                log_root_repair_rebuild(&root_repair);
2589                drop(store);
2590                drop(writer_lease);
2591                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2592                let (store, _stats) =
2593                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2594                Ok(Some(store))
2595            }
2596            OpenRootRepair::NeedsRebuild { .. } => {
2597                if let Some(message) = note_repair_entry(&project_key) {
2598                    crate::slog_warn!("{message}");
2599                }
2600                Ok(None)
2601            }
2602            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2603        }
2604    }
2605
2606    pub fn cold_build_with_lease(
2607        callgraph_dir: PathBuf,
2608        project_root: PathBuf,
2609        files: &[PathBuf],
2610    ) -> Result<(Self, ColdBuildStats)> {
2611        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2612    }
2613
2614    pub fn cold_build_with_lease_chunked(
2615        callgraph_dir: PathBuf,
2616        project_root: PathBuf,
2617        files: &[PathBuf],
2618        chunk_size: usize,
2619    ) -> Result<(Self, ColdBuildStats)> {
2620        Self::cold_build_with_lease_chunked_inner(
2621            callgraph_dir,
2622            project_root,
2623            files,
2624            chunk_size,
2625            false,
2626        )
2627    }
2628
2629    pub(crate) fn force_cold_build_with_lease_chunked(
2630        callgraph_dir: PathBuf,
2631        project_root: PathBuf,
2632        files: &[PathBuf],
2633        chunk_size: usize,
2634    ) -> Result<(Self, ColdBuildStats)> {
2635        Self::cold_build_with_lease_chunked_inner(
2636            callgraph_dir,
2637            project_root,
2638            files,
2639            chunk_size,
2640            true,
2641        )
2642    }
2643
2644    fn cold_build_with_lease_chunked_inner(
2645        callgraph_dir: PathBuf,
2646        project_root: PathBuf,
2647        files: &[PathBuf],
2648        chunk_size: usize,
2649        require_new_publication: bool,
2650    ) -> Result<(Self, ColdBuildStats)> {
2651        let project_key = crate::search_index::artifact_cache_key(&project_root);
2652        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2653        else {
2654            let operation = if require_new_publication {
2655                "forced rebuild"
2656            } else {
2657                "cold build"
2658            };
2659            return Err(CallGraphStoreError::Unavailable(format!(
2660                "{operation} could not acquire writer capability"
2661            )));
2662        };
2663        std::fs::create_dir_all(&callgraph_dir)?;
2664        let (stats, generation) = Self::cold_build_publish_locked(
2665            &callgraph_dir,
2666            &project_root,
2667            &project_key,
2668            files,
2669            chunk_size,
2670            Arc::clone(&writer_lease),
2671        )?;
2672        let store = Self::open_generation(
2673            &callgraph_dir,
2674            project_root,
2675            project_key,
2676            generation,
2677            writer_lease,
2678        )?;
2679        Ok((store, stats))
2680    }
2681
2682    pub fn ensure_built_with_lease(
2683        callgraph_dir: PathBuf,
2684        project_root: PathBuf,
2685        files: &[PathBuf],
2686    ) -> Result<(Self, Option<ColdBuildStats>)> {
2687        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2688    }
2689
2690    pub fn ensure_built_with_lease_chunked(
2691        callgraph_dir: PathBuf,
2692        project_root: PathBuf,
2693        files: &[PathBuf],
2694        chunk_size: usize,
2695    ) -> Result<(Self, Option<ColdBuildStats>)> {
2696        let project_key = crate::search_index::artifact_cache_key(&project_root);
2697        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2698        else {
2699            return Err(CallGraphStoreError::Unavailable(
2700                "callgraph ensure could not acquire writer capability".to_string(),
2701            ));
2702        };
2703        std::fs::create_dir_all(&callgraph_dir)?;
2704        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2705        // Another process may have published a ready generation while we waited
2706        // for the lock — open it instead of rebuilding. If that generation is
2707        // from this same project at an older filesystem root, repair the root
2708        // metadata in-place while still holding the build lease. If data rows
2709        // contain absolute paths, publish a fresh generation under this lease
2710        // rather than recursively reacquiring the same lock.
2711        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2712        {
2713            let OpenedStore { store, root_repair } = Self::open_at_path(
2714                project_root.clone(),
2715                project_key.clone(),
2716                sqlite_path,
2717                generation,
2718                true,
2719                Some(Arc::clone(&writer_lease)),
2720                None,
2721            )?;
2722            match root_repair {
2723                OpenRootRepair::NeedsRebuild { .. } => {
2724                    log_root_repair_rebuild(&root_repair);
2725                    drop(store);
2726                    let (stats, generation) = Self::cold_build_publish_locked(
2727                        &callgraph_dir,
2728                        &project_root,
2729                        &project_key,
2730                        files,
2731                        chunk_size,
2732                        Arc::clone(&writer_lease),
2733                    )?;
2734                    let store = Self::open_generation(
2735                        &callgraph_dir,
2736                        project_root,
2737                        project_key,
2738                        generation,
2739                        writer_lease,
2740                    )?;
2741                    return Ok((store, Some(stats)));
2742                }
2743                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2744                    return Ok((store, None));
2745                }
2746            }
2747        }
2748        if let Some(store) = try_legacy_migration_or_fallback(
2749            &callgraph_dir,
2750            &project_root,
2751            &project_key,
2752            Arc::clone(&writer_lease),
2753        )? {
2754            return Ok((store, None));
2755        }
2756        let (stats, generation) = Self::cold_build_publish_locked(
2757            &callgraph_dir,
2758            &project_root,
2759            &project_key,
2760            files,
2761            chunk_size,
2762            Arc::clone(&writer_lease),
2763        )?;
2764        let store = Self::open_generation(
2765            &callgraph_dir,
2766            project_root,
2767            project_key,
2768            generation,
2769            writer_lease,
2770        )?;
2771        Ok((store, Some(stats)))
2772    }
2773
2774    /// Migrate a legacy harness-partition store without falling through to a
2775    /// cold build. This is used after a query has already opened a read-only
2776    /// fallback: the caller runs it on the same limited background lane as cold
2777    /// builds while queries continue using that fallback. Public so crash/retry
2778    /// tests can drive the migration synchronously on a thread where the
2779    /// thread-local failure seams apply.
2780    pub fn migrate_legacy_with_lease(
2781        callgraph_dir: PathBuf,
2782        project_root: PathBuf,
2783    ) -> Result<Option<Self>> {
2784        let project_key = crate::search_index::artifact_cache_key(&project_root);
2785        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2786        else {
2787            return Ok(None);
2788        };
2789        std::fs::create_dir_all(&callgraph_dir)?;
2790        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2791
2792        // Another writer may have completed the migration while this worker was
2793        // waiting for the lease. Adopt its root-keyed generation rather than
2794        // copying the legacy source a second time.
2795        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2796        {
2797            let OpenedStore { store, root_repair } = Self::open_at_path(
2798                project_root,
2799                project_key,
2800                sqlite_path,
2801                generation,
2802                true,
2803                Some(writer_lease),
2804                None,
2805            )?;
2806            return match root_repair {
2807                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2808                OpenRootRepair::NeedsRebuild { reason, .. } => {
2809                    Err(CallGraphStoreError::Unavailable(format!(
2810                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2811                    )))
2812                }
2813            };
2814        }
2815
2816        let store = try_legacy_migration_or_fallback(
2817            &callgraph_dir,
2818            &project_root,
2819            &project_key,
2820            writer_lease,
2821        )?;
2822        // A disk-floor or backup-budget failure returns a readable legacy store.
2823        // Keep the already-resident fallback instead of sending this duplicate
2824        // reader through the background-install channel.
2825        Ok(store.filter(|store| !store.is_legacy_fallback()))
2826    }
2827
2828    /// Build a fresh DB and publish it as a new generation, then atomically flip
2829    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2830    /// succeeds even when other processes hold an older generation open (the
2831    /// multi-TUI Windows case). The builder owns the temp + generation files
2832    /// exclusively (unique pid+nanos names), so it can rename/replace them
2833    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2834    ///
2835    /// Returns the published generation file name so callers open exactly the
2836    /// generation they built (avoiding a race where a concurrent build's flip
2837    /// would otherwise reopen a different generation).
2838    fn cold_build_publish_locked(
2839        callgraph_dir: &Path,
2840        project_root: &Path,
2841        project_key: &str,
2842        files: &[PathBuf],
2843        chunk_size: usize,
2844        writer_lease: Arc<crate::root_cache::WriterLease>,
2845    ) -> Result<(ColdBuildStats, String)> {
2846        if let Some((previous_root, remaining)) =
2847            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2848        {
2849            return Err(CallGraphStoreError::Unavailable(format!(
2850                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2851                previous_root.display(),
2852                remaining.as_millis()
2853            )));
2854        }
2855        let breaker = crate::build_breaker::BuildDeathBreaker::open(
2856            callgraph_dir.join("build-breaker.sqlite"),
2857        )
2858        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2859
2860        let generation = generation_file_name(project_key);
2861        let gen_path = callgraph_dir.join(&generation);
2862        // A writer lease makes this root/domain's staging generation exclusive.
2863        // Keep its identity stable so a replacement process adopts committed
2864        // batches instead of minting a second temp and starting from zero.
2865        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2866        let adopting_staging = temp_path.exists();
2867        if !adopting_staging {
2868            remove_sqlite_file_set(&temp_path);
2869        }
2870
2871        let (stats, breaker_key) = {
2872            if adopting_staging {
2873                crate::slog_info!(
2874                    "resuming callgraph cold build from staged generation {}",
2875                    temp_path.display()
2876                );
2877            }
2878            let temp_store = Self::open_at_path(
2879                project_root.to_path_buf(),
2880                project_key.to_string(),
2881                temp_path.clone(),
2882                None,
2883                false,
2884                Some(Arc::clone(&writer_lease)),
2885                None,
2886            )?
2887            .store;
2888            // Admission must precede every expensive build phase and every
2889            // staging write: a suspended root is refused before the process
2890            // spends anything, and a death during enumeration is attributable
2891            // to an admitted attempt. The breaker key needs the corpus
2892            // fingerprint, so that one input is resolved by a standalone
2893            // streaming walk first (sanctioned pre-admission work) - the
2894            // inventory pass below recomputes it while staging; the staged
2895            // value governs resume cursors, while the admission key stays
2896            // pinned to the admitted fingerprint so a file racing the walk
2897            // cannot detach the attempt from its breaker record.
2898            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
2899            let breaker_key = crate::build_breaker::BreakerKey::new(
2900                project_root.display().to_string(),
2901                crate::build_breaker::BuildDomain::CallgraphCold,
2902                admission_fingerprint,
2903            );
2904            match breaker
2905                .admit(&breaker_key, 0)
2906                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
2907            {
2908                crate::build_breaker::BreakerAdmission::Admitted(_) => {}
2909                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
2910                    return Err(CallGraphStoreError::Suspended(suspension));
2911                }
2912            }
2913            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
2914            let stats = temp_store
2915                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
2916            let _ = temp_store.checkpoint_wal_truncate();
2917            temp_store.prepare_for_atomic_swap()?;
2918            (stats, breaker_key)
2919        };
2920
2921        notify_cold_build_before_publish_observer();
2922        let publication = publish_if_current(|| {
2923            verify_writer_lease(&writer_lease)?;
2924            // Move the finished build to its final generation path. This target is
2925            // brand-new and owned by us, so the rename never hits an open file.
2926            remove_sqlite_file_set(&gen_path);
2927            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2928            crate::fs_lock::sync_parent(&gen_path);
2929            remove_sqlite_sidecars(&gen_path);
2930
2931            notify_cold_build_swap_observer(&temp_path, &gen_path);
2932
2933            // Atomically publish the new generation, then best-effort GC old ones.
2934            verify_writer_lease(&writer_lease)?;
2935            publish_pointer(callgraph_dir, project_key, &generation)?;
2936            gc_old_generations(callgraph_dir, project_key, &generation);
2937            // Store-wide orphan sweep on the same cadence: reclaims aged build
2938            // temps for roots that no longer build here, which the per-root GC
2939            // above never reaches.
2940            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2941            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2942                let inspect_root =
2943                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2944                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2945                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2946            }
2947            Ok(())
2948        });
2949        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2950            remove_sqlite_file_set(&temp_path);
2951        }
2952        publication?;
2953        // Pointer publication is the only automatic breaker reset. The staging
2954        // batches above never reset history because a process can die after them.
2955        breaker
2956            .record_ready_publication(&breaker_key)
2957            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2958        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2959        Ok((stats, generation))
2960    }
2961
2962    /// Open a specific just-published generation (read-write, WAL) so a builder
2963    /// returns a store pinned to exactly what it built.
2964    fn open_generation(
2965        callgraph_dir: &Path,
2966        project_root: PathBuf,
2967        project_key: String,
2968        generation: String,
2969        writer_lease: Arc<crate::root_cache::WriterLease>,
2970    ) -> Result<Self> {
2971        let gen_path = callgraph_dir.join(&generation);
2972        Ok(Self::open_at_path(
2973            project_root,
2974            project_key,
2975            gen_path,
2976            Some(generation),
2977            true,
2978            Some(writer_lease),
2979            None,
2980        )?
2981        .store)
2982    }
2983
2984    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2985        let project_key = crate::search_index::artifact_cache_key(project_root);
2986        // A cold build is needed unless a ready generation (or ready legacy DB)
2987        // is currently published.
2988        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2989    }
2990
2991    /// Check the durable callgraph-domain breaker before a query starts a cold
2992    /// worker. This only runs while no ready generation exists; it never builds
2993    /// inline and lets a tripped root return a terminal answer instead of an
2994    /// endless `Building` response.
2995    pub fn cold_build_suspension(
2996        callgraph_dir: &Path,
2997        project_root: &Path,
2998    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
2999        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3000        if !breaker_path.exists() {
3001            return Ok(None);
3002        }
3003        let key = crate::build_breaker::BreakerKey::new(
3004            project_root.display().to_string(),
3005            crate::build_breaker::BuildDomain::CallgraphCold,
3006            callgraph_corpus_fingerprint(project_root)?,
3007        );
3008        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3009            .and_then(|breaker| breaker.suspension(&key))
3010            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3011    }
3012
3013    fn open_at_path(
3014        project_root: PathBuf,
3015        project_key: String,
3016        sqlite_path: PathBuf,
3017        generation: Option<String>,
3018        use_wal: bool,
3019        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3020        read_marker: Option<crate::root_cache::ReadMarker>,
3021    ) -> Result<OpenedStore> {
3022        Self::open_at_path_with_root_repair(
3023            project_root,
3024            project_key,
3025            sqlite_path,
3026            generation,
3027            use_wal,
3028            writer_lease,
3029            read_marker,
3030            true,
3031        )
3032    }
3033
3034    fn open_at_path_with_root_repair(
3035        project_root: PathBuf,
3036        project_key: String,
3037        sqlite_path: PathBuf,
3038        generation: Option<String>,
3039        use_wal: bool,
3040        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3041        read_marker: Option<crate::root_cache::ReadMarker>,
3042        allow_root_repair: bool,
3043    ) -> Result<OpenedStore> {
3044        if let Some(lease) = writer_lease.as_ref() {
3045            verify_writer_lease(lease)?;
3046        }
3047        if let Some(parent) = sqlite_path.parent() {
3048            std::fs::create_dir_all(parent)?;
3049        }
3050        let mut conn = Connection::open(&sqlite_path)?;
3051        if use_wal {
3052            configure_connection(&conn)?;
3053        } else {
3054            configure_build_connection(&conn)?;
3055        }
3056        if let Some(lease) = writer_lease.as_ref() {
3057            verify_writer_lease(lease)?;
3058        }
3059        initialize_schema(&conn)?;
3060        if let Some(lease) = writer_lease.as_ref() {
3061            verify_writer_lease(lease)?;
3062        }
3063        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3064        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3065            (Some(marker), _, _) => Some(marker),
3066            (None, Some(label), Some(cache_dir)) => {
3067                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3068            }
3069            (None, _, _) => None,
3070        };
3071        let publication_dir = sqlite_path
3072            .parent()
3073            .map(Path::to_path_buf)
3074            .unwrap_or_default();
3075        let store = Self::from_connection(
3076            project_root,
3077            project_key,
3078            sqlite_path,
3079            publication_dir,
3080            false,
3081            generation,
3082            writer_lease,
3083            read_marker,
3084            conn,
3085        );
3086        Ok(OpenedStore { store, root_repair })
3087    }
3088
3089    fn prepare_for_atomic_swap(&self) -> Result<()> {
3090        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3091        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3092        Ok(())
3093    }
3094
3095    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3096        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3097            self.sqlite_path
3098                .parent()
3099                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3100        });
3101        if protected_reader {
3102            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3103        } else {
3104            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3105        }
3106    }
3107
3108    fn from_connection(
3109        project_root: PathBuf,
3110        project_key: String,
3111        sqlite_path: PathBuf,
3112        publication_dir: PathBuf,
3113        legacy_fallback: bool,
3114        generation: Option<String>,
3115        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3116        read_marker: Option<crate::root_cache::ReadMarker>,
3117        conn: Connection,
3118    ) -> Self {
3119        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3120        Self {
3121            project_root,
3122            project_key,
3123            sqlite_path,
3124            publication_dir,
3125            legacy_fallback,
3126            generation,
3127            writer_lease,
3128            read_marker,
3129            database_ready: AtomicBool::new(false),
3130            write_metrics,
3131            conn: Mutex::new(conn),
3132        }
3133    }
3134
3135    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3136        if self.database_ready.load(AtomicOrdering::Acquire) {
3137            return Ok(());
3138        }
3139        ensure_database_ready(conn)?;
3140        self.database_ready.store(true, AtomicOrdering::Release);
3141        Ok(())
3142    }
3143
3144    pub fn project_root(&self) -> &Path {
3145        &self.project_root
3146    }
3147
3148    pub fn project_key(&self) -> &str {
3149        &self.project_key
3150    }
3151
3152    pub fn sqlite_path(&self) -> &Path {
3153        &self.sqlite_path
3154    }
3155
3156    /// The generation file named by the publication pointer when this store opened.
3157    pub(crate) fn projection_generation(&self) -> Option<&str> {
3158        self.generation.as_deref()
3159    }
3160
3161    /// Read the durable revision that changes in the same transaction as graph writes.
3162    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3163        self.refresh_read_marker()?;
3164        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3165        self.ensure_ready(&conn)?;
3166        projection_write_revision(&conn)
3167    }
3168
3169    /// Whether this store is reading from a legacy harness partition because
3170    /// the root-keyed store has not published a generation yet.
3171    pub fn is_legacy_fallback(&self) -> bool {
3172        self.legacy_fallback
3173    }
3174
3175    pub(crate) fn is_legacy_migration(&self) -> bool {
3176        self.generation.as_deref().is_some_and(|generation| {
3177            migration_generation_requires_manifest(generation)
3178                && migration_manifest_valid(&self.publication_dir, generation)
3179        })
3180    }
3181
3182    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3183        self.writer_lease.as_ref().map(|lease| lease.epoch())
3184    }
3185
3186    fn verify_writer_lease(&self) -> Result<()> {
3187        let Some(lease) = self.writer_lease.as_ref() else {
3188            return Err(CallGraphStoreError::Unavailable(
3189                "callgraph store opened read-only; write API is unavailable".to_string(),
3190            ));
3191        };
3192        verify_writer_lease(lease)
3193    }
3194
3195    fn refresh_read_marker(&self) -> Result<()> {
3196        if let Some(marker) = self.read_marker.as_ref() {
3197            marker.touch_if_due()?;
3198        }
3199        Ok(())
3200    }
3201
3202    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3203        self.write_metrics
3204            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3205    }
3206
3207    fn checkpoint_wal_truncate(&self) -> bool {
3208        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3209        checkpoint_wal_truncate(&conn)
3210    }
3211
3212    /// True if this store still reflects the currently-published generation.
3213    /// Cheap (one small pointer-file read). When false, another process (or a
3214    /// local cold rebuild) has published a newer generation and the holder
3215    /// should drop this store and reopen via the pointer to converge. A missing
3216    /// pointer keeps the current store (legacy DB still valid, or transient).
3217    pub fn is_current(&self) -> bool {
3218        let _ = self.refresh_read_marker();
3219        match (
3220            read_pointer(&self.publication_dir, &self.project_key),
3221            &self.generation,
3222        ) {
3223            // Even when both generations happen to have the same filename, the
3224            // root-keyed pointer names a different directory from the fallback.
3225            (Some(_), _) if self.legacy_fallback => false,
3226            (Some(published), Some(opened)) => &published == opened,
3227            // A generation now supersedes the legacy single-file DB we opened.
3228            (Some(_), None) => false,
3229            // No pointer: keep serving (legacy DB, or an anomalous pointer
3230            // removal where our open generation file is still valid).
3231            (None, _) => true,
3232        }
3233    }
3234
3235    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3236        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3237    }
3238
3239    /// Build in two durable passes. Discovery first commits a disk-backed file
3240    /// inventory, extraction consumes bounded batches from that inventory, and
3241    /// resolution pages through staged raw references after all symbols exist.
3242    pub fn cold_build_chunked(
3243        &self,
3244        files: &[PathBuf],
3245        chunk_size: usize,
3246    ) -> Result<ColdBuildStats> {
3247        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3248        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3249    }
3250
3251    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3252        note_cold_build_phase("enumeration");
3253        if files.is_empty() {
3254            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3255                &self.project_root,
3256            ))
3257        } else {
3258            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3259        }
3260    }
3261
3262    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3263    where
3264        I: IntoIterator<Item = PathBuf>,
3265    {
3266        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3267        self.verify_writer_lease()?;
3268        let total_changes_before = conn.total_changes();
3269        let tx = conn.transaction()?;
3270        tx.execute("DELETE FROM staging_file_inventory", [])?;
3271        tx.commit()?;
3272        self.record_commit(total_changes_before, &conn);
3273
3274        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3275        for path in paths {
3276            let path = normalize_file_path(&self.project_root, &path)?;
3277            let rel_path = relative_path(&self.project_root, &path);
3278            let size = std::fs::metadata(&path)
3279                .map(|metadata| metadata.len())
3280                .unwrap_or(0);
3281            batch.push((rel_path, size));
3282            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3283                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3284                batch.clear();
3285            }
3286        }
3287        if !batch.is_empty() {
3288            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3289        }
3290
3291        staged_corpus_fingerprint(&conn, &self.project_root)
3292    }
3293
3294    fn insert_staged_file_inventory_batch(
3295        &self,
3296        conn: &mut Connection,
3297        batch: &[(String, u64)],
3298    ) -> Result<()> {
3299        self.verify_writer_lease()?;
3300        let total_changes_before = conn.total_changes();
3301        let tx = conn.transaction()?;
3302        {
3303            let mut insert = tx.prepare(
3304                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3305            )?;
3306            for (path, size) in batch {
3307                insert.execute(params![path, *size as i64])?;
3308            }
3309        }
3310        tx.commit()?;
3311        self.record_commit(total_changes_before, conn);
3312        Ok(())
3313    }
3314
3315    fn cold_build_chunked_from_staged_inventory(
3316        &self,
3317        chunk_size: usize,
3318        corpus_fingerprint: &str,
3319    ) -> Result<ColdBuildStats> {
3320        let started = Instant::now();
3321        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3322        let workspace_root = self.project_root.display().to_string();
3323        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3324
3325        self.verify_writer_lease()?;
3326        let mut phase = staged_build_phase(&conn)?;
3327        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3328        if phase.as_deref().is_none_or(|phase| phase == "ready")
3329            || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3330        {
3331            let total_changes_before = conn.total_changes();
3332            let tx = conn.transaction()?;
3333            clear_tables(&tx)?;
3334            tx.execute("DELETE FROM staging_ref_context", [])?;
3335            insert_meta(&tx)?;
3336            drop_cold_build_secondary_indexes(&tx)?;
3337            set_meta_ready(&tx, false)?;
3338            set_staged_build_phase(&tx, "extracting")?;
3339            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3340            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3341            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3342            tx.commit()?;
3343            self.record_commit(total_changes_before, &conn);
3344            phase = Some("extracting".to_string());
3345        }
3346
3347        // A crashed extraction pass has already committed complete batches. Compare the
3348        // staged content identity with the current file before parsing so unchanged
3349        // committed files are not restarted from zero after adoption.
3350        note_cold_build_phase("extraction");
3351        if phase.as_deref() == Some("extracting") {
3352            prune_staged_files_not_in_inventory(&mut conn)?;
3353
3354            let mut after_path = String::new();
3355            loop {
3356                let Some(batch) = load_staged_file_batch(
3357                    &conn,
3358                    &self.project_root,
3359                    &after_path,
3360                    batch_files,
3361                    COLD_BUILD_EXTRACT_BATCH_BYTES,
3362                )?
3363                else {
3364                    break;
3365                };
3366                after_path = batch.last_path;
3367
3368                let mut needs_extract = Vec::with_capacity(batch.paths.len());
3369                for path in batch.paths {
3370                    if !staged_content_matches(&conn, &self.project_root, &path)? {
3371                        needs_extract.push(path);
3372                    }
3373                }
3374                if needs_extract.is_empty() {
3375                    continue;
3376                }
3377
3378                let build = build_extracts_parallel(&self.project_root, &needs_extract);
3379                self.verify_writer_lease()?;
3380                let total_changes_before = conn.total_changes();
3381                let tx = conn.transaction()?;
3382                let mut extracted_bytes = 0u64;
3383                {
3384                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3385                    for extract in &build.extracts {
3386                        delete_staged_file_rows(&tx, &extract.rel_path)?;
3387                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3388                        for raw in &extract.raw_refs {
3389                            insert_staged_ref_prepared(&mut inserts, raw)?;
3390                        }
3391                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3392                    }
3393                    for failure in &build.failures {
3394                        insert_backend_state_prepared(
3395                            &mut inserts.backend_state,
3396                            &workspace_root,
3397                            &failure.rel_path,
3398                            failure
3399                                .freshness
3400                                .as_ref()
3401                                .map(|freshness| &freshness.content_hash),
3402                            "stale",
3403                        )?;
3404                    }
3405                }
3406                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3407                note_cold_build_commit_barrier("extraction_batch_before_commit");
3408                tx.commit()?;
3409                note_cold_build_commit_barrier("extraction_batch_committed");
3410                self.record_commit(total_changes_before, &conn);
3411            }
3412
3413            let total_changes_before = conn.total_changes();
3414            let tx = conn.transaction()?;
3415            set_staged_build_phase(&tx, "indexing")?;
3416            tx.commit()?;
3417            self.record_commit(total_changes_before, &conn);
3418            phase = Some("indexing".to_string());
3419        }
3420
3421        // Secondary indexes are intentionally created only after every extract is
3422        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
3423        // corpus-wide symbol/export table.
3424        note_cold_build_phase("symbol_export_index");
3425        if phase.as_deref() == Some("indexing") {
3426            self.verify_writer_lease()?;
3427            let total_changes_before = conn.total_changes();
3428            let tx = conn.transaction()?;
3429            create_cold_build_secondary_indexes(&tx)?;
3430            set_staged_build_phase(&tx, "resolving")?;
3431            tx.commit()?;
3432            self.record_commit(total_changes_before, &conn);
3433        }
3434
3435        note_cold_build_phase("resolution");
3436        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3437        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3438        loop {
3439            let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3440            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3441                break;
3442            };
3443
3444            self.verify_writer_lease()?;
3445            let total_changes_before = conn.total_changes();
3446            let tx = conn.transaction()?;
3447            {
3448                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3449                let mut offset = 0;
3450                while offset < staged.len() {
3451                    let caller_file = staged[offset].raw.caller_file.clone();
3452                    let end = staged[offset..]
3453                        .iter()
3454                        .position(|entry| entry.raw.caller_file != caller_file)
3455                        .map(|relative| offset + relative)
3456                        .unwrap_or(staged.len());
3457                    let caller_extract = build_file_extract(
3458                        &self.project_root,
3459                        &self.project_root.join(&caller_file),
3460                    );
3461                    if let Ok(caller_extract) = caller_extract {
3462                        let index = DiskProjectIndex {
3463                            project_root: &self.project_root,
3464                            conn: &tx,
3465                            caller_file: &caller_file,
3466                            caller_data: &caller_extract.data,
3467                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3468                        };
3469                        for staged_ref in &staged[offset..end] {
3470                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3471                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3472                        }
3473                    } else {
3474                        for staged_ref in &staged[offset..end] {
3475                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3476                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3477                        }
3478                    }
3479                    offset = end;
3480                }
3481            }
3482            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3483            tx.commit()?;
3484            self.record_commit(total_changes_before, &conn);
3485            resolve_cursor = last_rowid;
3486        }
3487
3488        note_cold_build_phase("publication");
3489        self.verify_writer_lease()?;
3490        let total_changes_before = conn.total_changes();
3491        let tx = conn.transaction()?;
3492        let _supplemental_edge_count =
3493            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3494        set_meta_ready(&tx, true)?;
3495        set_staged_build_phase(&tx, "ready")?;
3496        tx.execute("DELETE FROM staging_file_inventory", [])?;
3497        tx.execute("DELETE FROM staging_ref_context", [])?;
3498        bump_projection_write_revision(&tx)?;
3499        tx.commit()?;
3500        self.record_commit(total_changes_before, &conn);
3501
3502        let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3503        let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3504        let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3505        let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3506        let failed_files = staged_failed_files(&conn)?;
3507        let elapsed_ms = started.elapsed().as_millis();
3508        crate::slog_info!(
3509            "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3510            files,
3511            nodes,
3512            refs,
3513            edges,
3514            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3515            elapsed_ms
3516        );
3517        Ok(ColdBuildStats {
3518            files,
3519            nodes,
3520            refs,
3521            edges,
3522            failed_files,
3523            elapsed_ms,
3524        })
3525    }
3526
3527    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3528        self.refresh_files_with_workspace_crate_prefix_cache(
3529            changed_files,
3530            WorkspaceCratePrefixCache::default(),
3531        )
3532    }
3533
3534    fn refresh_files_with_workspace_crate_prefix_cache(
3535        &self,
3536        changed_files: &[PathBuf],
3537        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3538    ) -> Result<IncrementalStats> {
3539        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3540            changed_files,
3541            workspace_crate_prefixes,
3542        )?;
3543        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3544            eprintln!("refresh_files phases: {}", profile.report());
3545        }
3546        Ok(stats)
3547    }
3548
3549    /// Run an incremental refresh and return phase timings for an offline store copy.
3550    #[doc(hidden)]
3551    pub fn refresh_files_profiled(
3552        &self,
3553        changed_files: &[PathBuf],
3554    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3555        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3556            changed_files,
3557            WorkspaceCratePrefixCache::default(),
3558        )
3559    }
3560
3561    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3562        &self,
3563        changed_files: &[PathBuf],
3564        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3565    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3566        let total_started = Instant::now();
3567        let mut profile = RefreshFilesProfile::default();
3568        self.verify_writer_lease()?;
3569        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3570        ensure_database_ready(&conn)?;
3571        let total_changes_before = conn.total_changes();
3572        let mut changed = Vec::new();
3573        let mut surface_changed = BTreeSet::new();
3574        let mut deleted = BTreeSet::new();
3575        let mut own_refresh = BTreeSet::new();
3576        let mut candidate_own_refresh = BTreeSet::new();
3577        let mut confirmed_fresh = BTreeSet::new();
3578        let mut unchanged_extracts = 0usize;
3579        let mut selected_ref_ids = BTreeSet::new();
3580        let mut selected_refs_by_caller = BTreeMap::new();
3581        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3582        let mut fresh_metadata = BTreeMap::new();
3583
3584        for input in changed_files {
3585            let abs_path = normalize_file_path(&self.project_root, input)?;
3586            let rel_path = relative_path(&self.project_root, &abs_path);
3587            changed.push(rel_path.clone());
3588            let old_row = load_file_row(&conn, &rel_path)?;
3589            if !abs_path.exists() {
3590                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3591                    surface_changed.insert(rel_path.clone());
3592                    let started = Instant::now();
3593                    let dependent_refs =
3594                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3595                    profile.dependency_selection += started.elapsed();
3596                    record_dependent_refs(
3597                        &mut selected_ref_ids,
3598                        &mut selected_refs_by_caller,
3599                        dependent_refs,
3600                    );
3601                }
3602                continue;
3603            }
3604
3605            if let Some(row) = &old_row {
3606                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3607                    FreshnessVerdict::HotFresh => {
3608                        // Content still matches the stored graph. A prior failed
3609                        // refresh may have left backend_file_state='stale' without
3610                        // changing bytes; skip the extract but still clear that
3611                        // leftover so dead-code projection can use this store.
3612                        confirmed_fresh.insert(rel_path.clone());
3613                        continue;
3614                    }
3615                    FreshnessVerdict::ContentFresh {
3616                        new_mtime,
3617                        new_size,
3618                    } => {
3619                        fresh_metadata.insert(
3620                            rel_path.clone(),
3621                            FileFreshness {
3622                                content_hash: row.freshness.content_hash,
3623                                mtime: new_mtime,
3624                                size: new_size,
3625                            },
3626                        );
3627                        continue;
3628                    }
3629                    FreshnessVerdict::Deleted => {
3630                        if deleted.insert(rel_path.clone()) {
3631                            surface_changed.insert(rel_path.clone());
3632                            let started = Instant::now();
3633                            let dependent_refs =
3634                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3635                            profile.dependency_selection += started.elapsed();
3636                            record_dependent_refs(
3637                                &mut selected_ref_ids,
3638                                &mut selected_refs_by_caller,
3639                                dependent_refs,
3640                            );
3641                        }
3642                        continue;
3643                    }
3644                    FreshnessVerdict::Stale => {}
3645                }
3646            }
3647
3648            let started = Instant::now();
3649            let extract = build_file_extract(&self.project_root, &abs_path)?;
3650            profile.parse += started.elapsed();
3651            let surface_is_changed = old_row
3652                .as_ref()
3653                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3654                .unwrap_or(true);
3655            if surface_is_changed {
3656                surface_changed.insert(rel_path.clone());
3657                let started = Instant::now();
3658                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3659                profile.dependency_selection += started.elapsed();
3660                record_dependent_refs(
3661                    &mut selected_ref_ids,
3662                    &mut selected_refs_by_caller,
3663                    dependent_refs,
3664                );
3665            }
3666            candidate_own_refresh.insert(rel_path.clone());
3667            changed_extracts.insert(rel_path, extract);
3668        }
3669
3670        let dependency_selected_refs = selected_ref_ids.len();
3671        let mut touched_callers: BTreeSet<String> =
3672            selected_refs_by_caller.keys().cloned().collect();
3673        touched_callers.extend(candidate_own_refresh.iter().cloned());
3674
3675        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3676        for rel_path in &touched_callers {
3677            if deleted.contains(rel_path) {
3678                continue;
3679            }
3680            if let Some(extract) = changed_extracts.get(rel_path) {
3681                caller_extracts.insert(rel_path.clone(), extract.clone());
3682                continue;
3683            }
3684            let abs_path = self.project_root.join(rel_path);
3685            if abs_path.exists() {
3686                let started = Instant::now();
3687                let extract = build_file_extract(&self.project_root, &abs_path)?;
3688                profile.dependent_parse += started.elapsed();
3689                caller_extracts.insert(rel_path.clone(), extract);
3690            }
3691        }
3692
3693        let tx = conn.transaction()?;
3694        for (rel_path, freshness) in fresh_metadata {
3695            update_file_fresh_metadata(
3696                &tx,
3697                &self.project_root,
3698                &rel_path,
3699                &freshness.content_hash,
3700                freshness.mtime,
3701                freshness.size,
3702            )?;
3703        }
3704        for rel_path in &confirmed_fresh {
3705            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3706        }
3707        for rel_path in &deleted {
3708            let started = Instant::now();
3709            delete_file_rows(&tx, rel_path)?;
3710            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3711            profile.row_deletes += started.elapsed();
3712        }
3713
3714        let started = Instant::now();
3715        let index = ProjectIndex::from_db_and_callers(
3716            &tx,
3717            &self.project_root,
3718            &caller_extracts,
3719            workspace_crate_prefixes,
3720        )?;
3721        profile.index_load += started.elapsed();
3722
3723        let workspace_root = self.project_root.display().to_string();
3724        {
3725            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3726            for rel_path in &candidate_own_refresh {
3727                let Some(extract) = changed_extracts.get(rel_path) else {
3728                    continue;
3729                };
3730                if !write_amplification_baseline_enabled()
3731                    && stored_extract_matches(&tx, rel_path, extract, &index)?
3732                {
3733                    unchanged_extracts += 1;
3734                    update_file_fresh_metadata(
3735                        &tx,
3736                        &self.project_root,
3737                        rel_path,
3738                        &extract.freshness.content_hash,
3739                        extract.freshness.mtime,
3740                        extract.freshness.size,
3741                    )?;
3742                    continue;
3743                }
3744
3745                own_refresh.insert(rel_path.clone());
3746                let started = Instant::now();
3747                delete_file_rows(&tx, rel_path)?;
3748                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3749                profile.row_deletes += started.elapsed();
3750                let started = Instant::now();
3751                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3752                profile.row_inserts += started.elapsed();
3753            }
3754
3755            let dependency_callers = touched_callers
3756                .iter()
3757                .filter(|rel_path| {
3758                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3759                })
3760                .cloned()
3761                .collect::<Vec<_>>();
3762            for rel_path in dependency_callers {
3763                let Some(extract) = caller_extracts.get(&rel_path) else {
3764                    continue;
3765                };
3766                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3767                    continue;
3768                }
3769
3770                own_refresh.insert(rel_path.clone());
3771                let started = Instant::now();
3772                delete_file_rows(&tx, &rel_path)?;
3773                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3774                profile.row_deletes += started.elapsed();
3775                let started = Instant::now();
3776                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3777                profile.row_inserts += started.elapsed();
3778            }
3779            let started = Instant::now();
3780            for rel_path in &touched_callers {
3781                if deleted.contains(rel_path) {
3782                    continue;
3783                }
3784                let Some(extract) = caller_extracts.get(rel_path) else {
3785                    continue;
3786                };
3787                if own_refresh.contains(rel_path) {
3788                    delete_refs_for_caller(&tx, rel_path)?;
3789                    for raw_ref in &extract.raw_refs {
3790                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3791                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3792                    }
3793                    continue;
3794                }
3795
3796                let selected_for_caller = selected_refs_by_caller
3797                    .get(rel_path)
3798                    .cloned()
3799                    .unwrap_or_default();
3800                delete_ref_ids(&tx, &selected_for_caller)?;
3801                for raw_ref in &extract.raw_refs {
3802                    if selected_for_caller.contains(&raw_ref.ref_id) {
3803                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3804                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3805                    }
3806                }
3807            }
3808            profile.ref_resolution += started.elapsed();
3809        }
3810
3811        let started = Instant::now();
3812        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3813        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3814        profile.method_dispatch += started.elapsed();
3815
3816        bump_projection_write_revision(&tx)?;
3817        let started = Instant::now();
3818        commit_incremental_if_current(tx)?;
3819        self.record_commit(total_changes_before, &conn);
3820        profile.commit += started.elapsed();
3821        profile.total = total_started.elapsed();
3822        Ok((
3823            IncrementalStats {
3824                changed_files: changed,
3825                surface_changed: surface_changed.into_iter().collect(),
3826                deleted_files: deleted.into_iter().collect(),
3827                dependency_selected_refs,
3828                refreshed_own_files: own_refresh.len(),
3829                unchanged_extract_files: unchanged_extracts,
3830            },
3831            profile,
3832        ))
3833    }
3834
3835    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3836        self.cold_build(current_files)
3837    }
3838
3839    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3840        self.verify_writer_lease()?;
3841        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3842        let total_changes_before = conn.total_changes();
3843        let tx = conn.transaction()?;
3844        let mut marked = Vec::new();
3845        for path in files {
3846            let abs_path = normalize_file_path(&self.project_root, path)?;
3847            let rel_path = relative_path(&self.project_root, &abs_path);
3848            let freshness = cache_freshness::collect(&abs_path).ok();
3849            mark_backend_state(
3850                &tx,
3851                &self.project_root,
3852                &rel_path,
3853                freshness.as_ref().map(|freshness| &freshness.content_hash),
3854                "stale",
3855            )?;
3856            marked.push(rel_path);
3857        }
3858        bump_projection_write_revision(&tx)?;
3859        tx.commit()?;
3860        self.record_commit(total_changes_before, &conn);
3861        marked.sort();
3862        marked.dedup();
3863        Ok(marked)
3864    }
3865
3866    pub fn stale_files(&self) -> Result<Vec<String>> {
3867        self.refresh_read_marker()?;
3868        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3869        let mut stmt = conn.prepare(
3870            "SELECT DISTINCT file_path FROM backend_file_state
3871             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3872             ORDER BY file_path",
3873        )?;
3874        let rows = stmt.query_map(
3875            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3876            |row| row.get::<_, String>(0),
3877        )?;
3878        rows.collect::<std::result::Result<Vec<_>, _>>()
3879            .map_err(Into::into)
3880    }
3881
3882    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3883        self.refresh_read_marker()?;
3884        let rel_path = relative_path(
3885            &self.project_root,
3886            &normalize_file_path(&self.project_root, file)?,
3887        );
3888        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3889        conn.query_row(
3890            "SELECT status FROM backend_file_state
3891             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3892             ORDER BY updated_at DESC LIMIT 1",
3893            params![
3894                BACKEND_TREESITTER,
3895                self.project_root.display().to_string(),
3896                rel_path
3897            ],
3898            |row| row.get(0),
3899        )
3900        .optional()
3901        .map_err(Into::into)
3902    }
3903
3904    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3905        self.refresh_read_marker()?;
3906        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3907        self.ensure_ready(&conn)?;
3908        edge_snapshot_with_conn(&conn)
3909    }
3910
3911    pub fn indexed_file_count(&self) -> Result<usize> {
3912        self.refresh_read_marker()?;
3913        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3914        self.ensure_ready(&conn)?;
3915        indexed_file_count(&conn)
3916    }
3917
3918    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3919        self.refresh_read_marker()?;
3920        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3921        let rel_path = relative_path(&self.project_root, &abs_path);
3922        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3923        self.ensure_ready(&conn)?;
3924        resolve_node_for_rel(&conn, &rel_path, symbol)
3925    }
3926
3927    /// Return all positional nodes matching a legacy symbol query in a file.
3928    ///
3929    /// Consumers that need legacy compatibility can collapse these by
3930    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3931    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3932        self.refresh_read_marker()?;
3933        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3934        let rel_path = relative_path(&self.project_root, &abs_path);
3935        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3936        self.ensure_ready(&conn)?;
3937        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3938    }
3939
3940    /// Return all positional nodes matching a symbol query anywhere in the store.
3941    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3942        self.refresh_read_marker()?;
3943        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3944        self.ensure_ready(&conn)?;
3945        nodes_matching_symbol(&conn, symbol)
3946    }
3947
3948    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3949    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3950        self.refresh_read_marker()?;
3951        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3952        let rel_path = relative_path(&self.project_root, &abs_path);
3953        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3954        self.ensure_ready(&conn)?;
3955        direct_callers_for_tuple(&conn, &rel_path, symbol)
3956    }
3957
3958    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
3959    pub fn direct_callers_for_symbols(
3960        &self,
3961        targets: &[(String, String)],
3962    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3963        if targets.is_empty() {
3964            return Ok(HashMap::new());
3965        }
3966        self.refresh_read_marker()?;
3967        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3968        self.ensure_ready(&conn)?;
3969        direct_callers_for_tuples(&conn, targets)
3970    }
3971
3972    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3973    pub fn direct_caller_counts_of(
3974        &self,
3975        targets: &[(String, String)],
3976    ) -> Result<HashMap<(String, String), usize>> {
3977        if targets.is_empty() {
3978            return Ok(HashMap::new());
3979        }
3980        self.refresh_read_marker()?;
3981        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3982        self.ensure_ready(&conn)?;
3983        direct_caller_counts_for_tuples(&conn, targets)
3984    }
3985
3986    pub fn callers_of(
3987        &self,
3988        file_rel: &Path,
3989        symbol: &str,
3990        depth: usize,
3991    ) -> Result<StoreCallersResult> {
3992        let target = self.node_for(file_rel, symbol)?;
3993        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3994        self.ensure_ready(&conn)?;
3995        let effective_depth = depth.max(1);
3996        let mut visited = HashSet::new();
3997        let mut callers = Vec::new();
3998        let mut depth_limited = false;
3999        let mut truncated = 0usize;
4000        collect_callers_recursive(
4001            &conn,
4002            &target.file,
4003            &target.symbol,
4004            effective_depth,
4005            0,
4006            &mut visited,
4007            &mut callers,
4008            &mut depth_limited,
4009            &mut truncated,
4010        )?;
4011        Ok(StoreCallersResult {
4012            target,
4013            callers,
4014            scanned_files: indexed_file_count(&conn)?,
4015            depth_limited,
4016            truncated,
4017        })
4018    }
4019
4020    pub fn impact_of(
4021        &self,
4022        file_rel: &Path,
4023        symbol: &str,
4024        depth: usize,
4025    ) -> Result<StoreImpactResult> {
4026        let callers = self.callers_of(file_rel, symbol, depth)?;
4027        let target_parameters = callers
4028            .target
4029            .signature
4030            .as_deref()
4031            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4032            .unwrap_or_default();
4033        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4034        for site in &callers.callers {
4035            source_lines_by_file
4036                .entry(site.caller.file.clone())
4037                .or_insert_with(|| {
4038                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4039                });
4040        }
4041        let enriched = callers
4042            .callers
4043            .iter()
4044            .map(|site| StoreImpactCaller {
4045                site: site.clone(),
4046                signature: site.caller.signature.clone(),
4047                is_entry_point: site.caller.is_entry_point,
4048                call_expression: source_lines_by_file
4049                    .get(&site.caller.file)
4050                    .and_then(|lines| lines.as_ref())
4051                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4052                    .cloned(),
4053                parameters: site
4054                    .caller
4055                    .signature
4056                    .as_deref()
4057                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4058                    .unwrap_or_default(),
4059            })
4060            .collect();
4061        Ok(StoreImpactResult {
4062            target: callers.target,
4063            parameters: target_parameters,
4064            callers: enriched,
4065            depth_limited: callers.depth_limited,
4066            truncated: callers.truncated,
4067        })
4068    }
4069
4070    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4071        self.refresh_read_marker()?;
4072        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4073        self.ensure_ready(&conn)?;
4074        outgoing_calls_for_node(&conn, node)
4075    }
4076
4077    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4078    pub fn outgoing_calls_for_symbols(
4079        &self,
4080        sources: &[(String, String)],
4081    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4082        if sources.is_empty() {
4083            return Ok(HashMap::new());
4084        }
4085        self.refresh_read_marker()?;
4086        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4087        self.ensure_ready(&conn)?;
4088        outgoing_calls_for_symbol_tuples(&conn, sources)
4089    }
4090
4091    /// Return resolved direct self-call refs suppressed from the general edge table.
4092    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4093        self.refresh_read_marker()?;
4094        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4095        self.ensure_ready(&conn)?;
4096        resolved_self_calls_for_node(&conn, node)
4097    }
4098
4099    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4100        self.refresh_read_marker()?;
4101        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4102        self.ensure_ready(&conn)?;
4103        unresolved_calls_for_node(&conn, node)
4104    }
4105
4106    pub fn call_tree(
4107        &self,
4108        file_rel: &Path,
4109        symbol: &str,
4110        max_depth: usize,
4111    ) -> Result<callgraph::CallTreeNode> {
4112        let node = self.node_for(file_rel, symbol)?;
4113        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4114        self.ensure_ready(&conn)?;
4115        let mut visited = HashSet::new();
4116        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4117    }
4118
4119    pub fn trace_to(
4120        &self,
4121        file_rel: &Path,
4122        symbol: &str,
4123        max_depth: usize,
4124    ) -> Result<callgraph::TraceToResult> {
4125        let target = self.node_for(file_rel, symbol)?;
4126        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4127        self.ensure_ready(&conn)?;
4128        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4129
4130        #[derive(Clone)]
4131        struct PathElem {
4132            node: StoreNode,
4133        }
4134
4135        let initial = vec![PathElem {
4136            node: target.clone(),
4137        }];
4138        let mut complete_paths = Vec::new();
4139        if target.is_entry_point {
4140            complete_paths.push(initial.clone());
4141        }
4142
4143        let mut queue = vec![(initial, 0usize)];
4144        let mut max_depth_reached = false;
4145        let mut truncated_paths = 0usize;
4146
4147        while let Some((path, depth)) = queue.pop() {
4148            if depth >= effective_max {
4149                max_depth_reached = true;
4150                continue;
4151            }
4152            let Some(current) = path.last() else {
4153                continue;
4154            };
4155            let callers =
4156                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4157            if callers.is_empty() {
4158                if path.len() > 1 {
4159                    truncated_paths += 1;
4160                }
4161                continue;
4162            }
4163
4164            let mut has_new_path = false;
4165            for site in callers {
4166                if path.iter().any(|elem| {
4167                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4168                }) {
4169                    continue;
4170                }
4171                has_new_path = true;
4172                let mut new_path = path.clone();
4173                new_path.push(PathElem {
4174                    node: site.caller.clone(),
4175                });
4176                if site.caller.is_entry_point {
4177                    complete_paths.push(new_path.clone());
4178                }
4179                queue.push((new_path, depth + 1));
4180            }
4181            if !has_new_path && path.len() > 1 {
4182                truncated_paths += 1;
4183            }
4184        }
4185
4186        let mut paths: Vec<callgraph::TracePath> = complete_paths
4187            .into_iter()
4188            .map(|mut elems| {
4189                elems.reverse();
4190                let hops = elems
4191                    .iter()
4192                    .enumerate()
4193                    .map(|(index, elem)| callgraph::TraceHop {
4194                        symbol: elem.node.symbol.clone(),
4195                        file: elem.node.file.clone(),
4196                        line: elem.node.line,
4197                        signature: elem.node.signature.clone(),
4198                        is_entry_point: index == 0 && elem.node.is_entry_point,
4199                    })
4200                    .collect();
4201                callgraph::TracePath { hops }
4202            })
4203            .collect();
4204        paths.sort_by(|left, right| {
4205            let left_entry = left
4206                .hops
4207                .first()
4208                .map(|hop| hop.symbol.as_str())
4209                .unwrap_or("");
4210            let right_entry = right
4211                .hops
4212                .first()
4213                .map(|hop| hop.symbol.as_str())
4214                .unwrap_or("");
4215            left_entry
4216                .cmp(right_entry)
4217                .then(left.hops.len().cmp(&right.hops.len()))
4218        });
4219        let entry_points_found = paths
4220            .iter()
4221            .filter_map(|path| path.hops.first())
4222            .filter(|hop| hop.is_entry_point)
4223            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4224            .collect::<HashSet<_>>()
4225            .len();
4226
4227        Ok(callgraph::TraceToResult {
4228            target_symbol: target.symbol,
4229            target_file: target.file,
4230            total_paths: paths.len(),
4231            paths,
4232            entry_points_found,
4233            max_depth_reached,
4234            truncated_paths,
4235        })
4236    }
4237
4238    pub fn trace_to_symbol_candidates(
4239        &self,
4240        to_symbol: &str,
4241    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4242        self.refresh_read_marker()?;
4243        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4244        self.ensure_ready(&conn)?;
4245        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4246        for node in nodes_matching_symbol(&conn, to_symbol)? {
4247            candidates_by_file
4248                .entry(node.file)
4249                .and_modify(|line| *line = (*line).min(node.line))
4250                .or_insert(node.line);
4251        }
4252        let mut candidates: Vec<_> = candidates_by_file
4253            .into_iter()
4254            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4255            .collect();
4256        candidates
4257            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4258        Ok(candidates)
4259    }
4260
4261    pub fn trace_to_symbol(
4262        &self,
4263        file_rel: &Path,
4264        symbol: &str,
4265        to_symbol: &str,
4266        to_file: Option<&Path>,
4267        max_depth: usize,
4268    ) -> Result<callgraph::TraceToSymbolResult> {
4269        let origin = self.node_for(file_rel, symbol)?;
4270        let target_file = to_file
4271            .map(|path| normalize_file_path(&self.project_root, path))
4272            .transpose()?
4273            .map(|path| relative_path(&self.project_root, &path));
4274        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4275        self.ensure_ready(&conn)?;
4276        let effective_max = if max_depth == 0 {
4277            10
4278        } else {
4279            max_depth.min(16)
4280        };
4281
4282        let start_hop = trace_to_symbol_hop(&origin);
4283        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4284            return Ok(callgraph::TraceToSymbolResult {
4285                path: Some(vec![start_hop]),
4286                complete: true,
4287                reason: None,
4288            });
4289        }
4290
4291        let mut queue = VecDeque::new();
4292        queue.push_back((origin.clone(), vec![start_hop], 0usize));
4293        let mut visited = HashSet::new();
4294        visited.insert((origin.file.clone(), origin.symbol.clone()));
4295        let mut max_depth_exhausted = false;
4296
4297        while let Some((current, path, depth)) = queue.pop_front() {
4298            let callees = outgoing_calls_for_node(&conn, &current)?
4299                .into_iter()
4300                .filter_map(|site| site.target)
4301                .collect::<Vec<_>>();
4302
4303            if depth >= effective_max {
4304                if callees
4305                    .iter()
4306                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4307                {
4308                    max_depth_exhausted = true;
4309                }
4310                continue;
4311            }
4312
4313            for callee in callees {
4314                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4315                    continue;
4316                }
4317                let mut next_path = path.clone();
4318                next_path.push(trace_to_symbol_hop(&callee));
4319                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4320                    return Ok(callgraph::TraceToSymbolResult {
4321                        path: Some(next_path),
4322                        complete: true,
4323                        reason: None,
4324                    });
4325                }
4326                queue.push_back((callee, next_path, depth + 1));
4327            }
4328        }
4329
4330        if max_depth_exhausted {
4331            Ok(callgraph::TraceToSymbolResult {
4332                path: None,
4333                complete: false,
4334                reason: Some("max_depth_exhausted".to_string()),
4335            })
4336        } else {
4337            Ok(callgraph::TraceToSymbolResult {
4338                path: None,
4339                complete: true,
4340                reason: Some("no_path_found".to_string()),
4341            })
4342        }
4343    }
4344}
4345
4346impl ReadonlyCallGraphStore {
4347    fn from_inner(inner: CallGraphStore) -> Self {
4348        Self { inner }
4349    }
4350
4351    pub fn project_root(&self) -> &Path {
4352        self.inner.project_root()
4353    }
4354
4355    pub fn project_key(&self) -> &str {
4356        self.inner.project_key()
4357    }
4358
4359    pub fn sqlite_path(&self) -> &Path {
4360        self.inner.sqlite_path()
4361    }
4362
4363    pub fn stale_files(&self) -> Result<Vec<String>> {
4364        self.inner.stale_files()
4365    }
4366
4367    pub(crate) fn projection_generation(&self) -> Option<&str> {
4368        self.inner.projection_generation()
4369    }
4370
4371    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4372        self.inner.projection_write_revision()
4373    }
4374
4375    /// Report the open generation handle. SQLite-owned allocations are measured
4376    /// once by the process-wide SQLite allocator counters.
4377    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4378        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4379    }
4380
4381    /// Whether this reader is temporarily serving a legacy harness partition.
4382    pub fn is_legacy_fallback(&self) -> bool {
4383        self.inner.is_legacy_fallback()
4384    }
4385
4386    pub fn is_current(&self) -> bool {
4387        self.inner.is_current()
4388    }
4389
4390    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4391        self.inner.edge_snapshot()
4392    }
4393
4394    pub fn indexed_file_count(&self) -> Result<usize> {
4395        self.inner.indexed_file_count()
4396    }
4397
4398    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4399        self.inner.node_for(file_rel, symbol)
4400    }
4401
4402    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4403        self.inner.nodes_for(file_rel, symbol)
4404    }
4405
4406    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4407        self.inner.nodes_matching(symbol)
4408    }
4409
4410    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4411        self.inner.direct_callers_of(file_rel, symbol)
4412    }
4413
4414    pub fn direct_callers_for_symbols(
4415        &self,
4416        targets: &[(String, String)],
4417    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4418        self.inner.direct_callers_for_symbols(targets)
4419    }
4420
4421    pub fn direct_caller_counts_of(
4422        &self,
4423        targets: &[(String, String)],
4424    ) -> Result<HashMap<(String, String), usize>> {
4425        self.inner.direct_caller_counts_of(targets)
4426    }
4427
4428    pub fn callers_of(
4429        &self,
4430        file_rel: &Path,
4431        symbol: &str,
4432        depth: usize,
4433    ) -> Result<StoreCallersResult> {
4434        self.inner.callers_of(file_rel, symbol, depth)
4435    }
4436
4437    pub fn impact_of(
4438        &self,
4439        file_rel: &Path,
4440        symbol: &str,
4441        depth: usize,
4442    ) -> Result<StoreImpactResult> {
4443        self.inner.impact_of(file_rel, symbol, depth)
4444    }
4445
4446    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4447        self.inner.outgoing_calls_of(node)
4448    }
4449
4450    pub fn outgoing_calls_for_symbols(
4451        &self,
4452        sources: &[(String, String)],
4453    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4454        self.inner.outgoing_calls_for_symbols(sources)
4455    }
4456
4457    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4458        self.inner.resolved_self_calls_of(node)
4459    }
4460
4461    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4462        self.inner.unresolved_calls_of(node)
4463    }
4464
4465    pub fn call_tree(
4466        &self,
4467        file_rel: &Path,
4468        symbol: &str,
4469        depth: usize,
4470    ) -> Result<callgraph::CallTreeNode> {
4471        self.inner.call_tree(file_rel, symbol, depth)
4472    }
4473
4474    pub fn trace_to(
4475        &self,
4476        file_rel: &Path,
4477        symbol: &str,
4478        max_depth: usize,
4479    ) -> Result<callgraph::TraceToResult> {
4480        self.inner.trace_to(file_rel, symbol, max_depth)
4481    }
4482
4483    pub fn trace_to_symbol_candidates(
4484        &self,
4485        to_symbol: &str,
4486    ) -> Result<Vec<TraceToSymbolCandidate>> {
4487        self.inner.trace_to_symbol_candidates(to_symbol)
4488    }
4489
4490    pub fn trace_to_symbol(
4491        &self,
4492        file_rel: &Path,
4493        symbol: &str,
4494        to_symbol: &str,
4495        to_file: Option<&Path>,
4496        max_depth: usize,
4497    ) -> Result<callgraph::TraceToSymbolResult> {
4498        self.inner
4499            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4500    }
4501}
4502
4503impl CallGraphRead for CallGraphStore {
4504    fn project_root(&self) -> &Path {
4505        CallGraphStore::project_root(self)
4506    }
4507    fn project_key(&self) -> &str {
4508        CallGraphStore::project_key(self)
4509    }
4510    fn sqlite_path(&self) -> &Path {
4511        CallGraphStore::sqlite_path(self)
4512    }
4513    fn is_current(&self) -> bool {
4514        CallGraphStore::is_current(self)
4515    }
4516    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4517        CallGraphStore::edge_snapshot(self)
4518    }
4519    fn indexed_file_count(&self) -> Result<usize> {
4520        CallGraphStore::indexed_file_count(self)
4521    }
4522    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4523        CallGraphStore::node_for(self, file_rel, symbol)
4524    }
4525    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4526        CallGraphStore::nodes_for(self, file_rel, symbol)
4527    }
4528    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4529        CallGraphStore::nodes_matching(self, symbol)
4530    }
4531    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4532        CallGraphStore::direct_callers_of(self, file_rel, symbol)
4533    }
4534    fn direct_callers_for_symbols(
4535        &self,
4536        targets: &[(String, String)],
4537    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4538        CallGraphStore::direct_callers_for_symbols(self, targets)
4539    }
4540    fn direct_caller_counts_of(
4541        &self,
4542        targets: &[(String, String)],
4543    ) -> Result<HashMap<(String, String), usize>> {
4544        CallGraphStore::direct_caller_counts_of(self, targets)
4545    }
4546    fn callers_of(
4547        &self,
4548        file_rel: &Path,
4549        symbol: &str,
4550        depth: usize,
4551    ) -> Result<StoreCallersResult> {
4552        CallGraphStore::callers_of(self, file_rel, symbol, depth)
4553    }
4554    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4555        CallGraphStore::impact_of(self, file_rel, symbol, depth)
4556    }
4557    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4558        CallGraphStore::outgoing_calls_of(self, node)
4559    }
4560    fn outgoing_calls_for_symbols(
4561        &self,
4562        sources: &[(String, String)],
4563    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4564        CallGraphStore::outgoing_calls_for_symbols(self, sources)
4565    }
4566    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4567        CallGraphStore::resolved_self_calls_of(self, node)
4568    }
4569    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4570        CallGraphStore::unresolved_calls_of(self, node)
4571    }
4572    fn call_tree(
4573        &self,
4574        file_rel: &Path,
4575        symbol: &str,
4576        depth: usize,
4577    ) -> Result<callgraph::CallTreeNode> {
4578        CallGraphStore::call_tree(self, file_rel, symbol, depth)
4579    }
4580    fn trace_to(
4581        &self,
4582        file_rel: &Path,
4583        symbol: &str,
4584        max_depth: usize,
4585    ) -> Result<callgraph::TraceToResult> {
4586        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4587    }
4588    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4589        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4590    }
4591    fn trace_to_symbol(
4592        &self,
4593        file_rel: &Path,
4594        symbol: &str,
4595        to_symbol: &str,
4596        to_file: Option<&Path>,
4597        max_depth: usize,
4598    ) -> Result<callgraph::TraceToSymbolResult> {
4599        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4600    }
4601}
4602
4603impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4604    fn project_root(&self) -> &Path {
4605        (**self).project_root()
4606    }
4607    fn project_key(&self) -> &str {
4608        (**self).project_key()
4609    }
4610    fn sqlite_path(&self) -> &Path {
4611        (**self).sqlite_path()
4612    }
4613    fn is_current(&self) -> bool {
4614        (**self).is_current()
4615    }
4616    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4617        (**self).edge_snapshot()
4618    }
4619    fn indexed_file_count(&self) -> Result<usize> {
4620        (**self).indexed_file_count()
4621    }
4622    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4623        (**self).node_for(file_rel, symbol)
4624    }
4625    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4626        (**self).nodes_for(file_rel, symbol)
4627    }
4628    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4629        (**self).nodes_matching(symbol)
4630    }
4631    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4632        (**self).direct_callers_of(file_rel, symbol)
4633    }
4634    fn direct_callers_for_symbols(
4635        &self,
4636        targets: &[(String, String)],
4637    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4638        (**self).direct_callers_for_symbols(targets)
4639    }
4640    fn direct_caller_counts_of(
4641        &self,
4642        targets: &[(String, String)],
4643    ) -> Result<HashMap<(String, String), usize>> {
4644        (**self).direct_caller_counts_of(targets)
4645    }
4646    fn callers_of(
4647        &self,
4648        file_rel: &Path,
4649        symbol: &str,
4650        depth: usize,
4651    ) -> Result<StoreCallersResult> {
4652        (**self).callers_of(file_rel, symbol, depth)
4653    }
4654    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4655        (**self).impact_of(file_rel, symbol, depth)
4656    }
4657    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4658        (**self).outgoing_calls_of(node)
4659    }
4660    fn outgoing_calls_for_symbols(
4661        &self,
4662        sources: &[(String, String)],
4663    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4664        (**self).outgoing_calls_for_symbols(sources)
4665    }
4666    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4667        (**self).resolved_self_calls_of(node)
4668    }
4669    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4670        (**self).unresolved_calls_of(node)
4671    }
4672    fn call_tree(
4673        &self,
4674        file_rel: &Path,
4675        symbol: &str,
4676        depth: usize,
4677    ) -> Result<callgraph::CallTreeNode> {
4678        (**self).call_tree(file_rel, symbol, depth)
4679    }
4680    fn trace_to(
4681        &self,
4682        file_rel: &Path,
4683        symbol: &str,
4684        max_depth: usize,
4685    ) -> Result<callgraph::TraceToResult> {
4686        (**self).trace_to(file_rel, symbol, max_depth)
4687    }
4688    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4689        (**self).trace_to_symbol_candidates(to_symbol)
4690    }
4691    fn trace_to_symbol(
4692        &self,
4693        file_rel: &Path,
4694        symbol: &str,
4695        to_symbol: &str,
4696        to_file: Option<&Path>,
4697        max_depth: usize,
4698    ) -> Result<callgraph::TraceToSymbolResult> {
4699        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4700    }
4701}
4702
4703impl CallGraphRead for ReadonlyCallGraphStore {
4704    fn project_root(&self) -> &Path {
4705        self.project_root()
4706    }
4707    fn project_key(&self) -> &str {
4708        self.project_key()
4709    }
4710    fn sqlite_path(&self) -> &Path {
4711        self.sqlite_path()
4712    }
4713    fn is_current(&self) -> bool {
4714        self.is_current()
4715    }
4716    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4717        self.edge_snapshot()
4718    }
4719    fn indexed_file_count(&self) -> Result<usize> {
4720        self.indexed_file_count()
4721    }
4722    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4723        self.node_for(file_rel, symbol)
4724    }
4725    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4726        self.nodes_for(file_rel, symbol)
4727    }
4728    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4729        self.nodes_matching(symbol)
4730    }
4731    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4732        self.direct_callers_of(file_rel, symbol)
4733    }
4734    fn direct_callers_for_symbols(
4735        &self,
4736        targets: &[(String, String)],
4737    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4738        self.direct_callers_for_symbols(targets)
4739    }
4740    fn direct_caller_counts_of(
4741        &self,
4742        targets: &[(String, String)],
4743    ) -> Result<HashMap<(String, String), usize>> {
4744        self.direct_caller_counts_of(targets)
4745    }
4746    fn callers_of(
4747        &self,
4748        file_rel: &Path,
4749        symbol: &str,
4750        depth: usize,
4751    ) -> Result<StoreCallersResult> {
4752        self.callers_of(file_rel, symbol, depth)
4753    }
4754    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4755        self.impact_of(file_rel, symbol, depth)
4756    }
4757    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4758        self.outgoing_calls_of(node)
4759    }
4760    fn outgoing_calls_for_symbols(
4761        &self,
4762        sources: &[(String, String)],
4763    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4764        self.outgoing_calls_for_symbols(sources)
4765    }
4766    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4767        self.resolved_self_calls_of(node)
4768    }
4769    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4770        self.unresolved_calls_of(node)
4771    }
4772    fn call_tree(
4773        &self,
4774        file_rel: &Path,
4775        symbol: &str,
4776        depth: usize,
4777    ) -> Result<callgraph::CallTreeNode> {
4778        self.call_tree(file_rel, symbol, depth)
4779    }
4780    fn trace_to(
4781        &self,
4782        file_rel: &Path,
4783        symbol: &str,
4784        max_depth: usize,
4785    ) -> Result<callgraph::TraceToResult> {
4786        self.trace_to(file_rel, symbol, max_depth)
4787    }
4788    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4789        self.trace_to_symbol_candidates(to_symbol)
4790    }
4791    fn trace_to_symbol(
4792        &self,
4793        file_rel: &Path,
4794        symbol: &str,
4795        to_symbol: &str,
4796        to_file: Option<&Path>,
4797        max_depth: usize,
4798    ) -> Result<callgraph::TraceToSymbolResult> {
4799        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4800    }
4801}
4802
4803fn indexed_file_count(conn: &Connection) -> Result<usize> {
4804    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4805    Ok(count.max(0) as usize)
4806}
4807
4808fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4809    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4810    match candidates.as_slice() {
4811        [candidate] => Ok(candidate.clone()),
4812        [] => Err(AftError::SymbolNotFound {
4813            name: symbol.to_string(),
4814            file: rel_path.to_string(),
4815        }
4816        .into()),
4817        _ => Err(AftError::AmbiguousSymbol {
4818            name: symbol.to_string(),
4819            candidates: candidates
4820                .iter()
4821                .map(|candidate| candidate.symbol.clone())
4822                .collect(),
4823        }
4824        .into()),
4825    }
4826}
4827
4828fn nodes_for_file_matching_symbol(
4829    conn: &Connection,
4830    rel_path: &str,
4831    symbol: &str,
4832) -> Result<Vec<StoreNode>> {
4833    let qualified_query = symbol.contains("::");
4834    let sql = if qualified_query {
4835        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4836                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4837         FROM nodes n JOIN files f ON f.path = n.file_path
4838         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4839         ORDER BY n.scoped_name, n.start_line, n.start_col"
4840    } else {
4841        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4842                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4843         FROM nodes n JOIN files f ON f.path = n.file_path
4844         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4845         ORDER BY n.scoped_name, n.start_line, n.start_col"
4846    };
4847    let mut stmt = conn.prepare(sql)?;
4848    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4849    rows.collect::<std::result::Result<Vec<_>, _>>()
4850        .map_err(Into::into)
4851}
4852
4853fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4854    let qualified_query = symbol.contains("::");
4855    let sql = if qualified_query {
4856        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4857                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4858         FROM nodes n JOIN files f ON f.path = n.file_path
4859         WHERE n.scoped_name = ?1
4860         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4861    } else {
4862        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4863                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4864         FROM nodes n JOIN files f ON f.path = n.file_path
4865         WHERE n.scoped_name = ?1 OR n.name = ?1
4866         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4867    };
4868    let mut stmt = conn.prepare(sql)?;
4869    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4870    rows.collect::<std::result::Result<Vec<_>, _>>()
4871        .map_err(Into::into)
4872}
4873
4874fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4875    store_node_from_row_at(row, 0)
4876}
4877
4878fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4879    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4880    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4881    let lang_label_value: String = row.get(offset + 10)?;
4882    Ok(StoreNode {
4883        node_id: row.get(offset)?,
4884        file: row.get(offset + 1)?,
4885        symbol: row.get(offset + 2)?,
4886        name: row.get(offset + 3)?,
4887        kind: row.get(offset + 4)?,
4888        line: start_line.saturating_add(1),
4889        end_line: end_line.saturating_add(1),
4890        signature: row.get(offset + 7)?,
4891        exported: row.get::<_, i64>(offset + 8)? != 0,
4892        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4893        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4894    })
4895}
4896
4897fn optional_store_node_from_row_at(
4898    row: &rusqlite::Row<'_>,
4899    offset: usize,
4900) -> rusqlite::Result<Option<StoreNode>> {
4901    if row.get::<_, Option<String>>(offset)?.is_some() {
4902        store_node_from_row_at(row, offset).map(Some)
4903    } else {
4904        Ok(None)
4905    }
4906}
4907
4908#[allow(clippy::too_many_arguments)]
4909fn collect_callers_recursive(
4910    conn: &Connection,
4911    file: &str,
4912    symbol: &str,
4913    max_depth: usize,
4914    current_depth: usize,
4915    visited: &mut HashSet<(String, String)>,
4916    result: &mut Vec<StoreCallSite>,
4917    depth_limited: &mut bool,
4918    truncated: &mut usize,
4919) -> Result<()> {
4920    if current_depth >= max_depth {
4921        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4922        if omitted > 0 {
4923            *depth_limited = true;
4924            *truncated += omitted;
4925        }
4926        return Ok(());
4927    }
4928
4929    if !visited.insert((file.to_string(), symbol.to_string())) {
4930        return Ok(());
4931    }
4932
4933    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4934    for site in sites {
4935        result.push(site.clone());
4936        if current_depth + 1 < max_depth {
4937            collect_callers_recursive(
4938                conn,
4939                &site.caller.file,
4940                &site.caller.symbol,
4941                max_depth,
4942                current_depth + 1,
4943                visited,
4944                result,
4945                depth_limited,
4946                truncated,
4947            )?;
4948        } else {
4949            let omitted =
4950                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4951            if omitted > 0 {
4952                *depth_limited = true;
4953                *truncated += omitted;
4954            }
4955        }
4956    }
4957    Ok(())
4958}
4959
4960// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4961const DIRECT_CALLER_BATCH_SIZE: usize = 499;
4962
4963fn direct_caller_counts_for_tuples(
4964    conn: &Connection,
4965    targets: &[(String, String)],
4966) -> Result<HashMap<(String, String), usize>> {
4967    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4968    let mut counts = unique_targets
4969        .iter()
4970        .cloned()
4971        .map(|target| (target, 0usize))
4972        .collect::<HashMap<_, _>>();
4973
4974    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4975    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
4976        let requested_values = (0..chunk.len())
4977            .map(|_| "(?, ?)")
4978            .collect::<Vec<_>>()
4979            .join(", ");
4980        let sql = format!(
4981            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4982             deduped AS (
4983                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4984                 FROM requested requested
4985                 JOIN edges e
4986                   ON e.target_file = requested.target_file
4987                  AND e.target_symbol = requested.target_symbol
4988                  AND e.kind = 'call'
4989                 JOIN refs r ON r.ref_id = e.ref_id
4990                 JOIN nodes src ON src.id = e.source_node
4991                 JOIN files src_file ON src_file.path = src.file_path
4992                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4993             )
4994             SELECT target_file, target_symbol, COUNT(*)
4995             FROM deduped
4996             GROUP BY target_file, target_symbol"
4997        );
4998        let bindings = chunk
4999            .iter()
5000            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5001        let mut stmt = conn.prepare(&sql)?;
5002        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5003            Ok((
5004                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5005                row.get::<_, i64>(2)?,
5006            ))
5007        })?;
5008        for row in rows {
5009            let (target, count) = row?;
5010            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5011        }
5012    }
5013
5014    Ok(counts)
5015}
5016
5017fn direct_caller_count_for_tuple(
5018    conn: &Connection,
5019    target_file: &str,
5020    target_symbol: &str,
5021) -> Result<usize> {
5022    let count: i64 = conn.query_row(
5023        "SELECT COUNT(*)
5024         FROM edges e
5025         JOIN refs r ON r.ref_id = e.ref_id
5026         JOIN nodes src ON src.id = e.source_node
5027         JOIN files src_file ON src_file.path = src.file_path
5028         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5029        params![target_file, target_symbol],
5030        |row| row.get(0),
5031    )?;
5032    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5033}
5034
5035fn direct_callers_for_tuple(
5036    conn: &Connection,
5037    target_file: &str,
5038    target_symbol: &str,
5039) -> Result<Vec<StoreCallSite>> {
5040    let mut stmt = conn.prepare(
5041        "SELECT e.target_file, e.target_symbol, e.line,
5042                r.byte_start, r.byte_end, r.status, e.provenance,
5043                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5044                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5045                src_file.lang,
5046                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5047                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5048                tgt_file.lang
5049         FROM edges e
5050         JOIN refs r ON r.ref_id = e.ref_id
5051         JOIN nodes src ON src.id = e.source_node
5052         JOIN files src_file ON src_file.path = src.file_path
5053         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5054             ON tgt.id = e.target_node
5055         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5056         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5057    )?;
5058    let rows = stmt.query_map(
5059        params![target_file, target_symbol],
5060        direct_call_site_from_row,
5061    )?;
5062    rows.collect::<std::result::Result<Vec<_>, _>>()
5063        .map_err(Into::into)
5064}
5065
5066fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5067    let caller = store_node_from_row_at(row, 7)?;
5068    let target = optional_store_node_from_row_at(row, 18)?;
5069    Ok(StoreCallSite {
5070        caller,
5071        target_file: row.get(0)?,
5072        target_symbol: row.get(1)?,
5073        target,
5074        line: row.get::<_, i64>(2)?.max(0) as u32,
5075        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5076        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5077        resolved: row.get::<_, String>(5)? == "resolved",
5078        provenance: row.get(6)?,
5079    })
5080}
5081
5082fn direct_callers_for_tuples(
5083    conn: &Connection,
5084    targets: &[(String, String)],
5085) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5086    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5087    let mut callers_by_target = unique_targets
5088        .iter()
5089        .cloned()
5090        .map(|target| (target, Vec::new()))
5091        .collect::<HashMap<_, _>>();
5092    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5093
5094    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5095        let requested_values = (0..chunk.len())
5096            .map(|_| "(?, ?)")
5097            .collect::<Vec<_>>()
5098            .join(", ");
5099        let sql = format!(
5100            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5101             SELECT e.target_file, e.target_symbol, e.line,
5102                    r.byte_start, r.byte_end, r.status, e.provenance,
5103                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5104                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5105                    src_file.lang,
5106                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5107                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5108                    tgt_file.lang
5109             FROM requested requested
5110             JOIN edges e
5111               ON e.target_file = requested.target_file
5112              AND e.target_symbol = requested.target_symbol
5113              AND e.kind = 'call'
5114             JOIN refs r ON r.ref_id = e.ref_id
5115             JOIN nodes src ON src.id = e.source_node
5116             JOIN files src_file ON src_file.path = src.file_path
5117             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5118                 ON tgt.id = e.target_node
5119             ORDER BY e.target_file, e.target_symbol, e.source_node,
5120                      r.byte_start, r.line, r.ref_id"
5121        );
5122        let bindings = chunk
5123            .iter()
5124            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5125        let mut stmt = conn.prepare(&sql)?;
5126        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5127            let call = direct_call_site_from_row(row)?;
5128            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5129            Ok((target_key, call))
5130        })?;
5131        for row in rows {
5132            let (target, call) = row?;
5133            callers_by_target
5134                .get_mut(&target)
5135                .expect("batched caller row belongs to a requested target")
5136                .push(call);
5137        }
5138    }
5139
5140    Ok(callers_by_target)
5141}
5142
5143// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5144const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5145// Outgoing-edge batches bind one source node per parameter.
5146const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5147
5148fn outgoing_calls_for_symbol_tuples(
5149    conn: &Connection,
5150    sources: &[(String, String)],
5151) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5152    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5153    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5154    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5155    let source_nodes = unique_sources
5156        .iter()
5157        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5158        .cloned()
5159        .collect::<Vec<_>>();
5160    let source_nodes_by_id = source_nodes
5161        .iter()
5162        .cloned()
5163        .map(|node| (node.node_id.clone(), node))
5164        .collect::<HashMap<_, _>>();
5165    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5166
5167    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5168        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5169        let sql = format!(
5170            "SELECT e.source_node,
5171                    e.target_file, e.target_symbol, e.line,
5172                    r.byte_start, r.byte_end, r.status, e.provenance,
5173                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5174                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5175                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5176                    tgt_file.lang
5177             FROM edges e
5178             JOIN refs r ON r.ref_id = e.ref_id
5179             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5180             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5181             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5182             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5183        );
5184        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5185        let mut stmt = conn.prepare(&sql)?;
5186        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5187            let source_node_id = row.get::<_, String>(0)?;
5188            let caller = source_nodes_by_id
5189                .get(&source_node_id)
5190                .expect("batched outgoing row belongs to a requested source node")
5191                .clone();
5192            let target = optional_store_node_from_row_at(row, 8)?;
5193            Ok((
5194                source_node_id,
5195                StoreCallSite {
5196                    caller,
5197                    target_file: row.get(1)?,
5198                    target_symbol: row.get(2)?,
5199                    target,
5200                    line: row.get::<_, i64>(3)?.max(0) as u32,
5201                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5202                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5203                    resolved: row.get::<_, String>(6)? == "resolved",
5204                    provenance: row.get(7)?,
5205                },
5206            ))
5207        })?;
5208        for row in rows {
5209            let (source_node_id, call) = row?;
5210            calls_by_node.entry(source_node_id).or_default().push(call);
5211        }
5212    }
5213
5214    let mut calls_by_source = HashMap::new();
5215    for source in &unique_sources {
5216        let mut calls = Vec::new();
5217        if let Some(nodes) = source_nodes_by_symbol.get(source) {
5218            for node in nodes {
5219                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5220                    calls.extend(node_calls);
5221                }
5222            }
5223        }
5224        calls_by_source.insert(source.clone(), calls);
5225    }
5226
5227    // Resolve each logical target once for the whole frontier. Keeping this separate
5228    // preserves positional-symbol representatives without a correlated lookup per edge.
5229    let target_tuples = calls_by_source
5230        .values()
5231        .flatten()
5232        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5233        .collect::<Vec<_>>();
5234    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5235    for calls in calls_by_source.values_mut() {
5236        for call in calls {
5237            if let Some(target) = target_nodes
5238                .get(&(call.target_file.clone(), call.target_symbol.clone()))
5239                .and_then(|nodes| nodes.first())
5240            {
5241                call.target = Some(target.clone());
5242            }
5243        }
5244    }
5245
5246    Ok(calls_by_source)
5247}
5248
5249fn nodes_for_symbol_tuples(
5250    conn: &Connection,
5251    symbols: &[(String, String)],
5252) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5253    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5254    let mut nodes_by_symbol = unique_symbols
5255        .iter()
5256        .cloned()
5257        .map(|symbol| (symbol, Vec::new()))
5258        .collect::<HashMap<_, _>>();
5259    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5260
5261    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5262        let requested_values = (0..chunk.len())
5263            .map(|_| "(?, ?)")
5264            .collect::<Vec<_>>()
5265            .join(", ");
5266        let sql = format!(
5267            "WITH requested(file, symbol) AS (VALUES {requested_values})
5268             SELECT requested.file, requested.symbol,
5269                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
5270                    node.start_line, node.end_line, node.signature, node.exported,
5271                    node.is_callgraph_entry_point, node_file.lang
5272             FROM requested
5273             JOIN nodes node INDEXED BY idx_nodes_file
5274               ON node.file_path = requested.file
5275              AND node.scoped_name = requested.symbol
5276             JOIN files node_file ON node_file.path = node.file_path
5277             ORDER BY requested.file, requested.symbol,
5278                      node.scoped_name, node.start_line, node.end_line,
5279                      node.start_col, node.range_ordinal"
5280        );
5281        let bindings = chunk
5282            .iter()
5283            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5284        let mut stmt = conn.prepare(&sql)?;
5285        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5286            Ok((
5287                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5288                store_node_from_row_at(row, 2)?,
5289            ))
5290        })?;
5291        for row in rows {
5292            let (symbol, node) = row?;
5293            nodes_by_symbol.entry(symbol).or_default().push(node);
5294        }
5295    }
5296
5297    Ok(nodes_by_symbol)
5298}
5299
5300fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5301    let mut stmt = conn.prepare(
5302        "SELECT e.target_file, e.target_symbol, e.line,
5303                r.byte_start, r.byte_end, r.status, e.provenance,
5304                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5305                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5306                tgt_file.lang
5307         FROM edges e
5308         JOIN refs r ON r.ref_id = e.ref_id
5309         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5310             ON tgt.id = e.target_node
5311         WHERE e.kind = 'call' AND e.source_node = ?1
5312         ORDER BY r.byte_start, r.line, r.ref_id",
5313    )?;
5314    let rows = stmt.query_map(params![node.node_id], |row| {
5315        let target = optional_store_node_from_row_at(row, 7)?;
5316        Ok(StoreCallSite {
5317            caller: node.clone(),
5318            target_file: row.get(0)?,
5319            target_symbol: row.get(1)?,
5320            target,
5321            line: row.get::<_, i64>(2)?.max(0) as u32,
5322            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5323            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5324            resolved: row.get::<_, String>(5)? == "resolved",
5325            provenance: row.get(6)?,
5326        })
5327    })?;
5328    rows.collect::<std::result::Result<Vec<_>, _>>()
5329        .map_err(Into::into)
5330}
5331
5332fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5333    let mut stmt = conn.prepare(
5334        "SELECT r.target_file, r.target_symbol, r.line,
5335                r.byte_start, r.byte_end, r.status, r.provenance,
5336                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5337                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5338                tgt_file.lang
5339         FROM refs r
5340         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5341             ON tgt.id = r.target_node
5342         WHERE r.caller_node = ?1
5343           AND r.kind = 'call'
5344           AND r.status <> 'unresolved'
5345           AND r.target_file = ?2
5346           AND r.target_symbol = ?3
5347           AND r.provenance = ?4
5348           AND NOT EXISTS (
5349               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5350           )
5351         ORDER BY r.byte_start, r.line, r.ref_id",
5352    )?;
5353    let rows = stmt.query_map(
5354        params![
5355            &node.node_id,
5356            &node.file,
5357            &node.symbol,
5358            PROVENANCE_TREESITTER
5359        ],
5360        |row| {
5361            let target = optional_store_node_from_row_at(row, 7)?;
5362            Ok(StoreCallSite {
5363                caller: node.clone(),
5364                target_file: row.get(0)?,
5365                target_symbol: row.get(1)?,
5366                target,
5367                line: row.get::<_, i64>(2)?.max(0) as u32,
5368                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5369                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5370                resolved: row.get::<_, String>(5)? == "resolved",
5371                provenance: row.get(6)?,
5372            })
5373        },
5374    )?;
5375    rows.collect::<std::result::Result<Vec<_>, _>>()
5376        .map_err(Into::into)
5377}
5378
5379fn unresolved_calls_for_node(
5380    conn: &Connection,
5381    node: &StoreNode,
5382) -> Result<Vec<StoreUnresolvedCall>> {
5383    let mut stmt = conn.prepare(
5384        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5385         FROM refs
5386         WHERE caller_node = ?1
5387           AND kind = 'call'
5388           AND status = 'unresolved'
5389           AND NOT EXISTS (
5390               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5391           )
5392         ORDER BY byte_start, line, ref_id",
5393    )?;
5394    let rows = stmt.query_map(params![node.node_id], |row| {
5395        Ok(StoreUnresolvedCall {
5396            caller: node.clone(),
5397            symbol: row.get(0)?,
5398            full_ref: row.get(1)?,
5399            line: row.get::<_, i64>(2)?.max(0) as u32,
5400            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5401            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5402        })
5403    })?;
5404    rows.collect::<std::result::Result<Vec<_>, _>>()
5405        .map_err(Into::into)
5406}
5407
5408fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5409    let mut calls = Vec::new();
5410    calls.extend(
5411        outgoing_calls_for_node(conn, node)?
5412            .into_iter()
5413            .map(StoreForwardCall::Resolved),
5414    );
5415    calls.extend(
5416        unresolved_calls_for_node(conn, node)?
5417            .into_iter()
5418            .map(StoreForwardCall::Unresolved),
5419    );
5420    calls.sort_by(|left, right| {
5421        left.byte_start()
5422            .cmp(&right.byte_start())
5423            .then(left.line().cmp(&right.line()))
5424    });
5425    Ok(calls)
5426}
5427
5428fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5429    let resolved_count: i64 = conn.query_row(
5430        "SELECT COUNT(*)
5431         FROM edges e
5432         JOIN refs r ON r.ref_id = e.ref_id
5433         WHERE e.kind = 'call' AND e.source_node = ?1",
5434        params![&node.node_id],
5435        |row| row.get(0),
5436    )?;
5437    let unresolved_count: i64 = conn.query_row(
5438        "SELECT COUNT(*)
5439         FROM refs
5440         WHERE caller_node = ?1
5441           AND kind = 'call'
5442           AND status = 'unresolved'
5443           AND NOT EXISTS (
5444               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5445           )",
5446        params![&node.node_id],
5447        |row| row.get(0),
5448    )?;
5449    let total = resolved_count.saturating_add(unresolved_count);
5450    Ok(usize::try_from(total).unwrap_or(usize::MAX))
5451}
5452
5453fn call_tree_inner(
5454    conn: &Connection,
5455    node: &StoreNode,
5456    max_depth: usize,
5457    current_depth: usize,
5458    visited: &mut HashSet<(String, String)>,
5459) -> Result<callgraph::CallTreeNode> {
5460    let visit_key = (node.file.clone(), node.symbol.clone());
5461    if visited.contains(&visit_key) {
5462        return Ok(callgraph::CallTreeNode {
5463            name: node.symbol.clone(),
5464            file: node.file.clone(),
5465            line: node.line,
5466            signature: node.signature.clone(),
5467            resolved: true,
5468            children: Vec::new(),
5469            depth_limited: false,
5470            truncated: 0,
5471        });
5472    }
5473    visited.insert(visit_key.clone());
5474
5475    let mut children = Vec::new();
5476    let mut depth_limited = false;
5477    let mut truncated = 0usize;
5478
5479    if current_depth < max_depth {
5480        let calls = forward_calls_for_node(conn, node)?;
5481        for call in calls {
5482            match call {
5483                StoreForwardCall::Resolved(site) => {
5484                    if let Some(target) = site.target {
5485                        let child =
5486                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5487                        depth_limited |= child.depth_limited;
5488                        truncated += child.truncated;
5489                        children.push(child);
5490                    } else {
5491                        children.push(callgraph::CallTreeNode {
5492                            name: site.target_symbol,
5493                            file: site.target_file,
5494                            line: site.line,
5495                            signature: None,
5496                            resolved: false,
5497                            children: Vec::new(),
5498                            depth_limited: false,
5499                            truncated: 0,
5500                        });
5501                    }
5502                }
5503                StoreForwardCall::Unresolved(call) => {
5504                    children.push(callgraph::CallTreeNode {
5505                        name: call.symbol,
5506                        file: call.caller.file,
5507                        line: call.line,
5508                        signature: None,
5509                        resolved: false,
5510                        children: Vec::new(),
5511                        depth_limited: false,
5512                        truncated: 0,
5513                    });
5514                }
5515            }
5516        }
5517    } else {
5518        truncated = forward_call_count_for_node(conn, node)?;
5519        depth_limited = truncated > 0;
5520    }
5521
5522    visited.remove(&visit_key);
5523    Ok(callgraph::CallTreeNode {
5524        name: node.symbol.clone(),
5525        file: node.file.clone(),
5526        line: node.line,
5527        signature: node.signature.clone(),
5528        resolved: true,
5529        children,
5530        depth_limited,
5531        truncated,
5532    })
5533}
5534
5535fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5536    callgraph::TraceToSymbolHop {
5537        symbol: node.symbol.clone(),
5538        file: node.file.clone(),
5539        line: node.line,
5540    }
5541}
5542
5543fn trace_to_symbol_matches_target(
5544    node: &StoreNode,
5545    to_symbol: &str,
5546    to_file: Option<&str>,
5547) -> bool {
5548    if !symbol_query_matches(&node.symbol, to_symbol) {
5549        return false;
5550    }
5551    match to_file {
5552        Some(file) => node.file == file,
5553        None => true,
5554    }
5555}
5556
5557fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5558    symbol == query || unqualified_name(symbol) == query
5559}
5560
5561fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5562    let source = std::fs::read_to_string(path).ok()?;
5563    Some(source.lines().map(|line| line.trim().to_string()).collect())
5564}
5565
5566#[doc(hidden)]
5567pub fn live_callgraph_edge_snapshot(
5568    project_root: &Path,
5569    files: &[PathBuf],
5570) -> Result<BTreeSet<StoredEdge>> {
5571    let files = normalize_file_list(project_root, files)?;
5572    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5573    let mut file_data = Vec::new();
5574    for file in &files {
5575        let canon = canonicalize_path(file);
5576        let data = graph.build_file(&canon)?.clone();
5577        file_data.push((canon, data));
5578    }
5579
5580    let mut edges = BTreeSet::new();
5581    for (caller_file, data) in &file_data {
5582        for (caller_symbol, call_sites) in &data.calls_by_symbol {
5583            for call_site in call_sites {
5584                let resolution = graph.resolve_cross_file_edge(
5585                    &call_site.full_callee,
5586                    &call_site.callee_name,
5587                    caller_file,
5588                    &data.import_block,
5589                );
5590                let (target_file, target_symbol) = match resolution {
5591                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5592                    EdgeResolution::Unresolved { callee_name } => {
5593                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5594                            continue;
5595                        }
5596                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5597                            data,
5598                            caller_file,
5599                            &callee_name,
5600                        ) else {
5601                            continue;
5602                        };
5603                        (caller_file.clone(), target_symbol)
5604                    }
5605                };
5606                if target_file == *caller_file && target_symbol == *caller_symbol {
5607                    continue;
5608                }
5609                edges.insert(StoredEdge {
5610                    source_file: relative_path(project_root, caller_file),
5611                    source_symbol: caller_symbol.clone(),
5612                    target_file: relative_path(project_root, &target_file),
5613                    target_symbol,
5614                    kind: "call".to_string(),
5615                    line: call_site.line,
5616                });
5617            }
5618        }
5619    }
5620    Ok(edges)
5621}
5622
5623fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5624{
5625    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5626}
5627
5628fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5629    RebuildCooldownKey {
5630        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5631            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5632        project_key: project_key.to_string(),
5633    }
5634}
5635
5636fn rebuild_cooldown_denial(
5637    callgraph_dir: &Path,
5638    project_key: &str,
5639    project_root: &Path,
5640    now: Instant,
5641) -> Option<(PathBuf, Duration)> {
5642    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5643    let records = rebuild_cooldown_records()
5644        .lock()
5645        .unwrap_or_else(std::sync::PoisonError::into_inner);
5646    let record = records.get(&key)?;
5647    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5648        return None;
5649    }
5650    let elapsed = now.saturating_duration_since(record.published_at);
5651    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5652}
5653
5654fn record_successful_rebuild(
5655    callgraph_dir: &Path,
5656    project_key: &str,
5657    project_root: &Path,
5658    published_at: Instant,
5659) {
5660    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5661    let mut records = rebuild_cooldown_records()
5662        .lock()
5663        .unwrap_or_else(std::sync::PoisonError::into_inner);
5664    if records.len() >= 4_096 && !records.contains_key(&key) {
5665        if let Some(evict) = records.keys().next().cloned() {
5666            records.remove(&evict);
5667        }
5668    }
5669    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5670        previous.cross_root_cooldown_armed || previous.project_root != project_root
5671    });
5672    records.insert(
5673        key,
5674        RebuildCooldownRecord {
5675            project_root: project_root.to_path_buf(),
5676            published_at,
5677            cross_root_cooldown_armed,
5678        },
5679    );
5680}
5681
5682fn acquire_writer_lease(
5683    callgraph_dir: &Path,
5684    project_key: &str,
5685    project_root: &Path,
5686) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5687    crate::root_cache::WriterLease::acquire_shared(
5688        crate::root_cache::RootCacheDomain::Callgraph,
5689        callgraph_dir,
5690        project_key,
5691        project_root,
5692    )
5693    .map_err(CallGraphStoreError::from)
5694}
5695
5696fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5697    if lease.verify()? {
5698        Ok(())
5699    } else {
5700        Err(CallGraphStoreError::Unavailable(format!(
5701            "callgraph writer lease for key {} lost epoch {}; aborting write",
5702            lease.key(),
5703            lease.epoch()
5704        )))
5705    }
5706}
5707
5708fn legacy_migration_completion_line(
5709    project_key: &str,
5710    method: &str,
5711    legacy_bytes: u64,
5712    migrated_bytes: u64,
5713) -> String {
5714    format!(
5715        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5716    )
5717}
5718
5719fn log_legacy_migration_completion(
5720    project_key: &str,
5721    method: &str,
5722    legacy_bytes: u64,
5723    migrated_bytes: u64,
5724) {
5725    crate::slog_info!(
5726        "{}",
5727        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5728    );
5729}
5730
5731fn try_legacy_migration_or_fallback(
5732    callgraph_dir: &Path,
5733    project_root: &Path,
5734    project_key: &str,
5735    writer_lease: Arc<crate::root_cache::WriterLease>,
5736) -> Result<Option<CallGraphStore>> {
5737    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5738    if partitions.is_empty() {
5739        return Ok(None);
5740    }
5741
5742    for partition in &partitions {
5743        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5744            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5745                return open_legacy_fallback_store(
5746                    callgraph_dir,
5747                    project_root,
5748                    project_key,
5749                    &partitions,
5750                );
5751            }
5752            match publish_generation_copy_migration(
5753                callgraph_dir,
5754                project_key,
5755                &source,
5756                Arc::clone(&writer_lease),
5757            ) {
5758                Ok(published) => {
5759                    log_legacy_migration_completion(
5760                        project_key,
5761                        "generation_copy",
5762                        source.source_bytes,
5763                        published.migrated_bytes,
5764                    );
5765                    return CallGraphStore::open_generation(
5766                        callgraph_dir,
5767                        project_root.to_path_buf(),
5768                        project_key.to_string(),
5769                        published.generation,
5770                        writer_lease,
5771                    )
5772                    .map(Some);
5773                }
5774                Err(error) => {
5775                    crate::slog_warn!(
5776                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5777                        source.sqlite_path.display(),
5778                        error
5779                    );
5780                    return open_legacy_fallback_store(
5781                        callgraph_dir,
5782                        project_root,
5783                        project_key,
5784                        &partitions,
5785                    );
5786                }
5787            }
5788        }
5789
5790        if let Some(source) = current_legacy_generation(partition)? {
5791            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5792                return open_legacy_fallback_store(
5793                    callgraph_dir,
5794                    project_root,
5795                    project_key,
5796                    &partitions,
5797                );
5798            }
5799            match publish_backup_migration(
5800                callgraph_dir,
5801                project_key,
5802                &source,
5803                Arc::clone(&writer_lease),
5804            ) {
5805                Ok(published) => {
5806                    log_legacy_migration_completion(
5807                        project_key,
5808                        "sqlite_backup",
5809                        source.source_bytes,
5810                        published.migrated_bytes,
5811                    );
5812                    return CallGraphStore::open_generation(
5813                        callgraph_dir,
5814                        project_root.to_path_buf(),
5815                        project_key.to_string(),
5816                        published.generation,
5817                        writer_lease,
5818                    )
5819                    .map(Some);
5820                }
5821                Err(error) => {
5822                    crate::slog_warn!(
5823                        "root-keyed callgraph backup migration failed from {}: {}",
5824                        source.sqlite_path.display(),
5825                        error
5826                    );
5827                    return open_legacy_fallback_store(
5828                        callgraph_dir,
5829                        project_root,
5830                        project_key,
5831                        &partitions,
5832                    );
5833                }
5834            }
5835        }
5836    }
5837
5838    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5839}
5840
5841fn open_legacy_fallback_store(
5842    callgraph_dir: &Path,
5843    project_root: &Path,
5844    project_key: &str,
5845    partitions: &[LegacyCallgraphPartition],
5846) -> Result<Option<CallGraphStore>> {
5847    let Some(target) = first_ready_legacy_target(partitions)? else {
5848        return Ok(None);
5849    };
5850    crate::slog_warn!(
5851        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5852        target.partition.harness,
5853        target.sqlite_path.display()
5854    );
5855    let conn = open_readonly_connection(&target.sqlite_path)?;
5856    if !database_ready(&conn).unwrap_or(false) {
5857        return Ok(None);
5858    }
5859    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5860    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5861    Ok(Some(CallGraphStore::from_connection(
5862        project_root.to_path_buf(),
5863        project_key.to_string(),
5864        target.sqlite_path,
5865        callgraph_dir.to_path_buf(),
5866        true,
5867        target.generation,
5868        None,
5869        Some(read_marker),
5870        conn,
5871    )))
5872}
5873
5874fn migration_disk_floor_allows(
5875    source: &LegacyCallgraphTarget,
5876    callgraph_dir: &Path,
5877) -> Result<bool> {
5878    let available = migration_available_disk(callgraph_dir)?;
5879    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5880        source.source_bytes,
5881        available,
5882    );
5883    if decision.should_skip_copy() {
5884        crate::slog_warn!(
5885            "{}",
5886            decision.warning_message(&source.sqlite_path, callgraph_dir)
5887        );
5888        return Ok(false);
5889    }
5890    Ok(true)
5891}
5892
5893fn migration_available_disk(path: &Path) -> Result<u64> {
5894    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5895        return Ok(bytes);
5896    }
5897    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5898}
5899
5900fn legacy_callgraph_partitions(
5901    callgraph_dir: &Path,
5902    project_key: &str,
5903) -> Result<Vec<LegacyCallgraphPartition>> {
5904    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5905        return Ok(Vec::new());
5906    };
5907    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5908    let mut partitions = inventory
5909        .into_iter()
5910        .filter(|entry| {
5911            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5912                && entry.key == project_key
5913        })
5914        .map(|entry| {
5915            let dir = if entry.path.is_dir() {
5916                entry.path.clone()
5917            } else {
5918                entry
5919                    .path
5920                    .parent()
5921                    .map(Path::to_path_buf)
5922                    .unwrap_or_else(|| entry.path.clone())
5923            };
5924            LegacyCallgraphPartition {
5925                harness: entry.harness,
5926                dir,
5927                key: entry.key,
5928                bytes: entry.bytes,
5929                freshness: entry.callgraph_pointer_mtime,
5930            }
5931        })
5932        .collect::<Vec<_>>();
5933    partitions.sort_by(|left, right| {
5934        right
5935            .freshness
5936            .cmp(&left.freshness)
5937            .then_with(|| right.bytes.cmp(&left.bytes))
5938            .then_with(|| left.harness.cmp(&right.harness))
5939    });
5940    Ok(partitions)
5941}
5942
5943fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5944    let domain_dir = callgraph_dir.parent()?;
5945    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5946        return None;
5947    }
5948    domain_dir.parent().map(Path::to_path_buf)
5949}
5950
5951pub(crate) fn all_legacy_partitions_migrated_for_keys(
5952    callgraph_dir: &Path,
5953    configured_keys: &BTreeSet<String>,
5954) -> Result<bool> {
5955    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5956        return Ok(false);
5957    };
5958    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5959        .into_iter()
5960        .filter(|entry| {
5961            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5962                && configured_keys.contains(&entry.key)
5963        })
5964        .map(|entry| entry.key)
5965        .collect::<BTreeSet<_>>();
5966    if legacy_keys.is_empty() {
5967        return Ok(false);
5968    }
5969
5970    for key in legacy_keys {
5971        let migrated_dir = storage_root.join("callgraph").join(&key);
5972        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5973            return Ok(false);
5974        };
5975        if !migration_generation_requires_manifest(&generation)
5976            || !migration_manifest_valid(&migrated_dir, &generation)
5977        {
5978            return Ok(false);
5979        }
5980    }
5981    Ok(true)
5982}
5983
5984fn newest_superseded_legacy_generation(
5985    partition: &LegacyCallgraphPartition,
5986) -> Result<Option<LegacyCallgraphTarget>> {
5987    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5988        return Ok(None);
5989    };
5990    let prefix = format!("{}.g", partition.key);
5991    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5992        return Ok(None);
5993    };
5994    let mut candidates = Vec::new();
5995    for entry in entries.flatten() {
5996        let name = entry.file_name().to_string_lossy().to_string();
5997        if name == current
5998            || name.contains(".tmp.")
5999            || !name.starts_with(&prefix)
6000            || !name.ends_with(".sqlite")
6001        {
6002            continue;
6003        }
6004        let path = entry.path();
6005        if !db_path_ready(&path) {
6006            continue;
6007        }
6008        let modified = entry
6009            .metadata()
6010            .and_then(|metadata| metadata.modified())
6011            .unwrap_or(SystemTime::UNIX_EPOCH);
6012        candidates.push((modified, path, name));
6013    }
6014    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6015    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6016        return Ok(None);
6017    };
6018    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6019    Ok(Some(LegacyCallgraphTarget {
6020        partition: partition.clone(),
6021        sqlite_path,
6022        generation: Some(generation),
6023        source_bytes,
6024        source_blake3: String::new(),
6025    }))
6026}
6027
6028fn current_legacy_generation(
6029    partition: &LegacyCallgraphPartition,
6030) -> Result<Option<LegacyCallgraphTarget>> {
6031    let Some(target) = ready_legacy_target(partition)? else {
6032        return Ok(None);
6033    };
6034    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6035    if has_superseded {
6036        return Ok(None);
6037    }
6038    Ok(Some(target))
6039}
6040
6041fn freshest_legacy_fallback_target(
6042    callgraph_dir: &Path,
6043    project_key: &str,
6044) -> Result<Option<LegacyCallgraphTarget>> {
6045    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6046    first_ready_legacy_target(&partitions)
6047}
6048
6049fn first_ready_legacy_target(
6050    partitions: &[LegacyCallgraphPartition],
6051) -> Result<Option<LegacyCallgraphTarget>> {
6052    for partition in partitions {
6053        if let Some(target) = ready_legacy_target(partition)? {
6054            return Ok(Some(target));
6055        }
6056    }
6057    Ok(None)
6058}
6059
6060fn ready_legacy_target(
6061    partition: &LegacyCallgraphPartition,
6062) -> Result<Option<LegacyCallgraphTarget>> {
6063    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6064        let sqlite_path = partition.dir.join(&generation);
6065        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6066            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6067            return Ok(Some(LegacyCallgraphTarget {
6068                partition: partition.clone(),
6069                sqlite_path,
6070                generation: Some(generation),
6071                source_bytes,
6072                source_blake3: String::new(),
6073            }));
6074        }
6075    }
6076
6077    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6078    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6079        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6080        return Ok(Some(LegacyCallgraphTarget {
6081            partition: partition.clone(),
6082            sqlite_path,
6083            generation: None,
6084            source_bytes,
6085            source_blake3: String::new(),
6086        }));
6087    }
6088    Ok(None)
6089}
6090
6091fn publish_generation_copy_migration(
6092    callgraph_dir: &Path,
6093    project_key: &str,
6094    source: &LegacyCallgraphTarget,
6095    writer_lease: Arc<crate::root_cache::WriterLease>,
6096) -> Result<PublishedLegacyMigration> {
6097    let generation = migration_generation_file_name(project_key, "copy");
6098    let temp_path = migration_temp_path(callgraph_dir, &generation);
6099    remove_sqlite_file_set(&temp_path);
6100    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6101    fail_after_temp_copy_for_test()?;
6102
6103    let mut source = source.clone();
6104    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6105    source.source_blake3 = fingerprint.blake3;
6106    let generation = publish_migrated_generation(
6107        callgraph_dir,
6108        project_key,
6109        &generation,
6110        &temp_path,
6111        &source,
6112        fingerprint.bytes,
6113        writer_lease,
6114        "generation_copy",
6115    )?;
6116    Ok(PublishedLegacyMigration {
6117        generation,
6118        migrated_bytes: fingerprint.bytes,
6119    })
6120}
6121
6122fn publish_backup_migration(
6123    callgraph_dir: &Path,
6124    project_key: &str,
6125    source: &LegacyCallgraphTarget,
6126    writer_lease: Arc<crate::root_cache::WriterLease>,
6127) -> Result<PublishedLegacyMigration> {
6128    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6129        return Err(CallGraphStoreError::Unavailable(
6130            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6131        ));
6132    }
6133
6134    let generation = migration_generation_file_name(project_key, "backup");
6135    let temp_path = migration_temp_path(callgraph_dir, &generation);
6136    remove_sqlite_file_set(&temp_path);
6137
6138    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6139    let mut destination = Connection::open(&temp_path)?;
6140    destination.busy_timeout(Duration::from_secs(5))?;
6141    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6142    let started = Instant::now();
6143    let mut retries = 0;
6144    loop {
6145        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6146            rusqlite::backup::StepResult::Done => break,
6147            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6148            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6149                retries += 1;
6150                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6151                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6152                {
6153                    return Err(CallGraphStoreError::Unavailable(format!(
6154                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6155                    )));
6156                }
6157                std::thread::sleep(Duration::from_millis(20));
6158            }
6159            _ => {
6160                return Err(CallGraphStoreError::Unavailable(
6161                    "legacy callgraph backup returned an unknown step result".to_string(),
6162                ));
6163            }
6164        }
6165    }
6166    drop(backup);
6167
6168    let integrity: String =
6169        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6170    if integrity != "ok" {
6171        return Err(CallGraphStoreError::Unavailable(format!(
6172            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6173        )));
6174    }
6175    if !database_ready(&destination)? {
6176        return Err(CallGraphStoreError::Unavailable(
6177            "legacy callgraph backup produced a database without ready metadata".to_string(),
6178        ));
6179    }
6180    destination.execute_batch("PRAGMA optimize;")?;
6181    drop(destination);
6182    sync_file(&temp_path)?;
6183    fail_after_temp_copy_for_test()?;
6184
6185    let mut source = source.clone();
6186    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6187    source.source_blake3 = fingerprint.blake3;
6188    let generation = publish_migrated_generation(
6189        callgraph_dir,
6190        project_key,
6191        &generation,
6192        &temp_path,
6193        &source,
6194        fingerprint.bytes,
6195        writer_lease,
6196        "sqlite_backup",
6197    )?;
6198    Ok(PublishedLegacyMigration {
6199        generation,
6200        migrated_bytes: fingerprint.bytes,
6201    })
6202}
6203
6204fn publish_migrated_generation(
6205    callgraph_dir: &Path,
6206    project_key: &str,
6207    generation: &str,
6208    temp_path: &Path,
6209    source: &LegacyCallgraphTarget,
6210    migrated_bytes: u64,
6211    writer_lease: Arc<crate::root_cache::WriterLease>,
6212    method: &str,
6213) -> Result<String> {
6214    let gen_path = callgraph_dir.join(generation);
6215    checkpoint_sqlite_before_publication(temp_path);
6216    let publication = publish_if_current(|| {
6217        verify_writer_lease(&writer_lease)?;
6218        remove_sqlite_file_set(&gen_path);
6219        rename_sqlite_file_set(temp_path, &gen_path)?;
6220        crate::fs_lock::sync_parent(&gen_path);
6221
6222        verify_writer_lease(&writer_lease)?;
6223        publish_pointer(callgraph_dir, project_key, generation)?;
6224        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6225        Ok(generation.to_string())
6226    });
6227    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6228        remove_sqlite_file_set(temp_path);
6229    }
6230    publication
6231}
6232
6233fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6234    if let Some(parent) = destination.parent() {
6235        std::fs::create_dir_all(parent)?;
6236    }
6237    for suffix in SQLITE_FILE_SET_SUFFIXES {
6238        let source_path = sqlite_file_set_path(source, suffix);
6239        if !source_path.is_file() {
6240            continue;
6241        }
6242        let destination_path = sqlite_file_set_path(destination, suffix);
6243        std::fs::copy(&source_path, &destination_path)?;
6244        sync_file(&destination_path)?;
6245    }
6246    Ok(())
6247}
6248
6249fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6250    for suffix in SQLITE_FILE_SET_SUFFIXES {
6251        let source_path = sqlite_file_set_path(source, suffix);
6252        if !source_path.exists() {
6253            continue;
6254        }
6255        let destination_path = sqlite_file_set_path(destination, suffix);
6256        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6257            let _ = std::fs::remove_file(&source_path);
6258            return Err(error.into());
6259        }
6260    }
6261    Ok(())
6262}
6263
6264fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6265    let mut bytes = 0_u64;
6266    for suffix in SQLITE_FILE_SET_SUFFIXES {
6267        let member = sqlite_file_set_path(path, suffix);
6268        if !member.is_file() {
6269            continue;
6270        }
6271        bytes = bytes.saturating_add(member.metadata()?.len());
6272    }
6273    Ok(bytes)
6274}
6275
6276fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6277    let mut hasher = blake3::Hasher::new();
6278    let mut bytes = 0_u64;
6279    let mut buffer = [0_u8; 64 * 1024];
6280    for suffix in SQLITE_FILE_SET_SUFFIXES {
6281        let member = sqlite_file_set_path(path, suffix);
6282        if !member.is_file() {
6283            continue;
6284        }
6285        hasher.update(suffix.as_bytes());
6286        let mut file = std::fs::File::open(&member)?;
6287        loop {
6288            let read = file.read(&mut buffer)?;
6289            if read == 0 {
6290                break;
6291            }
6292            bytes = bytes.saturating_add(read as u64);
6293            hasher.update(&buffer[..read]);
6294        }
6295    }
6296    Ok(SourceFingerprint {
6297        bytes,
6298        blake3: hash_to_hex(hasher.finalize()),
6299    })
6300}
6301
6302fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6303    if suffix.is_empty() {
6304        path.to_path_buf()
6305    } else {
6306        PathBuf::from(format!("{}{suffix}", path.display()))
6307    }
6308}
6309
6310fn sync_file(path: &Path) -> Result<()> {
6311    let file = std::fs::OpenOptions::new()
6312        .read(true)
6313        .write(true)
6314        .open(path)?;
6315    file.sync_all()?;
6316    Ok(())
6317}
6318
6319fn fail_after_temp_copy_for_test() -> Result<()> {
6320    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6321        return Err(CallGraphStoreError::Unavailable(
6322            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6323        ));
6324    }
6325    Ok(())
6326}
6327
6328fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6329    format!(
6330        "{project_key}.g{}.{}{}{}.sqlite",
6331        now_nanos(),
6332        std::process::id(),
6333        MIGRATION_GENERATION_TAG,
6334        method
6335    )
6336}
6337
6338fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6339    callgraph_dir.join(format!(
6340        "{generation}.tmp.{}.{}",
6341        std::process::id(),
6342        now_nanos()
6343    ))
6344}
6345
6346fn write_migration_manifest(
6347    callgraph_dir: &Path,
6348    generation: &str,
6349    source: &LegacyCallgraphTarget,
6350    migrated_bytes: u64,
6351    method: &str,
6352) -> Result<()> {
6353    let manifest_path = migration_manifest_path(callgraph_dir, generation);
6354    let temp_path = manifest_path.with_extension(format!(
6355        "migration.json.tmp.{}.{}",
6356        std::process::id(),
6357        now_nanos()
6358    ));
6359    let manifest = serde_json::json!({
6360        "version": MIGRATION_MANIFEST_VERSION,
6361        "method": method,
6362        "target_generation": generation,
6363        "source_harness": source.partition.harness,
6364        "source_path": source.sqlite_path.display().to_string(),
6365        "source_generation": source.generation,
6366        "source_bytes": source.source_bytes,
6367        "source_blake3": source.source_blake3,
6368        "migrated_bytes": migrated_bytes,
6369    });
6370    {
6371        use std::io::Write as _;
6372        let mut file = std::fs::File::create(&temp_path)?;
6373        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6374        file.write_all(b"\n")?;
6375        file.sync_all()?;
6376    }
6377    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6378        let _ = std::fs::remove_file(&temp_path);
6379        return Err(error.into());
6380    }
6381    crate::fs_lock::sync_parent(&manifest_path);
6382    Ok(())
6383}
6384
6385fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6386    callgraph_dir.join(format!("{generation}.migration.json"))
6387}
6388
6389fn migration_generation_requires_manifest(generation: &str) -> bool {
6390    generation.contains(MIGRATION_GENERATION_TAG)
6391}
6392
6393fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6394    if !migration_generation_requires_manifest(generation) {
6395        return true;
6396    }
6397    let path = migration_manifest_path(callgraph_dir, generation);
6398    let Ok(bytes) = std::fs::read(path) else {
6399        return false;
6400    };
6401    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6402        return false;
6403    };
6404    value.get("version").and_then(serde_json::Value::as_u64)
6405        == Some(MIGRATION_MANIFEST_VERSION as u64)
6406        && value
6407            .get("target_generation")
6408            .and_then(serde_json::Value::as_str)
6409            == Some(generation)
6410        && value
6411            .get("source_bytes")
6412            .and_then(serde_json::Value::as_u64)
6413            .is_some_and(|bytes| bytes > 0)
6414        && value
6415            .get("source_blake3")
6416            .and_then(serde_json::Value::as_str)
6417            .is_some_and(|hash| hash.len() == 64)
6418}
6419
6420fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6421    let pointer_generation = read_pointer(callgraph_dir, project_key);
6422    if let Some(generation) = pointer_generation.as_deref() {
6423        if migration_generation_requires_manifest(generation)
6424            && !migration_manifest_valid(callgraph_dir, generation)
6425        {
6426            let path = callgraph_dir.join(generation);
6427            remove_sqlite_file_set(&path);
6428            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6429            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6430        }
6431    }
6432
6433    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6434        return;
6435    };
6436    for entry in entries.flatten() {
6437        let name = entry.file_name().to_string_lossy().to_string();
6438        let path = entry.path();
6439        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6440            let _ = std::fs::remove_file(path);
6441            continue;
6442        }
6443        if name.starts_with(&format!("{project_key}.g"))
6444            && name.ends_with(".sqlite")
6445            && name.contains(MIGRATION_GENERATION_TAG)
6446            && pointer_generation.as_deref() != Some(&name)
6447            && !migration_manifest_valid(callgraph_dir, &name)
6448        {
6449            remove_sqlite_file_set(&path);
6450            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6451        }
6452    }
6453    crate::fs_lock::sync_parent(callgraph_dir);
6454}
6455
6456fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6457    let mut hasher = blake3::Hasher::new();
6458    hasher.update(path.to_string_lossy().as_bytes());
6459    if let Some(generation) = generation {
6460        hasher.update(generation.as_bytes());
6461    }
6462    let digest = hash_to_hex(hasher.finalize());
6463    format!("legacy-{}", &digest[..16])
6464}
6465
6466fn open_readonly_connection(path: &Path) -> Result<Connection> {
6467    let uri = sqlite_readonly_uri(path);
6468    let conn = Connection::open_with_flags(
6469        &uri,
6470        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6471    )?;
6472    conn.pragma_update(
6473        None,
6474        "synchronous",
6475        if write_amplification_baseline_enabled() {
6476            "FULL"
6477        } else {
6478            "NORMAL"
6479        },
6480    )?;
6481    conn.busy_timeout(reader_busy_timeout())?;
6482    conn.execute_batch("PRAGMA query_only=ON;")?;
6483    Ok(conn)
6484}
6485
6486fn reader_busy_timeout() -> Duration {
6487    let jitter = (now_nanos() % 500) as u64;
6488    Duration::from_millis(250 + jitter)
6489}
6490
6491fn sqlite_readonly_uri(path: &Path) -> String {
6492    let raw = path.to_string_lossy().replace('\\', "/");
6493    let encoded = percent_encode_sqlite_uri_path(&raw);
6494    if raw.starts_with('/') {
6495        format!("file://{encoded}?mode=ro")
6496    } else if raw.as_bytes().get(1) == Some(&b':') {
6497        format!("file:///{encoded}?mode=ro")
6498    } else {
6499        format!("file:{encoded}?mode=ro")
6500    }
6501}
6502
6503fn percent_encode_sqlite_uri_path(path: &str) -> String {
6504    let mut encoded = String::with_capacity(path.len());
6505    for byte in path.bytes() {
6506        match byte {
6507            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6508                encoded.push(byte as char)
6509            }
6510            _ => encoded.push_str(&format!("%{byte:02X}")),
6511        }
6512    }
6513    encoded
6514}
6515
6516fn configure_connection(conn: &Connection) -> Result<()> {
6517    conn.pragma_update(None, "journal_mode", "WAL")?;
6518    let baseline = write_amplification_baseline_enabled();
6519    conn.pragma_update(
6520        None,
6521        "synchronous",
6522        if baseline { "FULL" } else { "NORMAL" },
6523    )?;
6524    conn.pragma_update(
6525        None,
6526        "wal_autocheckpoint",
6527        if baseline {
6528            1_000
6529        } else {
6530            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6531        },
6532    )?;
6533    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6534    conn.pragma_update(None, "busy_timeout", 5_000)?;
6535    Ok(())
6536}
6537
6538fn configure_build_connection(conn: &Connection) -> Result<()> {
6539    // The staging database commits independently recoverable batches. WAL keeps
6540    // those commits durable without forcing a rollback journal rewrite per batch.
6541    conn.pragma_update(None, "journal_mode", "WAL")?;
6542    conn.pragma_update(
6543        None,
6544        "synchronous",
6545        if write_amplification_baseline_enabled() {
6546            "FULL"
6547        } else {
6548            "NORMAL"
6549        },
6550    )?;
6551    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6552    conn.pragma_update(None, "busy_timeout", 5_000)?;
6553    Ok(())
6554}
6555
6556/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
6557/// private temporary copy before publishing it; a busy reader is harmless because
6558/// the next publication or cleanup pass can retry without affecting the source.
6559fn checkpoint_sqlite_before_publication(path: &Path) {
6560    let Ok(conn) = Connection::open(path) else {
6561        return;
6562    };
6563    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6564    let _ = conn.busy_timeout(Duration::from_secs(5));
6565    let _ = checkpoint_wal_truncate(&conn);
6566}
6567
6568fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6569    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6570        row.get::<_, i64>(0)
6571    }) {
6572        Ok(0) => true,
6573        Ok(_) => false,
6574        Err(rusqlite::Error::SqliteFailure(error, _))
6575            if matches!(
6576                error.code,
6577                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6578            ) =>
6579        {
6580            false
6581        }
6582        Err(error) => {
6583            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6584            false
6585        }
6586    }
6587}
6588
6589fn initialize_schema(conn: &Connection) -> Result<()> {
6590    conn.execute_batch(
6591        "CREATE TABLE IF NOT EXISTS files (
6592            path                TEXT PRIMARY KEY,
6593            content_hash        TEXT NOT NULL,
6594            mtime_ns            INTEGER NOT NULL,
6595            size                INTEGER NOT NULL,
6596            lang                TEXT NOT NULL,
6597            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
6598            is_public_api       INTEGER NOT NULL DEFAULT 0,
6599            surface_fingerprint TEXT NOT NULL,
6600            indexed_at          INTEGER NOT NULL
6601        );
6602
6603        CREATE TABLE IF NOT EXISTS nodes (
6604            id                         TEXT PRIMARY KEY,
6605            file_path                  TEXT NOT NULL,
6606            name                       TEXT NOT NULL,
6607            scoped_name                TEXT NOT NULL,
6608            kind                       TEXT NOT NULL,
6609            start_line                 INTEGER NOT NULL,
6610            start_col                  INTEGER NOT NULL,
6611            end_line                   INTEGER NOT NULL,
6612            end_col                    INTEGER NOT NULL,
6613            range_ordinal              INTEGER NOT NULL,
6614            signature                  TEXT,
6615            exported                   INTEGER NOT NULL,
6616            is_default_export          INTEGER NOT NULL,
6617            is_type_like               INTEGER NOT NULL,
6618            is_callgraph_entry_point   INTEGER NOT NULL,
6619            provenance                 TEXT NOT NULL,
6620            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6621        );
6622        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6623        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6624        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6625
6626        CREATE TABLE IF NOT EXISTS refs (
6627            ref_id          TEXT PRIMARY KEY,
6628            caller_node     TEXT,
6629            caller_file     TEXT NOT NULL,
6630            kind            TEXT NOT NULL,
6631            short_name      TEXT,
6632            full_ref        TEXT,
6633            module_path     TEXT,
6634            import_kind     TEXT,
6635            local_name      TEXT,
6636            requested_name  TEXT,
6637            namespace_alias TEXT,
6638            wildcard        INTEGER NOT NULL DEFAULT 0,
6639            line            INTEGER NOT NULL,
6640            byte_start      INTEGER NOT NULL,
6641            byte_end        INTEGER NOT NULL,
6642            status          TEXT NOT NULL,
6643            target_node     TEXT,
6644            target_file     TEXT,
6645            target_symbol   TEXT,
6646            provenance      TEXT NOT NULL
6647        );
6648        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6649        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6650        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6651        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6652        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6653
6654        CREATE TABLE IF NOT EXISTS file_dependencies (
6655            file_path   TEXT NOT NULL,
6656            dep_file    TEXT NOT NULL,
6657            PRIMARY KEY(file_path, dep_file)
6658        );
6659        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6660
6661        CREATE TABLE IF NOT EXISTS edges (
6662            edge_id       TEXT PRIMARY KEY,
6663            ref_id        TEXT NOT NULL,
6664            source_node   TEXT NOT NULL,
6665            target_node   TEXT,
6666            target_file   TEXT NOT NULL,
6667            target_symbol TEXT NOT NULL,
6668            kind          TEXT NOT NULL,
6669            line          INTEGER NOT NULL,
6670            provenance    TEXT NOT NULL
6671        );
6672        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6673        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6674        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6675        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6676
6677        CREATE TABLE IF NOT EXISTS dispatch_hints (
6678            id           TEXT PRIMARY KEY,
6679            method_name  TEXT NOT NULL,
6680            caller_node  TEXT NOT NULL,
6681            file         TEXT NOT NULL,
6682            line         INTEGER NOT NULL,
6683            byte_start   INTEGER NOT NULL,
6684            byte_end     INTEGER NOT NULL,
6685            provenance   TEXT NOT NULL
6686        );
6687        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6688        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6689
6690        CREATE TABLE IF NOT EXISTS type_ref_names (
6691            name TEXT PRIMARY KEY
6692        );
6693
6694        CREATE TABLE IF NOT EXISTS backend_file_state (
6695            backend        TEXT NOT NULL,
6696            workspace_root TEXT NOT NULL,
6697            file_path      TEXT NOT NULL,
6698            content_hash   TEXT NOT NULL,
6699            status         TEXT NOT NULL,
6700            updated_at     INTEGER NOT NULL,
6701            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6702        );
6703        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6704
6705        CREATE TABLE IF NOT EXISTS meta (
6706            k TEXT PRIMARY KEY,
6707            v TEXT NOT NULL
6708        );
6709
6710        -- The file walk is staged on disk so extraction can page through a
6711        -- deterministic inventory without retaining every source path in heap.
6712        CREATE TABLE IF NOT EXISTS staging_file_inventory (
6713            path TEXT PRIMARY KEY,
6714            size INTEGER NOT NULL
6715        ) WITHOUT ROWID;
6716
6717        -- Context needed only while a generation is staged. Raw refs live in
6718        -- `refs` with status `staged`; this table preserves the caller symbol
6719        -- needed to avoid inventing self edges during the later resolve pass.
6720        CREATE TABLE IF NOT EXISTS staging_ref_context (
6721            ref_id        TEXT PRIMARY KEY,
6722            caller_symbol TEXT
6723        );",
6724    )?;
6725    insert_meta(conn)?;
6726    Ok(())
6727}
6728
6729fn insert_meta(conn: &Connection) -> Result<()> {
6730    conn.execute(
6731        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6732        params![SCHEMA_VERSION.to_string()],
6733    )?;
6734    conn.execute(
6735        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6736        params![schema_fingerprint()],
6737    )?;
6738    conn.execute(
6739        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6740        [],
6741    )?;
6742    Ok(())
6743}
6744
6745/// Return the durable revision paired atomically with graph mutations. Stores
6746/// created by older binaries lack the revision row, so callers cannot detect
6747/// in-place graph changes and must not cache their snapshots.
6748fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6749    let revision: Option<String> = conn
6750        .query_row(
6751            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6752            [],
6753            |row| row.get(0),
6754        )
6755        .optional()?;
6756    revision
6757        .map(|revision| {
6758            revision.parse::<u64>().map_err(|error| {
6759                CallGraphStoreError::Unavailable(format!(
6760                    "callgraph projection write revision is invalid: {error}"
6761                ))
6762            })
6763        })
6764        .transpose()
6765}
6766
6767/// Advance the projection revision inside the graph mutation transaction so a
6768/// cached snapshot never survives an in-place refresh.
6769fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6770    tx.execute(
6771        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6772         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6773        [],
6774    )?;
6775    Ok(())
6776}
6777
6778fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6779    conn.execute(
6780        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6781        params![if ready { "1" } else { "0" }],
6782    )?;
6783    Ok(())
6784}
6785
6786fn database_ready(conn: &Connection) -> Result<bool> {
6787    let schema_version: Option<String> = conn
6788        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6789            row.get(0)
6790        })
6791        .optional()?;
6792    let fingerprint: Option<String> = conn
6793        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6794            row.get(0)
6795        })
6796        .optional()?;
6797    let ready: Option<String> = conn
6798        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6799        .optional()?;
6800
6801    let expected_schema = SCHEMA_VERSION.to_string();
6802    let expected_fingerprint = schema_fingerprint();
6803    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6804        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6805        && ready.as_deref() == Some("1"))
6806}
6807
6808fn ensure_database_ready(conn: &Connection) -> Result<()> {
6809    if database_ready(conn)? {
6810        Ok(())
6811    } else {
6812        Err(CallGraphStoreError::Unavailable(
6813            "database is missing, stale, or mid-build".to_string(),
6814        ))
6815    }
6816}
6817
6818fn schema_fingerprint() -> String {
6819    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6820    // edge sources, broader call extraction) even if the table SHAPE is
6821    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6822    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6823    let input =
6824        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6825    hash_to_hex(blake3::hash(input.as_bytes()))
6826}
6827
6828fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6829    tx.execute_batch(
6830        "DELETE FROM staging_ref_context;
6831         DELETE FROM edges;
6832         DELETE FROM file_dependencies;
6833         DELETE FROM refs;
6834         DELETE FROM dispatch_hints;
6835         DELETE FROM type_ref_names;
6836         DELETE FROM backend_file_state;
6837         DELETE FROM nodes;
6838         DELETE FROM files;",
6839    )?;
6840    Ok(())
6841}
6842
6843fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
6844    conn.query_row(
6845        "SELECT v FROM meta WHERE k = ?1",
6846        params![STAGED_BUILD_PHASE],
6847        |row| row.get(0),
6848    )
6849    .optional()
6850    .map_err(Into::into)
6851}
6852
6853fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
6854    let value = staged_string(conn, key)?;
6855    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
6856}
6857
6858fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
6859    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
6860        row.get::<_, String>(0)
6861    })
6862    .optional()
6863    .map_err(Into::into)
6864}
6865
6866fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
6867    tx.execute(
6868        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6869        params![STAGED_BUILD_PHASE, phase],
6870    )?;
6871    Ok(())
6872}
6873
6874fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
6875    set_staged_string(tx, key, &value.to_string())
6876}
6877
6878fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
6879    tx.execute(
6880        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6881        params![key, value],
6882    )?;
6883    Ok(())
6884}
6885
6886/// The extract rows and this counter update share a SQLite transaction. This is
6887/// intentionally not inferred from file/page growth: rollback removes both the
6888/// rows and the claimed credit, while page reuse cannot fabricate credit.
6889fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
6890    tx.execute(
6891        "INSERT INTO meta(k, v) VALUES(?1, ?2)
6892         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
6893        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
6894    )?;
6895    Ok(())
6896}
6897
6898fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
6899    let Ok(source) = std::fs::read_to_string(path) else {
6900        return Ok(false);
6901    };
6902    let Ok(freshness) = collect_source_freshness(path, &source) else {
6903        return Ok(false);
6904    };
6905    let rel_path = relative_path(project_root, path);
6906    let staged_hash = conn
6907        .query_row(
6908            "SELECT content_hash FROM files WHERE path = ?1",
6909            params![rel_path],
6910            |row| row.get::<_, String>(0),
6911        )
6912        .optional()?;
6913    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
6914}
6915
6916fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6917    tx.execute(
6918        "DELETE FROM staging_ref_context
6919         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
6920        params![rel_path],
6921    )?;
6922    delete_file_rows(tx, rel_path)
6923}
6924
6925fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
6926    loop {
6927        let removed = {
6928            let mut statement = conn.prepare(
6929                "SELECT path
6930                 FROM files
6931                 WHERE NOT EXISTS (
6932                     SELECT 1 FROM staging_file_inventory inventory
6933                     WHERE inventory.path = files.path
6934                 )
6935                 ORDER BY path
6936                 LIMIT ?1",
6937            )?;
6938            let paths = statement
6939                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
6940                    row.get::<_, String>(0)
6941                })?
6942                .collect::<std::result::Result<Vec<_>, _>>()?;
6943            paths
6944        };
6945        if removed.is_empty() {
6946            return Ok(());
6947        }
6948        let tx = conn.transaction()?;
6949        for path in removed {
6950            delete_staged_file_rows(&tx, &path)?;
6951        }
6952        tx.commit()?;
6953    }
6954}
6955
6956struct StagedFileBatch {
6957    paths: Vec<PathBuf>,
6958    last_path: String,
6959}
6960
6961fn load_staged_file_batch(
6962    conn: &Connection,
6963    project_root: &Path,
6964    after_path: &str,
6965    max_files: usize,
6966    max_bytes: u64,
6967) -> Result<Option<StagedFileBatch>> {
6968    let mut statement = conn.prepare(
6969        "SELECT path, size
6970         FROM staging_file_inventory
6971         WHERE path > ?1
6972         ORDER BY path
6973         LIMIT ?2",
6974    )?;
6975    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
6976    let mut paths = Vec::with_capacity(max_files.max(1));
6977    let mut last_path = String::new();
6978    let mut batch_bytes = 0u64;
6979    while let Some(row) = rows.next()? {
6980        let rel_path = row.get::<_, String>(0)?;
6981        let size = row.get::<_, i64>(1)?.max(0) as u64;
6982        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
6983            break;
6984        }
6985        batch_bytes = batch_bytes.saturating_add(size);
6986        last_path.clone_from(&rel_path);
6987        paths.push(project_root.join(rel_path));
6988    }
6989    if paths.is_empty() {
6990        Ok(None)
6991    } else {
6992        Ok(Some(StagedFileBatch { paths, last_path }))
6993    }
6994}
6995
6996fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
6997    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
6998    let mut rows = statement.query([])?;
6999    let mut fingerprint = CorpusFingerprint::default();
7000    while let Some(row) = rows.next()? {
7001        let rel_path = row.get::<_, String>(0)?;
7002        fingerprint.add_path(project_root, &project_root.join(rel_path));
7003    }
7004    Ok(fingerprint.finish(project_root))
7005}
7006
7007fn load_staged_ref_window(
7008    conn: &Connection,
7009    after_rowid: u64,
7010    limit: usize,
7011) -> Result<Vec<StagedRef>> {
7012    let mut statement = conn.prepare(
7013        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7014                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7015                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7016                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7017         FROM refs
7018         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7019         WHERE refs.status = 'staged' AND refs.rowid > ?1
7020         ORDER BY refs.rowid
7021         LIMIT ?2",
7022    )?;
7023    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7024        Ok(StagedRef {
7025            rowid: row.get::<_, i64>(0)? as u64,
7026            raw: RawRef {
7027                ref_id: row.get(1)?,
7028                caller_node: row.get(2)?,
7029                caller_file: row.get(3)?,
7030                kind: row.get(4)?,
7031                short_name: row.get(5)?,
7032                full_ref: row.get(6)?,
7033                module_path: row.get(7)?,
7034                import_kind: row.get(8)?,
7035                local_name: row.get(9)?,
7036                requested_name: row.get(10)?,
7037                namespace_alias: row.get(11)?,
7038                wildcard: row.get::<_, i64>(12)? != 0,
7039                line: row.get::<_, i64>(13)? as u32,
7040                byte_start: row.get::<_, i64>(14)? as usize,
7041                byte_end: row.get::<_, i64>(15)? as usize,
7042                caller_symbol: row.get(16)?,
7043                dependencies: BTreeSet::new(),
7044            },
7045        })
7046    })?;
7047    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7048    drop(statement);
7049
7050    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7051    let mut dependency_statement = conn
7052        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7053    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7054        if !dependencies.contains_key(&raw.caller_file) {
7055            let rows =
7056                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7057            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7058            dependencies.insert(raw.caller_file.clone(), values);
7059        }
7060        raw.dependencies = dependencies
7061            .get(&raw.caller_file)
7062            .cloned()
7063            .unwrap_or_default();
7064    }
7065    Ok(refs)
7066}
7067
7068fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7069    ResolvedRef {
7070        dependencies: raw.dependencies.clone(),
7071        raw,
7072        status: "unresolved".to_string(),
7073        target_node: None,
7074        target_file: None,
7075        target_symbol: None,
7076        edge: None,
7077    }
7078}
7079
7080fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7081    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7082        .map(|count| count.max(0) as u64)
7083        .map_err(Into::into)
7084}
7085
7086fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7087    let mut statement = conn.prepare(
7088        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7089    )?;
7090    let rows = statement.query_map([], |row| row.get(0))?;
7091    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7092}
7093
7094fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7095    tx.execute_batch(
7096        "DROP INDEX IF EXISTS idx_nodes_file;
7097         DROP INDEX IF EXISTS idx_nodes_name;
7098         DROP INDEX IF EXISTS idx_nodes_scoped;
7099         DROP INDEX IF EXISTS idx_refs_short_name;
7100         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7101         DROP INDEX IF EXISTS idx_refs_caller_file;
7102         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7103         DROP INDEX IF EXISTS idx_refs_target_file;
7104         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7105         DROP INDEX IF EXISTS idx_edges_source_kind;
7106         DROP INDEX IF EXISTS idx_edges_target_kind;
7107         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7108         DROP INDEX IF EXISTS idx_edges_ref_id;
7109         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7110         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7111         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7112    )?;
7113    Ok(())
7114}
7115
7116fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7117    tx.execute_batch(
7118        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7119         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7120         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7121         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7122         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7123         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7124         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7125         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7126         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7127         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7128         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7129         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7130         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7131         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7132         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7133         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7134    )?;
7135    Ok(())
7136}
7137
7138const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7139    ("files", "path"),
7140    ("nodes", "file_path"),
7141    ("refs", "caller_file"),
7142    ("refs", "target_file"),
7143    ("file_dependencies", "file_path"),
7144    ("file_dependencies", "dep_file"),
7145    ("edges", "target_file"),
7146    ("dispatch_hints", "file"),
7147    ("backend_file_state", "file_path"),
7148];
7149
7150/// Reconcile `backend_file_state.workspace_root` when the opener's project root
7151/// differs from what is stored. The store key is the git-root commit hash, so
7152/// multiple live checkouts/clones share one on-disk generation.
7153///
7154/// Cheap in-place re-root is only safe when every previously stored root path is
7155/// gone from disk (true move/rename). If any stale root still exists, another
7156/// clone is still alive and rewriting metadata would ping-pong relative rows
7157/// between trees (possibly on different branches). We then return
7158/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
7159/// opener. That can make each clone rebuild on open when they alternate — bounded
7160/// by open frequency — but each rebuild is correct for its opener, unlike silent
7161/// cross-clone corruption.
7162fn reconcile_workspace_roots(
7163    conn: &mut Connection,
7164    project_root: &Path,
7165    allow_repair: bool,
7166) -> Result<OpenRootRepair> {
7167    let roots = stored_workspace_roots(conn)?;
7168    let current_root = project_root.display().to_string();
7169    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7170        return Ok(OpenRootRepair::None);
7171    }
7172
7173    if let Some(sample) = sample_absolute_data_path(conn)? {
7174        return Ok(OpenRootRepair::NeedsRebuild {
7175            previous_roots: roots,
7176            current_root,
7177            reason: format!("absolute store data path row {sample}"),
7178        });
7179    }
7180
7181    for stored_root in roots.iter() {
7182        if stored_root == &current_root {
7183            continue;
7184        }
7185        if Path::new(stored_root).exists() {
7186            let reason = format!(
7187                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7188            );
7189            return Ok(OpenRootRepair::NeedsRebuild {
7190                previous_roots: roots,
7191                current_root,
7192                reason,
7193            });
7194        }
7195    }
7196
7197    if !allow_repair {
7198        return Ok(OpenRootRepair::NeedsRebuild {
7199            previous_roots: roots,
7200            current_root,
7201            reason: "workspace root metadata requires deferred repair".to_string(),
7202        });
7203    }
7204
7205    publish_if_current(|| {
7206        let tx = conn.transaction()?;
7207        tx.execute(
7208            "UPDATE OR IGNORE backend_file_state
7209             SET workspace_root = ?1
7210             WHERE workspace_root <> ?1",
7211            params![&current_root],
7212        )?;
7213        tx.execute(
7214            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7215            params![&current_root],
7216        )?;
7217        tx.commit()?;
7218        Ok(())
7219    })?;
7220
7221    crate::slog_info!(
7222        "callgraph store re-rooted from {} to {}",
7223        roots.join(", "),
7224        current_root
7225    );
7226    Ok(OpenRootRepair::ReRooted)
7227}
7228
7229fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7230    let mut stmt = conn.prepare(
7231        "SELECT DISTINCT workspace_root
7232         FROM backend_file_state
7233         ORDER BY workspace_root",
7234    )?;
7235    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7236    rows.collect::<std::result::Result<Vec<_>, _>>()
7237        .map_err(Into::into)
7238}
7239
7240fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7241    for (table, column) in STORE_DATA_PATH_COLUMNS {
7242        let sql = format!(
7243            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7244        );
7245        let mut stmt = conn.prepare(&sql)?;
7246        let mut rows = stmt.query([])?;
7247        while let Some(row) = rows.next()? {
7248            let value: String = row.get(0)?;
7249            if stored_path_is_absolute(&value) {
7250                return Ok(Some(format!("{table}.{column}={value}")));
7251            }
7252        }
7253    }
7254    Ok(None)
7255}
7256
7257fn stored_path_is_absolute(value: &str) -> bool {
7258    if value.is_empty() {
7259        return false;
7260    }
7261    if Path::new(value).is_absolute() || value.starts_with('/') {
7262        return true;
7263    }
7264    let bytes = value.as_bytes();
7265    if bytes.len() >= 3
7266        && bytes[1] == b':'
7267        && (bytes[2] == b'/' || bytes[2] == b'\\')
7268        && bytes[0].is_ascii_alphabetic()
7269    {
7270        return true;
7271    }
7272    value.starts_with("\\\\") || value.starts_with("//")
7273}
7274
7275fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7276    if let OpenRootRepair::NeedsRebuild {
7277        previous_roots,
7278        current_root,
7279        reason,
7280    } = repair
7281    {
7282        crate::slog_info!(
7283            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7284            previous_roots.join(", "),
7285            current_root,
7286            reason
7287        );
7288    }
7289}
7290
7291/// Nanosecond clock used to make temp/generation file names unique.
7292fn now_nanos() -> u128 {
7293    SystemTime::now()
7294        .duration_since(UNIX_EPOCH)
7295        .unwrap_or(Duration::ZERO)
7296        .as_nanos()
7297}
7298
7299/// The pointer file `<dir>/<key>.current`. Its single line names the current
7300/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
7301/// can always be atomically replaced via rename even on Windows — Rust opens
7302/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
7303fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7304    callgraph_dir.join(format!("{project_key}.current"))
7305}
7306
7307/// The legacy single-file DB path used before the generation scheme. Still read
7308/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
7309/// build publishes a generation.
7310fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7311    callgraph_dir.join(format!("{project_key}.sqlite"))
7312}
7313
7314/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
7315/// cold build writes a brand-new generation file, so publishing NEVER replaces
7316/// a file another process holds open (the root Windows fix).
7317fn generation_file_name(project_key: &str) -> String {
7318    format!(
7319        "{project_key}.g{}.{}.sqlite",
7320        now_nanos(),
7321        std::process::id()
7322    )
7323}
7324
7325/// Read the pointer; returns the generation file name if present and non-empty.
7326fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7327    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7328    let name = text.trim();
7329    if name.is_empty() {
7330        None
7331    } else {
7332        Some(name.to_string())
7333    }
7334}
7335
7336/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
7337/// `ready` flag). Uses a throwaway read-only connection.
7338fn db_path_ready(path: &Path) -> bool {
7339    (|| -> Result<bool> {
7340        let conn = open_readonly_connection(path)?;
7341        database_ready(&conn)
7342    })()
7343    .unwrap_or(false)
7344}
7345
7346/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
7347/// where `generation` is `Some(name)` for a pointer-published generation or
7348/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
7349/// published (caller treats that as "needs cold build").
7350///
7351/// Handles the GC race (the pointer names a generation that was just deleted) by
7352/// re-reading the pointer and retrying a few times.
7353fn resolve_ready_target(
7354    callgraph_dir: &Path,
7355    project_key: &str,
7356) -> Option<(PathBuf, Option<String>)> {
7357    for _ in 0..5 {
7358        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7359            let gen_path = callgraph_dir.join(&generation);
7360            if gen_path.is_file() {
7361                return (migration_manifest_valid(callgraph_dir, &generation)
7362                    && db_path_ready(&gen_path))
7363                .then_some((gen_path, Some(generation)));
7364            }
7365            // Pointer names a missing generation (a GC/publish race): re-read the
7366            // pointer and retry rather than failing the reader.
7367            std::thread::sleep(Duration::from_millis(5));
7368            continue;
7369        }
7370        // No pointer: fall back to the legacy single-file DB if it is ready.
7371        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7372        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7373    }
7374    None
7375}
7376
7377/// Atomically publish `generation` as the current store by flipping the pointer
7378/// file. Writes a temp file, fsyncs, then renames over the pointer — never
7379/// replacing an open DB file, so it succeeds cross-platform.
7380fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7381    let pointer = pointer_path(callgraph_dir, project_key);
7382    let tmp = callgraph_dir.join(format!(
7383        "{project_key}.current.tmp.{}.{}",
7384        std::process::id(),
7385        now_nanos()
7386    ));
7387    {
7388        use std::io::Write as _;
7389        let mut file = std::fs::File::create(&tmp)?;
7390        file.write_all(generation.as_bytes())?;
7391        file.write_all(b"\n")?;
7392        file.sync_all()?;
7393    }
7394    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7395        let _ = std::fs::remove_file(&tmp);
7396        return Err(error.into());
7397    }
7398    crate::fs_lock::sync_parent(&pointer);
7399    Ok(())
7400}
7401
7402#[derive(Clone, Debug)]
7403struct GenerationGcCandidate {
7404    name: String,
7405    path: PathBuf,
7406    modified: SystemTime,
7407}
7408
7409/// Best-effort GC of superseded generation files. The current pointer target and
7410/// newest previous generation are always retained. Older generations are removed
7411/// when they have no protected read marker, or after the absolute retention TTL
7412/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
7413/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
7414fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7415    let temp_grace = Duration::from_secs(60);
7416    let now = SystemTime::now();
7417    let pointer_current =
7418        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7419    let gen_prefix = format!("{project_key}.g");
7420    let tmp_prefixes = [
7421        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
7422        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
7423        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
7424    ];
7425    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7426        return;
7427    };
7428    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7429    for entry in entries.flatten() {
7430        let name = entry.file_name();
7431        let name = name.to_string_lossy().to_string();
7432        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7433        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7434
7435        // Orphaned temp files from a crashed build/publish: remove once aged out.
7436        if name.contains(".tmp.") {
7437            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7438                let _ = std::fs::remove_file(entry.path());
7439            }
7440            continue;
7441        }
7442
7443        // Superseded legacy single-file DB: best-effort delete once a generation
7444        // is published (ignored if another process still holds it open).
7445        if name == format!("{project_key}.sqlite") {
7446            remove_sqlite_file_set(&entry.path());
7447            continue;
7448        }
7449
7450        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7451            gens.push(GenerationGcCandidate {
7452                name,
7453                path: entry.path(),
7454                modified: mtime,
7455            });
7456        }
7457    }
7458
7459    let mut superseded = gens
7460        .iter()
7461        .filter(|generation| generation.name != pointer_current)
7462        .collect::<Vec<_>>();
7463    superseded.sort_by(|left, right| {
7464        right
7465            .modified
7466            .cmp(&left.modified)
7467            .then_with(|| right.name.cmp(&left.name))
7468    });
7469    let previous = superseded.first().map(|generation| generation.name.clone());
7470
7471    for generation in gens {
7472        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7473        if generation.name == pointer_current
7474            || Some(generation.name.as_str()) == previous.as_deref()
7475        {
7476            continue;
7477        }
7478
7479        let age = now
7480            .duration_since(generation.modified)
7481            .unwrap_or(Duration::ZERO);
7482        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7483            continue;
7484        }
7485
7486        remove_sqlite_file_set(&generation.path);
7487        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7488        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7489            callgraph_dir,
7490            &generation.name,
7491        ));
7492    }
7493}
7494
7495fn remove_sqlite_file_set(path: &Path) {
7496    let _ = std::fs::remove_file(path);
7497    remove_sqlite_sidecars(path);
7498}
7499
7500fn remove_sqlite_sidecars(path: &Path) {
7501    let path_text = path.to_string_lossy();
7502    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7503    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7504    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7505}
7506
7507/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
7508///
7509/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
7510/// place on success; a build that dies (process kill, crash, host restart) leaves
7511/// the temporary behind. The largest observed cold build finishes well under a
7512/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
7513/// never rename. A live build's temporary is minutes old at most.
7514///
7515/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
7516/// liveness check read false-positive on exactly the oldest files — the ones most
7517/// worth deleting: in production an orphan's embedded pid had been recycled to an
7518/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
7519/// cannot lie that way, so it is the honest orphan predicate.
7520const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7521
7522/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
7523/// same cadence as [`gc_old_generations`] (after a generation is published) but,
7524/// unlike it, is not scoped to the building root: it covers every directory in the
7525/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
7526///
7527/// That last case is the production hole this fixes. The per-root cleanup in
7528/// [`gc_old_generations`] only fires when a root actually builds, so when activity
7529/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
7530/// store's orphans become permanent — gigabytes accumulated in a legacy store
7531/// whose roots no longer built there, while the active store stayed clean. A
7532/// sibling root that still builds triggers this pass and cleans both layouts.
7533fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7534    sweep_orphaned_build_temps(callgraph_dir);
7535    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7536        return;
7537    };
7538    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7539
7540    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
7541    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
7542        for entry in entries.flatten() {
7543            if entry.path().is_dir() {
7544                sweep_orphaned_build_temps(&entry.path());
7545            }
7546        }
7547    }
7548
7549    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
7550    if let Ok(entries) = std::fs::read_dir(&storage_root) {
7551        for entry in entries.flatten() {
7552            let legacy_dir = entry.path().join(domain);
7553            if legacy_dir.is_dir() {
7554                sweep_orphaned_build_temps(&legacy_dir);
7555            }
7556        }
7557    }
7558}
7559
7560/// Sweep one callgraph directory, removing build temporaries older than
7561/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
7562fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7563    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7564}
7565
7566/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
7567/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
7568fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7569    let now = SystemTime::now();
7570    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7571        return;
7572    };
7573    let mut removed_any = false;
7574    for entry in entries.flatten() {
7575        let name = entry.file_name().to_string_lossy().to_string();
7576        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
7577        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
7578        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
7579        // without that substring — a completed `.sqlite` generation, a pointer, a
7580        // read-marker dir — is left alone: those belong to generation GC.
7581        if !name.contains(".sqlite.tmp.") {
7582            continue;
7583        }
7584        let mtime = entry
7585            .metadata()
7586            .and_then(|meta| meta.modified())
7587            .unwrap_or(now);
7588        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7589            continue;
7590        }
7591        // Deletion races a concurrent build finishing: that build renames the temp
7592        // into place, so the file is gone by the time we unlink. The 24h age makes
7593        // this overlap practically impossible, but treat a missing file as success
7594        // (the rename won) rather than an error, and never touch a path that does
7595        // not match the temporary shape above.
7596        match std::fs::remove_file(entry.path()) {
7597            Ok(()) => removed_any = true,
7598            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7599            Err(_) => {}
7600        }
7601    }
7602    if removed_any {
7603        crate::fs_lock::sync_parent(callgraph_dir);
7604    }
7605}
7606
7607/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
7608/// the global all-cores rayon pool. The store cold-build is the heaviest
7609/// background pass (parse-dominated) and runs on a separate thread off the
7610/// single-threaded request loop; left unbounded it monopolizes every core and
7611/// starves the bridge so interactive tools time out (the same starvation the
7612/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
7613/// match the main thread, since the extract walks tree-sitter ASTs.
7614fn build_pool_size() -> usize {
7615    std::thread::available_parallelism()
7616        .map(|parallelism| parallelism.get())
7617        .unwrap_or(1)
7618        .div_ceil(2)
7619        .clamp(1, 8)
7620}
7621
7622fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7623    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7624        Ok(extract) => Ok(extract),
7625        Err(error) => {
7626            let abs_path =
7627                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7628            let rel_path = relative_path(project_root, &abs_path);
7629            let freshness = cache_freshness::collect(&abs_path).ok();
7630            log::debug!(
7631                "callgraph store: skipping {} during cold build: {}",
7632                abs_path.display(),
7633                error
7634            );
7635            Err(ExtractFailure {
7636                rel_path,
7637                freshness,
7638            })
7639        }
7640    };
7641
7642    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7643        files.par_iter().map(extract_one).collect()
7644    };
7645
7646    // Run inside a dedicated bounded pool when one builds; fall back to the
7647    // global pool only if the bounded pool can't be constructed.
7648    let results = match rayon::ThreadPoolBuilder::new()
7649        .num_threads(build_pool_size())
7650        .thread_name(|index| format!("aft-callgraph-build-{index}"))
7651        .stack_size(8 * 1024 * 1024)
7652        .build()
7653    {
7654        Ok(pool) => pool.install(run),
7655        Err(error) => {
7656            log::warn!(
7657                "callgraph store: bounded build pool unavailable ({error}); using global pool"
7658            );
7659            run()
7660        }
7661    };
7662
7663    let mut extracts = Vec::new();
7664    let mut failures = Vec::new();
7665    for result in results {
7666        match result {
7667            Ok(extract) => extracts.push(extract),
7668            Err(failure) => failures.push(failure),
7669        }
7670    }
7671    BuildExtractsResult { extracts, failures }
7672}
7673
7674fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7675    let metadata = std::fs::metadata(path)?;
7676    let size = metadata.len();
7677    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7678        cache_freshness::zero_hash()
7679    } else if source.len() as u64 == size {
7680        cache_freshness::hash_bytes(source.as_bytes())
7681    } else {
7682        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7683    };
7684    Ok(FileFreshness {
7685        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7686        size,
7687        content_hash,
7688    })
7689}
7690
7691fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7692    let abs_path = normalize_file_path(project_root, path)?;
7693    let rel_path = relative_path(project_root, &abs_path);
7694    let source = std::fs::read_to_string(&abs_path)?;
7695    let freshness = collect_source_freshness(&abs_path, &source)?;
7696    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7697    let lang = data.lang;
7698    if lang == LangId::Rust {
7699        extend_rust_imports_with_nested_uses(&source, &mut data);
7700    }
7701    let mut nodes = build_node_records(&rel_path, &source, &data)?;
7702    let node_by_scoped: HashMap<String, String> = nodes
7703        .iter()
7704        .map(|node| (node.scoped_name.clone(), node.id.clone()))
7705        .collect();
7706    let import_dependencies =
7707        import_dependencies(project_root, &abs_path, &data.import_block.imports);
7708    let line_index = LineIndex::new(&source);
7709    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7710    let rust_reexports = if lang == LangId::Rust {
7711        collect_rust_pub_use_reexport_refs(
7712            project_root,
7713            &abs_path,
7714            &rel_path,
7715            &data.import_block.imports,
7716            &line_index,
7717        )
7718    } else {
7719        ReexportRefs {
7720            raw_refs: Vec::new(),
7721            surface_parts: Vec::new(),
7722        }
7723    };
7724    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7725    let mut raw_refs = Vec::new();
7726    raw_refs.extend(build_call_refs(
7727        &rel_path,
7728        &data,
7729        &node_by_scoped,
7730        &import_dependencies,
7731    ));
7732    raw_refs.extend(build_value_ref_refs(
7733        &rel_path,
7734        &data,
7735        &node_by_scoped,
7736        &import_dependencies,
7737    ));
7738    raw_refs.extend(build_import_refs(
7739        project_root,
7740        &abs_path,
7741        &rel_path,
7742        &data.import_block.imports,
7743        &line_index,
7744    ));
7745    let mut surface_parts = reexports.surface_parts;
7746    surface_parts.extend(rust_reexports.surface_parts);
7747    surface_parts.extend(source_less_exports.surface_parts);
7748    raw_refs.extend(reexports.raw_refs);
7749    raw_refs.extend(rust_reexports.raw_refs);
7750    raw_refs.extend(source_less_exports.raw_refs);
7751    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7752    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7753
7754    Ok(FileExtract {
7755        rel_path,
7756        freshness,
7757        lang,
7758        data,
7759        nodes,
7760        raw_refs,
7761        dispatch_hints,
7762        surface_fingerprint,
7763    })
7764}
7765
7766fn build_node_records(
7767    rel_path: &str,
7768    source: &str,
7769    data: &FileCallData,
7770) -> Result<Vec<NodeRecord>> {
7771    let mut records = Vec::new();
7772    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7773    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7774    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7775
7776    for (scoped_name, meta) in metadata {
7777        let name = unqualified_name(scoped_name).to_string();
7778        let range = selection_range(source, scoped_name, &name, &meta.range);
7779        let range_key = (
7780            range.start_line,
7781            range.start_col,
7782            range.end_line,
7783            range.end_col,
7784        );
7785        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
7786        let range_ordinal = *ordinal;
7787        *ordinal += 1;
7788        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
7789        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
7790        let is_default_export = data
7791            .default_export_symbol
7792            .as_deref()
7793            .map(|default| default == scoped_name || default == name)
7794            .unwrap_or(false);
7795        records.push(NodeRecord {
7796            id,
7797            file_path: rel_path.to_string(),
7798            name: name.clone(),
7799            scoped_name: scoped_name.clone(),
7800            kind: symbol_kind_label(&meta.kind).to_string(),
7801            range,
7802            range_ordinal,
7803            signature: meta.signature.clone(),
7804            exported,
7805            is_default_export,
7806            is_type_like: is_type_like(&meta.kind),
7807            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
7808                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
7809        });
7810    }
7811
7812    Ok(records)
7813}
7814
7815fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
7816    if scoped_name == TOP_LEVEL_SYMBOL {
7817        return Range {
7818            start_line: 0,
7819            start_col: 0,
7820            end_line: 0,
7821            end_col: 0,
7822        };
7823    }
7824    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
7825        return fallback.clone();
7826    };
7827    let start_col = fallback.start_col as usize;
7828    let search_start = start_col.min(line.len());
7829    if let Some(offset) = line[search_start..].find(name) {
7830        let col = search_start + offset;
7831        return Range {
7832            start_line: fallback.start_line,
7833            start_col: col as u32,
7834            end_line: fallback.start_line,
7835            end_col: (col + name.len()) as u32,
7836        };
7837    }
7838    if let Some(offset) = line.find(name) {
7839        return Range {
7840            start_line: fallback.start_line,
7841            start_col: offset as u32,
7842            end_line: fallback.start_line,
7843            end_col: (offset + name.len()) as u32,
7844        };
7845    }
7846    Range {
7847        start_line: fallback.start_line,
7848        start_col: fallback.start_col,
7849        end_line: fallback.start_line,
7850        end_col: fallback.start_col.saturating_add(name.len() as u32),
7851    }
7852}
7853
7854fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
7855    if scoped_name == TOP_LEVEL_SYMBOL {
7856        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
7857    }
7858    let input = format!(
7859        "{rel_path}:{}:{}:{}:{}:{ordinal}",
7860        range.start_line, range.start_col, range.end_line, range.end_col
7861    );
7862    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
7863}
7864
7865fn build_call_refs(
7866    rel_path: &str,
7867    data: &FileCallData,
7868    node_by_scoped: &HashMap<String, String>,
7869    import_dependencies: &BTreeSet<String>,
7870) -> Vec<RawRef> {
7871    build_callable_refs(
7872        rel_path,
7873        &data.calls_by_symbol,
7874        node_by_scoped,
7875        import_dependencies,
7876        "call",
7877    )
7878}
7879
7880fn build_value_ref_refs(
7881    rel_path: &str,
7882    data: &FileCallData,
7883    node_by_scoped: &HashMap<String, String>,
7884    import_dependencies: &BTreeSet<String>,
7885) -> Vec<RawRef> {
7886    build_callable_refs(
7887        rel_path,
7888        &data.value_refs_by_symbol,
7889        node_by_scoped,
7890        import_dependencies,
7891        "value_ref",
7892    )
7893}
7894
7895fn build_callable_refs(
7896    rel_path: &str,
7897    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
7898    node_by_scoped: &HashMap<String, String>,
7899    import_dependencies: &BTreeSet<String>,
7900    kind: &str,
7901) -> Vec<RawRef> {
7902    let mut refs = Vec::new();
7903    let mut ordinal = 0usize;
7904    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
7905    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
7906    for (caller_symbol, call_sites) in symbols {
7907        let caller_node = node_by_scoped.get(caller_symbol).cloned();
7908        for call_site in call_sites {
7909            ordinal += 1;
7910            let ref_id = ref_id(&[
7911                rel_path,
7912                kind,
7913                caller_symbol,
7914                &call_site.line.to_string(),
7915                &call_site.byte_start.to_string(),
7916                &call_site.byte_end.to_string(),
7917                &call_site.full_callee,
7918                &ordinal.to_string(),
7919            ]);
7920            refs.push(RawRef {
7921                ref_id,
7922                caller_node: caller_node.clone(),
7923                caller_symbol: Some(caller_symbol.clone()),
7924                caller_file: rel_path.to_string(),
7925                kind: kind.to_string(),
7926                short_name: Some(call_site.callee_name.clone()),
7927                full_ref: Some(call_site.full_callee.clone()),
7928                module_path: None,
7929                import_kind: None,
7930                local_name: Some(call_site.callee_name.clone()),
7931                requested_name: Some(call_site.callee_name.clone()),
7932                namespace_alias: namespace_alias(&call_site.full_callee),
7933                wildcard: false,
7934                line: call_site.line,
7935                byte_start: call_site.byte_start,
7936                byte_end: call_site.byte_end,
7937                dependencies: import_dependencies.clone(),
7938            });
7939        }
7940    }
7941    refs
7942}
7943
7944fn build_import_refs(
7945    project_root: &Path,
7946    abs_path: &Path,
7947    rel_path: &str,
7948    imports: &[ImportStatement],
7949    line_index: &LineIndex,
7950) -> Vec<RawRef> {
7951    let mut refs = Vec::new();
7952    for (index, import) in imports.iter().enumerate() {
7953        let import_kind = import_kind_label(import.kind).to_string();
7954        let local_name = import_local_names(import).join(",");
7955        let requested_name = import_requested_names(import).join(",");
7956        let ref_id = ref_id(&[
7957            rel_path,
7958            "import",
7959            &import.byte_range.start.to_string(),
7960            &import.byte_range.end.to_string(),
7961            &import.module_path,
7962            &index.to_string(),
7963        ]);
7964        refs.push(RawRef {
7965            ref_id,
7966            caller_node: None,
7967            caller_symbol: None,
7968            caller_file: rel_path.to_string(),
7969            kind: "import".to_string(),
7970            short_name: None,
7971            full_ref: Some(import.raw_text.clone()),
7972            module_path: Some(import.module_path.clone()),
7973            import_kind: Some(import_kind),
7974            local_name: empty_to_none(local_name),
7975            requested_name: empty_to_none(requested_name),
7976            namespace_alias: import.namespace_import.clone(),
7977            wildcard: import_is_wildcard(import),
7978            line: line_index.byte_to_line(import.byte_range.start),
7979            byte_start: import.byte_range.start,
7980            byte_end: import.byte_range.end,
7981            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
7982        });
7983    }
7984    refs
7985}
7986
7987fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
7988    let grammar = grammar_for(LangId::Rust);
7989    let mut parser = Parser::new();
7990    if parser.set_language(&grammar).is_err() {
7991        return;
7992    }
7993    let Some(tree) = parser.parse(source, None) else {
7994        return;
7995    };
7996
7997    let mut seen = data
7998        .import_block
7999        .imports
8000        .iter()
8001        .map(|import| (import.byte_range.start, import.byte_range.end))
8002        .collect::<HashSet<_>>();
8003    let mut nested_imports = Vec::new();
8004    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8005    if nested_imports.is_empty() {
8006        return;
8007    }
8008
8009    data.import_block.imports.extend(nested_imports);
8010    data.import_block
8011        .imports
8012        .sort_by_key(|import| import.byte_range.start);
8013    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8014}
8015
8016fn collect_rust_use_imports(
8017    source: &str,
8018    node: Node<'_>,
8019    seen: &mut HashSet<(usize, usize)>,
8020    imports: &mut Vec<ImportStatement>,
8021) {
8022    if node.kind() == "use_declaration" {
8023        let range = node.byte_range();
8024        if seen.insert((range.start, range.end)) {
8025            if let Some(import) = rust_import_from_use_node(source, node) {
8026                imports.push(import);
8027            }
8028        }
8029    }
8030
8031    let mut cursor = node.walk();
8032    if !cursor.goto_first_child() {
8033        return;
8034    }
8035    loop {
8036        collect_rust_use_imports(source, cursor.node(), seen, imports);
8037        if !cursor.goto_next_sibling() {
8038            break;
8039        }
8040    }
8041}
8042
8043fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8044    let raw_text = source[node.byte_range()].to_string();
8045    let body = rust_use_body(&raw_text)?.to_string();
8046    let visibility = rust_use_visibility(&raw_text);
8047    let names = rust_use_list_names(&body);
8048    let group = classify_rust_import_group(&body);
8049    let byte_range = node.byte_range();
8050
8051    Some(ImportStatement {
8052        module_path: body,
8053        names: names.clone(),
8054        default_import: visibility.clone(),
8055        namespace_import: None,
8056        kind: ImportKind::Value,
8057        group,
8058        byte_range,
8059        raw_text,
8060        form: ImportForm::RustUse {
8061            visibility,
8062            named: names,
8063        },
8064    })
8065}
8066
8067fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8068    let start = imports.iter().map(|import| import.byte_range.start).min()?;
8069    let end = imports.iter().map(|import| import.byte_range.end).max()?;
8070    Some(start..end)
8071}
8072
8073fn rust_use_visibility(raw_text: &str) -> Option<String> {
8074    let use_pos = raw_text.find("use ")?;
8075    let prefix = raw_text[..use_pos].trim();
8076    if prefix.is_empty() {
8077        None
8078    } else {
8079        Some(prefix.to_string())
8080    }
8081}
8082
8083fn rust_use_body(raw_text: &str) -> Option<&str> {
8084    let use_pos = raw_text.find("use ")?;
8085    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8086}
8087
8088fn rust_use_list_names(body: &str) -> Vec<String> {
8089    let Some(open) = body.find("::{") else {
8090        return Vec::new();
8091    };
8092    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8093        return Vec::new();
8094    };
8095    body[open + 3..close]
8096        .split(',')
8097        .filter_map(|spec| {
8098            let spec = spec.trim();
8099            if spec.is_empty() {
8100                None
8101            } else {
8102                Some(spec.to_string())
8103            }
8104        })
8105        .collect()
8106}
8107
8108fn classify_rust_import_group(body: &str) -> ImportGroup {
8109    let first = body
8110        .split("::")
8111        .next()
8112        .unwrap_or(body)
8113        .split_whitespace()
8114        .next()
8115        .unwrap_or(body);
8116    match first.trim() {
8117        "std" | "core" | "alloc" => ImportGroup::Stdlib,
8118        "crate" | "self" | "super" => ImportGroup::Internal,
8119        _ => ImportGroup::External,
8120    }
8121}
8122
8123#[derive(Debug, Clone)]
8124struct ReexportRefs {
8125    raw_refs: Vec<RawRef>,
8126    surface_parts: Vec<String>,
8127}
8128
8129fn collect_reexport_refs(
8130    project_root: &Path,
8131    abs_path: &Path,
8132    rel_path: &str,
8133    source: &str,
8134) -> ReexportRefs {
8135    let mut raw_refs = Vec::new();
8136    let mut surface_parts = Vec::new();
8137    let mut search_start = 0usize;
8138    let mut ordinal = 0usize;
8139    while let Some(export_offset) = source[search_start..].find("export") {
8140        let start = search_start + export_offset;
8141        let Some(statement_end_offset) = source[start..].find(';') else {
8142            break;
8143        };
8144        let end = start + statement_end_offset + 1;
8145        let statement = &source[start..end];
8146        search_start = end;
8147        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8148            continue;
8149        }
8150        let Some(module_path) = quoted_module_path(statement) else {
8151            continue;
8152        };
8153        ordinal += 1;
8154        let wildcard = statement.contains('*');
8155        let line = source[..start]
8156            .bytes()
8157            .filter(|byte| *byte == b'\n')
8158            .count() as u32
8159            + 1;
8160        let ref_id = ref_id(&[
8161            rel_path,
8162            "reexport",
8163            &start.to_string(),
8164            &end.to_string(),
8165            &module_path,
8166            &ordinal.to_string(),
8167        ]);
8168        surface_parts.push(format!("reexport\t{statement}"));
8169        raw_refs.push(RawRef {
8170            ref_id,
8171            caller_node: None,
8172            caller_symbol: None,
8173            caller_file: rel_path.to_string(),
8174            kind: "reexport".to_string(),
8175            short_name: None,
8176            full_ref: Some(statement.to_string()),
8177            module_path: Some(module_path.clone()),
8178            import_kind: Some("reexport".to_string()),
8179            local_name: None,
8180            requested_name: None,
8181            namespace_alias: None,
8182            wildcard,
8183            line,
8184            byte_start: start,
8185            byte_end: end,
8186            dependencies: module_dependencies(project_root, abs_path, &module_path),
8187        });
8188    }
8189    ReexportRefs {
8190        raw_refs,
8191        surface_parts,
8192    }
8193}
8194
8195fn collect_rust_pub_use_reexport_refs(
8196    project_root: &Path,
8197    abs_path: &Path,
8198    rel_path: &str,
8199    imports: &[ImportStatement],
8200    line_index: &LineIndex,
8201) -> ReexportRefs {
8202    let mut raw_refs = Vec::new();
8203    let mut surface_parts = Vec::new();
8204    let mut ordinal = 0usize;
8205
8206    for import in imports {
8207        let Some(visibility) = &import.default_import else {
8208            continue;
8209        };
8210        if !visibility.starts_with("pub") {
8211            continue;
8212        }
8213        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8214            continue;
8215        };
8216        ordinal += 1;
8217        let ref_id = ref_id(&[
8218            rel_path,
8219            "rust_reexport",
8220            &import.byte_range.start.to_string(),
8221            &import.byte_range.end.to_string(),
8222            &module_path,
8223            &ordinal.to_string(),
8224        ]);
8225        surface_parts.push(format!("reexport\t{}", import.raw_text));
8226        raw_refs.push(RawRef {
8227            ref_id,
8228            caller_node: None,
8229            caller_symbol: None,
8230            caller_file: rel_path.to_string(),
8231            kind: "reexport".to_string(),
8232            short_name: None,
8233            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8234            module_path: Some(module_path.clone()),
8235            import_kind: Some("reexport".to_string()),
8236            local_name: None,
8237            requested_name: None,
8238            namespace_alias: None,
8239            wildcard,
8240            line: line_index.byte_to_line(import.byte_range.start),
8241            byte_start: import.byte_range.start,
8242            byte_end: import.byte_range.end,
8243            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8244        });
8245    }
8246
8247    ReexportRefs {
8248        raw_refs,
8249        surface_parts,
8250    }
8251}
8252
8253fn rust_pub_use_reexport_parts(
8254    import: &ImportStatement,
8255) -> Option<(String, HashMap<String, String>, bool)> {
8256    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8257    let body = body.trim();
8258    if let Some(module_path) = body.strip_suffix("::*") {
8259        return Some((module_path.trim().to_string(), HashMap::new(), true));
8260    }
8261
8262    if let Some(brace_start) = body.find("::{") {
8263        let module_path = body[..brace_start].trim().to_string();
8264        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8265        if names.is_empty() {
8266            return None;
8267        }
8268        return Some((module_path, names, false));
8269    }
8270
8271    let (module_path, spec) = body.rsplit_once("::")?;
8272    let names = rust_reexport_names_from_specs(spec);
8273    if names.is_empty() {
8274        return None;
8275    }
8276    Some((module_path.trim().to_string(), names, false))
8277}
8278
8279fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8280    let mut names = HashMap::new();
8281    for spec in specs.split(',') {
8282        let spec = spec.trim();
8283        if spec.is_empty() || spec == "self" {
8284            continue;
8285        }
8286        if let Some((source, local)) = spec.split_once(" as ") {
8287            let source = source.trim();
8288            let local = local.trim();
8289            if !source.is_empty() && !local.is_empty() && source != "self" {
8290                names.insert(local.to_string(), source.to_string());
8291            }
8292        } else {
8293            names.insert(spec.to_string(), spec.to_string());
8294        }
8295    }
8296    names
8297}
8298
8299fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8300    if named.is_empty() {
8301        return fallback.to_string();
8302    }
8303    let mut specs = named
8304        .iter()
8305        .map(|(local, source)| {
8306            if local == source {
8307                source.clone()
8308            } else {
8309                format!("{source} as {local}")
8310            }
8311        })
8312        .collect::<Vec<_>>();
8313    specs.sort();
8314    format!("pub use {{{}}};", specs.join(", "))
8315}
8316
8317fn quoted_module_path(statement: &str) -> Option<String> {
8318    let quote = match (statement.find('\''), statement.find('"')) {
8319        (Some(single), Some(double)) if single < double => '\'',
8320        (Some(_), Some(_)) => '"',
8321        (Some(_), None) => '\'',
8322        (None, Some(_)) => '"',
8323        (None, None) => return None,
8324    };
8325    let start = statement.find(quote)? + 1;
8326    let end = statement[start..].find(quote)? + start;
8327    Some(statement[start..end].to_string())
8328}
8329
8330#[derive(Debug, Clone)]
8331struct SourceLessExportRefs {
8332    raw_refs: Vec<RawRef>,
8333    surface_parts: Vec<String>,
8334}
8335
8336fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8337    let mut raw_refs = Vec::new();
8338    let mut surface_parts = Vec::new();
8339    let mut search_start = 0usize;
8340    let mut ordinal = 0usize;
8341    while let Some(export_offset) = source[search_start..].find("export") {
8342        let start = search_start + export_offset;
8343        let Some(statement_end_offset) = source[start..].find(';') else {
8344            break;
8345        };
8346        let end = start + statement_end_offset + 1;
8347        let statement = &source[start..end];
8348        search_start = end;
8349        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8350            continue;
8351        }
8352        let aliases = parse_reexport_names(statement);
8353        if aliases.is_empty() {
8354            continue;
8355        }
8356        let line = source[..start]
8357            .bytes()
8358            .filter(|byte| *byte == b'\n')
8359            .count() as u32
8360            + 1;
8361        for (exported, source_symbol) in aliases {
8362            ordinal += 1;
8363            let ref_id = ref_id(&[
8364                rel_path,
8365                "export_alias",
8366                &start.to_string(),
8367                &end.to_string(),
8368                &exported,
8369                &source_symbol,
8370                &ordinal.to_string(),
8371            ]);
8372            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8373            raw_refs.push(RawRef {
8374                ref_id,
8375                caller_node: None,
8376                caller_symbol: None,
8377                caller_file: rel_path.to_string(),
8378                kind: "export_alias".to_string(),
8379                short_name: None,
8380                full_ref: Some(statement.to_string()),
8381                module_path: None,
8382                import_kind: Some("export_alias".to_string()),
8383                local_name: Some(exported),
8384                requested_name: Some(source_symbol),
8385                namespace_alias: None,
8386                wildcard: false,
8387                line,
8388                byte_start: start,
8389                byte_end: end,
8390                dependencies: BTreeSet::new(),
8391            });
8392        }
8393    }
8394    SourceLessExportRefs {
8395        raw_refs,
8396        surface_parts,
8397    }
8398}
8399
8400fn build_dispatch_hints(
8401    rel_path: &str,
8402    data: &FileCallData,
8403    node_by_scoped: &HashMap<String, String>,
8404) -> Vec<DispatchHint> {
8405    let mut hints = Vec::new();
8406    let mut ordinal = 0usize;
8407    for (caller_symbol, call_sites) in &data.calls_by_symbol {
8408        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8409            continue;
8410        };
8411        for call_site in call_sites {
8412            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8413                continue;
8414            }
8415            ordinal += 1;
8416            hints.push(DispatchHint {
8417                id: ref_id(&[
8418                    rel_path,
8419                    "dispatch",
8420                    caller_symbol,
8421                    &call_site.line.to_string(),
8422                    &call_site.byte_start.to_string(),
8423                    &call_site.byte_end.to_string(),
8424                    &ordinal.to_string(),
8425                ]),
8426                method_name: call_site.callee_name.clone(),
8427                caller_node: caller_node.clone(),
8428                file: rel_path.to_string(),
8429                line: call_site.line,
8430                byte_start: call_site.byte_start,
8431                byte_end: call_site.byte_end,
8432            });
8433        }
8434    }
8435    hints
8436}
8437
8438fn surface_fingerprint(
8439    nodes: &mut [NodeRecord],
8440    data: &FileCallData,
8441    reexport_parts: &[String],
8442) -> String {
8443    nodes.sort_by(|left, right| {
8444        (left.file_path.as_str(), left.scoped_name.as_str())
8445            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8446    });
8447    let mut parts = Vec::new();
8448    for node in nodes.iter() {
8449        parts.push(format!(
8450            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8451            node.scoped_name,
8452            node.name,
8453            node.kind,
8454            node.exported,
8455            node.range.start_line,
8456            node.range.start_col,
8457            node.range.end_line,
8458            node.range.end_col,
8459            node.range_ordinal,
8460            node.signature.as_deref().unwrap_or("")
8461        ));
8462    }
8463    let mut exports = data.exported_symbols.clone();
8464    exports.sort();
8465    for export in exports {
8466        parts.push(format!("export\t{export}"));
8467    }
8468    if let Some(default_export) = &data.default_export_symbol {
8469        parts.push(format!("default\t{default_export}"));
8470    }
8471    let mut imports: Vec<String> = data
8472        .import_block
8473        .imports
8474        .iter()
8475        .map(|import| {
8476            format!(
8477                "import\t{}\t{:?}\t{}",
8478                import.module_path, import.form, import.raw_text
8479            )
8480        })
8481        .collect();
8482    imports.sort();
8483    parts.extend(imports);
8484    parts.extend(reexport_parts.iter().cloned());
8485    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8486}
8487
8488fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8489    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8490        return Ok(ResolvedRef {
8491            dependencies: raw.dependencies.clone(),
8492            raw,
8493            status: "unresolved".to_string(),
8494            target_node: None,
8495            target_file: None,
8496            target_symbol: None,
8497            edge: None,
8498        });
8499    }
8500
8501    let caller_file = raw.caller_file.clone();
8502    let caller_data =
8503        index
8504            .caller_data(&caller_file)
8505            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8506                file: caller_file.clone(),
8507            })?;
8508    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8509    let short_name = raw.short_name.as_deref().unwrap_or_default();
8510    let mut dependencies = raw.dependencies.clone();
8511
8512    let resolved = match index.lang_for(&caller_file) {
8513        Some(LangId::Rust) => {
8514            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8515        }
8516        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8517            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8518        }
8519        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8520    };
8521
8522    let Some((status, target_file, target_symbol)) = resolved else {
8523        return Ok(ResolvedRef {
8524            raw,
8525            status: "unresolved".to_string(),
8526            target_node: None,
8527            target_file: None,
8528            target_symbol: None,
8529            dependencies,
8530            edge: None,
8531        });
8532    };
8533
8534    dependencies.insert(target_file.clone());
8535    let target_node = index.node_for_symbol(&target_file, &target_symbol);
8536    if raw.kind == "value_ref"
8537        && !target_node
8538            .as_deref()
8539            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8540    {
8541        return Ok(ResolvedRef {
8542            raw,
8543            status: "unresolved".to_string(),
8544            target_node: None,
8545            target_file: None,
8546            target_symbol: None,
8547            dependencies,
8548            edge: None,
8549        });
8550    }
8551    let source_node = raw.caller_node.clone();
8552    let edge = if let Some(source_node) = source_node {
8553        if target_file == caller_file
8554            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8555        {
8556            None
8557        } else {
8558            Some(EdgeRecord {
8559                edge_id: ref_id(&[&raw.ref_id, "edge"]),
8560                source_node,
8561                target_node: target_node.clone(),
8562                target_file: target_file.clone(),
8563                target_symbol: target_symbol.clone(),
8564                kind: raw.kind.clone(),
8565                line: raw.line,
8566            })
8567        }
8568    } else {
8569        None
8570    };
8571
8572    Ok(ResolvedRef {
8573        raw,
8574        status,
8575        target_node,
8576        target_file: Some(target_file),
8577        target_symbol: Some(target_symbol),
8578        dependencies,
8579        edge,
8580    })
8581}
8582
8583fn resolve_js_ts_target<I: ResolverIndex>(
8584    index: &I,
8585    caller_file: &str,
8586    full_ref: &str,
8587    short_name: &str,
8588    caller_data: &FileCallData,
8589) -> Option<(String, String, String)> {
8590    if let Some((namespace, member)) = full_ref.split_once('.') {
8591        for import in &caller_data.import_block.imports {
8592            if import.namespace_import.as_deref() == Some(namespace) {
8593                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8594                    if let Some((file, symbol)) =
8595                        resolve_exported_symbol(index, &target_file, member, 0)
8596                    {
8597                        return Some(("resolved".to_string(), file, symbol));
8598                    }
8599                }
8600            }
8601        }
8602    }
8603
8604    for import in &caller_data.import_block.imports {
8605        for spec in &import.names {
8606            if crate::imports::specifier_local_name(spec) == short_name {
8607                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8608                    let requested = crate::imports::specifier_imported_name(spec);
8609                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8610                        .unwrap_or_else(|| (target_file, requested.to_string()));
8611                    return Some(("resolved".to_string(), file, symbol));
8612                }
8613            }
8614        }
8615
8616        if import.default_import.as_deref() == Some(short_name) {
8617            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8618                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8619                    .or_else(|| {
8620                        index
8621                            .default_export(&target_file)
8622                            .map(|symbol| (target_file.clone(), symbol))
8623                    })
8624                    .unwrap_or_else(|| {
8625                        let file_name = Path::new(&target_file)
8626                            .file_name()
8627                            .and_then(|name| name.to_str())
8628                            .unwrap_or("unknown")
8629                            .to_string();
8630                        (target_file, format!("<default:{file_name}>"))
8631                    });
8632                return Some(("resolved".to_string(), file, symbol));
8633            }
8634        }
8635    }
8636
8637    for import in &caller_data.import_block.imports {
8638        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8639            if index.has_export(&target_file, short_name) {
8640                return Some(("resolved".to_string(), target_file, short_name.to_string()));
8641            }
8642        }
8643    }
8644
8645    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8646}
8647
8648fn resolve_exported_symbol<I: ResolverIndex>(
8649    index: &I,
8650    file: &str,
8651    requested: &str,
8652    depth: usize,
8653) -> Option<(String, String)> {
8654    let mut visited = std::collections::HashMap::new();
8655    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8656}
8657
8658/// Re-export graphs are frequently cyclic (barrel files re-exporting each
8659/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
8660/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
8661/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
8662/// (file, symbol) pair — but only when the earlier visit had at least as much
8663/// remaining depth budget (a shallower re-visit can reach leaves the deeper
8664/// first visit had to cut off at the cap, so plain visited-set pruning would
8665/// lose resolutions the capped walk finds).
8666fn resolve_exported_symbol_inner<I: ResolverIndex>(
8667    index: &I,
8668    file: &str,
8669    requested: &str,
8670    depth: usize,
8671    visited: &mut std::collections::HashMap<(String, String), usize>,
8672) -> Option<(String, String)> {
8673    if depth > 16 {
8674        return None;
8675    }
8676    if requested != "default" {
8677        if let Some(source_symbol) = index.export_alias(file, requested) {
8678            return Some((file.to_string(), source_symbol));
8679        }
8680        if index.has_export(file, requested) {
8681            return Some((file.to_string(), requested.to_string()));
8682        }
8683    } else if let Some(default) = index.default_export(file) {
8684        return Some((file.to_string(), default));
8685    }
8686
8687    // Memo check sits after the local-export fast paths: the common direct
8688    // hit never allocates the key, and a hit through the memo would have
8689    // returned above anyway.
8690    match visited.entry((file.to_string(), requested.to_string())) {
8691        std::collections::hash_map::Entry::Occupied(mut seen) => {
8692            if *seen.get() <= depth {
8693                return None;
8694            }
8695            seen.insert(depth);
8696        }
8697        std::collections::hash_map::Entry::Vacant(slot) => {
8698            slot.insert(depth);
8699        }
8700    }
8701
8702    for reexport in index.reexports_for(file) {
8703        let mut next_requested = requested.to_string();
8704        let matches = if reexport.wildcard {
8705            true
8706        } else if let Some(source_name) = reexport.named.get(requested) {
8707            next_requested = source_name.clone();
8708            true
8709        } else {
8710            false
8711        };
8712        if !matches {
8713            continue;
8714        }
8715        if let Some(target_file) = &reexport.target_file {
8716            if let Some(target) = resolve_exported_symbol_inner(
8717                index,
8718                target_file,
8719                &next_requested,
8720                depth + 1,
8721                visited,
8722            ) {
8723                return Some(target);
8724            }
8725        }
8726    }
8727    None
8728}
8729
8730fn resolve_rust_target<I: ResolverIndex>(
8731    index: &I,
8732    caller_file: &str,
8733    full_ref: &str,
8734    short_name: &str,
8735    caller_data: &FileCallData,
8736    raw: &RawRef,
8737) -> Option<(String, String, String)> {
8738    if full_ref.contains("::") {
8739        if let Some((target_file, target_symbol)) =
8740            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8741        {
8742            return Some(("resolved".to_string(), target_file, target_symbol));
8743        }
8744    }
8745
8746    for import in &caller_data.import_block.imports {
8747        if let Some((target_file, target_symbol)) =
8748            rust_target_for_use(index, caller_file, import, short_name)
8749        {
8750            return Some(("resolved".to_string(), target_file, target_symbol));
8751        }
8752    }
8753
8754    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8755}
8756
8757fn rust_target_for_qualified<I: ResolverIndex>(
8758    index: &I,
8759    caller_file: &str,
8760    full_ref: &str,
8761    short_name: &str,
8762    caller_data: &FileCallData,
8763    raw: &RawRef,
8764) -> Option<(String, String)> {
8765    let mut segments: Vec<&str> = full_ref.split("::").collect();
8766    if segments.len() < 2 {
8767        return None;
8768    }
8769    segments.pop();
8770    let requested_symbol = rust_target_symbol(full_ref, short_name);
8771
8772    for path in rust_module_path_candidates(&segments, caller_data, raw) {
8773        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8774        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8775            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8776                return Some(rust_resolve_reexport_if_symbol_missing(
8777                    index,
8778                    target_file,
8779                    requested_symbol.clone(),
8780                ));
8781            }
8782        }
8783
8784        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
8785        if let Some(target) =
8786            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
8787        {
8788            return Some(target);
8789        }
8790        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
8791            return Some(rust_resolve_reexport_if_symbol_missing(
8792                index,
8793                target_file,
8794                requested_symbol.clone(),
8795            ));
8796        }
8797    }
8798    None
8799}
8800
8801fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
8802    full_ref
8803        .rsplit("::")
8804        .next()
8805        .filter(|name| !name.is_empty())
8806        .unwrap_or(short_name)
8807        .to_string()
8808}
8809
8810fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
8811    index: &I,
8812    target_file: String,
8813    target_symbol: String,
8814) -> (String, String) {
8815    if index
8816        .node_for_symbol(&target_file, &target_symbol)
8817        .is_some()
8818    {
8819        return (target_file, target_symbol);
8820    }
8821    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
8822        resolved
8823    } else {
8824        (target_file, target_symbol)
8825    }
8826}
8827
8828fn rust_module_path_candidates(
8829    segments: &[&str],
8830    caller_data: &FileCallData,
8831    raw: &RawRef,
8832) -> Vec<Vec<String>> {
8833    let mut candidates = Vec::new();
8834    if let Some(first) = segments.first().copied() {
8835        for import in &caller_data.import_block.imports {
8836            if !rust_import_is_visible_to_call(import, raw) {
8837                continue;
8838            }
8839            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
8840                continue;
8841            };
8842            if local_name == first {
8843                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
8844                rust_push_unique_path_candidate(&mut candidates, path_segments);
8845            }
8846        }
8847    }
8848    rust_push_unique_path_candidate(
8849        &mut candidates,
8850        segments
8851            .iter()
8852            .map(|segment| (*segment).to_string())
8853            .collect(),
8854    );
8855    candidates
8856}
8857
8858fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
8859    if !candidates.iter().any(|existing| existing == &candidate) {
8860        candidates.push(candidate);
8861    }
8862}
8863
8864fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
8865    import.byte_range.start <= raw.byte_start
8866}
8867
8868fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
8869    let path = import.module_path.trim().trim_end_matches(';').trim();
8870    if path.contains("::{") || path.contains('{') || path.contains('*') {
8871        return None;
8872    }
8873    let (path_without_alias, alias) = path
8874        .split_once(" as ")
8875        .map(|(left, right)| (left.trim(), Some(right.trim())))
8876        .unwrap_or((path, None));
8877    let segments = path_without_alias
8878        .split("::")
8879        .map(str::trim)
8880        .filter(|segment| !segment.is_empty())
8881        .collect::<Vec<_>>();
8882    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
8883    if local_name.chars().next().is_some_and(char::is_uppercase) {
8884        return None;
8885    }
8886    Some((
8887        local_name,
8888        segments
8889            .into_iter()
8890            .map(|segment| segment.to_string())
8891            .collect(),
8892    ))
8893}
8894
8895fn rust_inline_scoped_target<I: ResolverIndex>(
8896    index: &I,
8897    caller_file: &str,
8898    module_segments: &[String],
8899    short_name: &str,
8900) -> Option<(String, String)> {
8901    index.inline_scoped_target(caller_file, module_segments, short_name)
8902}
8903
8904fn rust_target_for_use<I: ResolverIndex>(
8905    index: &I,
8906    caller_file: &str,
8907    import: &ImportStatement,
8908    short_name: &str,
8909) -> Option<(String, String)> {
8910    let path = import.module_path.trim().trim_end_matches(';');
8911    if let Some(brace_start) = path.find("::{") {
8912        let prefix = &path[..brace_start];
8913        if import.names.iter().any(|name| name == short_name) {
8914            let prefix_segments: Vec<&str> = prefix.split("::").collect();
8915            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
8916            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8917            return Some((file, short_name.to_string()));
8918        }
8919        return None;
8920    }
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: Vec<&str> = path_without_alias.split("::").collect();
8927    let imported = alias.or_else(|| segments.last().copied())?;
8928    if imported != short_name {
8929        return None;
8930    }
8931    if segments.len() < 2 {
8932        return None;
8933    }
8934    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
8935    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
8936    Some((file, segments.last().unwrap_or(&short_name).to_string()))
8937}
8938
8939fn rust_workspace_file_for_segments<I: ResolverIndex>(
8940    index: &I,
8941    segments: &[&str],
8942) -> Option<String> {
8943    let crate_name = segments.first().copied()?;
8944    let src_prefix = index.crate_src_prefix(crate_name)?;
8945    let module_segments = segments[1..]
8946        .iter()
8947        .map(|segment| segment.to_string())
8948        .collect::<Vec<_>>();
8949    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
8950}
8951
8952#[cfg(test)]
8953static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
8954    OnceLock::new();
8955
8956#[cfg(test)]
8957fn note_workspace_crate_prefix_build(project_root: &Path) {
8958    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8959        .get_or_init(|| Mutex::new(HashMap::new()))
8960        .lock()
8961        .expect("workspace crate prefix build counts mutex poisoned");
8962    *counts.entry(project_root.to_path_buf()).or_default() += 1;
8963}
8964
8965#[cfg(not(test))]
8966fn note_workspace_crate_prefix_build(_project_root: &Path) {}
8967
8968#[cfg(test)]
8969fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
8970    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8971        .get_or_init(|| Mutex::new(HashMap::new()))
8972        .lock()
8973        .expect("workspace crate prefix build counts mutex poisoned")
8974        .remove(project_root);
8975}
8976
8977#[cfg(test)]
8978fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
8979    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
8980        .get_or_init(|| Mutex::new(HashMap::new()))
8981        .lock()
8982        .expect("workspace crate prefix build counts mutex poisoned")
8983        .get(project_root)
8984        .copied()
8985        .unwrap_or(0)
8986}
8987
8988/// Walk the project tree once and map every Rust crate name (package name with
8989/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
8990/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
8991/// longer re-walks the filesystem once per ref.
8992fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
8993    note_workspace_crate_prefix_build(project_root);
8994    let mut prefixes = HashMap::new();
8995    let mut stack = vec![project_root.to_path_buf()];
8996    while let Some(dir) = stack.pop() {
8997        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
8998        if matches!(name, "target" | "node_modules" | ".git") {
8999            continue;
9000        }
9001        let manifest = dir.join("Cargo.toml");
9002        if manifest.is_file() {
9003            let crate_names = rust_manifest_crate_names(&manifest);
9004            if !crate_names.is_empty() {
9005                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9006                for crate_name in crate_names {
9007                    prefixes
9008                        .entry(crate_name)
9009                        .or_insert_with(|| src_prefix.clone());
9010                }
9011            }
9012        }
9013        let Ok(entries) = std::fs::read_dir(&dir) else {
9014            continue;
9015        };
9016        for entry in entries.flatten() {
9017            let path = entry.path();
9018            if path.is_dir() {
9019                stack.push(path);
9020            }
9021        }
9022    }
9023    prefixes
9024}
9025
9026/// Extract the crate names a manifest defines: the normalized package name
9027/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
9028/// reachable by either spelling, matching the previous match semantics.
9029fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9030    let Ok(source) = std::fs::read_to_string(manifest) else {
9031        return Vec::new();
9032    };
9033    let mut in_lib = false;
9034    let mut package_name = None;
9035    let mut lib_name = None;
9036    for line in source.lines() {
9037        let trimmed = line.trim();
9038        if trimmed.starts_with('[') {
9039            in_lib = trimmed == "[lib]";
9040            continue;
9041        }
9042        let Some((key, value)) = trimmed.split_once('=') else {
9043            continue;
9044        };
9045        let key = key.trim();
9046        let value = value.trim().trim_matches('"');
9047        if in_lib && key == "name" {
9048            lib_name = Some(value.to_string());
9049        } else if !in_lib && key == "name" && package_name.is_none() {
9050            package_name = Some(value.to_string());
9051        }
9052    }
9053    let mut names = Vec::new();
9054    if let Some(lib) = lib_name {
9055        names.push(lib);
9056    }
9057    if let Some(package) = package_name {
9058        let normalized = package.replace('-', "_");
9059        if !names.contains(&normalized) {
9060            names.push(normalized);
9061        }
9062    }
9063    names
9064}
9065
9066fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9067    if segments.is_empty() {
9068        return Some(Vec::new());
9069    }
9070    let caller_segments = rust_module_segments_for_rel(caller_file);
9071    match segments[0] {
9072        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9073        "self" => {
9074            let mut resolved = caller_segments;
9075            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9076            Some(resolved)
9077        }
9078        "super" => {
9079            let mut resolved = caller_segments;
9080            resolved.pop();
9081            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9082            Some(resolved)
9083        }
9084        _ => {
9085            let mut resolved = caller_segments;
9086            resolved.pop();
9087            resolved.extend(segments.iter().map(|item| item.to_string()));
9088            Some(resolved)
9089        }
9090    }
9091}
9092
9093fn rust_file_for_segments<I: ResolverIndex>(
9094    index: &I,
9095    caller_file: &str,
9096    segments: &[String],
9097) -> Option<String> {
9098    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9099}
9100
9101fn rust_file_for_src_prefix<I: ResolverIndex>(
9102    index: &I,
9103    src_prefix: &str,
9104    segments: &[String],
9105) -> Option<String> {
9106    let candidate = if segments.is_empty() {
9107        [src_prefix, "lib.rs"].join("/")
9108    } else {
9109        format!("{}/{}.rs", src_prefix, segments.join("/"))
9110    };
9111    if index.contains_file(&candidate) {
9112        return Some(candidate);
9113    }
9114    if !segments.is_empty() {
9115        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9116        if index.contains_file(&mod_candidate) {
9117            return Some(mod_candidate);
9118        }
9119    }
9120    None
9121}
9122
9123fn rust_src_prefix(rel_path: &str) -> String {
9124    rel_path
9125        .split_once("/src/")
9126        .map(|(prefix, _)| format!("{prefix}/src"))
9127        .unwrap_or_else(|| "src".to_string())
9128}
9129
9130fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9131    let after_src = rel_path
9132        .split_once("/src/")
9133        .map(|(_, rest)| rest)
9134        .or_else(|| rel_path.strip_prefix("src/"))
9135        .unwrap_or(rel_path);
9136    if matches!(after_src, "lib.rs" | "main.rs") {
9137        return Vec::new();
9138    }
9139    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9140        return prefix.split('/').map(|item| item.to_string()).collect();
9141    }
9142    after_src
9143        .strip_suffix(".rs")
9144        .unwrap_or(after_src)
9145        .split('/')
9146        .map(|item| item.to_string())
9147        .collect()
9148}
9149
9150fn resolve_local_target<I: ResolverIndex>(
9151    _index: &I,
9152    caller_file: &str,
9153    full_ref: &str,
9154    short_name: &str,
9155    caller_data: &FileCallData,
9156) -> Option<(String, String, String)> {
9157    if !callgraph::is_bare_callee(full_ref, short_name) {
9158        return None;
9159    }
9160    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9161        .ok()
9162        .map(|symbol| {
9163            (
9164                "resolved_local".to_string(),
9165                caller_file.to_string(),
9166                symbol,
9167            )
9168        })
9169}
9170
9171impl<'a> ProjectIndex<'a> {
9172    fn from_parts(
9173        project_root: &Path,
9174        files: HashMap<String, DbFileIndex>,
9175        caller_data: HashMap<String, &'a FileCallData>,
9176        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9177    ) -> Self {
9178        Self {
9179            project_root: project_root.to_path_buf(),
9180            files,
9181            caller_data,
9182            workspace_crate_prefixes,
9183        }
9184    }
9185
9186    fn from_db_and_callers(
9187        tx: &Transaction<'_>,
9188        project_root: &Path,
9189        caller_extracts: &'a HashMap<String, FileExtract>,
9190        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9191    ) -> Result<Self> {
9192        let mut files = load_db_file_indexes(tx, project_root)?;
9193        let mut caller_data = HashMap::new();
9194        for (rel_path, extract) in caller_extracts {
9195            files.insert(
9196                rel_path.clone(),
9197                DbFileIndex::from_extract(project_root, extract),
9198            );
9199            caller_data.insert(rel_path.clone(), &extract.data);
9200        }
9201        Ok(Self::from_parts(
9202            project_root,
9203            files,
9204            caller_data,
9205            workspace_crate_prefixes,
9206        ))
9207    }
9208
9209    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9210        self.files.get(rel_path).and_then(|file| file.lang)
9211    }
9212
9213    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9214        self.files
9215            .get(caller_file)
9216            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9217    }
9218
9219    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9220        self.files
9221            .get(rel_path)
9222            .map(|file| file.reexports.as_slice())
9223            .unwrap_or(&[])
9224    }
9225
9226    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9227        self.files.get(rel_path).and_then(|file| {
9228            file.node_by_scoped
9229                .get(symbol)
9230                .cloned()
9231                .or_else(|| file.node_by_bare.get(symbol).cloned())
9232        })
9233    }
9234
9235    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9236        self.files
9237            .get(rel_path)
9238            .and_then(|file| file.node_kind_by_id.get(node_id))
9239            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9240    }
9241}
9242
9243impl DbFileIndex {
9244    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9245        let mut node_by_scoped = HashMap::new();
9246        let mut node_by_bare = HashMap::new();
9247        for node in &extract.nodes {
9248            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9249            node_by_bare
9250                .entry(node.name.clone())
9251                .or_insert(node.id.clone());
9252        }
9253        let node_kind_by_id = extract
9254            .nodes
9255            .iter()
9256            .map(|node| (node.id.clone(), node.kind.clone()))
9257            .collect();
9258        let mut export_aliases = HashMap::new();
9259        for raw_ref in &extract.raw_refs {
9260            if raw_ref.kind == "export_alias" {
9261                if let (Some(exported), Some(source_symbol)) =
9262                    (&raw_ref.local_name, &raw_ref.requested_name)
9263                {
9264                    export_aliases.insert(exported.clone(), source_symbol.clone());
9265                }
9266            }
9267        }
9268        let mut module_targets = HashMap::new();
9269        let mut reexports = Vec::new();
9270        for raw_ref in &extract.raw_refs {
9271            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9272                continue;
9273            }
9274            let Some(module_path) = &raw_ref.module_path else {
9275                continue;
9276            };
9277            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9278            module_targets
9279                .entry(module_path.clone())
9280                .or_insert_with(|| target_file.clone());
9281            if raw_ref.kind == "reexport" {
9282                reexports.push(reexport_index_from_raw(raw_ref, target_file));
9283            }
9284        }
9285        Self {
9286            lang: Some(extract.lang),
9287            exports: extract.data.exported_symbols.iter().cloned().collect(),
9288            default_export: extract.data.default_export_symbol.clone(),
9289            export_aliases,
9290            node_by_scoped,
9291            node_by_bare,
9292            node_kind_by_id,
9293            module_targets,
9294            reexports,
9295        }
9296    }
9297}
9298
9299fn load_db_file_indexes(
9300    tx: &Transaction<'_>,
9301    project_root: &Path,
9302) -> Result<HashMap<String, DbFileIndex>> {
9303    let mut files = HashMap::new();
9304    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9305    let rows = stmt.query_map([], |row| {
9306        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9307    })?;
9308    for row in rows {
9309        let (rel_path, lang) = row?;
9310        files.insert(
9311            rel_path.clone(),
9312            DbFileIndex {
9313                lang: lang_from_label(&lang),
9314                exports: HashSet::new(),
9315                default_export: None,
9316                export_aliases: HashMap::new(),
9317                node_by_scoped: HashMap::new(),
9318                node_by_bare: HashMap::new(),
9319                node_kind_by_id: HashMap::new(),
9320                module_targets: HashMap::new(),
9321                reexports: Vec::new(),
9322            },
9323        );
9324    }
9325
9326    let mut node_stmt = tx.prepare(
9327        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9328    )?;
9329    let nodes = node_stmt.query_map([], |row| {
9330        Ok((
9331            row.get::<_, String>(0)?,
9332            row.get::<_, String>(1)?,
9333            row.get::<_, String>(2)?,
9334            row.get::<_, String>(3)?,
9335            row.get::<_, String>(4)?,
9336            row.get::<_, i64>(5)? != 0,
9337            row.get::<_, i64>(6)? != 0,
9338        ))
9339    })?;
9340    for row in nodes {
9341        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9342        let file = files
9343            .entry(file_path.clone())
9344            .or_insert_with(|| DbFileIndex {
9345                lang: None,
9346                exports: HashSet::new(),
9347                default_export: None,
9348                export_aliases: HashMap::new(),
9349                node_by_scoped: HashMap::new(),
9350                node_by_bare: HashMap::new(),
9351                node_kind_by_id: HashMap::new(),
9352                module_targets: HashMap::new(),
9353                reexports: Vec::new(),
9354            });
9355        if exported {
9356            file.exports.insert(name.clone());
9357            file.exports.insert(scoped_name.clone());
9358        }
9359        if is_default_export {
9360            file.default_export = Some(scoped_name.clone());
9361        }
9362        file.node_by_scoped.insert(scoped_name, id.clone());
9363        file.node_by_bare.entry(name).or_insert(id.clone());
9364        file.node_kind_by_id.insert(id, kind);
9365    }
9366    let file_keys: HashSet<String> = files.keys().cloned().collect();
9367    // Persisted caller extracts supply import targets. Only reexports from other
9368    // files need dependency reconstruction, and their caller dependencies are
9369    // loaded once instead of issuing repeated SQLite queries per reference.
9370    let dependencies_by_file = load_file_dependencies_index(tx)?;
9371    let mut ref_stmt = tx.prepare(
9372        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9373         FROM refs WHERE kind IN ('reexport', 'export_alias')",
9374    )?;
9375    let ref_rows = ref_stmt.query_map([], |row| {
9376        Ok((
9377            row.get::<_, String>(0)?,
9378            row.get::<_, String>(1)?,
9379            row.get::<_, String>(2)?,
9380            row.get::<_, Option<String>>(3)?,
9381            row.get::<_, Option<String>>(4)?,
9382            row.get::<_, i64>(5)? != 0,
9383            row.get::<_, Option<String>>(6)?,
9384            row.get::<_, Option<String>>(7)?,
9385        ))
9386    })?;
9387    for row in ref_rows {
9388        let (
9389            ref_id,
9390            caller_file,
9391            kind,
9392            module_path,
9393            full_ref,
9394            wildcard,
9395            local_name,
9396            requested_name,
9397        ) = row?;
9398        if kind == "export_alias" {
9399            if let (Some(exported), Some(source_symbol), Some(file)) =
9400                (local_name, requested_name, files.get_mut(&caller_file))
9401            {
9402                file.export_aliases.insert(exported, source_symbol);
9403            }
9404            continue;
9405        }
9406        let Some(module_path) = module_path else {
9407            continue;
9408        };
9409        let file_deps = dependencies_by_file
9410            .get(&caller_file)
9411            .cloned()
9412            .unwrap_or_default();
9413        let deps = stored_dependencies_for_module(
9414            project_root,
9415            &caller_file,
9416            &module_path,
9417            &file_deps,
9418            &file_keys,
9419        );
9420        let target_file = deps
9421            .iter()
9422            .find(|dep| file_keys.contains(*dep))
9423            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9424        if let Some(file) = files.get_mut(&caller_file) {
9425            file.module_targets
9426                .entry(module_path.clone())
9427                .or_insert_with(|| target_file.clone());
9428            if kind == "reexport" {
9429                let raw = RawRef {
9430                    ref_id,
9431                    caller_node: None,
9432                    caller_symbol: None,
9433                    caller_file,
9434                    kind,
9435                    short_name: None,
9436                    full_ref,
9437                    module_path: Some(module_path),
9438                    import_kind: Some("reexport".to_string()),
9439                    local_name: None,
9440                    requested_name: None,
9441                    namespace_alias: None,
9442                    wildcard,
9443                    line: 0,
9444                    byte_start: 0,
9445                    byte_end: 0,
9446                    dependencies: deps,
9447                };
9448                file.reexports
9449                    .push(reexport_index_from_raw(&raw, target_file));
9450            }
9451        }
9452    }
9453
9454    Ok(files)
9455}
9456
9457fn stored_dependencies_for_module(
9458    project_root: &Path,
9459    caller_file: &str,
9460    module_path: &str,
9461    caller_dependencies: &BTreeSet<String>,
9462    indexed_files: &HashSet<String>,
9463) -> BTreeSet<String> {
9464    let caller_path = project_root.join(caller_file);
9465    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9466    if module_path.starts_with('.') {
9467        let caller_dir = caller_path.parent().unwrap_or(project_root);
9468        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9469            let normalized = if candidate.is_file() {
9470                canonicalize_path(&candidate)
9471            } else {
9472                candidate
9473            };
9474            candidates.insert(relative_path(project_root, &normalized));
9475        }
9476    }
9477    let exact = candidates
9478        .intersection(caller_dependencies)
9479        .filter(|dependency| indexed_files.contains(*dependency))
9480        .cloned()
9481        .collect::<BTreeSet<_>>();
9482    if !exact.is_empty() || module_path.starts_with('.') {
9483        return exact;
9484    }
9485
9486    let module_path = rust_module_path_without_alias_or_use_list(module_path)
9487        .trim_matches(|character| matches!(character, '\'' | '"'));
9488    let package_name = module_path
9489        .split('/')
9490        .next_back()
9491        .unwrap_or(module_path)
9492        .replace('_', "-");
9493    let matched = caller_dependencies
9494        .iter()
9495        .filter(|dependency| indexed_files.contains(*dependency))
9496        .filter(|dependency| {
9497            dependency.as_str() == module_path
9498                || dependency.ends_with(&format!("/{module_path}"))
9499                || Path::new(dependency).components().any(|component| {
9500                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9501                })
9502        })
9503        .cloned()
9504        .collect::<BTreeSet<_>>();
9505    if matched.len() == 1 {
9506        matched
9507    } else {
9508        BTreeSet::new()
9509    }
9510}
9511
9512fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9513    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9514    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9515    let rows = stmt.query_map([], |row| {
9516        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9517    })?;
9518    for row in rows {
9519        let (file_path, dependency) = row?;
9520        by_file.entry(file_path).or_default().insert(dependency);
9521    }
9522    Ok(by_file)
9523}
9524
9525struct ColdBuildInsertStatements<'stmt> {
9526    file: Statement<'stmt>,
9527    node: Statement<'stmt>,
9528    file_dependency: Statement<'stmt>,
9529    dispatch_hint: Statement<'stmt>,
9530    backend_state: Statement<'stmt>,
9531    reference: Statement<'stmt>,
9532    staging_ref_context: Statement<'stmt>,
9533    edge: Statement<'stmt>,
9534}
9535
9536impl<'stmt> ColdBuildInsertStatements<'stmt> {
9537    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9538        Ok(Self {
9539            file: tx.prepare(
9540                "INSERT OR REPLACE INTO files(
9541                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9542                    is_public_api, surface_fingerprint, indexed_at
9543                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9544            )?,
9545            node: tx.prepare(
9546                "INSERT OR REPLACE INTO nodes(
9547                    id, file_path, name, scoped_name, kind, start_line, start_col,
9548                    end_line, end_col, range_ordinal, signature, exported,
9549                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
9550                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9551            )?,
9552            file_dependency: tx.prepare(
9553                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9554            )?,
9555            dispatch_hint: tx.prepare(
9556                "INSERT OR REPLACE INTO dispatch_hints(
9557                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9558                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9559            )?,
9560            backend_state: tx.prepare(
9561                "INSERT OR REPLACE INTO backend_file_state(
9562                    backend, workspace_root, file_path, content_hash, status, updated_at
9563                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9564            )?,
9565            reference: tx.prepare(
9566                "INSERT OR REPLACE INTO refs(
9567                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9568                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9569                    byte_start, byte_end, status, target_node, target_file, target_symbol,
9570                    provenance
9571                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9572            )?,
9573            staging_ref_context: tx.prepare(
9574                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9575            )?,
9576            edge: tx.prepare(
9577                "INSERT OR REPLACE INTO edges(
9578                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9579                    kind, line, provenance
9580                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9581            )?,
9582        })
9583    }
9584}
9585
9586fn insert_file_extract_prepared(
9587    statements: &mut ColdBuildInsertStatements<'_>,
9588    workspace_root: &str,
9589    extract: &FileExtract,
9590) -> Result<()> {
9591    statements.file.execute(params![
9592        extract.rel_path,
9593        hash_to_hex(extract.freshness.content_hash),
9594        system_time_to_ns(extract.freshness.mtime),
9595        extract.freshness.size as i64,
9596        lang_label(extract.lang),
9597        extract.surface_fingerprint,
9598        unix_seconds_now(),
9599    ])?;
9600    for node in &extract.nodes {
9601        statements.node.execute(params![
9602            node.id,
9603            node.file_path,
9604            node.name,
9605            node.scoped_name,
9606            node.kind,
9607            node.range.start_line as i64,
9608            node.range.start_col as i64,
9609            node.range.end_line as i64,
9610            node.range.end_col as i64,
9611            node.range_ordinal as i64,
9612            node.signature,
9613            bool_int(node.exported),
9614            bool_int(node.is_default_export),
9615            bool_int(node.is_type_like),
9616            bool_int(node.is_callgraph_entry_point),
9617            PROVENANCE_TREESITTER,
9618        ])?;
9619    }
9620
9621    let mut dependencies = BTreeSet::new();
9622    for raw_ref in &extract.raw_refs {
9623        dependencies.extend(raw_ref.dependencies.iter().cloned());
9624    }
9625    for dep_file in &dependencies {
9626        statements
9627            .file_dependency
9628            .execute(params![extract.rel_path, dep_file])?;
9629    }
9630
9631    for hint in &extract.dispatch_hints {
9632        statements.dispatch_hint.execute(params![
9633            hint.id,
9634            hint.method_name,
9635            hint.caller_node,
9636            hint.file,
9637            hint.line as i64,
9638            hint.byte_start as i64,
9639            hint.byte_end as i64,
9640            PROVENANCE_TREESITTER,
9641        ])?;
9642    }
9643    insert_backend_state_prepared(
9644        &mut statements.backend_state,
9645        workspace_root,
9646        &extract.rel_path,
9647        Some(&extract.freshness.content_hash),
9648        "fresh",
9649    )?;
9650    Ok(())
9651}
9652
9653fn insert_backend_state_prepared(
9654    stmt: &mut Statement<'_>,
9655    workspace_root: &str,
9656    rel_path: &str,
9657    content_hash: Option<&blake3::Hash>,
9658    status: &str,
9659) -> Result<()> {
9660    let hash = content_hash
9661        .map(|hash| hash_to_hex(*hash))
9662        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9663    stmt.execute(params![
9664        BACKEND_TREESITTER,
9665        workspace_root,
9666        rel_path,
9667        hash,
9668        status,
9669        unix_seconds_now(),
9670    ])?;
9671    Ok(())
9672}
9673
9674fn insert_staged_ref_prepared(
9675    statements: &mut ColdBuildInsertStatements<'_>,
9676    raw: &RawRef,
9677) -> Result<()> {
9678    statements.reference.execute(params![
9679        raw.ref_id,
9680        raw.caller_node,
9681        raw.caller_file,
9682        raw.kind,
9683        raw.short_name,
9684        raw.full_ref,
9685        raw.module_path,
9686        raw.import_kind,
9687        raw.local_name,
9688        raw.requested_name,
9689        raw.namespace_alias,
9690        bool_int(raw.wildcard),
9691        raw.line as i64,
9692        raw.byte_start as i64,
9693        raw.byte_end as i64,
9694        "staged",
9695        Option::<String>::None,
9696        Option::<String>::None,
9697        Option::<String>::None,
9698        ref_provenance(raw),
9699    ])?;
9700    statements
9701        .staging_ref_context
9702        .execute(params![raw.ref_id, raw.caller_symbol])?;
9703    Ok(())
9704}
9705
9706fn insert_resolved_ref_prepared(
9707    statements: &mut ColdBuildInsertStatements<'_>,
9708    resolved: &ResolvedRef,
9709) -> Result<()> {
9710    let raw = &resolved.raw;
9711    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9712    statements.reference.execute(params![
9713        raw.ref_id,
9714        raw.caller_node,
9715        raw.caller_file,
9716        raw.kind,
9717        raw.short_name,
9718        raw.full_ref,
9719        raw.module_path,
9720        raw.import_kind,
9721        raw.local_name,
9722        raw.requested_name,
9723        raw.namespace_alias,
9724        bool_int(raw.wildcard),
9725        raw.line as i64,
9726        raw.byte_start as i64,
9727        raw.byte_end as i64,
9728        resolved.status,
9729        resolved.target_node,
9730        resolved.target_file,
9731        resolved.target_symbol,
9732        ref_provenance(raw),
9733    ])?;
9734    if let Some(edge) = &resolved.edge {
9735        statements.edge.execute(params![
9736            edge.edge_id,
9737            raw.ref_id,
9738            edge.source_node,
9739            edge.target_node,
9740            edge.target_file,
9741            edge.target_symbol,
9742            edge.kind,
9743            edge.line as i64,
9744            ref_provenance(raw),
9745        ])?;
9746    }
9747    Ok(())
9748}
9749
9750#[cfg(test)]
9751fn insert_file_extract(
9752    tx: &Transaction<'_>,
9753    project_root: &Path,
9754    extract: &FileExtract,
9755) -> Result<()> {
9756    tx.execute(
9757        "INSERT OR REPLACE INTO files(
9758            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9759            is_public_api, surface_fingerprint, indexed_at
9760        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9761        params![
9762            extract.rel_path,
9763            hash_to_hex(extract.freshness.content_hash),
9764            system_time_to_ns(extract.freshness.mtime),
9765            extract.freshness.size as i64,
9766            lang_label(extract.lang),
9767            extract.surface_fingerprint,
9768            unix_seconds_now(),
9769        ],
9770    )?;
9771    for node in &extract.nodes {
9772        tx.execute(
9773            "INSERT OR REPLACE INTO nodes(
9774                id, file_path, name, scoped_name, kind, start_line, start_col,
9775                end_line, end_col, range_ordinal, signature, exported,
9776                is_default_export, is_type_like, is_callgraph_entry_point, provenance
9777            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9778            params![
9779                node.id,
9780                node.file_path,
9781                node.name,
9782                node.scoped_name,
9783                node.kind,
9784                node.range.start_line as i64,
9785                node.range.start_col as i64,
9786                node.range.end_line as i64,
9787                node.range.end_col as i64,
9788                node.range_ordinal as i64,
9789                node.signature,
9790                bool_int(node.exported),
9791                bool_int(node.is_default_export),
9792                bool_int(node.is_type_like),
9793                bool_int(node.is_callgraph_entry_point),
9794                PROVENANCE_TREESITTER,
9795            ],
9796        )?;
9797    }
9798    let mut dependencies = BTreeSet::new();
9799    for raw_ref in &extract.raw_refs {
9800        dependencies.extend(raw_ref.dependencies.iter().cloned());
9801    }
9802    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
9803
9804    for hint in &extract.dispatch_hints {
9805        tx.execute(
9806            "INSERT OR REPLACE INTO dispatch_hints(
9807                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9808            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9809            params![
9810                hint.id,
9811                hint.method_name,
9812                hint.caller_node,
9813                hint.file,
9814                hint.line as i64,
9815                hint.byte_start as i64,
9816                hint.byte_end as i64,
9817                PROVENANCE_TREESITTER,
9818            ],
9819        )?;
9820    }
9821    mark_backend_state(
9822        tx,
9823        project_root,
9824        &extract.rel_path,
9825        Some(&extract.freshness.content_hash),
9826        "fresh",
9827    )?;
9828    Ok(())
9829}
9830
9831#[cfg(test)]
9832fn insert_file_dependencies(
9833    tx: &Transaction<'_>,
9834    file_path: &str,
9835    dependencies: &BTreeSet<String>,
9836) -> Result<()> {
9837    for dep_file in dependencies {
9838        tx.execute(
9839            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9840            params![file_path, dep_file],
9841        )?;
9842    }
9843    Ok(())
9844}
9845
9846fn ref_provenance(raw: &RawRef) -> &'static str {
9847    if raw.kind == "value_ref" {
9848        PROVENANCE_VALUE_REF
9849    } else {
9850        PROVENANCE_TREESITTER
9851    }
9852}
9853
9854#[cfg(test)]
9855fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
9856    let raw = &resolved.raw;
9857    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9858    tx.execute(
9859        "INSERT OR REPLACE INTO refs(
9860            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9861            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9862            byte_start, byte_end, status, target_node, target_file, target_symbol,
9863            provenance
9864        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9865        params![
9866            raw.ref_id,
9867            raw.caller_node,
9868            raw.caller_file,
9869            raw.kind,
9870            raw.short_name,
9871            raw.full_ref,
9872            raw.module_path,
9873            raw.import_kind,
9874            raw.local_name,
9875            raw.requested_name,
9876            raw.namespace_alias,
9877            bool_int(raw.wildcard),
9878            raw.line as i64,
9879            raw.byte_start as i64,
9880            raw.byte_end as i64,
9881            resolved.status,
9882            resolved.target_node,
9883            resolved.target_file,
9884            resolved.target_symbol,
9885            ref_provenance(raw),
9886        ],
9887    )?;
9888    if let Some(edge) = &resolved.edge {
9889        tx.execute(
9890            "INSERT OR REPLACE INTO edges(
9891                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9892                kind, line, provenance
9893            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9894            params![
9895                edge.edge_id,
9896                raw.ref_id,
9897                edge.source_node,
9898                edge.target_node,
9899                edge.target_file,
9900                edge.target_symbol,
9901                edge.kind,
9902                edge.line as i64,
9903                ref_provenance(raw),
9904            ],
9905        )?;
9906    }
9907    Ok(())
9908}
9909
9910fn insert_method_dispatch_edges(
9911    tx: &Transaction<'_>,
9912    project_root: &Path,
9913    caller_files: Option<&BTreeSet<String>>,
9914) -> Result<usize> {
9915    let references = load_name_match_refs(tx, caller_files)?;
9916    if references.is_empty() {
9917        return Ok(0);
9918    }
9919
9920    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
9921    let mut source_cache: DispatchSourceCache = HashMap::new();
9922    let mut inserted = 0usize;
9923    for reference in references {
9924        let key = (reference.method_name.clone(), reference.lang.clone());
9925        let candidates = match candidates_by_name.entry(key) {
9926            Entry::Occupied(entry) => entry.into_mut(),
9927            Entry::Vacant(entry) => {
9928                let candidates =
9929                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
9930                entry.insert(candidates)
9931            }
9932        };
9933
9934        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
9935            ReceiverTypeInference::Known(receiver_type) => {
9936                let Some(candidate) =
9937                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
9938                else {
9939                    continue;
9940                };
9941                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9942                inserted += 1;
9943                continue;
9944            }
9945            ReceiverTypeInference::RustDirectSelfField {
9946                receiver_type,
9947                declaration_file,
9948                module_scope,
9949            } => {
9950                let Some(candidate) = select_rust_direct_self_field_candidate(
9951                    project_root,
9952                    &reference,
9953                    candidates.as_slice(),
9954                    &receiver_type,
9955                    &declaration_file,
9956                    &module_scope,
9957                    &mut source_cache,
9958                ) else {
9959                    continue;
9960                };
9961                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
9962                inserted += 1;
9963                continue;
9964            }
9965            ReceiverTypeInference::KnownButUnresolved => continue,
9966            ReceiverTypeInference::Unknown => {}
9967        }
9968
9969        if method_name_match_denylisted(&reference.method_name) {
9970            continue;
9971        }
9972
9973        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
9974            continue;
9975        };
9976        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
9977        inserted += 1;
9978    }
9979    Ok(inserted)
9980}
9981
9982fn insert_method_dispatch_edges_chunked(
9983    tx: &Transaction<'_>,
9984    project_root: &Path,
9985    chunk_size: usize,
9986) -> Result<usize> {
9987    let mut inserted = 0usize;
9988    let mut after_file = String::new();
9989    loop {
9990        let caller_files = {
9991            let mut statement = tx.prepare(
9992                "SELECT DISTINCT caller_file
9993                 FROM refs
9994                 WHERE caller_file > ?1
9995                 ORDER BY caller_file
9996                 LIMIT ?2",
9997            )?;
9998            let rows = statement
9999                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10000                    row.get::<_, String>(0)
10001                })?;
10002            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10003        };
10004        let Some(last_file) = caller_files.last().cloned() else {
10005            break;
10006        };
10007        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10008        after_file = last_file;
10009    }
10010    Ok(inserted)
10011}
10012
10013fn insert_method_dispatch_edge(
10014    tx: &Transaction<'_>,
10015    reference: &NameMatchRef,
10016    candidate: &NameMatchCandidate,
10017    provenance: &str,
10018) -> Result<()> {
10019    tx.execute(
10020        "INSERT OR REPLACE INTO edges(
10021            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10022            kind, line, provenance
10023        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10024        params![
10025            ref_id(&[&reference.ref_id, provenance, "edge"]),
10026            &reference.ref_id,
10027            &reference.caller_node,
10028            &candidate.node_id,
10029            &candidate.file_path,
10030            &candidate.scoped_name,
10031            reference.line as i64,
10032            provenance,
10033        ],
10034    )?;
10035    Ok(())
10036}
10037
10038fn delete_method_dispatch_edges_for_callers(
10039    tx: &Transaction<'_>,
10040    caller_files: &BTreeSet<String>,
10041) -> Result<()> {
10042    if caller_files.is_empty() {
10043        return Ok(());
10044    }
10045
10046    let mut stmt = tx.prepare(
10047        "DELETE FROM edges
10048         WHERE provenance IN (?1, ?2)
10049           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10050    )?;
10051    for caller_file in caller_files {
10052        stmt.execute(params![
10053            PROVENANCE_NAME_MATCH,
10054            PROVENANCE_TYPE_MATCH,
10055            caller_file
10056        ])?;
10057    }
10058    Ok(())
10059}
10060
10061fn load_name_match_refs(
10062    tx: &Transaction<'_>,
10063    caller_files: Option<&BTreeSet<String>>,
10064) -> Result<Vec<NameMatchRef>> {
10065    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10066                           n.signature, r.short_name, r.full_ref, r.line, f.lang
10067                    FROM refs r
10068                    JOIN files f ON f.path = r.caller_file
10069                    JOIN nodes n ON n.id = r.caller_node
10070                    WHERE r.kind = 'call'
10071                      AND r.status = 'unresolved'
10072                      AND r.caller_node IS NOT NULL
10073                      AND r.full_ref IS NOT NULL
10074                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10075                      AND NOT EXISTS (
10076                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10077                      )";
10078    let mut references = Vec::new();
10079
10080    if let Some(caller_files) = caller_files {
10081        if caller_files.is_empty() {
10082            return Ok(references);
10083        }
10084        let sql = format!(
10085            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10086        );
10087        let mut stmt = tx.prepare(&sql)?;
10088        for caller_file in caller_files {
10089            let rows = stmt.query_map(params![caller_file], |row| {
10090                Ok((
10091                    row.get::<_, String>(0)?,
10092                    row.get::<_, Option<String>>(1)?,
10093                    row.get::<_, String>(2)?,
10094                    row.get::<_, String>(3)?,
10095                    row.get::<_, Option<String>>(4)?,
10096                    row.get::<_, Option<String>>(5)?,
10097                    row.get::<_, Option<String>>(6)?,
10098                    row.get::<_, i64>(7)?,
10099                    row.get::<_, String>(8)?,
10100                ))
10101            })?;
10102            for row in rows {
10103                let (
10104                    ref_id,
10105                    caller_node,
10106                    caller_file,
10107                    caller_symbol,
10108                    caller_signature,
10109                    short_name,
10110                    full_ref,
10111                    line,
10112                    lang,
10113                ) = row?;
10114                if let Some(reference) = name_match_ref_from_parts(
10115                    ref_id,
10116                    caller_node,
10117                    caller_file,
10118                    caller_symbol,
10119                    caller_signature,
10120                    short_name,
10121                    full_ref,
10122                    line,
10123                    lang,
10124                ) {
10125                    references.push(reference);
10126                }
10127            }
10128        }
10129        return Ok(references);
10130    }
10131
10132    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10133    let mut stmt = tx.prepare(&sql)?;
10134    let rows = stmt.query_map([], |row| {
10135        Ok((
10136            row.get::<_, String>(0)?,
10137            row.get::<_, Option<String>>(1)?,
10138            row.get::<_, String>(2)?,
10139            row.get::<_, String>(3)?,
10140            row.get::<_, Option<String>>(4)?,
10141            row.get::<_, Option<String>>(5)?,
10142            row.get::<_, Option<String>>(6)?,
10143            row.get::<_, i64>(7)?,
10144            row.get::<_, String>(8)?,
10145        ))
10146    })?;
10147    for row in rows {
10148        let (
10149            ref_id,
10150            caller_node,
10151            caller_file,
10152            caller_symbol,
10153            caller_signature,
10154            short_name,
10155            full_ref,
10156            line,
10157            lang,
10158        ) = row?;
10159        if let Some(reference) = name_match_ref_from_parts(
10160            ref_id,
10161            caller_node,
10162            caller_file,
10163            caller_symbol,
10164            caller_signature,
10165            short_name,
10166            full_ref,
10167            line,
10168            lang,
10169        ) {
10170            references.push(reference);
10171        }
10172    }
10173    Ok(references)
10174}
10175
10176#[allow(clippy::too_many_arguments)]
10177fn name_match_ref_from_parts(
10178    ref_id: String,
10179    caller_node: Option<String>,
10180    caller_file: String,
10181    caller_symbol: String,
10182    caller_signature: Option<String>,
10183    short_name: Option<String>,
10184    full_ref: Option<String>,
10185    line: i64,
10186    lang: String,
10187) -> Option<NameMatchRef> {
10188    let caller_node = caller_node?;
10189    let full_ref = full_ref?;
10190    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10191    let method_name = if member.is_empty() {
10192        short_name.as_deref()?.to_string()
10193    } else {
10194        member
10195    };
10196    Some(NameMatchRef {
10197        ref_id,
10198        caller_node,
10199        caller_file,
10200        caller_symbol,
10201        caller_signature,
10202        receiver_expression,
10203        receiver,
10204        method_name,
10205        colon_dispatch,
10206        line: line.max(0) as u32,
10207        lang,
10208    })
10209}
10210
10211fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10212    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10213    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10214    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10215    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10216        .into_iter()
10217        .flatten()
10218        .max_by_key(|(index, _, _)| *index)?;
10219    if delimiter == 0 {
10220        return None;
10221    }
10222    let member_start = delimiter + delimiter_len;
10223    if member_start >= full_ref.len() {
10224        return None;
10225    }
10226    let receiver_expression = full_ref[..delimiter].trim();
10227    let receiver = last_name_segment(receiver_expression).trim();
10228    let member = &full_ref[member_start..];
10229    if receiver.is_empty() || member.is_empty() {
10230        return None;
10231    }
10232    Some((
10233        receiver_expression.to_string(),
10234        receiver.to_string(),
10235        member.to_string(),
10236        colon_dispatch,
10237    ))
10238}
10239
10240fn last_name_segment(value: &str) -> &str {
10241    value
10242        .rsplit(['.', ':', '/', '\\', '-', '>'])
10243        .find(|segment| !segment.is_empty())
10244        .unwrap_or(value)
10245}
10246
10247fn load_name_match_candidates(
10248    tx: &Transaction<'_>,
10249    method_name: &str,
10250    lang: &str,
10251) -> Result<Vec<NameMatchCandidate>> {
10252    let mut stmt = tx.prepare(
10253        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10254         FROM nodes n JOIN files f ON f.path = n.file_path
10255         WHERE n.name = ?1
10256           AND f.lang = ?2
10257           AND n.kind IN ('method', 'function')
10258         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10259    )?;
10260    let rows = stmt.query_map(params![method_name, lang], |row| {
10261        Ok(NameMatchCandidate {
10262            node_id: row.get(0)?,
10263            file_path: row.get(1)?,
10264            scoped_name: row.get(2)?,
10265            kind: row.get(3)?,
10266            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10267        })
10268    })?;
10269    rows.collect::<std::result::Result<Vec<_>, _>>()
10270        .map_err(Into::into)
10271}
10272
10273struct ParsedDispatchSource {
10274    source: String,
10275    tree: tree_sitter::Tree,
10276}
10277
10278type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10279
10280#[derive(Debug, Clone, PartialEq, Eq)]
10281enum ReceiverTypeInference {
10282    Unknown,
10283    Known(String),
10284    RustDirectSelfField {
10285        receiver_type: String,
10286        declaration_file: String,
10287        module_scope: Vec<(usize, usize)>,
10288    },
10289    KnownButUnresolved,
10290}
10291
10292#[cfg(test)]
10293fn infer_receiver_type(
10294    project_root: &Path,
10295    reference: &NameMatchRef,
10296    source_cache: &mut DispatchSourceCache,
10297) -> Option<String> {
10298    match infer_receiver_type_state(project_root, reference, source_cache) {
10299        ReceiverTypeInference::Known(receiver_type)
10300        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10301        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10302    }
10303}
10304
10305fn infer_receiver_type_state(
10306    project_root: &Path,
10307    reference: &NameMatchRef,
10308    source_cache: &mut DispatchSourceCache,
10309) -> ReceiverTypeInference {
10310    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10311    match reference.lang.as_str() {
10312        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10313        "java" => {
10314            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10315                .map(known)
10316                .unwrap_or(ReceiverTypeInference::Unknown)
10317        }
10318        "kotlin" => {
10319            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10320                .map(known)
10321                .unwrap_or(ReceiverTypeInference::Unknown)
10322        }
10323        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10324            .map(known)
10325            .unwrap_or(ReceiverTypeInference::Unknown),
10326        _ => ReceiverTypeInference::Unknown,
10327    }
10328}
10329
10330fn parse_dispatch_source(
10331    project_root: &Path,
10332    caller_file: &str,
10333    lang: LangId,
10334) -> Option<ParsedDispatchSource> {
10335    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10336    let grammar = crate::parser::grammar_for(lang);
10337    let mut parser = tree_sitter::Parser::new();
10338    parser.set_language(&grammar).ok()?;
10339    let tree = parser.parse(&source, None)?;
10340    Some(ParsedDispatchSource { source, tree })
10341}
10342
10343fn parsed_dispatch_source<'a>(
10344    project_root: &Path,
10345    reference: &NameMatchRef,
10346    lang: LangId,
10347    source_cache: &'a mut DispatchSourceCache,
10348) -> Option<&'a ParsedDispatchSource> {
10349    parsed_dispatch_source_for_file(
10350        project_root,
10351        &reference.caller_file,
10352        &reference.lang,
10353        lang,
10354        source_cache,
10355    )
10356}
10357
10358fn parsed_dispatch_source_for_file<'a>(
10359    project_root: &Path,
10360    file_path: &str,
10361    lang_label: &str,
10362    lang: LangId,
10363    source_cache: &'a mut DispatchSourceCache,
10364) -> Option<&'a ParsedDispatchSource> {
10365    let key = (file_path.to_string(), lang_label.to_string());
10366    source_cache
10367        .entry(key)
10368        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10369        .as_ref()
10370}
10371
10372fn infer_java_like_receiver_type(
10373    project_root: &Path,
10374    reference: &NameMatchRef,
10375    lang: LangId,
10376    source_cache: &mut DispatchSourceCache,
10377) -> Option<String> {
10378    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10379        return None;
10380    }
10381
10382    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10383    let root = parsed.tree.root_node();
10384    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10385
10386    let callable_scope = type_node
10387        .and_then(|node| {
10388            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10389        })
10390        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10391
10392    if let Some(callable_scope) = callable_scope {
10393        if let Some(receiver_type) = infer_java_like_local_receiver_type(
10394            callable_scope,
10395            &parsed.source,
10396            &reference.receiver,
10397            reference.line.max(1),
10398            lang,
10399        ) {
10400            return Some(receiver_type);
10401        }
10402    }
10403
10404    type_node.and_then(|node| {
10405        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10406    })
10407}
10408
10409fn infer_cpp_receiver_type(
10410    project_root: &Path,
10411    reference: &NameMatchRef,
10412    source_cache: &mut DispatchSourceCache,
10413) -> Option<String> {
10414    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10415        return None;
10416    }
10417
10418    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10419    let root = parsed.tree.root_node();
10420    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10421    infer_cpp_receiver_type_from_scope(
10422        scope,
10423        &parsed.source,
10424        &reference.receiver,
10425        reference.line.max(1),
10426    )
10427}
10428
10429fn find_enclosing_java_like_type_node<'tree>(
10430    root: tree_sitter::Node<'tree>,
10431    source: &str,
10432    reference: &NameMatchRef,
10433    lang: LangId,
10434) -> Option<tree_sitter::Node<'tree>> {
10435    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10436        .and_then(|name| simple_type_name(&name));
10437    let line = reference.line.max(1);
10438    let mut best = None;
10439    let mut stack = vec![root];
10440    while let Some(node) = stack.pop() {
10441        if !node_contains_line(node, line) {
10442            continue;
10443        }
10444        if is_java_like_type_kind(node.kind(), lang) {
10445            let name = declaration_name(node, source);
10446            if expected_type
10447                .as_deref()
10448                .is_none_or(|expected| name == Some(expected))
10449            {
10450                best = tighter_node(best, node);
10451            }
10452        }
10453        push_named_children(node, &mut stack);
10454    }
10455    best
10456}
10457
10458fn find_enclosing_java_like_callable_node<'tree>(
10459    root: tree_sitter::Node<'tree>,
10460    source: &str,
10461    reference: &NameMatchRef,
10462    lang: LangId,
10463) -> Option<tree_sitter::Node<'tree>> {
10464    let expected_name = reference.caller_symbol.rsplit("::").next();
10465    let line = reference.line.max(1);
10466    let mut best = None;
10467    let mut stack = vec![root];
10468    while let Some(node) = stack.pop() {
10469        if !node_contains_line(node, line) {
10470            continue;
10471        }
10472        if is_java_like_callable_kind(node.kind(), lang) {
10473            let name = declaration_name(node, source);
10474            if expected_name.is_none_or(|expected| name == Some(expected)) {
10475                best = tighter_node(best, node);
10476            }
10477        }
10478        push_named_children(node, &mut stack);
10479    }
10480    best
10481}
10482
10483fn find_enclosing_cpp_callable_node<'tree>(
10484    root: tree_sitter::Node<'tree>,
10485    _source: &str,
10486    reference: &NameMatchRef,
10487) -> Option<tree_sitter::Node<'tree>> {
10488    let line = reference.line.max(1);
10489    let mut best = None;
10490    let mut stack = vec![root];
10491    while let Some(node) = stack.pop() {
10492        if !node_contains_line(node, line) {
10493            continue;
10494        }
10495        if node.kind() == "function_definition" {
10496            best = tighter_node(best, node);
10497        }
10498        push_named_children(node, &mut stack);
10499    }
10500    best
10501}
10502
10503fn tighter_node<'tree>(
10504    current: Option<tree_sitter::Node<'tree>>,
10505    candidate: tree_sitter::Node<'tree>,
10506) -> Option<tree_sitter::Node<'tree>> {
10507    match current {
10508        Some(current)
10509            if current.start_byte() > candidate.start_byte()
10510                || (current.start_byte() == candidate.start_byte()
10511                    && current.end_byte() <= candidate.end_byte()) =>
10512        {
10513            Some(current)
10514        }
10515        _ => Some(candidate),
10516    }
10517}
10518
10519fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10520    let start = node.start_position().row as u32 + 1;
10521    let end = node.end_position().row as u32 + 1;
10522    start <= line && line <= end
10523}
10524
10525fn push_named_children<'tree>(
10526    node: tree_sitter::Node<'tree>,
10527    stack: &mut Vec<tree_sitter::Node<'tree>>,
10528) {
10529    for index in 0..node.named_child_count() {
10530        if let Some(child) = node.named_child(index as u32) {
10531            stack.push(child);
10532        }
10533    }
10534}
10535
10536fn declaration_name<'source>(
10537    node: tree_sitter::Node<'_>,
10538    source: &'source str,
10539) -> Option<&'source str> {
10540    node.child_by_field_name("name")
10541        .map(|name| node_text(name, source))
10542        .or_else(|| {
10543            first_named_child_text(
10544                node,
10545                source,
10546                &["identifier", "type_identifier", "simple_identifier"],
10547            )
10548        })
10549}
10550
10551fn first_named_child_text<'source>(
10552    node: tree_sitter::Node<'_>,
10553    source: &'source str,
10554    kinds: &[&str],
10555) -> Option<&'source str> {
10556    for index in 0..node.named_child_count() {
10557        let child = node.named_child(index as u32)?;
10558        if kinds.contains(&child.kind()) {
10559            return Some(node_text(child, source));
10560        }
10561    }
10562    None
10563}
10564
10565fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10566    &source[node.byte_range()]
10567}
10568
10569fn infer_java_like_field_receiver_type(
10570    type_node: tree_sitter::Node<'_>,
10571    source: &str,
10572    receiver: &str,
10573    lang: LangId,
10574) -> Option<String> {
10575    let mut stack = Vec::new();
10576    push_named_children(type_node, &mut stack);
10577    while let Some(node) = stack.pop() {
10578        if is_java_like_field_kind(node.kind(), lang) {
10579            if let Some(receiver_type) =
10580                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10581            {
10582                return Some(receiver_type);
10583            }
10584        }
10585        if is_java_like_type_kind(node.kind(), lang)
10586            || is_java_like_callable_kind(node.kind(), lang)
10587        {
10588            continue;
10589        }
10590        push_named_children(node, &mut stack);
10591    }
10592    None
10593}
10594
10595fn infer_java_like_local_receiver_type(
10596    callable_node: tree_sitter::Node<'_>,
10597    source: &str,
10598    receiver: &str,
10599    call_line: u32,
10600    lang: LangId,
10601) -> Option<String> {
10602    let mut best: Option<(u32, String)> = None;
10603    let mut stack = Vec::new();
10604    push_named_children(callable_node, &mut stack);
10605    while let Some(node) = stack.pop() {
10606        let start_line = node.start_position().row as u32 + 1;
10607        if start_line > call_line {
10608            continue;
10609        }
10610        if is_java_like_local_kind(node.kind(), lang) {
10611            if let Some(receiver_type) =
10612                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10613            {
10614                if best
10615                    .as_ref()
10616                    .is_none_or(|(best_line, _)| start_line >= *best_line)
10617                {
10618                    best = Some((start_line, receiver_type));
10619                }
10620            }
10621        }
10622        if is_java_like_type_kind(node.kind(), lang)
10623            || is_java_like_callable_kind(node.kind(), lang)
10624        {
10625            continue;
10626        }
10627        push_named_children(node, &mut stack);
10628    }
10629    best.map(|(_, receiver_type)| receiver_type)
10630}
10631
10632fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10633    match lang {
10634        LangId::Java => matches!(
10635            kind,
10636            "class_declaration"
10637                | "interface_declaration"
10638                | "enum_declaration"
10639                | "record_declaration"
10640                | "annotation_type_declaration"
10641        ),
10642        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10643        _ => false,
10644    }
10645}
10646
10647fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10648    match lang {
10649        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10650        LangId::Kotlin => kind == "function_declaration",
10651        _ => false,
10652    }
10653}
10654
10655fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10656    match lang {
10657        LangId::Java => kind == "field_declaration",
10658        LangId::Kotlin => kind == "property_declaration",
10659        _ => false,
10660    }
10661}
10662
10663fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10664    match lang {
10665        LangId::Java => kind == "local_variable_declaration",
10666        LangId::Kotlin => kind == "property_declaration",
10667        _ => false,
10668    }
10669}
10670
10671fn extract_java_like_declared_type(
10672    declaration: &str,
10673    receiver: &str,
10674    lang: LangId,
10675) -> Option<String> {
10676    match lang {
10677        LangId::Java => extract_java_declared_type(declaration, receiver),
10678        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10679        _ => None,
10680    }
10681}
10682
10683fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10684    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10685    let after = declaration[receiver_start + receiver.len()..].trim_start();
10686    if after
10687        .chars()
10688        .next()
10689        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10690    {
10691        return None;
10692    }
10693
10694    let before = declaration[..receiver_start].trim_end();
10695    if before.contains(',') {
10696        return None;
10697    }
10698    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10699}
10700
10701fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10702    loop {
10703        value = value.trim_start();
10704        if let Some(stripped) = strip_leading_java_annotation(value) {
10705            value = stripped;
10706            continue;
10707        }
10708        if let Some(stripped) = strip_leading_java_modifier(value) {
10709            value = stripped;
10710            continue;
10711        }
10712        return value.trim();
10713    }
10714}
10715
10716fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10717    let value = value.trim_start();
10718    let mut chars = value.char_indices();
10719    let (_, first) = chars.next()?;
10720    if first != '@' {
10721        return None;
10722    }
10723    let mut end = first.len_utf8();
10724    for (index, ch) in chars {
10725        if !(is_code_ident_char(ch) || ch == '.') {
10726            end = index;
10727            break;
10728        }
10729        end = index + ch.len_utf8();
10730    }
10731    let rest = value[end..].trim_start();
10732    if let Some(stripped) = rest.strip_prefix('(') {
10733        let mut depth = 1usize;
10734        for (index, ch) in stripped.char_indices() {
10735            match ch {
10736                '(' => depth += 1,
10737                ')' => {
10738                    depth = depth.saturating_sub(1);
10739                    if depth == 0 {
10740                        return Some(stripped[index + ch.len_utf8()..].trim_start());
10741                    }
10742                }
10743                _ => {}
10744            }
10745        }
10746        return Some("");
10747    }
10748    Some(rest)
10749}
10750
10751fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10752    const MODIFIERS: &[&str] = &[
10753        "public",
10754        "protected",
10755        "private",
10756        "abstract",
10757        "static",
10758        "final",
10759        "transient",
10760        "volatile",
10761        "synchronized",
10762        "native",
10763        "strictfp",
10764    ];
10765    MODIFIERS
10766        .iter()
10767        .find_map(|modifier| strip_leading_word(value, modifier))
10768}
10769
10770fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10771    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10772    let before = &declaration[..receiver_start];
10773    if find_identifier_occurrence(before, "val").is_none()
10774        && find_identifier_occurrence(before, "var").is_none()
10775    {
10776        return None;
10777    }
10778
10779    let after = declaration[receiver_start + receiver.len()..].trim_start();
10780    if let Some(type_text) = after.strip_prefix(':') {
10781        return normalize_receiver_type_name(read_type_prefix(type_text));
10782    }
10783    after
10784        .strip_prefix('=')
10785        .and_then(infer_kotlin_constructor_type)
10786}
10787
10788fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
10789    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
10790    if rest.trim_start().starts_with('(') {
10791        normalize_receiver_type_name(head)
10792    } else {
10793        None
10794    }
10795}
10796
10797fn read_type_prefix(value: &str) -> &str {
10798    let mut angle_depth = 0usize;
10799    for (index, ch) in value.char_indices() {
10800        match ch {
10801            '<' => angle_depth += 1,
10802            '>' => angle_depth = angle_depth.saturating_sub(1),
10803            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
10804                return value[..index].trim();
10805            }
10806            _ => {}
10807        }
10808    }
10809    value.trim()
10810}
10811
10812fn infer_cpp_receiver_type_from_scope(
10813    scope: tree_sitter::Node<'_>,
10814    source: &str,
10815    receiver: &str,
10816    call_line: u32,
10817) -> Option<String> {
10818    let lines = source.lines().collect::<Vec<_>>();
10819    if lines.is_empty() {
10820        return None;
10821    }
10822    let scope_start = scope.start_position().row as usize;
10823    let call_index = (call_line as usize)
10824        .saturating_sub(1)
10825        .min(lines.len().saturating_sub(1));
10826    for index in (scope_start..=call_index).rev() {
10827        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
10828            return Some(receiver_type);
10829        }
10830    }
10831    None
10832}
10833
10834fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
10835    for receiver_start in identifier_occurrences(line, receiver) {
10836        let after = line[receiver_start + receiver.len()..].trim_start();
10837        if after
10838            .chars()
10839            .next()
10840            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
10841        {
10842            continue;
10843        }
10844        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
10845        let normalized = normalize_cpp_type_name(type_text)?;
10846        if normalized == "auto" {
10847            if let Some(rhs) = after.strip_prefix('=') {
10848                return infer_cpp_auto_receiver_type(rhs);
10849            }
10850            continue;
10851        }
10852        return Some(normalized);
10853    }
10854    None
10855}
10856
10857fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
10858    let candidate = prefix
10859        .rsplit([';', '{', '}', '('])
10860        .next()
10861        .unwrap_or(prefix)
10862        .trim();
10863    if candidate.is_empty() || candidate.ends_with(',') {
10864        None
10865    } else {
10866        Some(candidate)
10867    }
10868}
10869
10870fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
10871    let without_templates = strip_angle_groups(type_text);
10872    let mut cleaned = String::with_capacity(without_templates.len());
10873    for token in without_templates.split_whitespace() {
10874        if matches!(
10875            token,
10876            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
10877        ) {
10878            continue;
10879        }
10880        if !cleaned.is_empty() {
10881            cleaned.push(' ');
10882        }
10883        cleaned.push_str(token);
10884    }
10885    let token = cleaned
10886        .split_whitespace()
10887        .last()
10888        .unwrap_or(cleaned.trim())
10889        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
10890        .trim_matches(['*', '&']);
10891    let simple = token.rsplit("::").next().unwrap_or(token).trim();
10892    if simple.is_empty() || cpp_non_type_token(simple) {
10893        None
10894    } else {
10895        Some(simple.to_string())
10896    }
10897}
10898
10899fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
10900    let rhs = rhs.trim_start();
10901    if let Some(after_new) = rhs.strip_prefix("new ") {
10902        return infer_cpp_constructor_type(after_new);
10903    }
10904    infer_cpp_make_template_type(rhs)
10905        .or_else(|| infer_cpp_constructor_type(rhs))
10906        .or_else(|| infer_cpp_factory_type(rhs))
10907}
10908
10909fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
10910    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10911    let normalized = normalize_cpp_type_name(head)?;
10912    if !normalized
10913        .chars()
10914        .next()
10915        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10916    {
10917        return None;
10918    }
10919    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
10920        Some(normalized)
10921    } else {
10922        None
10923    }
10924}
10925
10926fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
10927    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10928    if !rest.trim_start().starts_with('(') {
10929        return None;
10930    }
10931    let base = head.split('<').next().unwrap_or(head);
10932    let base_simple = base.rsplit("::").next().unwrap_or(base);
10933    if !matches!(base_simple, "make_unique" | "make_shared") {
10934        return None;
10935    }
10936    first_angle_arg(head).and_then(normalize_cpp_type_name)
10937}
10938
10939fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
10940    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
10941    if !rest.trim_start().starts_with('(') {
10942        return None;
10943    }
10944    let simple = head
10945        .split('<')
10946        .next()
10947        .unwrap_or(head)
10948        .rsplit("::")
10949        .next()
10950        .unwrap_or(head);
10951    for prefix in ["make", "create", "build"] {
10952        if let Some(suffix) = simple.strip_prefix(prefix) {
10953            if suffix
10954                .chars()
10955                .next()
10956                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
10957            {
10958                return normalize_cpp_type_name(suffix);
10959            }
10960        }
10961    }
10962    None
10963}
10964
10965#[derive(Debug, Clone, Copy)]
10966enum JavaLikeInvocation {
10967    Kotlin,
10968    Cpp,
10969}
10970
10971fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
10972    let value = value.trim_start();
10973    let mut end = 0usize;
10974    for (index, ch) in value.char_indices() {
10975        let allowed_separator = match flavor {
10976            JavaLikeInvocation::Kotlin => ch == '.',
10977            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
10978        };
10979        if is_code_ident_char(ch) || allowed_separator {
10980            end = index + ch.len_utf8();
10981            continue;
10982        }
10983        break;
10984    }
10985    if end == 0 {
10986        return None;
10987    }
10988    let mut rest = &value[end..];
10989    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
10990        let skipped = skip_balanced_angle(stripped)?;
10991        let rest_start = rest.len() - rest.trim_start().len();
10992        let angle_len = 1 + skipped;
10993        end += rest_start + angle_len;
10994        rest = &value[end..];
10995    }
10996    Some((value[..end].trim(), rest))
10997}
10998
10999fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11000    let mut depth = 1usize;
11001    for (index, ch) in value_after_open.char_indices() {
11002        match ch {
11003            '<' => depth += 1,
11004            '>' => {
11005                depth = depth.saturating_sub(1);
11006                if depth == 0 {
11007                    return Some(index + ch.len_utf8());
11008                }
11009            }
11010            _ => {}
11011        }
11012    }
11013    None
11014}
11015
11016fn first_angle_arg(value: &str) -> Option<&str> {
11017    let open = value.find('<')?;
11018    let inner_len = skip_balanced_angle(&value[open + 1..])?;
11019    let inner = &value[open + 1..open + inner_len];
11020    split_top_level_commas(inner).into_iter().next()
11021}
11022
11023fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11024    let without_generics = strip_angle_groups(type_text);
11025    let cleaned = without_generics
11026        .replace("[]", " ")
11027        .replace("...", " ")
11028        .replace(['?', '&', '*'], " ");
11029    let token = cleaned
11030        .split_whitespace()
11031        .last()
11032        .unwrap_or(cleaned.trim())
11033        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11034    let token = token.rsplit("::").next().unwrap_or(token);
11035    let simple = token.rsplit('.').next().unwrap_or(token).trim();
11036    if simple.is_empty()
11037        || java_like_primitive_type(simple)
11038        || !simple
11039            .chars()
11040            .next()
11041            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11042    {
11043        None
11044    } else {
11045        Some(simple.to_string())
11046    }
11047}
11048
11049fn simple_type_name(scoped_name: &str) -> Option<String> {
11050    scoped_name
11051        .rsplit("::")
11052        .find(|segment| !segment.is_empty())
11053        .and_then(normalize_receiver_type_name)
11054}
11055
11056fn strip_angle_groups(value: &str) -> String {
11057    let mut output = String::with_capacity(value.len());
11058    let mut depth = 0usize;
11059    for ch in value.chars() {
11060        match ch {
11061            '<' => {
11062                if depth == 0 {
11063                    output.push(' ');
11064                }
11065                depth += 1;
11066            }
11067            '>' => depth = depth.saturating_sub(1),
11068            _ if depth == 0 => output.push(ch),
11069            _ => {}
11070        }
11071    }
11072    output
11073}
11074
11075fn java_like_primitive_type(value: &str) -> bool {
11076    matches!(
11077        value,
11078        "boolean"
11079            | "byte"
11080            | "char"
11081            | "double"
11082            | "float"
11083            | "int"
11084            | "long"
11085            | "short"
11086            | "void"
11087            | "Boolean"
11088            | "Byte"
11089            | "Char"
11090            | "Double"
11091            | "Float"
11092            | "Int"
11093            | "Long"
11094            | "Short"
11095            | "Unit"
11096    )
11097}
11098
11099fn cpp_non_type_token(value: &str) -> bool {
11100    matches!(
11101        value,
11102        "return"
11103            | "if"
11104            | "else"
11105            | "for"
11106            | "while"
11107            | "do"
11108            | "switch"
11109            | "case"
11110            | "default"
11111            | "break"
11112            | "continue"
11113            | "goto"
11114            | "throw"
11115            | "new"
11116            | "delete"
11117            | "co_await"
11118            | "co_yield"
11119            | "co_return"
11120            | "static_cast"
11121            | "const_cast"
11122            | "dynamic_cast"
11123            | "reinterpret_cast"
11124            | "sizeof"
11125            | "alignof"
11126            | "typeid"
11127            | "and"
11128            | "or"
11129            | "not"
11130            | "xor"
11131    )
11132}
11133
11134fn receiver_is_bare_identifier(value: &str) -> bool {
11135    let mut chars = value.chars();
11136    let Some(first) = chars.next() else {
11137        return false;
11138    };
11139    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11140}
11141
11142fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11143    identifier_occurrences(value, needle).into_iter().next()
11144}
11145
11146fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11147    value
11148        .match_indices(needle)
11149        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11150        .collect()
11151}
11152
11153fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11154    let before = value[..start].chars().next_back();
11155    let after = value[start + len..].chars().next();
11156    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11157}
11158
11159fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11160    let stripped = value.strip_prefix(word)?;
11161    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11162        Some(stripped.trim_start())
11163    } else {
11164        None
11165    }
11166}
11167
11168fn is_code_ident_char(ch: char) -> bool {
11169    ch == '_' || ch.is_ascii_alphanumeric()
11170}
11171
11172fn infer_rust_receiver_type(
11173    project_root: &Path,
11174    reference: &NameMatchRef,
11175    source_cache: &mut DispatchSourceCache,
11176) -> ReceiverTypeInference {
11177    if matches!(reference.receiver.as_str(), "self" | "Self") {
11178        return enclosing_type_from_scoped_name(&reference.caller_symbol)
11179            .map(ReceiverTypeInference::Known)
11180            .unwrap_or(ReceiverTypeInference::Unknown);
11181    }
11182
11183    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11184        return ReceiverTypeInference::Known(reference.receiver.clone());
11185    }
11186
11187    if let Some(receiver_type) = reference
11188        .caller_signature
11189        .as_deref()
11190        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11191    {
11192        return ReceiverTypeInference::Known(receiver_type);
11193    }
11194
11195    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11196}
11197
11198fn infer_rust_direct_self_field_receiver_type(
11199    project_root: &Path,
11200    reference: &NameMatchRef,
11201    source_cache: &mut DispatchSourceCache,
11202) -> ReceiverTypeInference {
11203    if reference.colon_dispatch {
11204        return ReceiverTypeInference::Unknown;
11205    }
11206    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11207        return ReceiverTypeInference::Unknown;
11208    };
11209    if field_name != reference.receiver {
11210        return ReceiverTypeInference::Unknown;
11211    }
11212
11213    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11214        return ReceiverTypeInference::Unknown;
11215    };
11216    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11217        return ReceiverTypeInference::KnownButUnresolved;
11218    };
11219    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11220    else {
11221        return ReceiverTypeInference::Unknown;
11222    };
11223    let Some(impl_node) =
11224        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11225    else {
11226        return ReceiverTypeInference::Unknown;
11227    };
11228    if impl_node.child_by_field_name("trait").is_some()
11229        || impl_node.child_by_field_name("type_parameters").is_some()
11230    {
11231        return ReceiverTypeInference::KnownButUnresolved;
11232    }
11233    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11234        return ReceiverTypeInference::KnownButUnresolved;
11235    };
11236    if impl_target.kind() != "type_identifier"
11237        || node_text(impl_target, &parsed.source) != impl_type
11238    {
11239        return ReceiverTypeInference::KnownButUnresolved;
11240    }
11241
11242    let module_scope = rust_module_scope(impl_node);
11243    let Some(struct_node) = find_unique_rust_struct(
11244        parsed.tree.root_node(),
11245        &parsed.source,
11246        struct_name,
11247        &module_scope,
11248    ) else {
11249        return ReceiverTypeInference::KnownButUnresolved;
11250    };
11251    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11252    else {
11253        return ReceiverTypeInference::KnownButUnresolved;
11254    };
11255    if field_type.kind() != "type_identifier" {
11256        return ReceiverTypeInference::KnownButUnresolved;
11257    }
11258    let field_type_name = node_text(field_type, &parsed.source);
11259    if find_unique_rust_struct(
11260        parsed.tree.root_node(),
11261        &parsed.source,
11262        field_type_name,
11263        &module_scope,
11264    )
11265    .is_none()
11266    {
11267        return ReceiverTypeInference::KnownButUnresolved;
11268    }
11269
11270    ReceiverTypeInference::RustDirectSelfField {
11271        receiver_type: field_type_name.to_string(),
11272        declaration_file: reference.caller_file.clone(),
11273        module_scope,
11274    }
11275}
11276
11277fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11278    let (base, field) = receiver_expression.split_once('.')?;
11279    let base = base.trim();
11280    let field = field.trim();
11281    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11282}
11283
11284fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11285    let name = value.rsplit("::").next()?.trim();
11286    (!name.is_empty()
11287        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11288        && name.chars().all(is_rust_ident_char))
11289    .then_some(name)
11290}
11291
11292fn find_enclosing_rust_impl_node<'tree>(
11293    root: tree_sitter::Node<'tree>,
11294    line: u32,
11295) -> Option<tree_sitter::Node<'tree>> {
11296    let mut best = None;
11297    let mut stack = vec![root];
11298    while let Some(node) = stack.pop() {
11299        if !node_contains_line(node, line) {
11300            continue;
11301        }
11302        if node.kind() == "impl_item" {
11303            best = tighter_node(best, node);
11304        }
11305        push_named_children(node, &mut stack);
11306    }
11307    best
11308}
11309
11310fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11311    let mut scope = Vec::new();
11312    let mut current = node.parent();
11313    while let Some(parent) = current {
11314        if parent.kind() == "mod_item" {
11315            scope.push((parent.start_byte(), parent.end_byte()));
11316        }
11317        current = parent.parent();
11318    }
11319    scope.reverse();
11320    scope
11321}
11322
11323fn find_unique_rust_struct<'tree>(
11324    root: tree_sitter::Node<'tree>,
11325    source: &str,
11326    expected_name: &str,
11327    module_scope: &[(usize, usize)],
11328) -> Option<tree_sitter::Node<'tree>> {
11329    let mut found = None;
11330    let mut stack = vec![root];
11331    while let Some(node) = stack.pop() {
11332        if node.kind() == "struct_item"
11333            && rust_module_scope(node) == module_scope
11334            && node.child_by_field_name("type_parameters").is_none()
11335            && declaration_name(node, source) == Some(expected_name)
11336        {
11337            if found.is_some() {
11338                return None;
11339            }
11340            found = Some(node);
11341        }
11342        push_named_children(node, &mut stack);
11343    }
11344    found
11345}
11346
11347fn rust_struct_field_type_node<'tree>(
11348    struct_node: tree_sitter::Node<'tree>,
11349    source: &str,
11350    field_name: &str,
11351) -> Option<tree_sitter::Node<'tree>> {
11352    let fields = struct_node.child_by_field_name("body")?;
11353    if fields.kind() != "field_declaration_list" {
11354        return None;
11355    }
11356    for index in 0..fields.named_child_count() {
11357        let field = fields.named_child(index as u32)?;
11358        if field.kind() != "field_declaration"
11359            || declaration_name(field, source) != Some(field_name)
11360        {
11361            continue;
11362        }
11363        return field.child_by_field_name("type");
11364    }
11365    None
11366}
11367
11368fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11369    receiver
11370        .chars()
11371        .next()
11372        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11373}
11374
11375fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11376    scoped_name
11377        .rsplit_once("::")
11378        .map(|(enclosing, _)| enclosing)
11379        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11380        .map(ToString::to_string)
11381}
11382
11383fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11384    let params = signature_parameter_text(signature)?;
11385    for param in split_top_level_commas(params) {
11386        let Some((pattern, type_text)) = param.split_once(':') else {
11387            continue;
11388        };
11389        let Some(name) = rust_parameter_name(pattern) else {
11390            continue;
11391        };
11392        if name == receiver {
11393            return normalize_rust_receiver_type(type_text);
11394        }
11395    }
11396    None
11397}
11398
11399fn signature_parameter_text(signature: &str) -> Option<&str> {
11400    let open = signature.find('(')?;
11401    let mut depth = 0usize;
11402    for (offset, ch) in signature[open..].char_indices() {
11403        match ch {
11404            '(' => depth += 1,
11405            ')' => {
11406                depth = depth.saturating_sub(1);
11407                if depth == 0 {
11408                    return Some(&signature[open + 1..open + offset]);
11409                }
11410            }
11411            _ => {}
11412        }
11413    }
11414    None
11415}
11416
11417fn split_top_level_commas(value: &str) -> Vec<&str> {
11418    let mut parts = Vec::new();
11419    let mut start = 0usize;
11420    let mut angle_depth = 0usize;
11421    let mut paren_depth = 0usize;
11422    let mut bracket_depth = 0usize;
11423    for (index, ch) in value.char_indices() {
11424        match ch {
11425            '<' => angle_depth += 1,
11426            '>' => angle_depth = angle_depth.saturating_sub(1),
11427            '(' => paren_depth += 1,
11428            ')' => paren_depth = paren_depth.saturating_sub(1),
11429            '[' => bracket_depth += 1,
11430            ']' => bracket_depth = bracket_depth.saturating_sub(1),
11431            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11432                let part = value[start..index].trim();
11433                if !part.is_empty() {
11434                    parts.push(part);
11435                }
11436                start = index + ch.len_utf8();
11437            }
11438            _ => {}
11439        }
11440    }
11441    let part = value[start..].trim();
11442    if !part.is_empty() {
11443        parts.push(part);
11444    }
11445    parts
11446}
11447
11448fn rust_parameter_name(pattern: &str) -> Option<&str> {
11449    let mut pattern = pattern.trim();
11450    if let Some(stripped) = pattern.strip_prefix("mut ") {
11451        pattern = stripped.trim_start();
11452    }
11453    pattern
11454        .rsplit(|ch: char| !is_rust_ident_char(ch))
11455        .find(|part| !part.is_empty())
11456}
11457
11458fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11459    let mut ty = strip_leading_rust_type_modifiers(type_text);
11460    let owned_inner;
11461    if let Some(inner) = single_outer_generic_arg(ty) {
11462        owned_inner = inner.trim().to_string();
11463        ty = strip_leading_rust_type_modifiers(&owned_inner);
11464    }
11465    rust_base_type_ident(ty)
11466}
11467
11468fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11469    loop {
11470        ty = ty.trim_start();
11471        if let Some(stripped) = ty.strip_prefix('&') {
11472            ty = stripped.trim_start();
11473            if let Some(stripped) = strip_leading_lifetime(ty) {
11474                ty = stripped.trim_start();
11475            }
11476            if let Some(stripped) = ty.strip_prefix("mut ") {
11477                ty = stripped.trim_start();
11478            }
11479            continue;
11480        }
11481        if let Some(stripped) = ty.strip_prefix("mut ") {
11482            ty = stripped.trim_start();
11483            continue;
11484        }
11485        if let Some(stripped) = ty.strip_prefix("dyn ") {
11486            ty = stripped.trim_start();
11487            continue;
11488        }
11489        if let Some(stripped) = ty.strip_prefix("impl ") {
11490            ty = stripped.trim_start();
11491            continue;
11492        }
11493        break ty.trim();
11494    }
11495}
11496
11497fn strip_leading_lifetime(value: &str) -> Option<&str> {
11498    let mut chars = value.char_indices();
11499    let (_, first) = chars.next()?;
11500    if first != '\'' {
11501        return None;
11502    }
11503    for (index, ch) in chars {
11504        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11505            return Some(&value[index..]);
11506        }
11507    }
11508    Some("")
11509}
11510
11511fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11512    let ty = ty.trim();
11513    let open = ty.find('<')?;
11514    let mut depth = 0usize;
11515    let mut close = None;
11516    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11517        match ch {
11518            '<' => depth += 1,
11519            '>' => {
11520                depth = depth.saturating_sub(1);
11521                if depth == 0 {
11522                    close = Some(index);
11523                    break;
11524                }
11525            }
11526            _ => {}
11527        }
11528    }
11529    let close = close?;
11530    if !ty[close + 1..].trim().is_empty() {
11531        return None;
11532    }
11533    let inner = &ty[open + 1..close];
11534    let args = split_top_level_commas(inner);
11535    match args.as_slice() {
11536        [arg] => Some(*arg),
11537        _ => None,
11538    }
11539}
11540
11541fn rust_base_type_ident(ty: &str) -> Option<String> {
11542    let ty = ty.trim();
11543    let head = ty
11544        .split([' ', '+', '='])
11545        .find(|part| !part.is_empty())
11546        .unwrap_or(ty);
11547    let head = head.split('<').next().unwrap_or(head).trim();
11548    let ident = head
11549        .rsplit("::")
11550        .next()
11551        .unwrap_or(head)
11552        .trim_matches(|ch: char| !is_rust_ident_char(ch));
11553    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11554        None
11555    } else {
11556        Some(ident.to_string())
11557    }
11558}
11559
11560fn is_rust_ident_char(ch: char) -> bool {
11561    ch == '_' || ch.is_ascii_alphanumeric()
11562}
11563
11564fn select_rust_direct_self_field_candidate(
11565    project_root: &Path,
11566    reference: &NameMatchRef,
11567    candidates: &[NameMatchCandidate],
11568    receiver_type: &str,
11569    declaration_file: &str,
11570    declaration_scope: &[(usize, usize)],
11571    source_cache: &mut DispatchSourceCache,
11572) -> Option<NameMatchCandidate> {
11573    let eligible = candidates
11574        .iter()
11575        .filter(|candidate| candidate.node_id != reference.caller_node)
11576        .filter(|candidate| {
11577            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11578        })
11579        .filter(|candidate| {
11580            rust_direct_self_field_candidate_matches_scope(
11581                project_root,
11582                candidate,
11583                receiver_type,
11584                declaration_file,
11585                declaration_scope,
11586                source_cache,
11587            )
11588        })
11589        .collect::<Vec<_>>();
11590    match eligible.as_slice() {
11591        [candidate] => Some((**candidate).clone()),
11592        _ => None,
11593    }
11594}
11595
11596fn rust_direct_self_field_candidate_matches_scope(
11597    project_root: &Path,
11598    candidate: &NameMatchCandidate,
11599    receiver_type: &str,
11600    declaration_file: &str,
11601    declaration_scope: &[(usize, usize)],
11602    source_cache: &mut DispatchSourceCache,
11603) -> bool {
11604    if candidate.file_path != declaration_file {
11605        return false;
11606    }
11607    let Some(parsed) = parsed_dispatch_source_for_file(
11608        project_root,
11609        &candidate.file_path,
11610        "rust",
11611        LangId::Rust,
11612        source_cache,
11613    ) else {
11614        return false;
11615    };
11616    let Some(impl_node) =
11617        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11618    else {
11619        return false;
11620    };
11621    if impl_node.child_by_field_name("trait").is_some()
11622        || impl_node.child_by_field_name("type_parameters").is_some()
11623    {
11624        return false;
11625    }
11626    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11627        return false;
11628    };
11629    impl_target.kind() == "type_identifier"
11630        && node_text(impl_target, &parsed.source) == receiver_type
11631        && rust_module_scope(impl_node) == declaration_scope
11632}
11633
11634fn select_type_match_candidate(
11635    reference: &NameMatchRef,
11636    candidates: &[NameMatchCandidate],
11637    receiver_type: &str,
11638) -> Option<NameMatchCandidate> {
11639    let candidates = candidates
11640        .iter()
11641        .filter(|candidate| candidate.node_id != reference.caller_node)
11642        .filter(|candidate| {
11643            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11644        })
11645        .collect::<Vec<_>>();
11646    match candidates.as_slice() {
11647        [candidate] => Some((**candidate).clone()),
11648        _ => None,
11649    }
11650}
11651
11652fn type_candidate_matches(
11653    candidate: &NameMatchCandidate,
11654    receiver_type: &str,
11655    method_name: &str,
11656) -> bool {
11657    let normalized_type = receiver_type.replace('.', "::");
11658    let suffix = format!("{normalized_type}::{method_name}");
11659    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11660}
11661
11662fn select_name_match_candidate(
11663    reference: &NameMatchRef,
11664    candidates: &[NameMatchCandidate],
11665) -> Option<NameMatchCandidate> {
11666    let candidates = candidates
11667        .iter()
11668        .filter(|candidate| candidate.node_id != reference.caller_node)
11669        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11670        .collect::<Vec<_>>();
11671    match candidates.as_slice() {
11672        [] => None,
11673        [candidate] => Some((**candidate).clone()),
11674        _ => select_scored_name_match_candidate(reference, &candidates),
11675    }
11676}
11677
11678fn candidate_allowed_for_reference(
11679    reference: &NameMatchRef,
11680    candidate: &NameMatchCandidate,
11681) -> bool {
11682    if !reference.colon_dispatch {
11683        return true;
11684    }
11685
11686    candidate.kind == "method"
11687        && candidate
11688            .scoped_name
11689            .split("::")
11690            .any(|segment| segment == reference.receiver)
11691}
11692
11693fn select_scored_name_match_candidate(
11694    reference: &NameMatchRef,
11695    candidates: &[&NameMatchCandidate],
11696) -> Option<NameMatchCandidate> {
11697    let receiver_words = split_camel_case(&reference.receiver);
11698    if receiver_words.is_empty() {
11699        return None;
11700    }
11701
11702    let mut best: Option<(&NameMatchCandidate, f64)> = None;
11703    let mut tied_best = false;
11704    for candidate in candidates {
11705        let candidate_words = split_camel_case(&candidate.scoped_name);
11706        let overlap = receiver_words
11707            .iter()
11708            .filter(|receiver_word| {
11709                candidate_words
11710                    .iter()
11711                    .any(|candidate_word| candidate_word == *receiver_word)
11712            })
11713            .count() as f64;
11714        let score =
11715            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11716        match best {
11717            None => {
11718                best = Some((*candidate, score));
11719                tied_best = false;
11720            }
11721            Some((_, best_score)) if score > best_score => {
11722                best = Some((*candidate, score));
11723                tied_best = false;
11724            }
11725            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11726                tied_best = true;
11727            }
11728            _ => {}
11729        }
11730    }
11731
11732    let (candidate, score) = best?;
11733    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11734        Some(candidate.clone())
11735    } else {
11736        None
11737    }
11738}
11739
11740fn method_name_match_denylisted(method_name: &str) -> bool {
11741    matches!(
11742        method_name,
11743        "and_then"
11744            | "as_bytes"
11745            | "as_deref"
11746            | "as_mut"
11747            | "as_ref"
11748            | "as_str"
11749            | "borrow"
11750            | "borrow_mut"
11751            | "clear"
11752            | "clone"
11753            | "collect"
11754            | "contains"
11755            | "contains_key"
11756            | "count"
11757            | "dedup"
11758            | "default"
11759            | "drain"
11760            | "ends_with"
11761            | "entry"
11762            | "err"
11763            | "expect"
11764            | "extend"
11765            | "filter"
11766            | "filter_map"
11767            | "find"
11768            | "from"
11769            | "get"
11770            | "get_mut"
11771            | "insert"
11772            | "into"
11773            | "into_iter"
11774            | "is_empty"
11775            | "is_err"
11776            | "is_none"
11777            | "is_ok"
11778            | "is_some"
11779            | "iter"
11780            | "iter_mut"
11781            | "join"
11782            | "len"
11783            | "lock"
11784            | "map"
11785            | "map_err"
11786            | "max"
11787            | "min"
11788            | "new"
11789            | "next"
11790            | "ok"
11791            | "or_default"
11792            | "or_else"
11793            | "or_insert"
11794            | "or_insert_with"
11795            | "parse"
11796            | "pop"
11797            | "position"
11798            | "push"
11799            | "read"
11800            | "recv"
11801            | "remove"
11802            | "replace"
11803            | "retain"
11804            | "send"
11805            | "sort"
11806            | "sort_by"
11807            | "split"
11808            | "starts_with"
11809            | "sum"
11810            | "take"
11811            | "to_owned"
11812            | "to_string"
11813            | "trim"
11814            | "try_from"
11815            | "try_into"
11816            | "unwrap"
11817            | "unwrap_or"
11818            | "unwrap_or_default"
11819            | "unwrap_or_else"
11820            | "with_capacity"
11821            | "write"
11822    )
11823}
11824
11825fn split_camel_case(value: &str) -> Vec<String> {
11826    let chars = value.chars().collect::<Vec<_>>();
11827    let mut normalized = String::with_capacity(value.len() + 8);
11828    for (index, ch) in chars.iter().enumerate() {
11829        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
11830        let next = chars.get(index + 1);
11831        let is_separator = ch.is_whitespace()
11832            || matches!(
11833                ch,
11834                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
11835            );
11836        if is_separator {
11837            normalized.push(' ');
11838            continue;
11839        }
11840        let camel_boundary = previous.is_some_and(|prev| {
11841            (prev.is_lowercase() && ch.is_uppercase())
11842                || (prev.is_ascii_digit() && ch.is_alphabetic())
11843                || (prev.is_uppercase()
11844                    && ch.is_uppercase()
11845                    && next.is_some_and(|next| next.is_lowercase()))
11846        });
11847        if camel_boundary {
11848            normalized.push(' ');
11849        }
11850        normalized.push(*ch);
11851    }
11852
11853    normalized
11854        .split_whitespace()
11855        .filter(|word| word.len() > 1)
11856        .map(|word| word.to_ascii_lowercase())
11857        .collect()
11858}
11859
11860fn compute_path_proximity(left: &str, right: &str) -> f64 {
11861    let left_dirs = left
11862        .rsplit_once('/')
11863        .map(|(dir, _)| dir)
11864        .unwrap_or_default()
11865        .split('/')
11866        .filter(|part| !part.is_empty());
11867    let right_dirs = right
11868        .rsplit_once('/')
11869        .map(|(dir, _)| dir)
11870        .unwrap_or_default()
11871        .split('/')
11872        .filter(|part| !part.is_empty());
11873
11874    let shared = left_dirs
11875        .zip(right_dirs)
11876        .take_while(|(left, right)| left == right)
11877        .count();
11878    ((shared as f64) * 0.05).min(0.5)
11879}
11880
11881fn mark_backend_state(
11882    tx: &Transaction<'_>,
11883    project_root: &Path,
11884    rel_path: &str,
11885    content_hash: Option<&blake3::Hash>,
11886    status: &str,
11887) -> Result<()> {
11888    clear_backend_state_for_file(tx, project_root, rel_path)?;
11889    let hash = content_hash
11890        .map(|hash| hash_to_hex(*hash))
11891        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11892    tx.execute(
11893        "INSERT OR REPLACE INTO backend_file_state(
11894            backend, workspace_root, file_path, content_hash, status, updated_at
11895        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11896        params![
11897            BACKEND_TREESITTER,
11898            project_root.display().to_string(),
11899            rel_path,
11900            hash,
11901            status,
11902            unix_seconds_now(),
11903        ],
11904    )?;
11905    Ok(())
11906}
11907
11908fn clear_backend_state_for_file(
11909    tx: &Transaction<'_>,
11910    project_root: &Path,
11911    rel_path: &str,
11912) -> Result<()> {
11913    tx.execute(
11914        "DELETE FROM backend_file_state
11915         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
11916        params![
11917            BACKEND_TREESITTER,
11918            project_root.display().to_string(),
11919            rel_path
11920        ],
11921    )?;
11922    Ok(())
11923}
11924
11925/// Mark a file whose graph bytes were just confirmed current as fresh.
11926///
11927/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
11928/// leftover `status='stale'` row from a failed refresh would keep blocking
11929/// dead-code projection even though the graph still matches disk.
11930fn clear_stale_backend_status_for_file(
11931    tx: &Transaction<'_>,
11932    project_root: &Path,
11933    rel_path: &str,
11934) -> Result<()> {
11935    tx.execute(
11936        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
11937         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
11938        params![
11939            BACKEND_TREESITTER,
11940            project_root.display().to_string(),
11941            rel_path,
11942            unix_seconds_now(),
11943        ],
11944    )?;
11945    Ok(())
11946}
11947
11948fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
11949    conn.query_row(
11950        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
11951        params![rel_path],
11952        |row| {
11953            let hash_text: String = row.get(1)?;
11954            Ok(FileRow {
11955                surface_fingerprint: row.get(0)?,
11956                freshness: FileFreshness {
11957                    content_hash: hash_from_hex(&hash_text)
11958                        .unwrap_or_else(cache_freshness::zero_hash),
11959                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
11960                    size: row.get::<_, i64>(3)? as u64,
11961                },
11962            })
11963        },
11964    )
11965    .optional()
11966    .map_err(CallGraphStoreError::from)
11967}
11968
11969fn stored_node_ids_match_extract(
11970    tx: &Transaction<'_>,
11971    rel_path: &str,
11972    extract: &FileExtract,
11973) -> Result<bool> {
11974    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
11975    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11976    let mut stored = BTreeSet::new();
11977    for row in rows {
11978        stored.insert(row?);
11979    }
11980    let extracted = extract
11981        .nodes
11982        .iter()
11983        .map(|node| node.id.clone())
11984        .collect::<BTreeSet<_>>();
11985    Ok(stored == extracted)
11986}
11987
11988/// Compare every persisted graph row that comes from this file before rewriting it.
11989/// Ranges and reference byte offsets are part of the key because queries expose
11990/// source locations; equal names and edges are not enough after a body shift.
11991fn stored_extract_matches(
11992    tx: &Transaction<'_>,
11993    rel_path: &str,
11994    extract: &FileExtract,
11995    index: &ProjectIndex<'_>,
11996) -> Result<bool> {
11997    let stored_file = tx
11998        .query_row(
11999            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12000            params![rel_path],
12001            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12002        )
12003        .optional()?;
12004    if stored_file
12005        != Some((
12006            lang_label(extract.lang).to_string(),
12007            extract.surface_fingerprint.clone(),
12008        ))
12009    {
12010        return Ok(false);
12011    }
12012
12013    let mut stored_nodes_stmt = tx.prepare(
12014        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12015                end_line, end_col, range_ordinal, signature, exported,
12016                is_default_export, is_type_like, is_callgraph_entry_point, provenance
12017         FROM nodes WHERE file_path = ?1",
12018    )?;
12019    let stored_nodes = stored_nodes_stmt
12020        .query_map(params![rel_path], |row| {
12021            Ok(serde_json::json!([
12022                row.get::<_, String>(0)?,
12023                row.get::<_, String>(1)?,
12024                row.get::<_, String>(2)?,
12025                row.get::<_, String>(3)?,
12026                row.get::<_, String>(4)?,
12027                row.get::<_, i64>(5)?,
12028                row.get::<_, i64>(6)?,
12029                row.get::<_, i64>(7)?,
12030                row.get::<_, i64>(8)?,
12031                row.get::<_, i64>(9)?,
12032                row.get::<_, Option<String>>(10)?,
12033                row.get::<_, i64>(11)?,
12034                row.get::<_, i64>(12)?,
12035                row.get::<_, i64>(13)?,
12036                row.get::<_, i64>(14)?,
12037                row.get::<_, String>(15)?,
12038            ])
12039            .to_string())
12040        })?
12041        .collect::<rusqlite::Result<Vec<_>>>()?;
12042    let expected_nodes = extract
12043        .nodes
12044        .iter()
12045        .map(|node| {
12046            serde_json::json!([
12047                node.id,
12048                node.file_path,
12049                node.name,
12050                node.scoped_name,
12051                node.kind,
12052                node.range.start_line,
12053                node.range.start_col,
12054                node.range.end_line,
12055                node.range.end_col,
12056                node.range_ordinal,
12057                node.signature,
12058                bool_int(node.exported),
12059                bool_int(node.is_default_export),
12060                bool_int(node.is_type_like),
12061                bool_int(node.is_callgraph_entry_point),
12062                PROVENANCE_TREESITTER,
12063            ])
12064            .to_string()
12065        })
12066        .collect::<Vec<_>>();
12067    let mut stored_nodes = stored_nodes;
12068    let mut expected_nodes = expected_nodes;
12069    stored_nodes.sort();
12070    expected_nodes.sort();
12071    if stored_nodes != expected_nodes {
12072        return Ok(false);
12073    }
12074
12075    let resolved_refs = extract
12076        .raw_refs
12077        .iter()
12078        .cloned()
12079        .map(|raw| resolve_ref(raw, index))
12080        .collect::<Result<Vec<_>>>()?;
12081    let mut stored_refs_stmt = tx.prepare(
12082        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12083                module_path, import_kind, local_name, requested_name, namespace_alias,
12084                wildcard, line, byte_start, byte_end, status, target_node,
12085                target_file, target_symbol, provenance
12086         FROM refs WHERE caller_file = ?1",
12087    )?;
12088    let stored_refs = stored_refs_stmt
12089        .query_map(params![rel_path], |row| {
12090            Ok(serde_json::json!([
12091                row.get::<_, String>(0)?,
12092                row.get::<_, Option<String>>(1)?,
12093                row.get::<_, String>(2)?,
12094                row.get::<_, String>(3)?,
12095                row.get::<_, Option<String>>(4)?,
12096                row.get::<_, Option<String>>(5)?,
12097                row.get::<_, Option<String>>(6)?,
12098                row.get::<_, Option<String>>(7)?,
12099                row.get::<_, Option<String>>(8)?,
12100                row.get::<_, Option<String>>(9)?,
12101                row.get::<_, Option<String>>(10)?,
12102                row.get::<_, i64>(11)?,
12103                row.get::<_, i64>(12)?,
12104                row.get::<_, i64>(13)?,
12105                row.get::<_, i64>(14)?,
12106                row.get::<_, String>(15)?,
12107                row.get::<_, Option<String>>(16)?,
12108                row.get::<_, Option<String>>(17)?,
12109                row.get::<_, Option<String>>(18)?,
12110                row.get::<_, String>(19)?,
12111            ])
12112            .to_string())
12113        })?
12114        .collect::<rusqlite::Result<Vec<_>>>()?;
12115    let expected_refs = resolved_refs
12116        .iter()
12117        .map(|resolved| {
12118            let raw = &resolved.raw;
12119            serde_json::json!([
12120                raw.ref_id,
12121                raw.caller_node,
12122                raw.caller_file,
12123                raw.kind,
12124                raw.short_name,
12125                raw.full_ref,
12126                raw.module_path,
12127                raw.import_kind,
12128                raw.local_name,
12129                raw.requested_name,
12130                raw.namespace_alias,
12131                bool_int(raw.wildcard),
12132                raw.line,
12133                raw.byte_start,
12134                raw.byte_end,
12135                resolved.status,
12136                resolved.target_node,
12137                resolved.target_file,
12138                resolved.target_symbol,
12139                PROVENANCE_TREESITTER,
12140            ])
12141            .to_string()
12142        })
12143        .collect::<Vec<_>>();
12144    let mut stored_refs = stored_refs;
12145    let mut expected_refs = expected_refs;
12146    stored_refs.sort();
12147    expected_refs.sort();
12148    if stored_refs != expected_refs {
12149        return Ok(false);
12150    }
12151
12152    let mut stored_edges_stmt = tx.prepare(
12153        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12154                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12155         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12156         WHERE r.caller_file = ?1 AND e.provenance = ?2",
12157    )?;
12158    let stored_edges = stored_edges_stmt
12159        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12160            Ok(serde_json::json!([
12161                row.get::<_, String>(0)?,
12162                row.get::<_, String>(1)?,
12163                row.get::<_, String>(2)?,
12164                row.get::<_, Option<String>>(3)?,
12165                row.get::<_, String>(4)?,
12166                row.get::<_, String>(5)?,
12167                row.get::<_, String>(6)?,
12168                row.get::<_, i64>(7)?,
12169                row.get::<_, String>(8)?,
12170            ])
12171            .to_string())
12172        })?
12173        .collect::<rusqlite::Result<Vec<_>>>()?;
12174    let expected_edges = resolved_refs
12175        .iter()
12176        .filter_map(|resolved| {
12177            resolved.edge.as_ref().map(|edge| {
12178                serde_json::json!([
12179                    edge.edge_id,
12180                    resolved.raw.ref_id,
12181                    edge.source_node,
12182                    edge.target_node,
12183                    edge.target_file,
12184                    edge.target_symbol,
12185                    edge.kind,
12186                    edge.line,
12187                    PROVENANCE_TREESITTER,
12188                ])
12189                .to_string()
12190            })
12191        })
12192        .collect::<Vec<_>>();
12193    let mut stored_edges = stored_edges;
12194    let mut expected_edges = expected_edges;
12195    stored_edges.sort();
12196    expected_edges.sort();
12197    if stored_edges != expected_edges {
12198        return Ok(false);
12199    }
12200
12201    let mut stored_dependencies_stmt =
12202        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12203    let stored_dependencies = stored_dependencies_stmt
12204        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12205        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12206    let expected_dependencies = extract
12207        .raw_refs
12208        .iter()
12209        .flat_map(|raw| raw.dependencies.iter().cloned())
12210        .collect::<BTreeSet<_>>();
12211    if stored_dependencies != expected_dependencies {
12212        return Ok(false);
12213    }
12214
12215    let mut stored_hints_stmt = tx.prepare(
12216        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12217         FROM dispatch_hints WHERE file = ?1",
12218    )?;
12219    let stored_hints = stored_hints_stmt
12220        .query_map(params![rel_path], |row| {
12221            Ok(serde_json::json!([
12222                row.get::<_, String>(0)?,
12223                row.get::<_, String>(1)?,
12224                row.get::<_, String>(2)?,
12225                row.get::<_, String>(3)?,
12226                row.get::<_, i64>(4)?,
12227                row.get::<_, i64>(5)?,
12228                row.get::<_, i64>(6)?,
12229                row.get::<_, String>(7)?,
12230            ])
12231            .to_string())
12232        })?
12233        .collect::<rusqlite::Result<Vec<_>>>()?;
12234    let expected_hints = extract
12235        .dispatch_hints
12236        .iter()
12237        .map(|hint| {
12238            serde_json::json!([
12239                hint.id,
12240                hint.method_name,
12241                hint.caller_node,
12242                hint.file,
12243                hint.line,
12244                hint.byte_start,
12245                hint.byte_end,
12246                PROVENANCE_TREESITTER,
12247            ])
12248            .to_string()
12249        })
12250        .collect::<Vec<_>>();
12251    let mut stored_hints = stored_hints;
12252    let mut expected_hints = expected_hints;
12253    stored_hints.sort();
12254    expected_hints.sort();
12255    Ok(stored_hints == expected_hints)
12256}
12257
12258fn update_file_fresh_metadata(
12259    tx: &Transaction<'_>,
12260    project_root: &Path,
12261    rel_path: &str,
12262    hash: &blake3::Hash,
12263    mtime: SystemTime,
12264    size: u64,
12265) -> Result<()> {
12266    tx.execute(
12267        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12268         WHERE path = ?1",
12269        params![
12270            rel_path,
12271            hash_to_hex(*hash),
12272            system_time_to_ns(mtime),
12273            size as i64,
12274            unix_seconds_now()
12275        ],
12276    )?;
12277    tx.execute(
12278        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12279         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12280        params![
12281            BACKEND_TREESITTER,
12282            rel_path,
12283            hash_to_hex(*hash),
12284            project_root.display().to_string(),
12285            unix_seconds_now(),
12286        ],
12287    )?;
12288    Ok(())
12289}
12290
12291#[derive(Debug, Clone, PartialEq, Eq)]
12292struct DependentRefSelection {
12293    ref_id: String,
12294    caller_file: String,
12295}
12296
12297fn ref_ids_depending_on(
12298    conn: &Connection,
12299    project_root: &Path,
12300    rel_path: &str,
12301) -> Result<Vec<DependentRefSelection>> {
12302    let mut stmt = conn.prepare(
12303        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12304         FROM refs r
12305         WHERE r.caller_file IN (
12306             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12307         )
12308            OR r.target_file = ?1
12309         ORDER BY r.ref_id",
12310    )?;
12311    let rows = stmt.query_map(params![rel_path], |row| {
12312        Ok(RefDependencyRow {
12313            ref_id: row.get(0)?,
12314            kind: row.get(1)?,
12315            caller_file: row.get(2)?,
12316            module_path: row.get(3)?,
12317            target_file: row.get(4)?,
12318        })
12319    })?;
12320    let mut ids = Vec::new();
12321    for row in rows {
12322        let row = row?;
12323        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12324            ids.push(DependentRefSelection {
12325                ref_id: row.ref_id,
12326                caller_file: row.caller_file,
12327            });
12328        }
12329    }
12330    Ok(ids)
12331}
12332
12333fn record_dependent_refs(
12334    selected_ref_ids: &mut BTreeSet<String>,
12335    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12336    dependent_refs: Vec<DependentRefSelection>,
12337) {
12338    for dependent_ref in dependent_refs {
12339        let DependentRefSelection {
12340            ref_id,
12341            caller_file,
12342        } = dependent_ref;
12343        selected_ref_ids.insert(ref_id.clone());
12344        selected_refs_by_caller
12345            .entry(caller_file)
12346            .or_default()
12347            .insert(ref_id);
12348    }
12349}
12350
12351#[cfg(test)]
12352fn refs_by_caller_for_ref_ids(
12353    tx: &Transaction<'_>,
12354    ref_ids: &BTreeSet<String>,
12355) -> Result<BTreeMap<String, BTreeSet<String>>> {
12356    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12357    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12358    for ref_id in ref_ids {
12359        if let Some(caller) = stmt
12360            .query_row(params![ref_id], |row| row.get::<_, String>(0))
12361            .optional()?
12362        {
12363            by_caller.entry(caller).or_default().insert(ref_id.clone());
12364        }
12365    }
12366    Ok(by_caller)
12367}
12368
12369fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12370    tx.execute(
12371        "DELETE FROM file_dependencies WHERE file_path = ?1",
12372        params![rel_path],
12373    )?;
12374    delete_refs_for_caller(tx, rel_path)?;
12375    tx.execute(
12376        "DELETE FROM dispatch_hints WHERE file = ?1",
12377        params![rel_path],
12378    )?;
12379    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12380    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12381    Ok(())
12382}
12383
12384fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12385    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12386    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12387    let mut ids = BTreeSet::new();
12388    for row in rows {
12389        ids.insert(row?);
12390    }
12391    delete_ref_ids(tx, &ids)
12392}
12393
12394fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12395    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12396    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12397    for ref_id in ref_ids {
12398        delete_edges.execute(params![ref_id])?;
12399        delete_refs.execute(params![ref_id])?;
12400    }
12401    Ok(())
12402}
12403
12404fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12405    let mut stmt = conn.prepare(
12406        "SELECT source.file_path, source.scoped_name, edges.target_file,
12407                edges.target_symbol, edges.kind, edges.line
12408         FROM edges
12409         JOIN nodes AS source ON source.id = edges.source_node
12410         ORDER BY source.file_path, source.scoped_name, edges.target_file,
12411                  edges.target_symbol, edges.kind, edges.line",
12412    )?;
12413    let rows = stmt.query_map([], |row| {
12414        Ok(StoredEdge {
12415            source_file: row.get(0)?,
12416            source_symbol: row.get(1)?,
12417            target_file: row.get(2)?,
12418            target_symbol: row.get(3)?,
12419            kind: row.get(4)?,
12420            line: row.get::<_, i64>(5)? as u32,
12421        })
12422    })?;
12423    let mut edges = BTreeSet::new();
12424    for row in rows {
12425        edges.insert(row?);
12426    }
12427    Ok(edges)
12428}
12429
12430fn module_target_from_dependencies(
12431    project_root: &Path,
12432    dependencies: &BTreeSet<String>,
12433) -> Option<String> {
12434    dependencies.iter().find_map(|dep| {
12435        let path = project_root.join(dep);
12436        if path.is_file() {
12437            Some(relative_path(project_root, &canonicalize_path(&path)))
12438        } else {
12439            None
12440        }
12441    })
12442}
12443
12444fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12445    let mut named = HashMap::new();
12446    if let Some(full_ref) = &raw_ref.full_ref {
12447        named = parse_reexport_names(full_ref);
12448    }
12449    ReexportIndex {
12450        target_file,
12451        named,
12452        wildcard: raw_ref.wildcard,
12453    }
12454}
12455
12456fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12457    let mut names = HashMap::new();
12458    let Some(open) = statement.find('{') else {
12459        return names;
12460    };
12461    let Some(close) = statement[open + 1..]
12462        .find('}')
12463        .map(|offset| open + 1 + offset)
12464    else {
12465        return names;
12466    };
12467    for spec in statement[open + 1..close].split(',') {
12468        let spec = spec.trim();
12469        if spec.is_empty() {
12470            continue;
12471        }
12472        if let Some((source, local)) = spec.split_once(" as ") {
12473            names.insert(local.trim().to_string(), source.trim().to_string());
12474        } else {
12475            names.insert(spec.to_string(), spec.to_string());
12476        }
12477    }
12478    names
12479}
12480
12481#[derive(Debug)]
12482struct RefDependencyRow {
12483    ref_id: String,
12484    kind: String,
12485    caller_file: String,
12486    module_path: Option<String>,
12487    target_file: Option<String>,
12488}
12489
12490fn ref_dependency_row_depends_on(
12491    project_root: &Path,
12492    row: &RefDependencyRow,
12493    rel_path: &str,
12494) -> bool {
12495    if row.target_file.as_deref() == Some(rel_path) {
12496        return true;
12497    }
12498
12499    match row.kind.as_str() {
12500        "call" => true,
12501        "import" | "reexport" => row
12502            .module_path
12503            .as_deref()
12504            .map(|module_path| {
12505                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12506                    .contains(rel_path)
12507            })
12508            .unwrap_or(false),
12509        "export_alias" => false,
12510        _ => false,
12511    }
12512}
12513
12514fn module_dependencies_for_ref(
12515    project_root: &Path,
12516    caller_file: &str,
12517    module_path: &str,
12518) -> BTreeSet<String> {
12519    module_dependencies(project_root, &project_root.join(caller_file), module_path)
12520}
12521
12522fn import_dependencies(
12523    project_root: &Path,
12524    abs_path: &Path,
12525    imports: &[ImportStatement],
12526) -> BTreeSet<String> {
12527    let mut deps = BTreeSet::new();
12528    for import in imports {
12529        deps.extend(module_dependencies(
12530            project_root,
12531            abs_path,
12532            &import.module_path,
12533        ));
12534    }
12535    deps
12536}
12537
12538fn module_dependencies(
12539    project_root: &Path,
12540    abs_path: &Path,
12541    module_path: &str,
12542) -> BTreeSet<String> {
12543    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12544    let caller_dir = abs_path.parent().unwrap_or(project_root);
12545    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12546        deps.insert(relative_path(project_root, &resolved));
12547    }
12548    if module_path.starts_with('.') {
12549        let base = caller_dir.join(module_path);
12550        for candidate in relative_module_candidates(&base) {
12551            deps.insert(relative_path(project_root, &candidate));
12552        }
12553    }
12554    deps
12555}
12556
12557fn rust_module_dependencies(
12558    project_root: &Path,
12559    abs_path: &Path,
12560    module_path: &str,
12561) -> BTreeSet<String> {
12562    let mut deps = BTreeSet::new();
12563    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12564    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12565        return deps;
12566    };
12567    let src_prefix = rust_src_prefix(&rel_path);
12568    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12569    if !path_segments.is_empty() {
12570        rust_push_module_dependency_candidate(
12571            project_root,
12572            &mut deps,
12573            &src_prefix,
12574            &path_segments[..path_segments.len() - 1],
12575        );
12576    }
12577    deps
12578}
12579
12580fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12581    let path = rust_module_path_without_alias_or_use_list(module_path);
12582    let segments = path
12583        .split("::")
12584        .map(str::trim)
12585        .filter(|segment| !segment.is_empty())
12586        .collect::<Vec<_>>();
12587    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12588        return None;
12589    }
12590    rust_resolve_segments(rel_path, &segments)
12591}
12592
12593fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12594    let path = module_path
12595        .trim()
12596        .trim_end_matches(';')
12597        .split_once(" as ")
12598        .map(|(left, _)| left.trim())
12599        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12600    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12601}
12602
12603fn rust_push_module_dependency_candidate(
12604    project_root: &Path,
12605    deps: &mut BTreeSet<String>,
12606    src_prefix: &str,
12607    segments: &[String],
12608) {
12609    let candidates = if segments.is_empty() {
12610        vec![
12611            format!("{src_prefix}/lib.rs"),
12612            format!("{src_prefix}/main.rs"),
12613        ]
12614    } else {
12615        vec![
12616            format!("{}/{}.rs", src_prefix, segments.join("/")),
12617            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12618        ]
12619    };
12620    for candidate in candidates {
12621        if project_root.join(&candidate).is_file() {
12622            deps.insert(candidate);
12623        }
12624    }
12625}
12626
12627fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12628    let mut candidates = Vec::new();
12629    if base.extension().is_some() {
12630        candidates.push(base.to_path_buf());
12631        return candidates;
12632    }
12633    for ext in JS_TS_EXTENSIONS {
12634        candidates.push(base.with_extension(ext));
12635    }
12636    for ext in JS_TS_EXTENSIONS {
12637        candidates.push(base.join(format!("index.{ext}")));
12638    }
12639    candidates
12640}
12641
12642fn import_local_names(import: &ImportStatement) -> Vec<String> {
12643    let mut names = Vec::new();
12644    if let Some(default) = &import.default_import {
12645        names.push(default.clone());
12646    }
12647    if let Some(namespace) = &import.namespace_import {
12648        names.push(namespace.clone());
12649    }
12650    for name in &import.names {
12651        names.push(crate::imports::specifier_local_name(name).to_string());
12652    }
12653    names
12654}
12655
12656fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12657    import
12658        .names
12659        .iter()
12660        .map(|name| crate::imports::specifier_imported_name(name).to_string())
12661        .collect()
12662}
12663
12664fn import_is_wildcard(import: &ImportStatement) -> bool {
12665    import.namespace_import.is_some() || import.raw_text.contains('*')
12666}
12667
12668fn namespace_alias(full_ref: &str) -> Option<String> {
12669    full_ref
12670        .split_once('.')
12671        .map(|(namespace, _)| namespace.to_string())
12672}
12673
12674fn import_kind_label(kind: ImportKind) -> &'static str {
12675    match kind {
12676        ImportKind::Value => "value",
12677        ImportKind::Type => "type",
12678        ImportKind::SideEffect => "side_effect",
12679    }
12680}
12681
12682fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12683    match kind {
12684        SymbolKind::Function => "function",
12685        SymbolKind::Class => "class",
12686        SymbolKind::Method => "method",
12687        SymbolKind::Struct => "struct",
12688        SymbolKind::Interface => "interface",
12689        SymbolKind::Enum => "enum",
12690        SymbolKind::TypeAlias => "type_alias",
12691        SymbolKind::Variable => "variable",
12692        SymbolKind::Heading => "heading",
12693        SymbolKind::FileSummary => "file_summary",
12694    }
12695}
12696
12697fn is_type_like(kind: &SymbolKind) -> bool {
12698    matches!(
12699        kind,
12700        SymbolKind::Class
12701            | SymbolKind::Struct
12702            | SymbolKind::Interface
12703            | SymbolKind::Enum
12704            | SymbolKind::TypeAlias
12705    )
12706}
12707
12708fn lang_label(lang: LangId) -> &'static str {
12709    match lang {
12710        LangId::TypeScript => "typescript",
12711        LangId::Tsx => "tsx",
12712        LangId::JavaScript => "javascript",
12713        LangId::Python => "python",
12714        LangId::Rust => "rust",
12715        LangId::Go => "go",
12716        LangId::C => "c",
12717        LangId::Cpp => "cpp",
12718        LangId::Zig => "zig",
12719        LangId::CSharp => "csharp",
12720        LangId::Bash => "bash",
12721        LangId::Html => "html",
12722        LangId::Markdown => "markdown",
12723        LangId::Solidity => "solidity",
12724        LangId::Scss => "scss",
12725        LangId::Vue => "vue",
12726        LangId::Json => "json",
12727        LangId::Scala => "scala",
12728        LangId::Java => "java",
12729        LangId::Ruby => "ruby",
12730        LangId::Kotlin => "kotlin",
12731        LangId::Swift => "swift",
12732        LangId::Php => "php",
12733        LangId::Lua => "lua",
12734        LangId::Perl => "perl",
12735        LangId::Yaml => "yaml",
12736        LangId::Pascal => "pascal",
12737        LangId::R => "r",
12738        LangId::Groovy => "groovy",
12739        LangId::ObjC => "objc",
12740    }
12741}
12742
12743fn lang_from_label(label: &str) -> Option<LangId> {
12744    match label {
12745        "typescript" => Some(LangId::TypeScript),
12746        "tsx" => Some(LangId::Tsx),
12747        "javascript" => Some(LangId::JavaScript),
12748        "python" => Some(LangId::Python),
12749        "rust" => Some(LangId::Rust),
12750        "go" => Some(LangId::Go),
12751        "c" => Some(LangId::C),
12752        "cpp" => Some(LangId::Cpp),
12753        "zig" => Some(LangId::Zig),
12754        "csharp" => Some(LangId::CSharp),
12755        "bash" => Some(LangId::Bash),
12756        "html" => Some(LangId::Html),
12757        "markdown" => Some(LangId::Markdown),
12758        "solidity" => Some(LangId::Solidity),
12759        "scss" => Some(LangId::Scss),
12760        "vue" => Some(LangId::Vue),
12761        "json" => Some(LangId::Json),
12762        "scala" => Some(LangId::Scala),
12763        "java" => Some(LangId::Java),
12764        "ruby" => Some(LangId::Ruby),
12765        "kotlin" => Some(LangId::Kotlin),
12766        "swift" => Some(LangId::Swift),
12767        "php" => Some(LangId::Php),
12768        "lua" => Some(LangId::Lua),
12769        "perl" => Some(LangId::Perl),
12770        "yaml" => Some(LangId::Yaml),
12771        "pascal" => Some(LangId::Pascal),
12772        "r" => Some(LangId::R),
12773        "groovy" => Some(LangId::Groovy),
12774        "objc" => Some(LangId::ObjC),
12775        _ => None,
12776    }
12777}
12778
12779fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12780    let mut normalized = if files.is_empty() {
12781        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
12782    } else {
12783        files
12784            .iter()
12785            .map(|path| normalize_file_path(project_root, path))
12786            .collect::<Result<Vec<_>>>()?
12787    };
12788    normalized.sort();
12789    normalized.dedup();
12790    Ok(normalized)
12791}
12792
12793fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
12794    let full_path = if path.is_relative() {
12795        project_root.join(path)
12796    } else {
12797        path.to_path_buf()
12798    };
12799    Ok(canonicalize_path(&full_path))
12800}
12801
12802fn canonicalize_path(path: &Path) -> PathBuf {
12803    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
12804}
12805
12806fn relative_path(project_root: &Path, path: &Path) -> String {
12807    if let Ok(stripped) = path.strip_prefix(project_root) {
12808        return stripped.to_string_lossy().replace('\\', "/");
12809    }
12810    let canon_root = canonicalize_path(project_root);
12811    let canon_path = canonicalize_path(path);
12812    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
12813        return stripped.to_string_lossy().replace('\\', "/");
12814    }
12815    canon_path.to_string_lossy().replace('\\', "/")
12816}
12817
12818fn unqualified_name(scoped: &str) -> &str {
12819    if scoped == TOP_LEVEL_SYMBOL {
12820        return scoped;
12821    }
12822    scoped
12823        .rsplit("::")
12824        .next()
12825        .unwrap_or(scoped)
12826        .rsplit('.')
12827        .next()
12828        .unwrap_or(scoped)
12829        .rsplit('#')
12830        .next()
12831        .unwrap_or(scoped)
12832}
12833
12834fn ref_id(parts: &[&str]) -> String {
12835    let joined = parts.join("\0");
12836    hash_to_hex(blake3::hash(joined.as_bytes()))
12837}
12838
12839fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
12840    let mut fingerprint = CorpusFingerprint::default();
12841    for path in callgraph::walk_project_files(project_root) {
12842        fingerprint.add_path(project_root, &path);
12843    }
12844    Ok(fingerprint.finish(project_root))
12845}
12846
12847/// Pre-admission fingerprint over the same source set the staging inventory
12848/// will consume: the walk when no explicit list is supplied, the list
12849/// otherwise. Streaming accumulator - no staging writes, bounded memory.
12850fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
12851    if files.is_empty() {
12852        return callgraph_corpus_fingerprint(project_root);
12853    }
12854    let mut fingerprint = CorpusFingerprint::default();
12855    for path in files {
12856        fingerprint.add_path(project_root, path);
12857    }
12858    Ok(fingerprint.finish(project_root))
12859}
12860
12861#[derive(Default)]
12862struct CorpusFingerprint {
12863    xor: [u8; 32],
12864    sums: [u64; 4],
12865    files: u64,
12866}
12867
12868impl CorpusFingerprint {
12869    fn add_path(&mut self, project_root: &Path, path: &Path) {
12870        let mut record = blake3::Hasher::new();
12871        record.update(relative_path(project_root, path).as_bytes());
12872        record.update(&[0]);
12873        match hash_file_bounded(path) {
12874            Ok(content_hash) => record.update(content_hash.as_bytes()),
12875            // Encoding a missing file as a distinct record changes the corpus
12876            // fingerprint, so breaker state keyed to the previous corpus is not reused.
12877            Err(error) => record.update(format!("missing:{error}").as_bytes()),
12878        };
12879        record.update(&[0]);
12880        let record = record.finalize();
12881        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
12882            self.xor[index] ^= byte;
12883        }
12884        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
12885            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
12886            self.sums[index] = self.sums[index].wrapping_add(value);
12887        }
12888        self.files = self.files.saturating_add(1);
12889    }
12890
12891    fn finish(self, project_root: &Path) -> String {
12892        // Combining both xor and modular sums keeps the digest independent of
12893        // walk order while retaining duplicate sensitivity for generic callers.
12894        let mut hasher = blake3::Hasher::new();
12895        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
12896        hasher.update(&self.files.to_le_bytes());
12897        hasher.update(&self.xor);
12898        for sum in self.sums {
12899            hasher.update(&sum.to_le_bytes());
12900        }
12901        let ignore_rules = project_root.join(".gitignore");
12902        if let Ok(contents) = std::fs::read(ignore_rules) {
12903            hasher.update(b".gitignore\0");
12904            hasher.update(blake3::hash(&contents).as_bytes());
12905        }
12906        hash_to_hex(hasher.finalize())
12907    }
12908}
12909
12910fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
12911    let mut file = std::fs::File::open(path)?;
12912    let mut hasher = blake3::Hasher::new();
12913    let mut buffer = [0u8; 64 * 1024];
12914    loop {
12915        let read = file.read(&mut buffer)?;
12916        if read == 0 {
12917            break;
12918        }
12919        hasher.update(&buffer[..read]);
12920    }
12921    Ok(hasher.finalize())
12922}
12923
12924#[cfg(test)]
12925pub(crate) fn callgraph_corpus_fingerprint_for_test(
12926    project_root: &Path,
12927    _files: &[PathBuf],
12928) -> Result<String> {
12929    // The streaming fingerprint walks the corpus itself (order-independent
12930    // accumulator, no resident file list); the test seam keeps its historical
12931    // signature so callers need not thread a walk of their own.
12932    callgraph_corpus_fingerprint(project_root)
12933}
12934
12935fn hash_to_hex(hash: blake3::Hash) -> String {
12936    hash.to_hex().to_string()
12937}
12938
12939fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
12940    let bytes = hex_to_bytes(value)?;
12941    Some(blake3::Hash::from_bytes(bytes))
12942}
12943
12944fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
12945    if value.len() != 64 {
12946        return None;
12947    }
12948    let mut bytes = [0u8; 32];
12949    for (index, slot) in bytes.iter_mut().enumerate() {
12950        let start = index * 2;
12951        let end = start + 2;
12952        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
12953    }
12954    Some(bytes)
12955}
12956
12957#[derive(Debug, Clone)]
12958struct LineIndex {
12959    newline_offsets: Vec<usize>,
12960    source_len: usize,
12961}
12962
12963impl LineIndex {
12964    fn new(source: &str) -> Self {
12965        Self {
12966            newline_offsets: source
12967                .bytes()
12968                .enumerate()
12969                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
12970                .collect(),
12971            source_len: source.len(),
12972        }
12973    }
12974
12975    fn byte_to_line(&self, byte_offset: usize) -> u32 {
12976        let byte_offset = byte_offset.min(self.source_len);
12977        self.newline_offsets
12978            .partition_point(|offset| *offset < byte_offset) as u32
12979            + 1
12980    }
12981}
12982
12983fn empty_to_none(value: String) -> Option<String> {
12984    if value.is_empty() {
12985        None
12986    } else {
12987        Some(value)
12988    }
12989}
12990
12991fn bool_int(value: bool) -> i64 {
12992    if value {
12993        1
12994    } else {
12995        0
12996    }
12997}
12998
12999fn system_time_to_ns(time: SystemTime) -> i64 {
13000    time.duration_since(UNIX_EPOCH)
13001        .unwrap_or_default()
13002        .as_nanos()
13003        .min(i64::MAX as u128) as i64
13004}
13005
13006fn ns_to_system_time(value: i64) -> SystemTime {
13007    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13008}
13009
13010pub(crate) fn unix_millis_now() -> u64 {
13011    SystemTime::now()
13012        .duration_since(UNIX_EPOCH)
13013        .unwrap_or_default()
13014        .as_millis()
13015        .min(u128::from(u64::MAX)) as u64
13016}
13017
13018fn unix_seconds_now() -> i64 {
13019    SystemTime::now()
13020        .duration_since(UNIX_EPOCH)
13021        .unwrap_or_default()
13022        .as_secs() as i64
13023}
13024
13025/// Serializes every test that drives the process-wide refresh worker
13026/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
13027/// worker down between another test's enqueue and its flush, deferring the
13028/// batch and zeroing that test's seam counts).
13029#[cfg(test)]
13030pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13031
13032#[cfg(test)]
13033mod refresh_worker_tests {
13034    use super::*;
13035    use std::fs;
13036    use tempfile::tempdir;
13037
13038    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13039        let temp = tempdir().unwrap();
13040        let root = temp.path().join("root");
13041        fs::create_dir_all(&root).unwrap();
13042        let artifact_key = crate::search_index::artifact_cache_key(&root);
13043        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13044        let callgraph_dir = temp
13045            .path()
13046            .join("storage")
13047            .join("callgraph")
13048            .join(artifact_key);
13049        let source = root.join("main.rs");
13050        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13051        let (store, _) = CallGraphStore::cold_build_with_lease(
13052            callgraph_dir.clone(),
13053            root.clone(),
13054            std::slice::from_ref(&source),
13055        )
13056        .unwrap();
13057        drop(store);
13058        (temp, root, callgraph_dir, source)
13059    }
13060
13061    fn pending_paths() -> PendingCallGraphStorePaths {
13062        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13063    }
13064
13065    fn wait_for_refresh_calls(root: &Path, expected: usize) {
13066        let deadline = Instant::now() + Duration::from_secs(12);
13067        while callgraph_refresh_worker_test_counts(root).0 < expected {
13068            assert!(
13069                Instant::now() < deadline,
13070                "timed out waiting for {expected} callgraph refresh worker call(s)"
13071            );
13072            std::thread::sleep(Duration::from_millis(5));
13073        }
13074    }
13075
13076    fn wait_for_refresh_worker_idle() {
13077        let deadline = Instant::now() + Duration::from_secs(12);
13078        loop {
13079            let worker = CALLGRAPH_REFRESH_WORKER
13080                .get_or_init(|| Mutex::new(None))
13081                .lock()
13082                .expect("callgraph refresh worker mutex poisoned")
13083                .clone();
13084            let idle = worker.is_none_or(|worker| {
13085                let queue = worker
13086                    .shared
13087                    .queue
13088                    .lock()
13089                    .expect("callgraph refresh queue mutex poisoned");
13090                queue.active.is_none() && queue.order.is_empty()
13091            });
13092            if idle {
13093                return;
13094            }
13095            assert!(
13096                Instant::now() < deadline,
13097                "timed out waiting for callgraph refresh worker to become idle"
13098            );
13099            std::thread::sleep(Duration::from_millis(5));
13100        }
13101    }
13102
13103    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13104        let temp = tempdir().unwrap();
13105        let root = temp.path().join("workspace");
13106        fs::create_dir_all(root.join("app/src")).unwrap();
13107        let artifact_key = crate::search_index::artifact_cache_key(&root);
13108        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13109        let callgraph_dir = temp
13110            .path()
13111            .join("storage")
13112            .join("callgraph")
13113            .join(artifact_key);
13114        fs::write(
13115            root.join("Cargo.toml"),
13116            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13117        )
13118        .unwrap();
13119        fs::write(
13120            root.join("app/Cargo.toml"),
13121            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13122        )
13123        .unwrap();
13124        let caller = root.join("app/src/lib.rs");
13125        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13126        let (store, _) = CallGraphStore::cold_build_with_lease(
13127            callgraph_dir.clone(),
13128            root.clone(),
13129            std::slice::from_ref(&caller),
13130        )
13131        .unwrap();
13132        drop(store);
13133        (temp, root, callgraph_dir, caller)
13134    }
13135
13136    #[test]
13137    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13138        let _guard = REFRESH_WORKER_TEST_LOCK
13139            .lock()
13140            .unwrap_or_else(std::sync::PoisonError::into_inner);
13141        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13142        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13143        reset_workspace_crate_prefix_build_count(&root);
13144        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13145
13146        for revision in ["first", "second"] {
13147            fs::write(
13148                &caller,
13149                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13150            )
13151            .unwrap();
13152            enqueue_callgraph_store_refresh(
13153                callgraph_dir.clone(),
13154                root.clone(),
13155                vec![caller.clone()],
13156                pending_paths(),
13157            );
13158            wait_for_refresh_worker_idle();
13159        }
13160
13161        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13162        assert!(flush_callgraph_store_refreshes_with_budget(
13163            Duration::from_secs(5)
13164        ));
13165        clear_callgraph_refresh_worker_test_seam(&root);
13166    }
13167
13168    #[test]
13169    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13170        let _guard = REFRESH_WORKER_TEST_LOCK
13171            .lock()
13172            .unwrap_or_else(std::sync::PoisonError::into_inner);
13173        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13174        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13175        reset_workspace_crate_prefix_build_count(&root);
13176        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13177
13178        fs::write(
13179            &caller,
13180            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13181        )
13182        .unwrap();
13183        enqueue_callgraph_store_refresh(
13184            callgraph_dir.clone(),
13185            root.clone(),
13186            vec![caller.clone()],
13187            pending_paths(),
13188        );
13189        wait_for_refresh_worker_idle();
13190        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13191
13192        let added_manifest = root.join("added/Cargo.toml");
13193        let added_source = root.join("added/src/lib.rs");
13194        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13195        fs::write(
13196            root.join("Cargo.toml"),
13197            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13198        )
13199        .unwrap();
13200        fs::write(
13201            &added_manifest,
13202            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13203        )
13204        .unwrap();
13205        fs::write(&added_source, "pub fn target() {}\n").unwrap();
13206        fs::write(
13207            &caller,
13208            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13209        )
13210        .unwrap();
13211
13212        enqueue_callgraph_store_refresh(
13213            callgraph_dir.clone(),
13214            root.clone(),
13215            vec![
13216                root.join("Cargo.toml"),
13217                added_manifest,
13218                added_source,
13219                caller,
13220            ],
13221            pending_paths(),
13222        );
13223        assert!(flush_callgraph_store_refreshes_with_budget(
13224            Duration::from_secs(12)
13225        ));
13226
13227        // This is the negative control for a permanently-static cache: without
13228        // manifest invalidation the build count stays at one and the call remains
13229        // unresolved because `added_crate` was absent when the map was primed.
13230        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13231        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13232            .unwrap()
13233            .expect("refreshed workspace store");
13234        let tree = store
13235            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13236            .unwrap();
13237        assert_eq!(tree.children.len(), 1);
13238        assert_eq!(tree.children[0].file, "added/src/lib.rs");
13239        assert_eq!(tree.children[0].name, "target");
13240        assert!(tree.children[0].resolved);
13241        clear_callgraph_refresh_worker_test_seam(&root);
13242    }
13243
13244    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13245        let temp = tempdir().unwrap();
13246        let main = temp.path().join("main");
13247        let worktree = temp.path().join("worktree");
13248        fs::create_dir_all(&main).unwrap();
13249        let mut git = std::process::Command::new("git");
13250        assert!(
13251            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13252                .status()
13253                .unwrap()
13254                .success()
13255        );
13256        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13257        for args in [
13258            vec![
13259                "-C",
13260                main.to_str().unwrap(),
13261                "config",
13262                "user.email",
13263                "test@example.com",
13264            ],
13265            vec![
13266                "-C",
13267                main.to_str().unwrap(),
13268                "config",
13269                "user.name",
13270                "AFT Test",
13271            ],
13272            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13273            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13274        ] {
13275            let mut command = std::process::Command::new("git");
13276            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13277                .status()
13278                .unwrap()
13279                .success());
13280        }
13281        let mut add_worktree = std::process::Command::new("git");
13282        assert!(crate::test_env::apply_hermetic_git_env(
13283            add_worktree
13284                .arg("-C")
13285                .arg(&main)
13286                .args(["worktree", "add", "--detach"])
13287                .arg(&worktree),
13288        )
13289        .status()
13290        .unwrap()
13291        .success());
13292        let main = fs::canonicalize(main).unwrap();
13293        let worktree = fs::canonicalize(worktree).unwrap();
13294        let project_key = crate::search_index::artifact_cache_key(&main);
13295        assert_eq!(
13296            crate::search_index::artifact_cache_key(&worktree),
13297            project_key
13298        );
13299        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13300        (temp, main, worktree, project_key, callgraph_dir)
13301    }
13302
13303    #[test]
13304    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13305        let _git_env = crate::test_env::hermetic_git_env_guard();
13306        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13307        crate::root_cache::configure_artifact_access(&root, &project_key, true);
13308        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13309        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13310        let publications_for_observer = Arc::clone(&publications);
13311        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13312            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13313        })));
13314        let source = root.join("lib.rs");
13315
13316        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13317            .expect_err("borrow-only writable open must remain unavailable");
13318        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13319        assert!(
13320            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13321                .unwrap()
13322                .is_none()
13323        );
13324        assert!(
13325            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13326                .unwrap()
13327                .is_none()
13328        );
13329        assert!(matches!(
13330            CallGraphStore::cold_build_with_lease(
13331                callgraph_dir.clone(),
13332                root.clone(),
13333                std::slice::from_ref(&source),
13334            ),
13335            Err(CallGraphStoreError::Unavailable(_))
13336        ));
13337        assert!(matches!(
13338            CallGraphStore::ensure_built_with_lease(
13339                callgraph_dir.clone(),
13340                root.clone(),
13341                std::slice::from_ref(&source),
13342            ),
13343            Err(CallGraphStoreError::Unavailable(_))
13344        ));
13345        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13346            callgraph_dir.clone(),
13347            root.clone(),
13348            &[source],
13349            1,
13350        )
13351        .expect_err("borrow-only forced rebuild must remain unsatisfied");
13352        set_cold_build_swap_observer(None);
13353
13354        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13355        assert_eq!(
13356            crate::root_cache::writer_lease_acquisition_count_for_test(
13357                crate::root_cache::RootCacheDomain::Callgraph,
13358                &project_key,
13359                &root,
13360            ),
13361            0
13362        );
13363        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13364        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13365    }
13366
13367    #[test]
13368    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13369        let _git_env = crate::test_env::hermetic_git_env_guard();
13370        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13371        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13372        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13373        let source = owner.join("lib.rs");
13374        let (store, _) = CallGraphStore::cold_build_with_lease(
13375            callgraph_dir.clone(),
13376            owner.clone(),
13377            std::slice::from_ref(&source),
13378        )
13379        .unwrap();
13380        let sqlite_path = store.sqlite_path().to_path_buf();
13381        drop(store);
13382
13383        let conn = Connection::open(&sqlite_path).unwrap();
13384        conn.execute(
13385            "UPDATE backend_file_state SET workspace_root = ?1",
13386            [worktree.display().to_string()],
13387        )
13388        .unwrap();
13389        drop(conn);
13390
13391        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13392        let publications_for_observer = Arc::clone(&publications);
13393        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13394            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13395        })));
13396        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13397
13398        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13399            .unwrap()
13400            .expect("owner should purge the storm-era worktree root");
13401        drop(repaired);
13402        for _ in 0..3 {
13403            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13404                .unwrap()
13405                .expect("linked worktree should borrow the owner generation");
13406            drop(borrower);
13407            assert!(
13408                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13409                    .unwrap()
13410                    .is_none()
13411            );
13412            let owner_store =
13413                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13414                    .unwrap()
13415                    .expect("owner generation should remain ready");
13416            drop(owner_store);
13417        }
13418        set_cold_build_swap_observer(None);
13419
13420        assert_eq!(
13421            publications.load(AtomicOrdering::SeqCst),
13422            1,
13423            "the owner performs one expected post-storm purge and alternation stays read-only"
13424        );
13425        assert_eq!(
13426            crate::root_cache::writer_lease_acquisition_count_for_test(
13427                crate::root_cache::RootCacheDomain::Callgraph,
13428                &project_key,
13429                &worktree,
13430            ),
13431            0
13432        );
13433    }
13434
13435    #[test]
13436    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13437        let temp = tempdir().unwrap();
13438        let root = temp.path().join("owner");
13439        let other_root = temp.path().join("other");
13440        fs::create_dir_all(&root).unwrap();
13441        fs::create_dir_all(&other_root).unwrap();
13442        let source = root.join("lib.rs");
13443        fs::write(&source, "pub fn marker() {}\n").unwrap();
13444        let project_key = crate::search_index::artifact_cache_key(&root);
13445        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13446        crate::root_cache::configure_artifact_access(&root, &project_key, false);
13447        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13448        rebuild_cooldown_records()
13449            .lock()
13450            .unwrap_or_else(std::sync::PoisonError::into_inner)
13451            .remove(&cooldown_key);
13452        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13453        let stale_epoch = epoch.current();
13454        epoch.next();
13455
13456        let failed = with_publish_epoch(epoch, stale_epoch, || {
13457            CallGraphStore::cold_build_with_lease(
13458                callgraph_dir.clone(),
13459                root.clone(),
13460                std::slice::from_ref(&source),
13461            )
13462        });
13463        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13464        assert!(
13465            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13466                .is_none()
13467        );
13468
13469        let (store, _) = CallGraphStore::cold_build_with_lease(
13470            callgraph_dir.clone(),
13471            root.clone(),
13472            std::slice::from_ref(&source),
13473        )
13474        .unwrap();
13475        drop(store);
13476        assert!(
13477            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13478                .is_none()
13479        );
13480
13481        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13482        assert!(
13483            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13484        );
13485    }
13486
13487    #[test]
13488    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13489        let _guard = REFRESH_WORKER_TEST_LOCK
13490            .lock()
13491            .unwrap_or_else(std::sync::PoisonError::into_inner);
13492        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13493        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13494        let pending = pending_paths();
13495        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13496
13497        let lifecycle = SubcLifecycleAdmission::default();
13498        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13499        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13500        let ticket = CallgraphRefreshTicket::new(
13501            lifecycle,
13502            Arc::clone(&generation),
13503            7,
13504            publish_epoch.clone(),
13505            publish_epoch.current(),
13506        );
13507        // Supersede before the worker runs: the batch must defer, not commit.
13508        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13509        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13510            .unwrap()
13511            .expect("ready store snapshot");
13512        let refresh_state = CallgraphRefreshState::new(
13513            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13514            Arc::new(AtomicBool::new(true)),
13515        );
13516
13517        enqueue_callgraph_store_refresh_fenced_with_state(
13518            callgraph_dir,
13519            root.clone(),
13520            vec![source.clone()],
13521            Arc::clone(&pending),
13522            refresh_state,
13523            ticket,
13524        );
13525        assert!(flush_callgraph_store_refreshes_with_budget(
13526            Duration::from_secs(5)
13527        ));
13528        assert_eq!(
13529            callgraph_refresh_worker_test_counts(&root).0,
13530            0,
13531            "superseded batch must not reach refresh_files or self-replay"
13532        );
13533        assert!(
13534            pending.lock().contains(&source),
13535            "superseded batch must defer its paths to the pending sink"
13536        );
13537        clear_callgraph_refresh_worker_test_seam(&root);
13538    }
13539
13540    #[test]
13541    fn superseded_open_failure_defers_without_self_replay() {
13542        let _guard = REFRESH_WORKER_TEST_LOCK
13543            .lock()
13544            .unwrap_or_else(std::sync::PoisonError::into_inner);
13545        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13546        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13547        let pending = pending_paths();
13548        let installed = Arc::new(
13549            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13550                .unwrap()
13551                .expect("ready store snapshot"),
13552        );
13553        let refresh_state = CallgraphRefreshState::new(
13554            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13555            Arc::new(AtomicBool::new(true)),
13556        );
13557        assert!(!installed.is_legacy_fallback());
13558        assert!(installed.is_current());
13559        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13560        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13561        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13562        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13563
13564        let lifecycle = SubcLifecycleAdmission::default();
13565        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13566        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13567        let ticket = CallgraphRefreshTicket::new(
13568            lifecycle,
13569            Arc::clone(&generation),
13570            7,
13571            publish_epoch.clone(),
13572            publish_epoch.current(),
13573        );
13574        enqueue_callgraph_store_refresh_fenced_with_state(
13575            callgraph_dir,
13576            root.clone(),
13577            vec![source.clone()],
13578            Arc::clone(&pending),
13579            refresh_state,
13580            ticket,
13581        );
13582        held_rx
13583            .recv_timeout(Duration::from_secs(12))
13584            .expect("refresh worker must hold after injected open failure");
13585
13586        // Mark the refresh request obsolete after the injected open failure,
13587        // then unblock the worker before its deferred retry can run.
13588        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13589        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13590        release_tx
13591            .send(())
13592            .expect("release superseded refresh worker");
13593        wait_for_refresh_worker_idle();
13594
13595        assert_eq!(
13596            callgraph_refresh_worker_test_counts(&root).0,
13597            1,
13598            "superseded open-failure batch must not self-replay"
13599        );
13600        assert_eq!(
13601            callgraph_refresh_worker_test_worker_calls(&root),
13602            1,
13603            "superseded open-failure batch must not create another worker call"
13604        );
13605        assert!(
13606            pending.lock().contains(&source),
13607            "superseded open-failure paths must remain in the pending sink"
13608        );
13609        let tree = installed
13610            .call_tree(Path::new("main.rs"), "entry", 1)
13611            .unwrap();
13612        assert_eq!(
13613            tree.children[0].name, "old_leaf",
13614            "superseded open-failure batch must not converge the store"
13615        );
13616        clear_callgraph_refresh_worker_test_seam(&root);
13617    }
13618
13619    #[test]
13620    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13621        let _guard = REFRESH_WORKER_TEST_LOCK
13622            .lock()
13623            .unwrap_or_else(std::sync::PoisonError::into_inner);
13624        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13625        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13626        let pending = pending_paths();
13627        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13628
13629        let lifecycle = SubcLifecycleAdmission::default();
13630        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13631        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13632        let expected_epoch = publish_epoch.current();
13633        let ticket = CallgraphRefreshTicket::new(
13634            lifecycle,
13635            generation,
13636            3,
13637            publish_epoch.clone(),
13638            expected_epoch,
13639        );
13640        // A cold build published a replacement generation after enqueue.
13641        publish_epoch.next();
13642
13643        enqueue_callgraph_store_refresh_fenced(
13644            callgraph_dir,
13645            root.clone(),
13646            vec![source.clone()],
13647            Arc::clone(&pending),
13648            ticket,
13649        );
13650        assert!(flush_callgraph_store_refreshes_with_budget(
13651            Duration::from_secs(5)
13652        ));
13653        assert_eq!(
13654            callgraph_refresh_worker_test_counts(&root).0,
13655            0,
13656            "epoch-superseded batch must not reach refresh_files"
13657        );
13658        assert!(
13659            pending.lock().contains(&source),
13660            "epoch-superseded batch must defer its paths to the pending sink"
13661        );
13662        clear_callgraph_refresh_worker_test_seam(&root);
13663    }
13664
13665    #[test]
13666    fn fenced_refresh_with_current_ticket_commits_normally() {
13667        let _guard = REFRESH_WORKER_TEST_LOCK
13668            .lock()
13669            .unwrap_or_else(std::sync::PoisonError::into_inner);
13670        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13671        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13672        let pending = pending_paths();
13673        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13674
13675        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13676
13677        let lifecycle = SubcLifecycleAdmission::default();
13678        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13679        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13680        let ticket = CallgraphRefreshTicket::new(
13681            lifecycle,
13682            generation,
13683            5,
13684            publish_epoch.clone(),
13685            publish_epoch.current(),
13686        );
13687
13688        enqueue_callgraph_store_refresh_fenced(
13689            callgraph_dir.clone(),
13690            root.clone(),
13691            vec![source.clone()],
13692            Arc::clone(&pending),
13693            ticket,
13694        );
13695        assert!(flush_callgraph_store_refreshes_with_budget(
13696            Duration::from_secs(5)
13697        ));
13698        assert_eq!(
13699            callgraph_refresh_worker_test_counts(&root).0,
13700            1,
13701            "current ticket must run the refresh"
13702        );
13703        assert!(
13704            pending.lock().is_empty(),
13705            "committed batch must not defer paths"
13706        );
13707
13708        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13709            .unwrap()
13710            .expect("published generation must remain readable");
13711        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13712        assert_eq!(
13713            tree.children[0].name, "new_leaf",
13714            "fenced commit must actually persist the refreshed content"
13715        );
13716        clear_callgraph_refresh_worker_test_seam(&root);
13717    }
13718
13719    #[test]
13720    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
13721        let _guard = REFRESH_WORKER_TEST_LOCK
13722            .lock()
13723            .unwrap_or_else(std::sync::PoisonError::into_inner);
13724        // Generous pre-drain: the refresh worker is process-wide, so a prior
13725        // test's still-running batch (slow Windows CI) must fully settle
13726        // before this test enqueues, or its wait deadline absorbs the
13727        // leftover work. Idle workers return immediately.
13728        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13729        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13730        let pending = pending_paths();
13731        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
13732
13733        enqueue_callgraph_store_refresh(
13734            callgraph_dir.clone(),
13735            root.clone(),
13736            vec![source.clone()],
13737            Arc::clone(&pending),
13738        );
13739        wait_for_refresh_calls(&root, 1);
13740        for _ in 0..3 {
13741            enqueue_callgraph_store_refresh(
13742                callgraph_dir.clone(),
13743                root.clone(),
13744                vec![source.clone()],
13745                Arc::clone(&pending),
13746            );
13747        }
13748
13749        assert!(flush_callgraph_store_refreshes_with_budget(
13750            Duration::from_secs(2)
13751        ));
13752        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
13753        assert!(pending.lock().is_empty());
13754        clear_callgraph_refresh_worker_test_seam(&root);
13755    }
13756
13757    #[test]
13758    fn queued_refresh_opens_generation_published_after_enqueue() {
13759        let _guard = REFRESH_WORKER_TEST_LOCK
13760            .lock()
13761            .unwrap_or_else(std::sync::PoisonError::into_inner);
13762        // Generous pre-drain: the refresh worker is process-wide, so a prior
13763        // test's still-running batch (slow Windows CI) must fully settle
13764        // before this test enqueues, or its wait deadline absorbs the
13765        // leftover work. Idle workers return immediately.
13766        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13767        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13768        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
13769        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
13770        let (active_held_rx, active_release_tx) =
13771            install_callgraph_refresh_worker_test_gate(active_root.clone());
13772        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
13773        enqueue_callgraph_store_refresh(
13774            active_dir,
13775            active_root.clone(),
13776            vec![active_source],
13777            pending_paths(),
13778        );
13779        active_held_rx
13780            .recv_timeout(Duration::from_secs(12))
13781            .expect("active refresh worker holds the queue");
13782
13783        fs::write(
13784            &target_source,
13785            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13786        )
13787        .unwrap();
13788        enqueue_callgraph_store_refresh(
13789            target_dir.clone(),
13790            target_root.clone(),
13791            vec![target_source.clone()],
13792            pending_paths(),
13793        );
13794        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
13795            target_dir.clone(),
13796            target_root.clone(),
13797            std::slice::from_ref(&target_source),
13798        )
13799        .unwrap();
13800        fs::write(
13801            &target_source,
13802            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
13803        )
13804        .unwrap();
13805        drop(new_generation);
13806
13807        active_release_tx
13808            .send(())
13809            .expect("release active refresh worker");
13810        wait_for_refresh_calls(&target_root, 1);
13811        assert!(flush_callgraph_store_refreshes_with_budget(
13812            Duration::from_secs(12)
13813        ));
13814        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
13815            .unwrap()
13816            .expect("current callgraph generation");
13817        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13818        assert_eq!(tree.children[0].name, "worker_leaf");
13819        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
13820        clear_callgraph_refresh_worker_test_seam(&active_root);
13821        clear_callgraph_refresh_worker_test_seam(&target_root);
13822    }
13823
13824    #[test]
13825    fn refresh_failure_marks_files_stale() {
13826        let _guard = REFRESH_WORKER_TEST_LOCK
13827            .lock()
13828            .unwrap_or_else(std::sync::PoisonError::into_inner);
13829        // Generous pre-drain: the refresh worker is process-wide, so a prior
13830        // test's still-running batch (slow Windows CI) must fully settle
13831        // before this test enqueues, or its wait deadline absorbs the
13832        // leftover work. Idle workers return immediately.
13833        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13834        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13835        let pending = pending_paths();
13836        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
13837
13838        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
13839        assert!(flush_callgraph_store_refreshes_with_budget(
13840            Duration::from_secs(2)
13841        ));
13842
13843        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
13844        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
13845            .unwrap()
13846            .expect("ready callgraph store");
13847        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
13848        clear_callgraph_refresh_worker_test_seam(&root);
13849    }
13850
13851    #[test]
13852    fn idle_refresh_truncates_wal() {
13853        let _guard = REFRESH_WORKER_TEST_LOCK
13854            .lock()
13855            .unwrap_or_else(std::sync::PoisonError::into_inner);
13856        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13857        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13858        let generation = read_pointer(
13859            &callgraph_dir,
13860            &crate::search_index::artifact_cache_key(&root),
13861        )
13862        .expect("fixture publishes a generation");
13863        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
13864        let pending = pending_paths();
13865        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13866
13867        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
13868        enqueue_callgraph_store_refresh(
13869            callgraph_dir.clone(),
13870            root.clone(),
13871            vec![source.clone()],
13872            Arc::clone(&pending),
13873        );
13874        wait_for_refresh_calls(&root, 1);
13875        wait_for_refresh_worker_idle();
13876        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
13877        while fs::metadata(&wal_path)
13878            .map(|metadata| metadata.len())
13879            .unwrap_or(0)
13880            != 0
13881        {
13882            assert!(
13883                Instant::now() < checkpoint_deadline,
13884                "idle checkpoint did not truncate WAL"
13885            );
13886            std::thread::sleep(Duration::from_millis(5));
13887        }
13888        assert_eq!(
13889            fs::metadata(&wal_path)
13890                .map(|metadata| metadata.len())
13891                .unwrap_or(0),
13892            0,
13893            "idle transition truncates the refresh WAL"
13894        );
13895
13896        clear_callgraph_refresh_worker_test_seam(&root);
13897    }
13898
13899    #[test]
13900    fn bounded_shutdown_defers_unprocessed_batches() {
13901        let _guard = REFRESH_WORKER_TEST_LOCK
13902            .lock()
13903            .unwrap_or_else(std::sync::PoisonError::into_inner);
13904        // Generous pre-drain: the refresh worker is process-wide, so a prior
13905        // test's still-running batch (slow Windows CI) must fully settle
13906        // before this test enqueues, or its wait deadline absorbs the
13907        // leftover work. Idle workers return immediately.
13908        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13909        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
13910        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
13911        let active_pending = pending_paths();
13912        let queued_pending = pending_paths();
13913        set_callgraph_refresh_worker_test_seam(
13914            active_root.clone(),
13915            Duration::from_millis(300),
13916            false,
13917        );
13918
13919        enqueue_callgraph_store_refresh(
13920            active_dir,
13921            active_root.clone(),
13922            vec![active_source.clone()],
13923            Arc::clone(&active_pending),
13924        );
13925        wait_for_refresh_calls(&active_root, 1);
13926        enqueue_callgraph_store_refresh(
13927            queued_dir,
13928            queued_root.clone(),
13929            vec![queued_source.clone()],
13930            Arc::clone(&queued_pending),
13931        );
13932
13933        assert!(!flush_callgraph_store_refreshes_with_budget(
13934            Duration::from_millis(20)
13935        ));
13936        assert!(active_pending.lock().contains(&active_source));
13937        assert!(queued_pending.lock().contains(&queued_source));
13938        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
13939        clear_callgraph_refresh_worker_test_seam(&active_root);
13940    }
13941}
13942
13943#[cfg(test)]
13944mod cold_build_insert_tests {
13945    use super::*;
13946    use crate::imports::ImportBlock;
13947    use std::cell::Cell;
13948    use std::fs;
13949    use std::path::{Path, PathBuf};
13950    use tempfile::tempdir;
13951
13952    thread_local! {
13953        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
13954        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
13955        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
13956    }
13957
13958    fn count_caller_traversal_selects(sql: &str) {
13959        let sql = sql.trim_start();
13960        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
13961            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
13962        }
13963        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
13964            && sql.contains("e.target_file =")
13965        {
13966            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
13967        }
13968        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
13969            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
13970        }
13971    }
13972
13973    #[test]
13974    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
13975        let dir = tempdir().unwrap();
13976        let previous_root = dir.path().join("previous-root");
13977        let current_root = dir.path().join("current-root");
13978        fs::create_dir_all(&previous_root).unwrap();
13979        fs::create_dir_all(&current_root).unwrap();
13980        fs::remove_dir(&previous_root).unwrap();
13981        let mut conn = Connection::open_in_memory().unwrap();
13982        initialize_schema(&conn).unwrap();
13983        conn.execute(
13984            "INSERT INTO backend_file_state(
13985                backend, workspace_root, file_path, content_hash, status, updated_at
13986             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
13987            params![previous_root.display().to_string()],
13988        )
13989        .unwrap();
13990
13991        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
13992
13993        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
13994        assert_eq!(
13995            stored_workspace_roots(&conn).unwrap(),
13996            vec![previous_root.display().to_string()]
13997        );
13998    }
13999
14000    #[test]
14001    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14002        assert_eq!(
14003            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14004            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14005        );
14006    }
14007
14008    #[test]
14009    fn legacy_migration_completion_log_has_operator_fields() {
14010        assert_eq!(
14011            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14012            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14013        );
14014    }
14015
14016    fn write_generation_with_age(
14017        dir: &Path,
14018        project_key: &str,
14019        ordinal: u64,
14020        age: Duration,
14021    ) -> String {
14022        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14023        let path = dir.join(&generation);
14024        fs::write(&path, b"sqlite placeholder").unwrap();
14025        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14026        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14027        generation
14028    }
14029
14030    #[test]
14031    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14032        let dir = tempfile::tempdir().unwrap();
14033        let project_key = "project";
14034        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14035        let previous =
14036            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14037        let pinned =
14038            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14039        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14040
14041        gc_old_generations(dir.path(), project_key, &current);
14042
14043        assert!(dir.path().join(&previous).is_file());
14044        assert!(dir.path().join(&pinned).is_file());
14045
14046        drop(marker);
14047        gc_old_generations(dir.path(), project_key, &current);
14048
14049        assert!(dir.path().join(&previous).is_file());
14050        assert!(!dir.path().join(&pinned).exists());
14051    }
14052
14053    #[test]
14054    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14055        let dir = tempfile::tempdir().unwrap();
14056        let project_key = "project";
14057        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14058        let _previous =
14059            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14060        let pinned =
14061            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14062        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14063        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14064
14065        gc_old_generations(dir.path(), project_key, &current);
14066
14067        assert!(dir.path().join(&pinned).is_file());
14068    }
14069
14070    #[test]
14071    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14072        let dir = tempfile::tempdir().unwrap();
14073        let project_key = "project";
14074        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14075        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14076        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14077        let old = write_generation_with_age(
14078            dir.path(),
14079            project_key,
14080            200,
14081            expired + Duration::from_secs(60),
14082        );
14083        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14084
14085        gc_old_generations(dir.path(), project_key, &current);
14086
14087        assert!(dir.path().join(&current).is_file());
14088        assert!(dir.path().join(&previous).is_file());
14089        assert!(!dir.path().join(&old).exists());
14090    }
14091
14092    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14093        let path = dir.join(name);
14094        fs::write(&path, b"temp placeholder").unwrap();
14095        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14096        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14097        path
14098    }
14099
14100    #[test]
14101    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14102        let dir = tempdir().unwrap();
14103        // One directory holds both an aged orphan (with its journal sidecar) and a
14104        // fresh temporary, so this proves the sweep SELECTS by age rather than
14105        // deleting everything in the directory.
14106        let aged = "project.g100.1.sqlite.tmp.1.200";
14107        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14108        let fresh = "project.g300.1.sqlite.tmp.1.400";
14109        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14110        write_build_temp_with_age(dir.path(), aged, aged_age);
14111        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14112        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14113
14114        sweep_orphaned_build_temps(dir.path());
14115
14116        assert!(
14117            !dir.path().join(aged).exists(),
14118            "aged orphan must be removed"
14119        );
14120        assert!(
14121            !dir.path().join(aged_journal).exists(),
14122            "aged journal sidecar must be removed"
14123        );
14124        assert!(
14125            dir.path().join(fresh).is_file(),
14126            "fresh temporary must survive"
14127        );
14128    }
14129
14130    #[test]
14131    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14132        let storage = tempdir().unwrap();
14133        let storage_root = storage.path();
14134        // The production shape: a legacy per-harness store whose root no longer
14135        // builds there — no `.current` pointer, no running build — so the per-root
14136        // cleanup never fires for it. A sibling root still building in the
14137        // root-keyed store triggers the store-wide sweep, which must reach into the
14138        // legacy directory and reclaim the orphan.
14139        let legacy_dir = storage_root.join("opencode").join("callgraph");
14140        fs::create_dir_all(&legacy_dir).unwrap();
14141        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14142        write_build_temp_with_age(
14143            &legacy_dir,
14144            orphan,
14145            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14146        );
14147        assert!(
14148            !legacy_dir.join("deadbeef.current").exists(),
14149            "the dead root has no current pointer"
14150        );
14151
14152        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14153        fs::create_dir_all(&root_keyed_dir).unwrap();
14154
14155        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14156
14157        assert!(
14158            !legacy_dir.join(orphan).exists(),
14159            "legacy orphan must be reclaimed by the store-wide sweep"
14160        );
14161    }
14162
14163    #[test]
14164    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14165        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
14166        // everything (min_age = 0) removes the fresh temporary that the real 24h
14167        // threshold spares in the test above. If a mutation to the age check leaves
14168        // the fresh file in place here, the predicate is no longer doing the
14169        // selection work the fresh-survives assertion relies on.
14170        let dir = tempdir().unwrap();
14171        let fresh = "project.g300.1.sqlite.tmp.1.400";
14172        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14173
14174        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14175
14176        assert!(
14177            !dir.path().join(fresh).exists(),
14178            "with the age predicate forced open, the fresh temporary is removed"
14179        );
14180    }
14181
14182    #[test]
14183    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14184        let dir = tempdir().unwrap();
14185        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
14186        // to be swept, plus a live read marker, is generation GC's jurisdiction.
14187        // The orphan sweep must not intersect it.
14188        let generation = write_generation_with_age(
14189            dir.path(),
14190            "project",
14191            400,
14192            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14193        );
14194        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14195
14196        sweep_orphaned_build_temps(dir.path());
14197
14198        assert!(
14199            dir.path().join(&generation).is_file(),
14200            "completed generation must survive the orphan sweep"
14201        );
14202        assert!(
14203            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14204            "read marker must survive the orphan sweep"
14205        );
14206    }
14207
14208    #[test]
14209    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14210        let dir = tempfile::tempdir().unwrap();
14211        let project_key = "project".to_string();
14212        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14213        let sqlite_path = dir.path().join(&generation);
14214        fs::remove_file(&sqlite_path).unwrap();
14215        let conn = Connection::open(&sqlite_path).unwrap();
14216        let store = CallGraphStore::from_connection(
14217            dir.path().to_path_buf(),
14218            project_key,
14219            sqlite_path,
14220            dir.path().to_path_buf(),
14221            false,
14222            Some(generation.clone()),
14223            None,
14224            None,
14225            conn,
14226        );
14227
14228        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14229        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14230
14231        drop(marker);
14232        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14233    }
14234
14235    #[test]
14236    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14237        let dir = tempdir().expect("temp dir");
14238        let file = dir.path().join("main.ts");
14239        fs::write(&file, "export function main() {}\n").expect("write fixture");
14240        let store = CallGraphStore::open(
14241            dir.path().join(".store-readiness-cache"),
14242            dir.path().to_path_buf(),
14243        )
14244        .expect("open store");
14245        {
14246            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14247            conn.trace(Some(count_caller_traversal_selects));
14248        }
14249
14250        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14251        assert!(store.indexed_file_count().is_err());
14252        assert!(store.indexed_file_count().is_err());
14253        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14254
14255        store
14256            .cold_build(std::slice::from_ref(&file))
14257            .expect("cold build");
14258        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14259        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14260        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14261        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14262
14263        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14264        conn.trace(None);
14265    }
14266
14267    #[test]
14268    fn direct_caller_frontier_chunks_sqlite_selects() {
14269        let dir = tempdir().expect("temp dir");
14270        let file = dir.path().join("main.ts");
14271        fs::write(
14272            &file,
14273            "export function caller() { target(); }\nexport function target() {}\n",
14274        )
14275        .expect("write fixture");
14276        let store = CallGraphStore::open(
14277            dir.path().join(".store-caller-frontier-query"),
14278            dir.path().to_path_buf(),
14279        )
14280        .expect("open store");
14281        store
14282            .cold_build(std::slice::from_ref(&file))
14283            .expect("cold build");
14284        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14285        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14286
14287        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14288        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14289        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14290        {
14291            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14292            conn.trace(Some(count_caller_traversal_selects));
14293        }
14294        let callers = store
14295            .direct_callers_for_symbols(&targets)
14296            .expect("batched callers");
14297        {
14298            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14299            conn.trace(None);
14300        }
14301
14302        assert_eq!(callers.len(), 1_000);
14303        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14304        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14305        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14306        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14307    }
14308
14309    #[test]
14310    fn callers_depth_boundary_batches_sqlite_counts() {
14311        const CALLER_COUNT: usize = 1_000;
14312
14313        let dir = tempdir().expect("temp dir");
14314        let file = dir.path().join("main.ts");
14315        let mut source = String::from("export function sharedHotHelper() {}\n");
14316        for index in 0..CALLER_COUNT {
14317            source.push_str(&format!(
14318                "export function caller{index}() {{ sharedHotHelper(); }}\n"
14319            ));
14320        }
14321        fs::write(&file, source).expect("write fixture");
14322
14323        let store = CallGraphStore::open(
14324            dir.path().join(".store-callers-query-fanout"),
14325            dir.path().to_path_buf(),
14326        )
14327        .expect("open store");
14328        store
14329            .cold_build(std::slice::from_ref(&file))
14330            .expect("cold build");
14331
14332        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14333        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14334        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14335        {
14336            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14337            conn.trace(Some(count_caller_traversal_selects));
14338        }
14339
14340        let started = Instant::now();
14341        let result = crate::commands::callgraph_store_adapter::callers_result(
14342            &store,
14343            Path::new("main.ts"),
14344            "sharedHotHelper",
14345            1,
14346            true,
14347        )
14348        .expect("callers result");
14349        let elapsed = started.elapsed();
14350
14351        {
14352            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14353            conn.trace(None);
14354        }
14355        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14356        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14357        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14358        eprintln!(
14359            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14360            result.total_callers,
14361            caller_queries,
14362            boundary_queries,
14363            total_selects,
14364            elapsed.as_secs_f64() * 1_000.0
14365        );
14366
14367        assert_eq!(result.total_callers, CALLER_COUNT);
14368        assert_eq!(caller_queries, 1);
14369        assert_eq!(boundary_queries, 3);
14370        assert_eq!(total_selects, 9);
14371    }
14372
14373    #[test]
14374    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14375        let dir = tempdir().expect("temp dir");
14376        let file = dir.path().join("main.ts");
14377        fs::write(
14378            &file,
14379            r#"export function topA() {
14380  root();
14381}
14382
14383export function topB() {
14384  root();
14385}
14386
14387export function root() {
14388  leaf();
14389  missing();
14390}
14391
14392export function leaf() {}
14393"#,
14394        )
14395        .expect("write fixture");
14396
14397        let store = CallGraphStore::open(
14398            dir.path().join(".store-depth-boundary-counts"),
14399            dir.path().to_path_buf(),
14400        )
14401        .expect("open store");
14402        store
14403            .cold_build(std::slice::from_ref(&file))
14404            .expect("cold build");
14405
14406        let root = store
14407            .node_for(Path::new("main.ts"), "root")
14408            .expect("root node");
14409        let leaf = store
14410            .node_for(Path::new("main.ts"), "leaf")
14411            .expect("leaf node");
14412
14413        let (full_forward_len, full_direct_len) = {
14414            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14415            conn.execute(
14416                "INSERT INTO edges (
14417                    edge_id, ref_id, source_node, target_node, target_file,
14418                    target_symbol, kind, line, provenance
14419                 ) VALUES (
14420                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14421                    ?2, ?3, 'call', 98, ?4
14422                 )",
14423                rusqlite::params![
14424                    &root.node_id,
14425                    &leaf.file,
14426                    &leaf.symbol,
14427                    PROVENANCE_TREESITTER
14428                ],
14429            )
14430            .expect("insert dangling forward edge");
14431            conn.execute(
14432                "INSERT INTO edges (
14433                    edge_id, ref_id, source_node, target_node, target_file,
14434                    target_symbol, kind, line, provenance
14435                 ) VALUES (
14436                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14437                    ?1, ?2, ?3, 'call', 99, ?4
14438                 )",
14439                rusqlite::params![
14440                    &root.node_id,
14441                    &root.file,
14442                    &root.symbol,
14443                    PROVENANCE_TREESITTER
14444                ],
14445            )
14446            .expect("insert dangling direct-caller edge");
14447
14448            let full_forward_len = forward_calls_for_node(&conn, &root)
14449                .expect("full forward calls")
14450                .len();
14451            let counted_forward_len =
14452                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14453            assert_eq!(
14454                counted_forward_len, full_forward_len,
14455                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14456            );
14457
14458            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14459                .expect("full direct callers");
14460            let full_direct_len = full_direct.len();
14461            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14462                .expect("counted direct callers");
14463            assert_eq!(
14464                counted_direct_len, full_direct_len,
14465                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14466            );
14467
14468            let distinct_direct_len = full_direct
14469                .iter()
14470                .map(|site| {
14471                    (
14472                        site.caller.file.clone(),
14473                        site.line,
14474                        site.target_file.clone(),
14475                        site.target_symbol.clone(),
14476                    )
14477                })
14478                .collect::<BTreeSet<_>>()
14479                .len();
14480            let batch_counts = direct_caller_counts_for_tuples(
14481                &conn,
14482                &[
14483                    (root.file.clone(), root.symbol.clone()),
14484                    (root.file.clone(), root.symbol.clone()),
14485                    (leaf.file.clone(), leaf.symbol.clone()),
14486                ],
14487            )
14488            .expect("batched direct-caller counts");
14489            assert_eq!(batch_counts.len(), 2);
14490            assert_eq!(
14491                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14492                Some(&distinct_direct_len)
14493            );
14494
14495            (full_forward_len, full_direct_len)
14496        };
14497
14498        assert_eq!(
14499            full_forward_len, 2,
14500            "fixture root should have one resolved and one unresolved outgoing call"
14501        );
14502        assert_eq!(
14503            full_direct_len, 2,
14504            "fixture root should have two real direct callers"
14505        );
14506
14507        let tree = store
14508            .call_tree(Path::new("main.ts"), "root", 0)
14509            .expect("call tree");
14510        assert!(tree.depth_limited);
14511        assert_eq!(tree.children.len(), 0);
14512        assert_eq!(
14513            tree.truncated, full_forward_len,
14514            "call_tree depth boundary must report the full forward-call list length"
14515        );
14516
14517        let callers = store
14518            .callers_of(Path::new("main.ts"), "leaf", 0)
14519            .expect("callers");
14520        assert!(callers.depth_limited);
14521        assert_eq!(callers.callers.len(), 1);
14522        assert_eq!(callers.callers[0].caller.symbol, "root");
14523        assert_eq!(
14524            callers.truncated, full_direct_len,
14525            "callers depth boundary must report the full direct-caller list length"
14526        );
14527    }
14528
14529    #[test]
14530    fn source_freshness_matches_cache_collect_for_same_bytes() {
14531        let dir = tempdir().expect("temp dir");
14532        let path = dir.path().join("fixture.ts");
14533        let source = "export function main() { return helper(); }\n";
14534        fs::write(&path, source).expect("write fixture");
14535
14536        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14537        let actual =
14538            collect_source_freshness(&path, source).expect("collect freshness from source");
14539
14540        assert_eq!(actual, expected);
14541    }
14542
14543    #[test]
14544    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14545        let root = tempfile::tempdir().unwrap();
14546        let callgraph_dir = tempfile::tempdir().unwrap();
14547        let source_dir = root.path().join("src");
14548        std::fs::create_dir_all(&source_dir).unwrap();
14549        let source = source_dir.join("lib.rs");
14550        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14551        let files = vec![source.clone()];
14552        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14553        let old_epoch = epoch.next();
14554        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14555        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14556        let old_epoch_flag = epoch.clone();
14557        let old_dir = callgraph_dir.path().to_path_buf();
14558        let old_root = root.path().to_path_buf();
14559        let old_files = files.clone();
14560        let old = std::thread::spawn(move || {
14561            set_cold_build_before_publish_observer(Some(Arc::new(move || {
14562                reached_tx.send(()).unwrap();
14563                release_rx.recv().unwrap();
14564            })));
14565            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14566                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14567            });
14568            set_cold_build_before_publish_observer(None);
14569            result
14570        });
14571        // Positive wait: the older build runs a real cold build (git probe +
14572        // SQLite schema init) before the barrier, which can exceed 5s on a
14573        // contended Windows CI runner. Only negative waits stay short.
14574        reached_rx
14575            .recv_timeout(Duration::from_secs(30))
14576            .expect("older build did not reach its publication barrier");
14577
14578        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14579        let new_epoch = epoch.next();
14580        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14581            CallGraphStore::cold_build_with_lease(
14582                callgraph_dir.path().to_path_buf(),
14583                root.path().to_path_buf(),
14584                &files,
14585            )
14586        })
14587        .expect("newer build should publish");
14588        drop(new_store);
14589
14590        release_tx.send(()).unwrap();
14591        assert!(matches!(
14592            old.join().unwrap(),
14593            Err(CallGraphStoreError::Superseded)
14594        ));
14595
14596        let current = CallGraphStore::open_readonly(
14597            callgraph_dir.path().to_path_buf(),
14598            root.path().to_path_buf(),
14599        )
14600        .unwrap()
14601        .expect("current callgraph generation");
14602        assert_eq!(
14603            current
14604                .nodes_matching("new_generation_marker")
14605                .unwrap()
14606                .len(),
14607            1
14608        );
14609        assert!(current
14610            .nodes_matching("old_generation_marker")
14611            .unwrap()
14612            .is_empty());
14613    }
14614
14615    #[test]
14616    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14617        let dir = tempdir().expect("temp dir");
14618        let project_root = dir.path();
14619        let extract = fixture_extract(project_root);
14620        let resolved = fixture_resolved(&extract);
14621
14622        let reference = build_reference_connection(project_root, &extract, &resolved);
14623        let optimized = build_optimized_connection(project_root, &extract, &resolved);
14624
14625        for table in [
14626            "files",
14627            "nodes",
14628            "file_dependencies",
14629            "dispatch_hints",
14630            "refs",
14631            "edges",
14632        ] {
14633            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
14634            // the reference and optimized builds run sequentially and can straddle a
14635            // one-second tick under load, so it is legitimately allowed to differ.
14636            // This mirrors the existing exclusions of `backend_file_state.updated_at`
14637            // and the chunked-vs-unchunked sibling test. The check is for structural
14638            // row equivalence of the optimized bulk insert, not wall-clock equality.
14639            let excluded: &[&str] = if table == "files" {
14640                &["indexed_at"]
14641            } else {
14642                &[]
14643            };
14644            assert_eq!(
14645                table_rows_without(&reference, table, excluded),
14646                table_rows_without(&optimized, table, excluded),
14647                "table `{table}` rows must match apart from wall-clock columns"
14648            );
14649        }
14650        assert_eq!(
14651            backend_state_rows(&reference),
14652            backend_state_rows(&optimized),
14653            "backend freshness rows must match apart from updated_at"
14654        );
14655        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14656    }
14657
14658    #[test]
14659    fn cold_build_chunked_matches_unchunked_logical_rows() {
14660        let dir = tempdir().expect("temp dir");
14661        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14662        write_chunked_equivalence_fixture(&project_root);
14663        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14664        assert!(
14665            files.len() > 6,
14666            "fixture should be large enough to split into multiple chunks"
14667        );
14668
14669        let unchunked = CallGraphStore::open(
14670            project_root.join(".store-unchunked"),
14671            project_root.to_path_buf(),
14672        )
14673        .expect("open unchunked store");
14674        let unchunked_stats = unchunked
14675            .cold_build_chunked(&files, 0)
14676            .expect("unchunked cold build");
14677
14678        let chunked = CallGraphStore::open(
14679            project_root.join(".store-chunked"),
14680            project_root.to_path_buf(),
14681        )
14682        .expect("open chunked store");
14683        let chunked_stats = chunked
14684            .cold_build_chunked(&files, 3)
14685            .expect("chunked cold build");
14686
14687        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14688        assert_eq!(
14689            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14690            chunked.edge_snapshot().expect("chunked edge snapshot"),
14691            "public edge snapshots must match"
14692        );
14693
14694        let dispatch_edges = {
14695            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14696            conn.query_row(
14697                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14698                [],
14699                |row| row.get::<_, i64>(0),
14700            )
14701            .expect("count dispatch edges")
14702        };
14703        assert!(
14704            dispatch_edges > 0,
14705            "fixture must exercise method-dispatch edge insertion"
14706        );
14707
14708        for table in [
14709            "edges",
14710            "refs",
14711            "nodes",
14712            "file_dependencies",
14713            "dispatch_hints",
14714        ] {
14715            assert_eq!(
14716                graph_table_rows(&unchunked, table),
14717                graph_table_rows(&chunked, table),
14718                "chunked cold build must match unchunked rows for {table}"
14719            );
14720        }
14721        assert_eq!(
14722            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
14723            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
14724            "files rows must match apart from indexed_at"
14725        );
14726        assert_eq!(
14727            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
14728            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
14729            "backend freshness rows must match apart from updated_at"
14730        );
14731
14732        let published_dir = project_root.join(".store-published");
14733        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
14734            published_dir.clone(),
14735            project_root.to_path_buf(),
14736            &files,
14737            0,
14738        )
14739        .expect("published unchunked cold build");
14740        assert!(
14741            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
14742                .expect("needs_cold_build after publish"),
14743            "published store should be ready"
14744        );
14745        drop(_published);
14746        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
14747            published_dir,
14748            project_root.to_path_buf(),
14749            &files,
14750            3,
14751        )
14752        .expect("ensure with a different chunk size");
14753        assert!(
14754            rebuild_stats.is_none(),
14755            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
14756        );
14757    }
14758
14759    // Perf A/B bench (not a gate): measures cold_build wall time at a given
14760    // chunk size against a real repo. Driven by env so the same binary can A/B
14761    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
14762    // memory work. Run:
14763    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
14764    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
14765    #[test]
14766    #[ignore]
14767    fn bench_cold_build_chunk() {
14768        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
14769        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
14770            .expect("AFT_PERF_CHUNK")
14771            .parse()
14772            .expect("AFT_PERF_CHUNK must be a non-negative integer");
14773        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
14774        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14775        let dir = tempdir().expect("temp dir");
14776        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
14777            .expect("open store");
14778        let started = Instant::now();
14779        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
14780        let ms = started.elapsed().as_millis();
14781        println!(
14782            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
14783            stats.files, stats.nodes, stats.refs, stats.edges
14784        );
14785    }
14786
14787    #[test]
14788    fn persisted_workspace_reexport_selects_its_package_dependency() {
14789        let root = tempdir().expect("temp dir");
14790        let dependencies = BTreeSet::from([
14791            "packages/aft-bridge/src/index.ts".to_string(),
14792            "packages/opencode-plugin/src/types.ts".to_string(),
14793        ]);
14794        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
14795
14796        assert_eq!(
14797            stored_dependencies_for_module(
14798                root.path(),
14799                "packages/opencode-plugin/src/shared/bash-hints.ts",
14800                "@cortexkit/aft-bridge",
14801                &dependencies,
14802                &indexed_files,
14803            ),
14804            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
14805        );
14806    }
14807
14808    #[test]
14809    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
14810        let dir = tempdir().expect("temp dir");
14811        let project_root = dir.path();
14812        let files =
14813            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
14814        let index_path = project_root.join("src/index.ts");
14815
14816        let store = CallGraphStore::open(
14817            project_root.join(".store-incremental-barrel"),
14818            project_root.to_path_buf(),
14819        )
14820        .expect("open incremental store");
14821        store.cold_build(&files).expect("initial cold build");
14822
14823        {
14824            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14825            let tx = conn.transaction().expect("dependency transaction");
14826            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
14827                .expect("dependent refs for barrel");
14828            let selected_ref_ids = dependent_refs
14829                .iter()
14830                .map(|dependent_ref| dependent_ref.ref_id.clone())
14831                .collect::<BTreeSet<_>>();
14832            let mut threaded_ref_ids = BTreeSet::new();
14833            let mut threaded_by_caller = BTreeMap::new();
14834            record_dependent_refs(
14835                &mut threaded_ref_ids,
14836                &mut threaded_by_caller,
14837                dependent_refs,
14838            );
14839            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
14840                .expect("old per-ref caller lookup");
14841
14842            assert_eq!(threaded_ref_ids, selected_ref_ids);
14843            assert_eq!(threaded_by_caller, old_by_caller);
14844            for consumer in [
14845                "src/consumer_a.ts",
14846                "src/consumer_b.ts",
14847                "src/consumer_c.ts",
14848            ] {
14849                assert!(
14850                    threaded_by_caller.contains_key(consumer),
14851                    "barrel edit should select dependent refs from {consumer}"
14852                );
14853            }
14854        }
14855
14856        fs::write(
14857            &index_path,
14858            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
14859        )
14860        .expect("edit barrel");
14861        let stats = store
14862            .refresh_files(std::slice::from_ref(&index_path))
14863            .expect("incremental refresh");
14864        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
14865        assert!(
14866            stats.dependency_selected_refs > 0,
14867            "barrel surface edit should select dependent refs"
14868        );
14869
14870        let cold_store = CallGraphStore::open(
14871            project_root.join(".store-cold-barrel"),
14872            project_root.to_path_buf(),
14873        )
14874        .expect("open cold rebuild store");
14875        cold_store
14876            .cold_build(&files)
14877            .expect("comparison cold build");
14878
14879        for table in [
14880            "nodes",
14881            "refs",
14882            "file_dependencies",
14883            "edges",
14884            "dispatch_hints",
14885        ] {
14886            assert_eq!(
14887                graph_table_rows(&store, table),
14888                graph_table_rows(&cold_store, table),
14889                "incremental refresh {table} rows must match cold rebuild"
14890            );
14891        }
14892
14893        let consumer_path = project_root.join("src/consumer_a.ts");
14894        fs::write(
14895            &consumer_path,
14896            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
14897        )
14898        .expect("edit barrel consumer");
14899        store
14900            .refresh_files(std::slice::from_ref(&consumer_path))
14901            .expect("refresh consumer through unchanged barrel");
14902        cold_store
14903            .cold_build(&files)
14904            .expect("comparison cold rebuild after consumer refresh");
14905        for table in [
14906            "nodes",
14907            "refs",
14908            "file_dependencies",
14909            "edges",
14910            "dispatch_hints",
14911        ] {
14912            assert_eq!(
14913                graph_table_rows(&store, table),
14914                graph_table_rows(&cold_store, table),
14915                "refresh through a persisted barrel must preserve cold-build {table} rows"
14916            );
14917        }
14918    }
14919
14920    fn build_reference_connection(
14921        project_root: &Path,
14922        extract: &FileExtract,
14923        resolved: &ResolvedRef,
14924    ) -> Connection {
14925        let mut conn = Connection::open_in_memory().expect("open reference db");
14926        configure_build_connection(&conn).expect("configure reference db");
14927        initialize_schema(&conn).expect("initialize reference schema");
14928        {
14929            let tx = conn.transaction().expect("reference transaction");
14930            clear_tables(&tx).expect("reference clear");
14931            insert_meta(&tx).expect("reference meta");
14932            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
14933            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
14934            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14935                .expect("reference dispatch edges");
14936            assert_eq!(supplemental, 0);
14937            tx.commit().expect("reference commit");
14938        }
14939        conn
14940    }
14941
14942    fn build_optimized_connection(
14943        project_root: &Path,
14944        extract: &FileExtract,
14945        resolved: &ResolvedRef,
14946    ) -> Connection {
14947        let mut conn = Connection::open_in_memory().expect("open optimized db");
14948        configure_build_connection(&conn).expect("configure optimized db");
14949        initialize_schema(&conn).expect("initialize optimized schema");
14950        {
14951            let tx = conn.transaction().expect("optimized transaction");
14952            clear_tables(&tx).expect("optimized clear");
14953            insert_meta(&tx).expect("optimized meta");
14954            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
14955            {
14956                let workspace_root = project_root.display().to_string();
14957                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
14958                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
14959                    .expect("optimized file extract");
14960                insert_resolved_ref_prepared(&mut inserts, resolved)
14961                    .expect("optimized resolved ref");
14962            }
14963            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
14964            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
14965                .expect("optimized dispatch edges");
14966            assert_eq!(supplemental, 0);
14967            tx.commit().expect("optimized commit");
14968        }
14969        conn
14970    }
14971
14972    fn fixture_extract(_project_root: &Path) -> FileExtract {
14973        let rel_path = "src/main.ts".to_string();
14974        let target_path = "src/helper.ts".to_string();
14975        let node = NodeRecord {
14976            id: "node-main".to_string(),
14977            file_path: rel_path.clone(),
14978            name: "main".to_string(),
14979            scoped_name: "main".to_string(),
14980            kind: "function".to_string(),
14981            range: Range {
14982                start_line: 0,
14983                start_col: 0,
14984                end_line: 0,
14985                end_col: 32,
14986            },
14987            range_ordinal: 0,
14988            signature: Some("export function main()".to_string()),
14989            exported: true,
14990            is_default_export: false,
14991            is_type_like: false,
14992            is_callgraph_entry_point: true,
14993        };
14994        let mut dependencies = BTreeSet::new();
14995        dependencies.insert(target_path.clone());
14996        let raw_ref = RawRef {
14997            ref_id: "ref-main-helper".to_string(),
14998            caller_node: Some(node.id.clone()),
14999            caller_symbol: Some(node.scoped_name.clone()),
15000            caller_file: rel_path.clone(),
15001            kind: "call".to_string(),
15002            short_name: Some("helper".to_string()),
15003            full_ref: Some("helper".to_string()),
15004            module_path: None,
15005            import_kind: None,
15006            local_name: Some("helper".to_string()),
15007            requested_name: Some("helper".to_string()),
15008            namespace_alias: None,
15009            wildcard: false,
15010            line: 1,
15011            byte_start: 24,
15012            byte_end: 32,
15013            dependencies,
15014        };
15015        FileExtract {
15016            rel_path,
15017            freshness: FileFreshness {
15018                mtime: UNIX_EPOCH + Duration::from_secs(123),
15019                size: 40,
15020                content_hash: cache_freshness::hash_bytes(b"fixture source"),
15021            },
15022            lang: LangId::TypeScript,
15023            data: FileCallData {
15024                calls_by_symbol: HashMap::new(),
15025                value_refs_by_symbol: HashMap::new(),
15026                exported_symbols: Vec::new(),
15027                symbol_metadata: HashMap::new(),
15028                default_export_symbol: None,
15029                import_block: ImportBlock::empty(),
15030                lang: LangId::TypeScript,
15031            },
15032            nodes: vec![node.clone()],
15033            raw_refs: vec![raw_ref],
15034            dispatch_hints: vec![DispatchHint {
15035                id: "dispatch-main-helper".to_string(),
15036                method_name: "helper".to_string(),
15037                caller_node: node.id,
15038                file: "src/main.ts".to_string(),
15039                line: 1,
15040                byte_start: 24,
15041                byte_end: 32,
15042            }],
15043            surface_fingerprint: "surface".to_string(),
15044        }
15045    }
15046
15047    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15048        let raw = extract.raw_refs[0].clone();
15049        let mut dependencies = raw.dependencies.clone();
15050        dependencies.insert("src/helper.ts".to_string());
15051        ResolvedRef {
15052            edge: Some(EdgeRecord {
15053                edge_id: "edge-main-helper".to_string(),
15054                source_node: raw.caller_node.clone().expect("caller node"),
15055                target_node: Some("node-helper".to_string()),
15056                target_file: "src/helper.ts".to_string(),
15057                target_symbol: "helper".to_string(),
15058                kind: "call".to_string(),
15059                line: raw.line,
15060            }),
15061            raw,
15062            status: "resolved".to_string(),
15063            target_node: Some("node-helper".to_string()),
15064            target_file: Some("src/helper.ts".to_string()),
15065            target_symbol: Some("helper".to_string()),
15066            dependencies,
15067        }
15068    }
15069
15070    fn write_chunked_equivalence_fixture(project_root: &Path) {
15071        let ts_dir = project_root.join("ts");
15072        fs::create_dir_all(&ts_dir).expect("create ts dir");
15073        fs::write(
15074            ts_dir.join("leaf.ts"),
15075            "export function leaf(value: number) {\n  return value + 1;\n}\n",
15076        )
15077        .expect("write ts leaf");
15078        fs::write(
15079            ts_dir.join("mid.ts"),
15080            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
15081        )
15082        .expect("write ts mid");
15083        fs::write(
15084            ts_dir.join("entry.ts"),
15085            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
15086        )
15087        .expect("write ts entry");
15088        fs::write(
15089            ts_dir.join("worker.ts"),
15090            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
15091        )
15092        .expect("write ts worker");
15093        for idx in 0..4 {
15094            fs::write(
15095                ts_dir.join(format!("extra_{idx}.ts")),
15096                format!(
15097                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
15098                ),
15099            )
15100            .expect("write ts extra");
15101        }
15102
15103        let rust_dir = project_root.join("src");
15104        let commands_dir = rust_dir.join("commands");
15105        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15106        fs::write(
15107            rust_dir.join("context.rs"),
15108            r#"pub struct AppContext;
15109
15110impl AppContext {
15111    pub fn callgraph_store_for_ops(&self) -> usize {
15112        1
15113    }
15114}
15115"#,
15116        )
15117        .expect("write rust context");
15118        fs::write(
15119            rust_dir.join("lib.rs"),
15120            "pub mod context;\npub mod commands;\n",
15121        )
15122        .expect("write rust lib");
15123        fs::write(
15124            commands_dir.join("mod.rs"),
15125            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15126        )
15127        .expect("write rust commands mod");
15128        for name in ["callers", "impact", "trace_to"] {
15129            fs::write(
15130                commands_dir.join(format!("{name}.rs")),
15131                format!(
15132                    r#"use crate::context::AppContext;
15133
15134pub fn handle_{name}(ctx: &AppContext) -> usize {{
15135    ctx.callgraph_store_for_ops()
15136}}
15137"#
15138                ),
15139            )
15140            .expect("write rust command");
15141        }
15142    }
15143
15144    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15145        let src_dir = project_root.join("src");
15146        fs::create_dir_all(&src_dir).expect("create src dir");
15147
15148        let target_path = src_dir.join("target.ts");
15149        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
15150            .expect("write target");
15151
15152        let index_path = src_dir.join("index.ts");
15153        fs::write(&index_path, barrel_source).expect("write barrel");
15154
15155        let mut files = vec![target_path, index_path];
15156        for (file_name, function_name) in [
15157            ("consumer_a.ts", "consumerA"),
15158            ("consumer_b.ts", "consumerB"),
15159            ("consumer_c.ts", "consumerC"),
15160        ] {
15161            let path = src_dir.join(file_name);
15162            fs::write(
15163                &path,
15164                format!(
15165                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
15166                ),
15167            )
15168            .expect("write consumer");
15169            files.push(path);
15170        }
15171        files
15172    }
15173
15174    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15175        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15176        table_rows(&conn, table)
15177    }
15178
15179    fn graph_table_rows_without(
15180        store: &CallGraphStore,
15181        table: &str,
15182        excluded_columns: &[&str],
15183    ) -> Vec<String> {
15184        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15185        table_rows_without(&conn, table, excluded_columns)
15186    }
15187
15188    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15189        table_rows_without(conn, table, &[])
15190    }
15191
15192    fn table_rows_without(
15193        conn: &Connection,
15194        table: &str,
15195        excluded_columns: &[&str],
15196    ) -> Vec<String> {
15197        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15198        let columns: Vec<String> = conn
15199            .prepare(&format!("PRAGMA table_info({table})"))
15200            .expect("prepare table_info")
15201            .query_map([], |row| row.get::<_, String>(1))
15202            .expect("query table_info")
15203            .collect::<std::result::Result<Vec<String>, _>>()
15204            .expect("collect columns")
15205            .into_iter()
15206            .filter(|column| !excluded_columns.contains(column.as_str()))
15207            .collect();
15208        let sql = format!(
15209            "SELECT {} FROM {table} ORDER BY {}",
15210            columns.join(", "),
15211            columns.join(", ")
15212        );
15213        conn.prepare(&sql)
15214            .expect("prepare table rows")
15215            .query_map([], |row| row_to_strings(row, columns.len()))
15216            .expect("query table rows")
15217            .collect::<std::result::Result<_, _>>()
15218            .expect("collect table rows")
15219    }
15220
15221    fn assert_cold_build_stats_match_except_elapsed(
15222        expected: &ColdBuildStats,
15223        actual: &ColdBuildStats,
15224    ) {
15225        assert_eq!(actual.files, expected.files, "file counts must match");
15226        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15227        assert_eq!(actual.refs, expected.refs, "ref counts must match");
15228        assert_eq!(actual.edges, expected.edges, "edge counts must match");
15229        assert_eq!(
15230            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15231            expected
15232                .failed_files
15233                .iter()
15234                .cloned()
15235                .collect::<BTreeSet<_>>(),
15236            "failed file sets must match"
15237        );
15238    }
15239
15240    fn backend_state_rows(conn: &Connection) -> Vec<String> {
15241        conn.prepare(
15242            "SELECT backend, workspace_root, file_path, content_hash, status
15243             FROM backend_file_state
15244             ORDER BY backend, workspace_root, file_path, content_hash, status",
15245        )
15246        .expect("prepare backend rows")
15247        .query_map([], |row| row_to_strings(row, 5))
15248        .expect("query backend rows")
15249        .collect::<std::result::Result<_, _>>()
15250        .expect("collect backend rows")
15251    }
15252
15253    fn secondary_indexes(conn: &Connection) -> Vec<String> {
15254        let mut indexes = Vec::new();
15255        for table in [
15256            "files",
15257            "nodes",
15258            "refs",
15259            "file_dependencies",
15260            "edges",
15261            "dispatch_hints",
15262            "type_ref_names",
15263            "backend_file_state",
15264            "meta",
15265        ] {
15266            let sql = format!("PRAGMA index_list({table})");
15267            let mut stmt = conn.prepare(&sql).expect("prepare index list");
15268            let rows = stmt
15269                .query_map([], |row| row.get::<_, String>(1))
15270                .expect("query index list");
15271            for name in rows {
15272                let name = name.expect("index name");
15273                if name.starts_with("idx_") {
15274                    indexes.push(format!("{table}:{name}"));
15275                }
15276            }
15277        }
15278        indexes.sort();
15279        indexes
15280    }
15281
15282    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15283        let mut values = Vec::with_capacity(len);
15284        for index in 0..len {
15285            let value = row.get_ref(index)?;
15286            values.push(match value {
15287                rusqlite::types::ValueRef::Null => "NULL".to_string(),
15288                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15289                rusqlite::types::ValueRef::Real(value) => value.to_string(),
15290                rusqlite::types::ValueRef::Text(value) => {
15291                    String::from_utf8_lossy(value).into_owned()
15292                }
15293                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15294            });
15295        }
15296        Ok(values.join("\u{1f}"))
15297    }
15298}
15299
15300#[cfg(test)]
15301mod rust_resolution_tests {
15302    use super::*;
15303    use crate::inspect::job::CallgraphSnapshot;
15304    use std::fs;
15305    use tempfile::tempdir;
15306
15307    #[test]
15308    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15309        let dir = tempdir().expect("tempdir");
15310        let root = dir.path();
15311        write_rust_manifest(root, "scoped-alias-fixture");
15312        write_file(
15313            root,
15314            "src/lib.rs",
15315            r#"pub mod finalization_contract;
15316
15317pub fn run_alias() {
15318    use crate::finalization_contract as fc;
15319    fc::check_mason_contract();
15320}
15321"#,
15322        );
15323        write_file(
15324            root,
15325            "src/finalization_contract.rs",
15326            r#"pub fn check_mason_contract() {}
15327fn planted_dead() {}
15328"#,
15329        );
15330
15331        let (store, snapshot) = cold_build_twice(root);
15332        assert_direct_caller(
15333            &store,
15334            "src/finalization_contract.rs",
15335            "check_mason_contract",
15336            "src/lib.rs",
15337            "run_alias",
15338        );
15339        assert_projected_call(
15340            root,
15341            &snapshot,
15342            "src/finalization_contract.rs",
15343            "check_mason_contract",
15344        );
15345        assert_no_projected_call(
15346            root,
15347            &snapshot,
15348            "src/finalization_contract.rs",
15349            "planted_dead",
15350        );
15351        assert!(
15352            store
15353                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15354                .expect("planted dead callers")
15355                .is_empty(),
15356            "planted-dead guard should stay without callers"
15357        );
15358    }
15359
15360    #[test]
15361    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15362        let dir = tempdir().expect("tempdir");
15363        let root = dir.path();
15364        write_rust_manifest(root, "inline-module-fixture");
15365        write_file(
15366            root,
15367            "src/lib.rs",
15368            r#"mod work_graph { fn operations() {} }
15369mod manifest { fn operations() {} }
15370mod audit { fn operations() {} }
15371mod dispatch { fn operations() {} }
15372mod finalization { fn operations() {} }
15373
15374pub fn run_inline_operations() {
15375    work_graph::operations();
15376    manifest::operations();
15377    audit::operations();
15378    dispatch::operations();
15379    finalization::operations();
15380}
15381
15382fn planted_dead() {}
15383"#,
15384        );
15385
15386        let (store, snapshot) = cold_build_twice(root);
15387        for module in [
15388            "work_graph",
15389            "manifest",
15390            "audit",
15391            "dispatch",
15392            "finalization",
15393        ] {
15394            assert_direct_caller(
15395                &store,
15396                "src/lib.rs",
15397                &format!("{module}::operations"),
15398                "src/lib.rs",
15399                "run_inline_operations",
15400            );
15401        }
15402        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15403        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15404    }
15405
15406    #[test]
15407    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15408        let dir = tempdir().expect("tempdir");
15409        let root = dir.path();
15410        fs::write(
15411            root.join("Cargo.toml"),
15412            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15413        )
15414        .expect("write workspace manifest");
15415        write_file(
15416            root,
15417            "crates/but-action/Cargo.toml",
15418            r#"[package]
15419name = "but-action"
15420version = "0.1.0"
15421edition = "2021"
15422"#,
15423        );
15424        write_file(
15425            root,
15426            "crates/but-action/src/lib.rs",
15427            "mod action;\npub use action::{list_actions};\n",
15428        );
15429        write_file(
15430            root,
15431            "crates/but-action/src/action.rs",
15432            "pub fn list_actions() {}\nfn planted_dead() {}\n",
15433        );
15434        write_file(
15435            root,
15436            "crates/app/Cargo.toml",
15437            r#"[package]
15438name = "app"
15439version = "0.1.0"
15440edition = "2021"
15441"#,
15442        );
15443        write_file(
15444            root,
15445            "crates/app/src/lib.rs",
15446            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
15447        );
15448
15449        let (store, snapshot) = cold_build_twice(root);
15450        assert_direct_caller(
15451            &store,
15452            "crates/but-action/src/action.rs",
15453            "list_actions",
15454            "crates/app/src/lib.rs",
15455            "run_actions",
15456        );
15457        assert!(
15458            store
15459                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15460                .expect("lib reexport callers")
15461                .is_empty(),
15462            "call should target the reexported source function, not lib.rs"
15463        );
15464        assert_projected_call(
15465            root,
15466            &snapshot,
15467            "crates/but-action/src/action.rs",
15468            "list_actions",
15469        );
15470        assert_no_projected_call(
15471            root,
15472            &snapshot,
15473            "crates/but-action/src/action.rs",
15474            "planted_dead",
15475        );
15476    }
15477
15478    #[test]
15479    fn rust_generic_self_turbofish_method_dispatch_resolves() {
15480        let dir = tempdir().expect("tempdir");
15481        let root = dir.path();
15482        write_rust_manifest(root, "generic-self-fixture");
15483        write_file(
15484            root,
15485            "src/lib.rs",
15486            r#"pub struct Matcher;
15487
15488impl Matcher {
15489    pub fn run(&self) -> bool {
15490        self.fuzzy_match_optimal::<usize>("needle")
15491    }
15492
15493    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15494        let _ = std::marker::PhantomData::<T>;
15495        true
15496    }
15497
15498    fn planted_dead(&self) {}
15499}
15500
15501pub fn entry() -> bool {
15502    let matcher = Matcher;
15503    matcher.run()
15504}
15505"#,
15506        );
15507
15508        let (store, snapshot) = cold_build_twice(root);
15509        assert_direct_caller(
15510            &store,
15511            "src/lib.rs",
15512            "Matcher::fuzzy_match_optimal",
15513            "src/lib.rs",
15514            "Matcher::run",
15515        );
15516        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15517        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15518    }
15519
15520    #[test]
15521    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15522        let dir = tempdir().expect("tempdir");
15523        let root = dir.path();
15524        write_rust_manifest(root, "manifest-operations-fixture");
15525        write_file(
15526            root,
15527            "src/main.rs",
15528            r#"mod dispatch;
15529use dispatch::{manifest_operations};
15530
15531fn main() {
15532    manifest_operations();
15533}
15534"#,
15535        );
15536        write_file(
15537            root,
15538            "src/dispatch.rs",
15539            r#"mod work_graph { fn operations() {} }
15540mod manifest { fn operations() {} }
15541mod audit { fn operations() {} }
15542mod descriptor { fn operations() {} }
15543mod writer { fn operations() {} }
15544
15545pub fn manifest_operations() {
15546    manifest::operations();
15547}
15548
15549pub fn work_graph_operations() {
15550    work_graph::operations();
15551}
15552
15553pub fn audit_operations() {
15554    audit::operations();
15555}
15556
15557pub fn descriptor_operations() {
15558    descriptor::operations();
15559}
15560
15561pub fn writer_operations() {
15562    writer::operations();
15563}
15564
15565fn planted_dead() {}
15566"#,
15567        );
15568
15569        let (store, snapshot) = cold_build_twice(root);
15570        assert_direct_caller(
15571            &store,
15572            "src/dispatch.rs",
15573            "manifest_operations",
15574            "src/main.rs",
15575            "main",
15576        );
15577        assert_direct_caller(
15578            &store,
15579            "src/dispatch.rs",
15580            "manifest::operations",
15581            "src/dispatch.rs",
15582            "manifest_operations",
15583        );
15584        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15585        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15586        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15587    }
15588
15589    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15590        let files = rust_files(root);
15591        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15592            .expect("open first store");
15593        first.cold_build(&files).expect("first cold build");
15594        let first_snapshot =
15595            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15596
15597        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15598            .expect("open second store");
15599        second.cold_build(&files).expect("second cold build");
15600        let second_snapshot =
15601            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15602
15603        assert_eq!(
15604            projection_rows(&first_snapshot),
15605            projection_rows(&second_snapshot),
15606            "cold-build projection should be deterministic"
15607        );
15608        (first, first_snapshot)
15609    }
15610
15611    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15612        let mut rows = Vec::new();
15613        for export in &snapshot.exported_symbols {
15614            rows.push(format!(
15615                "export\t{}\t{}\t{}\t{}",
15616                export.file.display(),
15617                export.symbol,
15618                export.kind,
15619                export.line
15620            ));
15621        }
15622        for call in &snapshot.outbound_calls {
15623            rows.push(format!(
15624                "call\t{}\t{}\t{}\t{}\t{}",
15625                call.caller_file.display(),
15626                call.caller_symbol,
15627                call.target,
15628                call.line,
15629                call.provenance
15630            ));
15631        }
15632        for file in &snapshot.entry_points {
15633            rows.push(format!("entry_file\t{}", file.display()));
15634        }
15635        for (file, symbols) in &snapshot.entry_point_symbols {
15636            for symbol in symbols {
15637                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15638            }
15639        }
15640        rows.sort();
15641        rows
15642    }
15643
15644    fn assert_direct_caller(
15645        store: &CallGraphStore,
15646        target_rel: &str,
15647        target_symbol: &str,
15648        caller_rel: &str,
15649        caller_symbol: &str,
15650    ) {
15651        let callers = store
15652            .direct_callers_of(Path::new(target_rel), target_symbol)
15653            .unwrap_or_else(|error| {
15654                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15655            });
15656        assert!(
15657            callers.iter().any(|site| {
15658                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15659            }),
15660            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15661        );
15662    }
15663
15664    fn assert_projected_call(
15665        root: &Path,
15666        snapshot: &CallgraphSnapshot,
15667        target_rel: &str,
15668        symbol: &str,
15669    ) {
15670        let target = projected_target(root, target_rel, symbol);
15671        assert!(
15672            snapshot.outbound_calls.iter().any(|call| {
15673                call.target == target
15674                    || call.target.starts_with(&format!(
15675                        "{target}{}",
15676                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15677                    ))
15678            }),
15679            "expected projected call to {target}; calls: {:#?}",
15680            snapshot.outbound_calls
15681        );
15682    }
15683
15684    fn assert_no_projected_call(
15685        root: &Path,
15686        snapshot: &CallgraphSnapshot,
15687        target_rel: &str,
15688        symbol: &str,
15689    ) {
15690        let target = projected_target(root, target_rel, symbol);
15691        assert!(
15692            snapshot.outbound_calls.iter().all(|call| {
15693                call.target != target
15694                    && !call.target.starts_with(&format!(
15695                        "{target}{}",
15696                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15697                    ))
15698            }),
15699            "did not expect projected call to {target}; calls: {:#?}",
15700            snapshot.outbound_calls
15701        );
15702    }
15703
15704    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15705        // Projection targets carry the normalized (verbatim-stripped)
15706        // canonical form; bare fs::canonicalize diverges on Windows.
15707        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15708        format!("{}::{symbol}", path.display())
15709    }
15710
15711    fn write_rust_manifest(root: &Path, name: &str) {
15712        write_file(
15713            root,
15714            "Cargo.toml",
15715            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15716        );
15717    }
15718
15719    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15720        let path = root.join(rel_path);
15721        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
15722        fs::write(&path, source).expect("write fixture file");
15723        path
15724    }
15725
15726    fn rust_files(root: &Path) -> Vec<PathBuf> {
15727        let mut files = Vec::new();
15728        collect_rust_files(root, &mut files);
15729        files.sort();
15730        files
15731    }
15732
15733    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
15734        for entry in fs::read_dir(dir).expect("read fixture dir") {
15735            let entry = entry.expect("read fixture entry");
15736            let path = entry.path();
15737            if path.is_dir() {
15738                let name = path
15739                    .file_name()
15740                    .and_then(|name| name.to_str())
15741                    .unwrap_or("");
15742                if !name.starts_with(".store") {
15743                    collect_rust_files(&path, files);
15744                }
15745            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
15746                files.push(path);
15747            }
15748        }
15749    }
15750}
15751
15752#[cfg(test)]
15753mod build_pool_tests {
15754    use super::build_pool_size;
15755
15756    #[test]
15757    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
15758        let size = build_pool_size();
15759        // Never zero, never the full core count, never above the 8 cap — this is
15760        // the starvation guard for the cold-build's all-cores tree-sitter pass.
15761        assert!(size >= 1, "pool size must be at least 1");
15762        assert!(size <= 8, "pool size must be capped at 8, got {size}");
15763
15764        let cores = std::thread::available_parallelism()
15765            .map(|p| p.get())
15766            .unwrap_or(1);
15767        let expected = cores.div_ceil(2).clamp(1, 8);
15768        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
15769    }
15770}
15771
15772#[cfg(test)]
15773mod reexport_resolution_tests {
15774    use super::*;
15775
15776    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
15777        ProjectIndex {
15778            project_root: PathBuf::from("/fixture"),
15779            files: files.into_iter().collect(),
15780            caller_data: HashMap::new(),
15781            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
15782        }
15783    }
15784
15785    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
15786        DbFileIndex {
15787            lang: None,
15788            exports: HashSet::new(),
15789            default_export: None,
15790            export_aliases: HashMap::new(),
15791            node_by_scoped: HashMap::new(),
15792            node_by_bare: HashMap::new(),
15793            node_kind_by_id: HashMap::new(),
15794            module_targets: HashMap::new(),
15795            reexports: reexport_targets
15796                .iter()
15797                .map(|target| ReexportIndex {
15798                    target_file: Some((*target).to_string()),
15799                    named: HashMap::new(),
15800                    wildcard: true,
15801                })
15802                .collect(),
15803        }
15804    }
15805
15806    /// A dense wildcard re-export cycle (barrel files re-exporting each
15807    /// other) must resolve in O(files), not O(branching^depth). Without the
15808    /// resolver's memoization, resolving a MISSING symbol through this
15809    /// 12-file complete digraph explores ~11^16 paths and this test never
15810    /// finishes: the depth cap bounds path length, not path count, and one
15811    /// such resolution can pin a worker thread at 100% CPU indefinitely.
15812    #[test]
15813    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
15814        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
15815        let files = names
15816            .iter()
15817            .map(|name| {
15818                let targets: Vec<&str> = names
15819                    .iter()
15820                    .filter(|other| *other != name)
15821                    .map(String::as_str)
15822                    .collect();
15823                (name.clone(), barrel_file(&targets))
15824            })
15825            .collect();
15826        let index = barrel_index(files);
15827
15828        assert_eq!(
15829            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
15830            None
15831        );
15832    }
15833
15834    /// Depth-dominance counterexample: the walk first reaches `shared` down a
15835    /// 16-hop chain (no budget left for its leaf), then reaches it again
15836    /// directly at depth 1. Plain visited-set pruning would skip the second
15837    /// visit and lose a resolution the capped resolver finds; the
15838    /// depth-dominance memo revisits because the second arrival is shallower.
15839    #[test]
15840    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
15841        let mut leaf = barrel_file(&[]);
15842        leaf.exports.insert("deep_symbol".to_string());
15843        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
15844        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
15845        // entry's SECOND reexport goes straight to shared.
15846        files.push((
15847            "src/entry.ts".to_string(),
15848            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
15849        ));
15850        for i in 0..15 {
15851            let next = if i == 14 {
15852                "src/shared.ts".to_string()
15853            } else {
15854                format!("src/chain{}.ts", i + 1)
15855            };
15856            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
15857        }
15858        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
15859        files.push(("src/leaf.ts".to_string(), leaf));
15860        let index = barrel_index(files);
15861
15862        assert_eq!(
15863            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
15864            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
15865            "a shallower re-visit must not be pruned by a deeper capped visit"
15866        );
15867    }
15868
15869    #[test]
15870    fn symbol_reachable_through_reexport_cycle_still_resolves() {
15871        let mut leaf = barrel_file(&[]);
15872        leaf.exports.insert("real_symbol".to_string());
15873        let index = barrel_index(vec![
15874            (
15875                "src/a.ts".to_string(),
15876                barrel_file(&["src/b.ts", "src/a.ts"]),
15877            ),
15878            (
15879                "src/b.ts".to_string(),
15880                barrel_file(&["src/a.ts", "src/leaf.ts"]),
15881            ),
15882            ("src/leaf.ts".to_string(), leaf),
15883        ]);
15884
15885        assert_eq!(
15886            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
15887            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
15888        );
15889    }
15890}
15891
15892#[cfg(test)]
15893mod method_dispatch_inference_tests {
15894    use super::*;
15895    use std::fs;
15896    use tempfile::tempdir;
15897
15898    #[test]
15899    fn java_field_receiver_type_selects_declared_class_method() {
15900        let source = r#"class EntryPoint {
15901    private UserService userService;
15902
15903    void handle() {
15904        userService.find();
15905    }
15906}
15907
15908class UserService {
15909    void find() {}
15910}
15911
15912class AuditService {
15913    void find() {}
15914}
15915"#;
15916        let dir = tempdir().expect("temp dir");
15917        let root = dir.path();
15918        write_fixture(root, "src/EntryPoint.java", source);
15919        let reference = reference(
15920            "java",
15921            "src/EntryPoint.java",
15922            "EntryPoint::handle",
15923            "userService",
15924            "find",
15925            line_of(source, "userService.find()"),
15926        );
15927        let mut cache = DispatchSourceCache::new();
15928
15929        let receiver_type =
15930            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
15931        assert_eq!(receiver_type, "UserService");
15932
15933        let candidates = vec![
15934            method_candidate("audit", "AuditService::find"),
15935            method_candidate("user", "UserService::find"),
15936        ];
15937        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
15938            .expect("type candidate");
15939        assert_eq!(selected.scoped_name, "UserService::find");
15940
15941        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
15942        assert!(
15943            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
15944        );
15945    }
15946
15947    #[test]
15948    fn kotlin_property_and_local_value_types_are_inferred() {
15949        let source = r#"class Handler {
15950    private val auditService: AuditService = AuditService()
15951
15952    fun handle() {
15953        auditService.find()
15954        val userService: UserService = UserService()
15955        userService.find()
15956        val billingService = BillingService()
15957        billingService.find()
15958    }
15959}
15960
15961class UserService { fun find() {} }
15962class AuditService { fun find() {} }
15963class BillingService { fun find() {} }
15964"#;
15965        let dir = tempdir().expect("temp dir");
15966        let root = dir.path();
15967        write_fixture(root, "src/Handler.kt", source);
15968        let mut cache = DispatchSourceCache::new();
15969
15970        let audit_ref = reference(
15971            "kotlin",
15972            "src/Handler.kt",
15973            "Handler::handle",
15974            "auditService",
15975            "find",
15976            line_of(source, "auditService.find()"),
15977        );
15978        assert_eq!(
15979            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
15980            Some("AuditService")
15981        );
15982
15983        let user_ref = reference(
15984            "kotlin",
15985            "src/Handler.kt",
15986            "Handler::handle",
15987            "userService",
15988            "find",
15989            line_of(source, "userService.find()"),
15990        );
15991        assert_eq!(
15992            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
15993            Some("UserService")
15994        );
15995
15996        let billing_ref = reference(
15997            "kotlin",
15998            "src/Handler.kt",
15999            "Handler::handle",
16000            "billingService",
16001            "find",
16002            line_of(source, "billingService.find()"),
16003        );
16004        assert_eq!(
16005            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16006            Some("BillingService")
16007        );
16008    }
16009
16010    #[test]
16011    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16012        let source = r#"struct Foo { void run(); };
16013struct PointerFoo { void run(); };
16014struct FactoryFoo { void run(); };
16015FactoryFoo makeFactoryFoo();
16016
16017void handle() {
16018    Foo foo;
16019    foo.run();
16020    PointerFoo* pointerFoo = nullptr;
16021    pointerFoo->run();
16022    auto factoryFoo = makeFactoryFoo();
16023    factoryFoo.run();
16024}
16025"#;
16026        let dir = tempdir().expect("temp dir");
16027        let root = dir.path();
16028        write_fixture(root, "src/fixture.cpp", source);
16029        let mut cache = DispatchSourceCache::new();
16030
16031        let foo_ref = reference(
16032            "cpp",
16033            "src/fixture.cpp",
16034            "handle",
16035            "foo",
16036            "run",
16037            line_of(source, "foo.run()"),
16038        );
16039        assert_eq!(
16040            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16041            Some("Foo")
16042        );
16043
16044        let pointer_ref = reference(
16045            "cpp",
16046            "src/fixture.cpp",
16047            "handle",
16048            "pointerFoo",
16049            "run",
16050            line_of(source, "pointerFoo->run()"),
16051        );
16052        assert_eq!(
16053            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16054            Some("PointerFoo")
16055        );
16056
16057        let factory_ref = reference(
16058            "cpp",
16059            "src/fixture.cpp",
16060            "handle",
16061            "factoryFoo",
16062            "run",
16063            line_of(source, "factoryFoo.run()"),
16064        );
16065        assert_eq!(
16066            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16067            Some("FactoryFoo")
16068        );
16069    }
16070
16071    #[test]
16072    fn rust_direct_self_field_name_trims_separator_whitespace() {
16073        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16074            assert_eq!(
16075                rust_direct_self_field_name(receiver_expression),
16076                Some("engine")
16077            );
16078        }
16079    }
16080
16081    #[test]
16082    fn rust_direct_self_field_receiver_type_is_conservative() {
16083        let source = r#"struct Engine;
16084
16085struct Car {
16086    engine: Engine,
16087}
16088
16089impl Car {
16090    fn run(&self) {
16091        self.engine.start();
16092    }
16093}
16094
16095struct NestedCar {
16096    engine: Engine,
16097}
16098
16099impl NestedCar {
16100    fn run(&self) {
16101        self.inner.engine.start();
16102    }
16103}
16104
16105struct WrappedCar {
16106    engine: Option<Engine>,
16107}
16108
16109impl WrappedCar {
16110    fn run(&self) {
16111        self.engine.start(); // wrapped
16112    }
16113}
16114
16115struct GenericCar<T> {
16116    engine: T,
16117}
16118
16119impl<T> GenericCar<T> {
16120    fn run(&self) {
16121        self.engine.start(); // generic
16122    }
16123}
16124
16125type EngineAlias = Engine;
16126
16127struct AliasCar {
16128    engine: EngineAlias,
16129}
16130
16131impl AliasCar {
16132    fn run(&self) {
16133        self.engine.start(); // alias
16134    }
16135}
16136"#;
16137        let dir = tempdir().expect("temp dir");
16138        let root = dir.path();
16139        write_fixture(root, "src/lib.rs", source);
16140        let mut cache = DispatchSourceCache::new();
16141
16142        let mut direct = reference(
16143            "rust",
16144            "src/lib.rs",
16145            "Car::run",
16146            "engine",
16147            "start",
16148            line_of(source, "self.engine.start()"),
16149        );
16150        direct.receiver_expression = "self.engine".to_string();
16151        assert_eq!(
16152            infer_receiver_type(root, &direct, &mut cache).as_deref(),
16153            Some("Engine")
16154        );
16155
16156        let mut mismatched_impl_target = direct.clone();
16157        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16158        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16159
16160        let mut nested = reference(
16161            "rust",
16162            "src/lib.rs",
16163            "NestedCar::run",
16164            "engine",
16165            "start",
16166            line_of(source, "self.inner.engine.start()"),
16167        );
16168        nested.receiver_expression = "self.inner.engine".to_string();
16169        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16170
16171        let mut wrapped = reference(
16172            "rust",
16173            "src/lib.rs",
16174            "WrappedCar::run",
16175            "engine",
16176            "start",
16177            line_of(source, "self.engine.start(); // wrapped"),
16178        );
16179        wrapped.receiver_expression = "self.engine".to_string();
16180        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16181
16182        let mut generic = reference(
16183            "rust",
16184            "src/lib.rs",
16185            "GenericCar::run",
16186            "engine",
16187            "start",
16188            line_of(source, "self.engine.start(); // generic"),
16189        );
16190        generic.receiver_expression = "self.engine".to_string();
16191        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16192
16193        let mut alias = reference(
16194            "rust",
16195            "src/lib.rs",
16196            "AliasCar::run",
16197            "engine",
16198            "start",
16199            line_of(source, "self.engine.start(); // alias"),
16200        );
16201        alias.receiver_expression = "self.engine".to_string();
16202        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16203    }
16204
16205    #[test]
16206    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16207        let source = r#"struct Engine;
16208
16209struct Car {
16210    engine: &'static Engine,
16211}
16212
16213impl Car {
16214    fn run(&self) {
16215        self.engine.start();
16216    }
16217}
16218"#;
16219        let dir = tempdir().expect("temp dir");
16220        let root = dir.path();
16221        write_fixture(root, "src/lib.rs", source);
16222        let mut cache = DispatchSourceCache::new();
16223        let mut reference = reference(
16224            "rust",
16225            "src/lib.rs",
16226            "Car::run",
16227            "engine",
16228            "start",
16229            line_of(source, "self.engine.start()"),
16230        );
16231        reference.receiver_expression = "self.engine".to_string();
16232
16233        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16234    }
16235
16236    #[test]
16237    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16238        let source = r#"trait Drive {
16239    fn run(&self);
16240}
16241
16242struct Engine;
16243
16244struct Car {
16245    engine: Engine,
16246}
16247
16248impl Drive for Car {
16249    fn run(&self) {
16250        self.engine.start();
16251    }
16252}
16253"#;
16254        let dir = tempdir().expect("temp dir");
16255        let root = dir.path();
16256        write_fixture(root, "src/lib.rs", source);
16257        let mut cache = DispatchSourceCache::new();
16258        let mut reference = reference(
16259            "rust",
16260            "src/lib.rs",
16261            "Car::run",
16262            "engine",
16263            "start",
16264            line_of(source, "self.engine.start()"),
16265        );
16266        reference.receiver_expression = "self.engine".to_string();
16267
16268        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16269    }
16270
16271    #[test]
16272    fn rust_self_field_does_not_bind_struct_from_another_module() {
16273        let source = r#"struct Engine;
16274
16275mod unrelated {
16276    struct Car {
16277        engine: Engine,
16278    }
16279}
16280
16281impl Car {
16282    fn run(&self) {
16283        self.engine.start();
16284    }
16285}
16286"#;
16287        let dir = tempdir().expect("temp dir");
16288        let root = dir.path();
16289        write_fixture(root, "src/lib.rs", source);
16290        let mut cache = DispatchSourceCache::new();
16291        let mut reference = reference(
16292            "rust",
16293            "src/lib.rs",
16294            "Car::run",
16295            "engine",
16296            "start",
16297            line_of(source, "self.engine.start()"),
16298        );
16299        reference.receiver_expression = "self.engine".to_string();
16300
16301        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16302    }
16303
16304    #[test]
16305    fn unknown_java_receiver_still_uses_name_match_fallback() {
16306        let source = r#"class EntryPoint {
16307    void handle() {
16308        service.runSpecial();
16309    }
16310}
16311
16312class OnlyService {
16313    void runSpecial() {}
16314}
16315"#;
16316        let dir = tempdir().expect("temp dir");
16317        let root = dir.path();
16318        write_fixture(root, "src/EntryPoint.java", source);
16319        let reference = reference(
16320            "java",
16321            "src/EntryPoint.java",
16322            "EntryPoint::handle",
16323            "service",
16324            "runSpecial",
16325            line_of(source, "service.runSpecial()"),
16326        );
16327        let mut cache = DispatchSourceCache::new();
16328
16329        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16330        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16331        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16332        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16333    }
16334
16335    fn reference(
16336        lang: &str,
16337        caller_file: &str,
16338        caller_symbol: &str,
16339        receiver: &str,
16340        method_name: &str,
16341        line: u32,
16342    ) -> NameMatchRef {
16343        NameMatchRef {
16344            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16345            caller_node: format!("{caller_symbol}:node"),
16346            caller_file: caller_file.to_string(),
16347            caller_symbol: caller_symbol.to_string(),
16348            caller_signature: None,
16349            receiver_expression: receiver.to_string(),
16350            receiver: receiver.to_string(),
16351            method_name: method_name.to_string(),
16352            colon_dispatch: false,
16353            line,
16354            lang: lang.to_string(),
16355        }
16356    }
16357
16358    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16359        NameMatchCandidate {
16360            node_id: node_id.to_string(),
16361            file_path: "src/targets.fixture".to_string(),
16362            scoped_name: scoped_name.to_string(),
16363            kind: "method".to_string(),
16364            start_line: 1,
16365        }
16366    }
16367
16368    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16369        let path = root.join(rel_path);
16370        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16371        fs::write(path, source).expect("write fixture");
16372    }
16373
16374    fn line_of(source: &str, needle: &str) -> u32 {
16375        source
16376            .lines()
16377            .position(|line| line.contains(needle))
16378            .map(|index| index as u32 + 1)
16379            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16380    }
16381}
16382
16383#[cfg(test)]
16384mod bounded_build_breaker_tests {
16385    use super::*;
16386    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16387    use tempfile::tempdir;
16388
16389    #[test]
16390    fn staged_inventory_drives_ordered_bounded_file_batches() {
16391        let temp = tempdir().unwrap();
16392        let root = temp.path().join("root");
16393        std::fs::create_dir_all(&root).unwrap();
16394        let first = root.join("a.ts");
16395        let second = root.join("b.ts");
16396        let third = root.join("c.ts");
16397        for path in [&first, &second, &third] {
16398            std::fs::write(path, "export function item() {}\n").unwrap();
16399        }
16400        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16401            .unwrap()
16402            .expect("test root may write its private staging database");
16403        let store = CallGraphStore::open_at_path(
16404            root.clone(),
16405            "inventory-key".to_string(),
16406            temp.path().join("inventory.sqlite"),
16407            None,
16408            true,
16409            Some(writer_lease),
16410            None,
16411        )
16412        .unwrap()
16413        .store;
16414        let fingerprint = store
16415            .stage_cold_build_file_inventory(&[
16416                third.clone(),
16417                first.clone(),
16418                second.clone(),
16419                first.clone(),
16420            ])
16421            .unwrap();
16422
16423        let conn = store.conn.lock().unwrap();
16424        assert_eq!(
16425            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16426            3,
16427            "the primary key deduplicates caller-supplied paths on disk"
16428        );
16429        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16430            .unwrap()
16431            .expect("first batch");
16432        assert_eq!(first_batch.paths, vec![first.clone(), second]);
16433        let second_batch =
16434            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16435                .unwrap()
16436                .expect("second batch");
16437        assert_eq!(second_batch.paths, vec![third]);
16438        assert_eq!(
16439            fingerprint,
16440            callgraph_corpus_fingerprint(&root).unwrap(),
16441            "staged and direct streaming fingerprints agree without walk-order dependence"
16442        );
16443    }
16444
16445    #[test]
16446    fn resumed_stage_preserves_committed_batch_and_counter() {
16447        let temp = tempdir().unwrap();
16448        let root = temp.path().join("root");
16449        std::fs::create_dir_all(&root).unwrap();
16450        let first = root.join("first.ts");
16451        let second = root.join("second.ts");
16452        std::fs::write(&first, "export function first() {}\n").unwrap();
16453        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16454        let staging = temp.path().join("stage.sqlite");
16455        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16456            .unwrap()
16457            .expect("test root may write its private staging database");
16458        let store = CallGraphStore::open_at_path(
16459            root.clone(),
16460            "test-key".to_string(),
16461            staging,
16462            None,
16463            true,
16464            Some(writer_lease),
16465            None,
16466        )
16467        .unwrap()
16468        .store;
16469        let corpus_fingerprint = store
16470            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16471            .unwrap();
16472        let first_extract = build_file_extract(&root, &first).unwrap();
16473        let first_bytes = first_extract.freshness.size;
16474        {
16475            let mut conn = store.conn.lock().unwrap();
16476            let tx = conn.transaction().unwrap();
16477            clear_tables(&tx).unwrap();
16478            insert_meta(&tx).unwrap();
16479            drop_cold_build_secondary_indexes(&tx).unwrap();
16480            set_meta_ready(&tx, false).unwrap();
16481            set_staged_build_phase(&tx, "extracting").unwrap();
16482            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16483            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16484            {
16485                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16486                insert_file_extract_prepared(
16487                    &mut inserts,
16488                    &root.display().to_string(),
16489                    &first_extract,
16490                )
16491                .unwrap();
16492                for raw in &first_extract.raw_refs {
16493                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16494                }
16495            }
16496            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16497            tx.commit().unwrap();
16498        }
16499
16500        store
16501            .cold_build_chunked(&[first.clone(), second.clone()], 1)
16502            .unwrap();
16503        let conn = store.conn.lock().unwrap();
16504        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16505        assert_eq!(
16506            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16507            first_bytes + std::fs::metadata(second).unwrap().len(),
16508            "the already committed batch and its credit survive adoption; only the new batch increments credit"
16509        );
16510        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16511    }
16512
16513    const SPECIMEN_CHILD_TEST: &str =
16514        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16515    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16516    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16517    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16518    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16519
16520    fn wait_for_child_barrier(path: &Path) {
16521        let deadline = Instant::now() + Duration::from_secs(10);
16522        while !path.exists() {
16523            assert!(
16524                Instant::now() < deadline,
16525                "callgraph child did not reach barrier {}",
16526                path.display()
16527            );
16528            std::thread::sleep(Duration::from_millis(5));
16529        }
16530    }
16531
16532    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16533        let signal = store.join("specimen-child.reached");
16534        let _ = std::fs::remove_file(&signal);
16535        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16536        command
16537            .arg("--exact")
16538            .arg(SPECIMEN_CHILD_TEST)
16539            .arg("--nocapture")
16540            .arg("--test-threads=1")
16541            .env(SPECIMEN_CHILD_ROOT, root)
16542            .env(SPECIMEN_CHILD_STORE, store)
16543            .env(SPECIMEN_CHILD_SIGNAL, &signal)
16544            .stdout(std::process::Stdio::null())
16545            .stderr(std::process::Stdio::null());
16546        if let Some(phase) = phase {
16547            command.env(SPECIMEN_CHILD_PHASE, phase);
16548        }
16549        command.spawn().unwrap()
16550    }
16551
16552    fn staging_path(root: &Path, store: &Path) -> PathBuf {
16553        let project_key = crate::search_index::artifact_cache_key(root);
16554        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16555    }
16556
16557    fn durable_staging_state(path: &Path) -> (u64, u64) {
16558        if !path.exists() {
16559            return (0, 0);
16560        }
16561        let conn = Connection::open(path).unwrap();
16562        (
16563            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16564            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16565        )
16566    }
16567
16568    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16569        wait_for_child_barrier(signal);
16570        child.kill().unwrap();
16571        let _ = child.wait().unwrap();
16572    }
16573
16574    #[test]
16575    fn respawn_loop_build_child() {
16576        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16577            return;
16578        };
16579        let root = PathBuf::from(root);
16580        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16581        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16582            let phase = phase.to_string_lossy().into_owned();
16583            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16584            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16585                if observed == phase {
16586                    std::fs::write(&signal, observed.as_bytes()).unwrap();
16587                    std::thread::sleep(Duration::from_secs(30));
16588                }
16589            })));
16590        }
16591        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16592        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16593    }
16594
16595    #[test]
16596    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16597        let temp = tempdir().unwrap();
16598        let root = temp.path().join("resumable-root");
16599        let store = temp.path().join("resumable-store");
16600        std::fs::create_dir_all(&root).unwrap();
16601        std::fs::create_dir_all(&store).unwrap();
16602        for index in 0..3 {
16603            std::fs::write(
16604                root.join(format!("file-{index}.ts")),
16605                format!("export function specimen{index}() {{ return {index}; }}\n"),
16606            )
16607            .unwrap();
16608        }
16609        let stage = staging_path(&root, &store);
16610        let signal = store.join("specimen-child.reached");
16611
16612        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16613        kill_barrier_child(&mut first, &signal);
16614        let (first_rows, first_bytes) = durable_staging_state(&stage);
16615        assert_eq!(first_rows, 1);
16616        assert!(first_bytes > 0);
16617
16618        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16619        kill_barrier_child(&mut second, &signal);
16620        let (second_rows, second_bytes) = durable_staging_state(&stage);
16621        assert_eq!(second_rows, 2);
16622        assert!(
16623            second_bytes > first_bytes,
16624            "a replacement process must adopt committed bytes instead of restarting from zero"
16625        );
16626
16627        let status = spawn_build_child(&root, &store, None).wait().unwrap();
16628        assert!(status.success(), "uninterrupted replacement build failed");
16629        assert!(!stage.exists(), "published staging file must be renamed");
16630        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16631            .unwrap()
16632            .expect("replacement attempts must converge to a published graph");
16633        assert_eq!(ready.indexed_file_count().unwrap(), 3);
16634
16635        let fast_root = temp.path().join("zero-credit-root");
16636        let fast_store = temp.path().join("zero-credit-store");
16637        std::fs::create_dir_all(&fast_root).unwrap();
16638        std::fs::create_dir_all(&fast_store).unwrap();
16639        std::fs::write(
16640            fast_root.join("main.ts"),
16641            "export function neverCommitted() {}\n",
16642        )
16643        .unwrap();
16644        let fast_stage = staging_path(&fast_root, &fast_store);
16645        let fast_signal = fast_store.join("specimen-child.reached");
16646        let breaker_path = fast_store.join("build-breaker.sqlite");
16647        let now = unix_millis_now();
16648
16649        for death in 0..3 {
16650            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16651            wait_for_child_barrier(&fast_signal);
16652            let attempt_id = Connection::open(&breaker_path)
16653                .unwrap()
16654                .query_row(
16655                    "SELECT attempt_id FROM breaker_attempts
16656                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16657                    [],
16658                    |row| row.get::<_, String>(0),
16659                )
16660                .unwrap();
16661            let (_, committed_bytes) = durable_staging_state(&fast_stage);
16662            assert_eq!(
16663                committed_bytes, 0,
16664                "the fast-kill schedule must not cross an extraction commit"
16665            );
16666            child.kill().unwrap();
16667            let _ = child.wait().unwrap();
16668
16669            let key = BreakerKey::new(
16670                fast_root.display().to_string(),
16671                BuildDomain::CallgraphCold,
16672                callgraph_corpus_fingerprint(&fast_root).unwrap(),
16673            );
16674            BuildDeathBreaker::open(&breaker_path)
16675                .unwrap()
16676                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16677                .unwrap();
16678        }
16679
16680        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16681        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16682            .unwrap()
16683            .expect("three zero-credit process deaths must suspend the root");
16684        assert_eq!(suspension.reason, "zero_credit_death_limit");
16685        assert_eq!(suspension.death_count, 3);
16686        let response = crate::commands::callgraph_store_adapter::suspended_response(
16687            "specimen",
16688            "callers",
16689            &suspension,
16690        );
16691        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16692        let message = response.data["message"].as_str().unwrap();
16693        assert!(
16694            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16695        );
16696        assert!(message.ends_with(
16697            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16698        ));
16699        let refused =
16700            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16701                .expect_err("a suspended root must not report a perpetually building worker");
16702        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16703    }
16704
16705    #[test]
16706    fn published_callgraph_build_respects_durable_domain_suspension() {
16707        let temp = tempdir().unwrap();
16708        let root = temp.path().join("root");
16709        let store_dir = temp.path().join("store");
16710        std::fs::create_dir_all(&root).unwrap();
16711        let source = root.join("main.ts");
16712        std::fs::write(&source, "export function marker() {}\n").unwrap();
16713        let files = vec![source];
16714        let key = BreakerKey::new(
16715            root.display().to_string(),
16716            BuildDomain::CallgraphCold,
16717            callgraph_corpus_fingerprint(&root).unwrap(),
16718        );
16719        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16720        for _ in 0..3 {
16721            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
16722                panic!("unexpected early suspension");
16723            };
16724            breaker
16725                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
16726                .unwrap();
16727        }
16728
16729        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
16730            .expect_err("durably tripped callgraph domain must refuse a new cold build");
16731        assert!(matches!(
16732            error,
16733            CallGraphStoreError::Suspended(ref suspension)
16734                if suspension.domain == BuildDomain::CallgraphCold
16735                    && suspension.death_count == 3
16736        ));
16737    }
16738}