Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const PROVENANCE_VALUE_REF: &str = "value_ref";
35const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
36const TOP_LEVEL_SYMBOL: &str = "<top-level>";
37const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
38const MIGRATION_MANIFEST_VERSION: u32 = 1;
39const MIGRATION_GENERATION_TAG: &str = ".migrated.";
40const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
41const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
42const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
43const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
44/// Marker-protected generations older than this absolute age are reclaimed even
45/// if a stale reader marker remains. Current and newest-previous generations are
46/// always retained, bounding the root-keyed callgraph store to roughly two or
47/// three large generations without adding user-visible configuration.
48const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
49const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
50const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
51pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
52const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
53const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
54const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
55const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
56/// Keep SQLite's per-connection page cache below the staged build working-set
57/// budget; negative values are KiB per SQLite's `cache_size` pragma.
58const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
59const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
60
61// Cold-build working-set limits are implementation constants rather than user
62// knobs so a large non-git root cannot accidentally opt back into an OOM path.
63const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
64const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
65// A 20k-reference resolver window kept peak RSS working-set shaped in the
66// committed 20k/40k corpus harness; 100k rows did not.
67const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
68const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
69const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
70const STAGED_BUILD_PHASE: &str = "staged_build_phase";
71const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
72
73fn write_amplification_baseline_enabled() -> bool {
74    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
75}
76
77type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
78pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
79
80static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
81    OnceLock::new();
82
83/// Install a process-local phase hook for the reproducible cold-build harness.
84/// Production callers leave it unset, so phase reporting adds no allocation on
85/// the build path.
86pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
87    *COLD_BUILD_PHASE_OBSERVER
88        .get_or_init(|| Mutex::new(None))
89        .lock()
90        .expect("cold build phase observer mutex poisoned") = observer;
91}
92
93fn note_cold_build_phase(phase: &'static str) {
94    if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
95        .get_or_init(|| Mutex::new(None))
96        .lock()
97        .expect("cold build phase observer mutex poisoned")
98        .as_ref()
99        .cloned()
100    {
101        observer(phase);
102    }
103}
104
105#[cfg(test)]
106fn note_cold_build_commit_barrier(phase: &'static str) {
107    note_cold_build_phase(phase);
108}
109
110#[cfg(not(test))]
111fn note_cold_build_commit_barrier(_phase: &'static str) {}
112
113#[derive(Clone, Debug, Eq, Hash, PartialEq)]
114struct RebuildCooldownKey {
115    callgraph_dir: PathBuf,
116    project_key: String,
117}
118
119#[derive(Clone, Debug)]
120struct RebuildCooldownRecord {
121    project_root: PathBuf,
122    published_at: Instant,
123    cross_root_cooldown_armed: bool,
124}
125
126// Prevent repeated rebuilds when requests rapidly switch between project
127// roots. Allow the first successful rebuild for a different root; after that
128// transition, report the artifact as unavailable instead of publishing another
129// complete generation. Record only successful publications in this map.
130static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
131    OnceLock::new();
132
133#[derive(Clone, Debug, Eq, Hash, PartialEq)]
134struct RootRepairWarningKey {
135    project_key: String,
136}
137
138#[derive(Clone, Debug)]
139struct RootRepairWarningRecord {
140    window_start: Instant,
141    last_emitted: Instant,
142    entry_count: u64,
143    suppressed: u64,
144}
145
146static ROOT_REPAIR_WARNINGS: OnceLock<
147    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
148> = OnceLock::new();
149
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub(crate) struct CallgraphWriteMetricsSnapshot {
152    pub commits_60s: u64,
153    pub pages_or_bytes_written_60s: u64,
154}
155
156#[derive(Debug, Default)]
157struct CallgraphWriteMetrics {
158    window_start_ms: AtomicU64,
159    commits_60s: AtomicU64,
160    pages_or_bytes_written_60s: AtomicU64,
161}
162
163static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
164    OnceLock::new();
165
166fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
167    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
168    let mut metrics = metrics
169        .lock()
170        .expect("callgraph write metrics mutex poisoned");
171    Arc::clone(
172        metrics
173            .entry(project_key.to_string())
174            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
175    )
176}
177
178fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
179    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
180    if current_start == 0 {
181        let _ = metrics.window_start_ms.compare_exchange(
182            0,
183            now_ms,
184            AtomicOrdering::AcqRel,
185            AtomicOrdering::Acquire,
186        );
187        return;
188    }
189    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
190        return;
191    }
192    if metrics
193        .window_start_ms
194        .compare_exchange(
195            current_start,
196            now_ms,
197            AtomicOrdering::AcqRel,
198            AtomicOrdering::Acquire,
199        )
200        .is_ok()
201    {
202        metrics.commits_60s.store(0, AtomicOrdering::Release);
203        metrics
204            .pages_or_bytes_written_60s
205            .store(0, AtomicOrdering::Release);
206    }
207}
208
209impl CallgraphWriteMetrics {
210    fn record_commit(&self, pages_or_bytes_written: u64) {
211        let now_ms = unix_millis_now();
212        roll_callgraph_write_metric_window(self, now_ms);
213        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
214        self.pages_or_bytes_written_60s
215            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
216    }
217
218    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
219        roll_callgraph_write_metric_window(self, unix_millis_now());
220        CallgraphWriteMetricsSnapshot {
221            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
222            pages_or_bytes_written_60s: self
223                .pages_or_bytes_written_60s
224                .load(AtomicOrdering::Acquire),
225        }
226    }
227}
228
229pub(crate) fn callgraph_write_metrics_for_project(
230    project_key: &str,
231) -> CallgraphWriteMetricsSnapshot {
232    callgraph_write_metrics_for_key(project_key).snapshot()
233}
234
235pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
236    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
237        return CallgraphWriteMetricsSnapshot::default();
238    };
239    let metrics = metrics
240        .lock()
241        .expect("callgraph write metrics mutex poisoned");
242    metrics.values().map(|metrics| metrics.snapshot()).fold(
243        CallgraphWriteMetricsSnapshot::default(),
244        |total, current| CallgraphWriteMetricsSnapshot {
245            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
246            pages_or_bytes_written_60s: total
247                .pages_or_bytes_written_60s
248                .saturating_add(current.pages_or_bytes_written_60s),
249        },
250    )
251}
252
253const ROOT_REPAIR_WARNING_TEXT: &str =
254    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
255
256fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
257    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
258    let mut warnings = warnings.lock().ok()?;
259    let entry = warnings.entry(key);
260    let record = match entry {
261        Entry::Vacant(entry) => {
262            entry.insert(RootRepairWarningRecord {
263                window_start: now,
264                last_emitted: now,
265                entry_count: 1,
266                suppressed: 0,
267            });
268            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
269        }
270        Entry::Occupied(entry) => entry.into_mut(),
271    };
272
273    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
274        let suppressed = record.suppressed;
275        record.window_start = now;
276        record.last_emitted = now;
277        record.entry_count = 1;
278        record.suppressed = 0;
279        return Some(if suppressed == 0 {
280            ROOT_REPAIR_WARNING_TEXT.to_string()
281        } else {
282            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
283        });
284    }
285
286    record.entry_count = record.entry_count.saturating_add(1);
287    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
288        record.suppressed = record.suppressed.saturating_add(1);
289        None
290    } else {
291        record.last_emitted = now;
292        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
293    }
294}
295
296pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
297    next_root_repair_warning(
298        RootRepairWarningKey {
299            project_key: project_key.to_string(),
300        },
301        Instant::now(),
302    )
303}
304
305/// Return the number of repair entries in the active 60-second window.
306///
307/// The window start is returned for callers that need to show freshness without
308/// adding another status verdict or turning this into a user-facing setting.
309pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
310    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
311    let warnings = warnings.lock().ok()?;
312    let record = warnings.get(&RootRepairWarningKey {
313        project_key: project_key.to_string(),
314    })?;
315    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
316        .then_some((record.entry_count, record.window_start))
317}
318
319pub(crate) fn repair_entry_rate_total() -> u64 {
320    let Ok(warnings) = ROOT_REPAIR_WARNINGS
321        .get_or_init(|| Mutex::new(HashMap::new()))
322        .lock()
323    else {
324        return 0;
325    };
326    let now = Instant::now();
327    warnings
328        .values()
329        .filter(|record| {
330            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
331        })
332        .map(|record| record.entry_count)
333        .sum()
334}
335
336#[cfg(test)]
337pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
338    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
339    let mut warnings = warnings.lock().unwrap();
340    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
341        project_key: project_key.to_string(),
342    }) {
343        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
344    }
345}
346
347#[cfg(test)]
348mod root_repair_warning_tests {
349    use super::*;
350
351    #[test]
352    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
353        let key = RootRepairWarningKey {
354            project_key: "test-project".to_string(),
355        };
356        let first_at = Instant::now();
357        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
358        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
359        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
360        assert_eq!(
361            repair_entry_rate("test-project").map(|rate| rate.0),
362            Some(2)
363        );
364
365        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
366        assert!(repeated.ends_with("(repeated 1x in 60s)"));
367        expire_repair_entry_window_for_test("test-project");
368        assert!(repair_entry_rate("test-project").is_none());
369    }
370}
371
372#[cfg(test)]
373mod write_amplification_tests {
374    use super::*;
375    use std::fs;
376    use tempfile::tempdir;
377
378    #[test]
379    fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
380        let temp = tempdir().unwrap();
381        let sqlite_path = temp.path().join("contended.sqlite");
382        let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
383        blocker
384            .execute_batch(
385                "PRAGMA journal_mode=DELETE;
386                 CREATE TABLE lock_probe (value INTEGER NOT NULL);
387                 INSERT INTO lock_probe VALUES (1);
388                 BEGIN EXCLUSIVE;
389                 UPDATE lock_probe SET value = 2;",
390            )
391            .expect("hold exclusive write transaction");
392
393        let (started_tx, started_rx) = std::sync::mpsc::channel();
394        let configure = std::thread::spawn(move || {
395            let conn = Connection::open(sqlite_path).expect("open contending connection");
396            started_tx.send(()).expect("signal configure start");
397            configure_connection(&conn)
398        });
399        started_rx.recv().expect("configure thread started");
400        std::thread::sleep(Duration::from_millis(100));
401        blocker.execute_batch("COMMIT").expect("release write lock");
402
403        configure
404            .join()
405            .expect("configure thread joined")
406            .expect("WAL setup waits for the writer instead of failing locked");
407    }
408
409    #[test]
410    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
411        let temp = tempdir().unwrap();
412        let root = temp.path().join("root");
413        fs::create_dir_all(&root).unwrap();
414        let source = root.join("main.ts");
415        fs::write(&source, "export function main() {}\n").unwrap();
416        let store_dir = temp.path().join("store");
417        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
418
419        let conn = store.conn.lock().unwrap();
420        let synchronous: i64 = conn
421            .pragma_query_value(None, "synchronous", |row| row.get(0))
422            .unwrap();
423        let autocheckpoint: i64 = conn
424            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
425            .unwrap();
426        let cache_size: i64 = conn
427            .pragma_query_value(None, "cache_size", |row| row.get(0))
428            .unwrap();
429        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
430        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
431        assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
432        drop(conn);
433        store.cold_build(std::slice::from_ref(&source)).unwrap();
434        drop(store);
435
436        let readonly = CallGraphStore::open_readonly(store_dir, root)
437            .unwrap()
438            .expect("writer-created empty schema should be readable");
439        let conn = readonly.inner.conn.lock().unwrap();
440        let synchronous: i64 = conn
441            .pragma_query_value(None, "synchronous", |row| row.get(0))
442            .unwrap();
443        assert_eq!(synchronous, 1);
444    }
445
446    #[test]
447    fn own_refresh_skips_identical_extract_but_not_position_shift() {
448        let temp = tempdir().unwrap();
449        let root = temp.path().join("root");
450        fs::create_dir_all(&root).unwrap();
451        let source = root.join("main.ts");
452        fs::write(&source, "export function main() { return 1; }\n").unwrap();
453        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
454        store.cold_build(std::slice::from_ref(&source)).unwrap();
455        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
456        assert!(write_metrics.commits_60s > 0);
457        assert!(write_metrics.pages_or_bytes_written_60s > 0);
458
459        let before = store.conn.lock().unwrap().total_changes();
460        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
461        let (stats, _) = store
462            .refresh_files_profiled(std::slice::from_ref(&source))
463            .unwrap();
464        let after = store.conn.lock().unwrap().total_changes();
465        assert_eq!(stats.unchanged_extract_files, 1);
466        assert_eq!(stats.refreshed_own_files, 0);
467        assert_eq!(
468            after - before,
469            3,
470            "files, backend freshness, and the durable projection revision update"
471        );
472
473        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
474        let (shifted_stats, _) = store
475            .refresh_files_profiled(std::slice::from_ref(&source))
476            .unwrap();
477        assert_eq!(shifted_stats.unchanged_extract_files, 0);
478        assert_eq!(shifted_stats.refreshed_own_files, 1);
479    }
480
481    #[cfg(unix)]
482    #[test]
483    fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
484        let temp = tempdir().unwrap();
485        let root = temp.path().join("project");
486        let source = root.join("src/lib.ts");
487        fs::create_dir_all(source.parent().unwrap()).unwrap();
488        fs::write(&source, "export function live() {}\n").unwrap();
489        let alias = temp.path().join("project-alias");
490        std::os::unix::fs::symlink(&root, &alias).unwrap();
491        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
492        store.cold_build(std::slice::from_ref(&source)).unwrap();
493        store
494            .mark_files_stale(std::slice::from_ref(&source))
495            .unwrap();
496
497        fs::remove_file(&source).unwrap();
498        let stats = store
499            .refresh_files(&[alias.join("src/lib.ts")])
500            .expect("deleted alias path must resolve through its existing parent");
501
502        assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
503        assert!(store.stale_files().unwrap().is_empty());
504    }
505
506    #[cfg(unix)]
507    #[test]
508    fn symlink_alias_refresh_preserves_real_mutation_detection() {
509        let temp = tempdir().unwrap();
510        let root = temp.path().join("project");
511        let source = root.join("src/lib.ts");
512        fs::create_dir_all(source.parent().unwrap()).unwrap();
513        fs::write(&source, "export function before() {}\n").unwrap();
514        let alias = temp.path().join("project-alias");
515        std::os::unix::fs::symlink(&root, &alias).unwrap();
516        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
517        store.cold_build(std::slice::from_ref(&source)).unwrap();
518
519        fs::write(&source, "export function after() {}\n").unwrap();
520        let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
521
522        assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
523        assert_eq!(stats.refreshed_own_files, 1);
524        assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
525    }
526
527    #[test]
528    fn unresolvable_refresh_path_records_a_path_identity_gap() {
529        let temp = tempdir().unwrap();
530        let root = temp.path().join("project");
531        let source = root.join("src/lib.ts");
532        fs::create_dir_all(source.parent().unwrap()).unwrap();
533        fs::write(&source, "export function live() {}\n").unwrap();
534        let foreign = temp.path().join("foreign.ts");
535        fs::write(&foreign, "export function foreign() {}\n").unwrap();
536        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
537        store.cold_build(std::slice::from_ref(&source)).unwrap();
538
539        let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
540        assert!(matches!(
541            error,
542            CallGraphStoreError::PathIdentityMismatch { .. }
543        ));
544        let conn = store.conn.lock().unwrap();
545        assert_eq!(
546            path_identity_mismatch_reason(&conn).unwrap(),
547            Some(format!(
548                "callgraph_path_identity_mismatch path={} project_root={}",
549                foreign.display(),
550                root.display()
551            ))
552        );
553    }
554
555    #[test]
556    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
557        let now = Instant::now();
558        assert!(idle_checkpoint_due(None, now));
559        assert!(!idle_checkpoint_due(
560            Some(now),
561            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
562        ));
563        assert!(idle_checkpoint_due(
564            Some(now),
565            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
566        ));
567    }
568
569    #[test]
570    fn write_metrics_decay_after_the_sixty_second_window() {
571        let key = format!("metrics-test-{}", now_nanos());
572        let metrics = callgraph_write_metrics_for_key(&key);
573        metrics.record_commit(17);
574        assert_eq!(metrics.snapshot().commits_60s, 1);
575        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
576        metrics.window_start_ms.store(
577            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
578            AtomicOrdering::Release,
579        );
580        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
581    }
582}
583
584#[cfg(test)]
585type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
586// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
587// thread running the cold build, and the only caller (a test) installs and
588// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
589// across parallel tests — one test's installed observer fired during ANOTHER
590// test's `cold_build_with_lease`, asserting against the wrong build's edges
591// (flaked on Windows CI under parallel scheduling). Production never sets it.
592thread_local! {
593    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
594        const { std::cell::RefCell::new(None) };
595    #[cfg(test)]
596    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
597        const { std::cell::RefCell::new(None) };
598    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
599        const { std::cell::RefCell::new(None) };
600    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
601    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
602        const { std::cell::Cell::new(false) };
603    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
604        const { std::cell::RefCell::new(None) };
605    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
606        const { std::cell::RefCell::new(None) };
607}
608
609mod dead_code_projection;
610pub use dead_code_projection::project_dead_code_snapshot;
611pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
612#[cfg(test)]
613pub(crate) use dead_code_projection::set_projection_before_open_observer;
614
615#[doc(hidden)]
616pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
617    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
618}
619
620#[cfg(test)]
621fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
622    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
623}
624
625#[cfg(test)]
626fn notify_cold_build_before_publish_observer() {
627    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
628    if let Some(observer) = observer {
629        observer();
630    }
631}
632
633#[cfg(not(test))]
634fn notify_cold_build_before_publish_observer() {}
635
636#[doc(hidden)]
637pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
638    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
639}
640
641#[doc(hidden)]
642pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
643    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
644}
645
646#[doc(hidden)]
647pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
648    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
649}
650
651struct PublishAdmissionGuard {
652    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
653}
654
655impl Drop for PublishAdmissionGuard {
656    fn drop(&mut self) {
657        PUBLISH_ADMISSION.with(|slot| {
658            *slot.borrow_mut() = self.previous.take();
659        });
660    }
661}
662
663pub(crate) fn with_publish_epoch<R>(
664    epoch: crate::root_cache::ArtifactPublishEpoch,
665    expected: u64,
666    run: impl FnOnce() -> R,
667) -> R {
668    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
669    let _guard = PublishAdmissionGuard { previous };
670    run()
671}
672
673fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
674    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
675    match admission {
676        Some((epoch, expected)) => epoch
677            .run_if_current(expected, publish)
678            .unwrap_or(Err(CallGraphStoreError::Superseded)),
679        None => publish(),
680    }
681}
682
683struct RefreshCommitAdmissionGuard {
684    previous: Option<(
685        SubcLifecycleAdmission,
686        Arc<std::sync::atomic::AtomicU64>,
687        u64,
688    )>,
689}
690
691impl Drop for RefreshCommitAdmissionGuard {
692    fn drop(&mut self) {
693        REFRESH_COMMIT_ADMISSION.with(|slot| {
694            *slot.borrow_mut() = self.previous.take();
695        });
696    }
697}
698
699fn with_refresh_commit_admission<R>(
700    lifecycle: SubcLifecycleAdmission,
701    generation_flag: Arc<std::sync::atomic::AtomicU64>,
702    expected_generation: u64,
703    run: impl FnOnce() -> R,
704) -> R {
705    let previous = REFRESH_COMMIT_ADMISSION
706        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
707    let _guard = RefreshCommitAdmissionGuard { previous };
708    run()
709}
710
711fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
712    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
713    let commit = || {
714        publish_if_current(|| {
715            tx.commit()?;
716            Ok(())
717        })
718    };
719    match admission {
720        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
721            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
722            .unwrap_or(Err(CallGraphStoreError::Superseded)),
723        None => commit(),
724    }
725}
726
727fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
728    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
729    if let Some(observer) = observer {
730        observer(temp_path, target_path);
731    }
732}
733
734#[derive(Debug)]
735pub enum CallGraphStoreError {
736    Io(std::io::Error),
737    Sqlite(rusqlite::Error),
738    Json(serde_json::Error),
739    Aft(AftError),
740    Lock(crate::fs_lock::AcquireError),
741    MissingCallerData {
742        file: String,
743    },
744    Unavailable(String),
745    PathIdentityMismatch {
746        path: PathBuf,
747        project_root: PathBuf,
748    },
749    Suspended(crate::build_breaker::BuildSuspension),
750    Superseded,
751    StaleFiles(Vec<String>),
752}
753
754impl CallGraphStoreError {
755    pub(crate) fn is_transient_lock_contention(&self) -> bool {
756        matches!(
757            self,
758            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
759                if matches!(
760                    error.code,
761                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
762                )
763        )
764    }
765}
766
767impl fmt::Display for CallGraphStoreError {
768    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
769        match self {
770            Self::Io(error) => write!(formatter, "I/O error: {error}"),
771            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
772            Self::Json(error) => write!(formatter, "json error: {error}"),
773            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
774            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
775            Self::MissingCallerData { file } => {
776                write!(formatter, "missing extracted caller data for {file}")
777            }
778            Self::Unavailable(message) => {
779                write!(formatter, "callgraph store unavailable: {message}")
780            }
781            Self::PathIdentityMismatch { path, project_root } => write!(
782                formatter,
783                "callgraph path identity mismatch: {} is not under project root {}",
784                path.display(),
785                project_root.display()
786            ),
787            Self::Suspended(suspension) => write!(
788                formatter,
789                "callgraph build suspended for {} after {} deaths ({})",
790                suspension.domain.as_str(),
791                suspension.death_count,
792                suspension.reason
793            ),
794            Self::Superseded => {
795                write!(formatter, "callgraph store build superseded before publish")
796            }
797            Self::StaleFiles(files) => {
798                write!(
799                    formatter,
800                    "callgraph store has stale files: {}",
801                    files.join(", ")
802                )
803            }
804        }
805    }
806}
807
808impl std::error::Error for CallGraphStoreError {}
809
810impl From<std::io::Error> for CallGraphStoreError {
811    fn from(error: std::io::Error) -> Self {
812        Self::Io(error)
813    }
814}
815
816impl From<rusqlite::Error> for CallGraphStoreError {
817    fn from(error: rusqlite::Error) -> Self {
818        Self::Sqlite(error)
819    }
820}
821
822impl From<serde_json::Error> for CallGraphStoreError {
823    fn from(error: serde_json::Error) -> Self {
824        Self::Json(error)
825    }
826}
827
828impl From<AftError> for CallGraphStoreError {
829    fn from(error: AftError) -> Self {
830        Self::Aft(error)
831    }
832}
833
834impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
835    fn from(error: crate::fs_lock::AcquireError) -> Self {
836        Self::Lock(error)
837    }
838}
839
840pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
841
842/// Config flag name gating whether the store is opened (default on). Production
843/// commands open it through `open_if_enabled` so the substrate can be disabled
844/// without code changes.
845pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
846
847#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
848pub struct CallGraphStoreOptions {
849    pub enabled: bool,
850}
851
852pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
853
854/// Shared context state that lets the refresh worker observe a store installed
855/// after its batch was opened. The worker clones the installed store Arc before
856/// checking it, so no context lock guard crosses the check or enqueue call.
857#[derive(Clone)]
858pub(crate) struct CallgraphRefreshState {
859    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
860    heavy_root_work_allowed: Arc<AtomicBool>,
861}
862
863impl CallgraphRefreshState {
864    pub(crate) fn new(
865        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
866        heavy_root_work_allowed: Arc<AtomicBool>,
867    ) -> Self {
868        Self {
869            store,
870            heavy_root_work_allowed,
871        }
872    }
873
874    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
875        self.store
876            .read()
877            .unwrap_or_else(std::sync::PoisonError::into_inner)
878            .as_ref()
879            .map(Arc::clone)
880    }
881}
882
883type WorkspaceCratePrefixes = HashMap<String, String>;
884
885#[derive(Clone, Debug, Default)]
886struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
887
888const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
889
890pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
891    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
892}
893
894#[derive(Clone, Debug, Hash, PartialEq, Eq)]
895struct RefreshRoot {
896    callgraph_dir: PathBuf,
897    project_root: PathBuf,
898}
899
900#[derive(Clone)]
901pub(crate) struct CallgraphRefreshTicket {
902    lifecycle: SubcLifecycleAdmission,
903    generation_flag: Arc<std::sync::atomic::AtomicU64>,
904    expected_generation: u64,
905    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
906    expected_publish_epoch: u64,
907}
908
909impl CallgraphRefreshTicket {
910    pub(crate) fn new(
911        lifecycle: SubcLifecycleAdmission,
912        generation_flag: Arc<std::sync::atomic::AtomicU64>,
913        expected_generation: u64,
914        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
915        expected_publish_epoch: u64,
916    ) -> Self {
917        Self {
918            lifecycle,
919            generation_flag,
920            expected_generation,
921            publish_epoch,
922            expected_publish_epoch,
923        }
924    }
925
926    fn is_current(&self) -> bool {
927        self.lifecycle
928            .is_current(self.generation_flag.as_ref(), self.expected_generation)
929            && self.publish_epoch.current() == self.expected_publish_epoch
930    }
931}
932
933#[derive(Clone)]
934struct RefreshBatch {
935    root: RefreshRoot,
936    paths: BTreeSet<PathBuf>,
937    pending_sinks: Vec<PendingCallGraphStorePaths>,
938    refresh_states: Vec<CallgraphRefreshState>,
939    ticket: Option<CallgraphRefreshTicket>,
940}
941
942impl RefreshBatch {
943    fn defer(&self) {
944        for sink in &self.pending_sinks {
945            sink.lock().extend(self.paths.iter().cloned());
946        }
947    }
948
949    fn defer_after_open_failure(&self) {
950        self.defer();
951        if self
952            .ticket
953            .as_ref()
954            .is_some_and(|ticket| !ticket.is_current())
955            || !self
956                .refresh_states
957                .iter()
958                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
959        {
960            return;
961        }
962
963        let ready_store_installed = self.refresh_states.iter().any(|state| {
964            let store = state.installed_store_snapshot();
965            store.is_some_and(|store| {
966                store.project_root() == self.root.project_root
967                    && !store.is_legacy_fallback()
968                    && store.is_current()
969            })
970        });
971        if !ready_store_installed {
972            return;
973        }
974
975        // This re-check and the ready-store install's pending-sink take form a
976        // check-then-act handoff: after this defer, exactly one site observes
977        // the parked paths with a ready current store, so no polling is needed.
978        for sink in &self.pending_sinks {
979            let paths = {
980                let mut pending = sink.lock();
981                self.paths
982                    .iter()
983                    .filter(|path| pending.remove(*path))
984                    .cloned()
985                    .collect::<Vec<_>>()
986            };
987            if paths.is_empty() {
988                continue;
989            }
990            let _ = enqueue_callgraph_store_refresh_inner(
991                self.root.callgraph_dir.clone(),
992                self.root.project_root.clone(),
993                paths,
994                Arc::clone(sink),
995                self.refresh_states.clone(),
996                self.ticket.clone(),
997            );
998        }
999    }
1000
1001    fn merge(
1002        &mut self,
1003        paths: impl IntoIterator<Item = PathBuf>,
1004        sink: PendingCallGraphStorePaths,
1005        refresh_states: Vec<CallgraphRefreshState>,
1006        ticket: Option<CallgraphRefreshTicket>,
1007    ) {
1008        self.paths.extend(paths);
1009        if ticket.is_some() {
1010            self.ticket = ticket;
1011        }
1012        if !self
1013            .pending_sinks
1014            .iter()
1015            .any(|existing| Arc::ptr_eq(existing, &sink))
1016        {
1017            self.pending_sinks.push(sink);
1018        }
1019        for refresh_state in refresh_states {
1020            if !self.refresh_states.iter().any(|existing| {
1021                Arc::ptr_eq(&existing.store, &refresh_state.store)
1022                    && Arc::ptr_eq(
1023                        &existing.heavy_root_work_allowed,
1024                        &refresh_state.heavy_root_work_allowed,
1025                    )
1026            }) {
1027                self.refresh_states.push(refresh_state);
1028            }
1029        }
1030    }
1031}
1032
1033#[derive(Default)]
1034struct RefreshQueue {
1035    order: VecDeque<RefreshRoot>,
1036    queued: HashMap<RefreshRoot, RefreshBatch>,
1037    active: Option<RefreshBatch>,
1038    shutdown_requested: bool,
1039}
1040
1041struct RefreshWorkerShared {
1042    queue: Mutex<RefreshQueue>,
1043    wake: Condvar,
1044}
1045
1046struct RefreshWorker {
1047    shared: Arc<RefreshWorkerShared>,
1048    thread: Mutex<Option<JoinHandle<()>>>,
1049}
1050
1051struct RefreshWorkerWatchdog {
1052    first_path: PathBuf,
1053    batch_len: usize,
1054    started: Instant,
1055}
1056
1057impl RefreshWorkerWatchdog {
1058    fn start(paths: &[PathBuf]) -> Self {
1059        Self {
1060            first_path: paths
1061                .first()
1062                .expect("non-empty callgraph refresh batch has a first path")
1063                .clone(),
1064            batch_len: paths.len(),
1065            started: Instant::now(),
1066        }
1067    }
1068}
1069
1070impl Drop for RefreshWorkerWatchdog {
1071    fn drop(&mut self) {
1072        let elapsed = self.started.elapsed();
1073        if elapsed < REFRESH_WORKER_WARN_AFTER {
1074            return;
1075        }
1076        let path = if self.batch_len == 1 {
1077            self.first_path.display().to_string()
1078        } else {
1079            format!(
1080                "{} (+{} paths)",
1081                self.first_path.display(),
1082                self.batch_len - 1
1083            )
1084        };
1085        log::warn!(
1086            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1087            path,
1088            elapsed.as_millis()
1089        );
1090        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1091            log::warn!(
1092                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1093                path,
1094                elapsed.as_millis()
1095            );
1096        }
1097    }
1098}
1099
1100impl RefreshWorker {
1101    fn spawn() -> Arc<Self> {
1102        let shared = Arc::new(RefreshWorkerShared {
1103            queue: Mutex::new(RefreshQueue::default()),
1104            wake: Condvar::new(),
1105        });
1106        let thread_shared = Arc::clone(&shared);
1107        let thread = std::thread::Builder::new()
1108            .name("aft-callgraph-refresh".to_string())
1109            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1110            .expect("failed to spawn callgraph refresh worker");
1111        Arc::new(Self {
1112            shared,
1113            thread: Mutex::new(Some(thread)),
1114        })
1115    }
1116
1117    fn enqueue(
1118        &self,
1119        root: RefreshRoot,
1120        paths: Vec<PathBuf>,
1121        pending_sink: PendingCallGraphStorePaths,
1122        refresh_states: Vec<CallgraphRefreshState>,
1123        ticket: Option<CallgraphRefreshTicket>,
1124    ) -> bool {
1125        let mut queue = self
1126            .shared
1127            .queue
1128            .lock()
1129            .expect("callgraph refresh queue mutex poisoned");
1130        if queue.shutdown_requested {
1131            pending_sink.lock().extend(paths);
1132            return false;
1133        }
1134        if let Some(batch) = queue.queued.get_mut(&root) {
1135            batch.merge(paths, pending_sink, refresh_states, ticket);
1136        } else {
1137            queue.order.push_back(root.clone());
1138            queue.queued.insert(
1139                root.clone(),
1140                RefreshBatch {
1141                    root,
1142                    paths: paths.into_iter().collect(),
1143                    pending_sinks: vec![pending_sink],
1144                    refresh_states,
1145                    ticket,
1146                },
1147            );
1148        }
1149        self.shared.wake.notify_one();
1150        true
1151    }
1152
1153    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1154        let deadline = Instant::now() + budget;
1155        let mut queue = self
1156            .shared
1157            .queue
1158            .lock()
1159            .expect("callgraph refresh queue mutex poisoned");
1160        queue.shutdown_requested = true;
1161        self.shared.wake.notify_one();
1162        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1163            let remaining = deadline.saturating_duration_since(Instant::now());
1164            let (next, _) = self
1165                .shared
1166                .wake
1167                .wait_timeout(queue, remaining)
1168                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1169            queue = next;
1170        }
1171        let drained = queue.active.is_none() && queue.order.is_empty();
1172        if !drained {
1173            if let Some(active) = queue.active.as_ref() {
1174                active.defer();
1175            }
1176            for batch in queue.queued.values() {
1177                batch.defer();
1178            }
1179            queue.order.clear();
1180            queue.queued.clear();
1181        }
1182        drop(queue);
1183
1184        if drained {
1185            if let Some(thread) = self
1186                .thread
1187                .lock()
1188                .expect("callgraph refresh worker thread mutex poisoned")
1189                .take()
1190            {
1191                let _ = thread.join();
1192            }
1193        }
1194        drained
1195    }
1196}
1197
1198static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1199
1200pub fn enqueue_callgraph_store_refresh(
1201    callgraph_dir: PathBuf,
1202    project_root: PathBuf,
1203    paths: Vec<PathBuf>,
1204    pending_sink: PendingCallGraphStorePaths,
1205) -> bool {
1206    enqueue_callgraph_store_refresh_inner(
1207        callgraph_dir,
1208        project_root,
1209        paths,
1210        pending_sink,
1211        Vec::new(),
1212        None,
1213    )
1214}
1215
1216#[cfg(test)]
1217pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1218    callgraph_dir: PathBuf,
1219    project_root: PathBuf,
1220    paths: Vec<PathBuf>,
1221    pending_sink: PendingCallGraphStorePaths,
1222    ticket: CallgraphRefreshTicket,
1223) -> bool {
1224    enqueue_callgraph_store_refresh_inner(
1225        callgraph_dir,
1226        project_root,
1227        paths,
1228        pending_sink,
1229        Vec::new(),
1230        Some(ticket),
1231    )
1232}
1233
1234pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1235    callgraph_dir: PathBuf,
1236    project_root: PathBuf,
1237    paths: Vec<PathBuf>,
1238    pending_sink: PendingCallGraphStorePaths,
1239    refresh_state: CallgraphRefreshState,
1240    ticket: CallgraphRefreshTicket,
1241) -> bool {
1242    enqueue_callgraph_store_refresh_inner(
1243        callgraph_dir,
1244        project_root,
1245        paths,
1246        pending_sink,
1247        vec![refresh_state],
1248        Some(ticket),
1249    )
1250}
1251
1252fn enqueue_callgraph_store_refresh_inner(
1253    callgraph_dir: PathBuf,
1254    project_root: PathBuf,
1255    paths: Vec<PathBuf>,
1256    pending_sink: PendingCallGraphStorePaths,
1257    refresh_states: Vec<CallgraphRefreshState>,
1258    ticket: Option<CallgraphRefreshTicket>,
1259) -> bool {
1260    if paths.is_empty() {
1261        return true;
1262    }
1263    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1264    let worker = {
1265        let mut worker = slot
1266            .lock()
1267            .expect("callgraph refresh worker mutex poisoned");
1268        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1269    };
1270    worker.enqueue(
1271        RefreshRoot {
1272            callgraph_dir,
1273            project_root,
1274        },
1275        paths,
1276        pending_sink,
1277        refresh_states,
1278        ticket,
1279    )
1280}
1281
1282pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1283    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1284}
1285
1286#[doc(hidden)]
1287pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1288    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1289    let worker = slot
1290        .lock()
1291        .expect("callgraph refresh worker mutex poisoned")
1292        .clone();
1293    let Some(worker) = worker else {
1294        return true;
1295    };
1296    let drained = worker.shutdown_with_budget(budget);
1297    if drained {
1298        let mut current = slot
1299            .lock()
1300            .expect("callgraph refresh worker mutex poisoned");
1301        if current
1302            .as_ref()
1303            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1304        {
1305            *current = None;
1306        }
1307    }
1308    drained
1309}
1310
1311fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1312    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1313}
1314
1315fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1316    // The worker owns these caches so maps are shared only by refreshes for the
1317    // same canonical root and disappear when the worker shuts down.
1318    let mut workspace_crate_prefixes = HashMap::new();
1319    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1320    loop {
1321        let batch = {
1322            let mut queue = shared
1323                .queue
1324                .lock()
1325                .expect("callgraph refresh queue mutex poisoned");
1326            loop {
1327                if let Some(root) = queue.order.pop_front() {
1328                    let batch = queue
1329                        .queued
1330                        .remove(&root)
1331                        .expect("queued callgraph refresh root has a batch");
1332                    queue.active = Some(batch.clone());
1333                    break batch;
1334                }
1335                if queue.shutdown_requested {
1336                    return;
1337                }
1338                queue = shared
1339                    .wake
1340                    .wait(queue)
1341                    .expect("callgraph refresh queue mutex poisoned while waiting");
1342            }
1343        };
1344
1345        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1346
1347        let mut queue = shared
1348            .queue
1349            .lock()
1350            .expect("callgraph refresh queue mutex poisoned");
1351        queue.active = None;
1352        let became_idle = queue.order.is_empty();
1353        shared.wake.notify_all();
1354        drop(queue);
1355
1356        if became_idle {
1357            let checkpoint_due = idle_checkpoint_due(
1358                last_idle_checkpoints.get(&batch.root).copied(),
1359                Instant::now(),
1360            );
1361            if checkpoint_due {
1362                if let Some(store) = store {
1363                    if store.checkpoint_wal_truncate() {
1364                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1365                    }
1366                }
1367            }
1368        }
1369    }
1370}
1371
1372fn process_callgraph_refresh_batch(
1373    batch: &RefreshBatch,
1374    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1375) -> Option<CallGraphStore> {
1376    // A manifest event is an invalidation signal, not a source file to parse.
1377    // Drop the root's map even for a superseded batch: the filesystem changed,
1378    // and a later configure must never inherit crate membership from before it.
1379    if batch
1380        .paths
1381        .iter()
1382        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1383    {
1384        workspace_crate_prefixes.remove(&batch.root);
1385    }
1386
1387    let paths = batch
1388        .paths
1389        .iter()
1390        .filter(|path| crate::parser::detect_language(path).is_some())
1391        .cloned()
1392        .collect::<Vec<_>>();
1393    if paths.is_empty() {
1394        return None;
1395    }
1396    note_refresh_worker_batch_for_test(&batch.root.project_root);
1397    if batch
1398        .ticket
1399        .as_ref()
1400        .is_some_and(|ticket| !ticket.is_current())
1401    {
1402        // Superseded before starting: park the paths so the next configure's
1403        // pending replay (or unbind cleanup) decides their fate.
1404        batch.defer();
1405        return None;
1406    }
1407    let workspace_crate_prefix_cache =
1408        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1409    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1410    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1411    note_refresh_worker_call_for_test(&batch.root.project_root);
1412    let opened = if test_seam.fail_open {
1413        Ok(None)
1414    } else {
1415        CallGraphStore::open_ready(
1416            batch.root.callgraph_dir.clone(),
1417            batch.root.project_root.clone(),
1418        )
1419    };
1420    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1421        // The gate is deliberately after open_ready so tests can hold a failed
1422        // open between its result and the defer that parks the batch.
1423        let _ = gate.held_tx.send(());
1424        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1425    }
1426    let store = match opened {
1427        Ok(Some(store)) => store,
1428        Ok(None) => {
1429            batch.defer_after_open_failure();
1430            return None;
1431        }
1432        Err(error) => {
1433            batch.defer_after_open_failure();
1434            crate::slog_warn!(
1435                "callgraph store writer open failed during refresh; deferred paths: {}",
1436                error
1437            );
1438            return None;
1439        }
1440    };
1441    if !test_seam.delay.is_zero() {
1442        std::thread::sleep(test_seam.delay);
1443    }
1444    if batch
1445        .ticket
1446        .as_ref()
1447        .is_some_and(|ticket| !ticket.is_current())
1448    {
1449        // This is a superseded-ticket defer, not an open-failure defer: leave
1450        // the paths for the replacement configure instead of self-replaying.
1451        batch.defer();
1452        return Some(store);
1453    }
1454    let refresh_result = if test_seam.fail_refresh {
1455        Err(CallGraphStoreError::Unavailable(
1456            "injected refresh worker failure".to_string(),
1457        ))
1458    } else if let Some(ticket) = &batch.ticket {
1459        with_publish_epoch(
1460            ticket.publish_epoch.clone(),
1461            ticket.expected_publish_epoch,
1462            || {
1463                with_refresh_commit_admission(
1464                    ticket.lifecycle.clone(),
1465                    Arc::clone(&ticket.generation_flag),
1466                    ticket.expected_generation,
1467                    || {
1468                        store
1469                            .refresh_files_with_workspace_crate_prefix_cache(
1470                                &paths,
1471                                workspace_crate_prefix_cache.clone(),
1472                            )
1473                            .map(|_| ())
1474                    },
1475                )
1476            },
1477        )
1478    } else {
1479        store
1480            .refresh_files_with_workspace_crate_prefix_cache(
1481                &paths,
1482                workspace_crate_prefix_cache.clone(),
1483            )
1484            .map(|_| ())
1485    };
1486    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1487        // The commit lost the fence race: a newer configure or publication
1488        // owns the store now. Defer instead of stale-marking — the paths were
1489        // never committed, and the replacement generation re-indexes them.
1490        batch.defer();
1491        return Some(store);
1492    }
1493    if let Err(error) = refresh_result {
1494        crate::slog_warn!("callgraph store refresh failed: {}", error);
1495        match store.mark_files_stale(&paths) {
1496            Ok(marked) => {
1497                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1498                crate::slog_warn!(
1499                    "marked {} callgraph store file(s) stale after refresh failure",
1500                    marked.len()
1501                );
1502            }
1503            Err(mark_error) => crate::slog_warn!(
1504                "failed to mark callgraph store files stale after refresh failure: {}",
1505                mark_error
1506            ),
1507        }
1508    } else {
1509        crate::logging::note_callgraph_invalidations(paths.len());
1510    }
1511    Some(store)
1512}
1513
1514fn workspace_crate_prefix_cache_for_root(
1515    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1516    root: &RefreshRoot,
1517) -> WorkspaceCratePrefixCache {
1518    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1519        // Eviction only costs a future rebuild; it cannot make resolution stale.
1520        if let Some(evicted) = caches.keys().next().cloned() {
1521            caches.remove(&evicted);
1522        }
1523    }
1524    caches.entry(root.clone()).or_default().clone()
1525}
1526
1527#[derive(Clone, Copy, Default)]
1528struct RefreshWorkerTestSeam {
1529    delay: Duration,
1530    fail_refresh: bool,
1531    fail_open: bool,
1532    refresh_calls: usize,
1533    worker_calls: usize,
1534    stale_marks: usize,
1535}
1536
1537static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1538    OnceLock::new();
1539
1540struct RefreshWorkerTestGate {
1541    held_tx: crossbeam_channel::Sender<()>,
1542    release_rx: crossbeam_channel::Receiver<()>,
1543}
1544
1545static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1546    OnceLock::new();
1547
1548#[doc(hidden)]
1549pub fn install_callgraph_refresh_worker_test_gate(
1550    project_root: PathBuf,
1551) -> (
1552    crossbeam_channel::Receiver<()>,
1553    crossbeam_channel::Sender<()>,
1554) {
1555    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1556    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1557    REFRESH_WORKER_TEST_GATES
1558        .get_or_init(|| Mutex::new(HashMap::new()))
1559        .lock()
1560        .expect("callgraph refresh test gate mutex poisoned")
1561        .insert(
1562            project_root,
1563            RefreshWorkerTestGate {
1564                held_tx,
1565                release_rx,
1566            },
1567        );
1568    (held_rx, release_tx)
1569}
1570
1571fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1572    REFRESH_WORKER_TEST_GATES
1573        .get_or_init(|| Mutex::new(HashMap::new()))
1574        .lock()
1575        .expect("callgraph refresh test gate mutex poisoned")
1576        .remove(project_root)
1577}
1578
1579fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1580    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1581        return RefreshWorkerTestSeam::default();
1582    };
1583    seams
1584        .lock()
1585        .expect("callgraph refresh test seam mutex poisoned")
1586        .get(project_root)
1587        .copied()
1588        .unwrap_or_default()
1589}
1590
1591fn note_refresh_worker_batch_for_test(project_root: &Path) {
1592    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1593        if let Some(seam) = seams
1594            .lock()
1595            .expect("callgraph refresh test seam mutex poisoned")
1596            .get_mut(project_root)
1597        {
1598            seam.worker_calls += 1;
1599        }
1600    }
1601}
1602
1603fn note_refresh_worker_call_for_test(project_root: &Path) {
1604    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1605        if let Some(seam) = seams
1606            .lock()
1607            .expect("callgraph refresh test seam mutex poisoned")
1608            .get_mut(project_root)
1609        {
1610            seam.refresh_calls += 1;
1611        }
1612    }
1613}
1614
1615fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1616    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1617        if let Some(seam) = seams
1618            .lock()
1619            .expect("callgraph refresh test seam mutex poisoned")
1620            .get_mut(project_root)
1621        {
1622            seam.stale_marks += 1;
1623        }
1624    }
1625}
1626
1627#[doc(hidden)]
1628pub fn set_callgraph_refresh_worker_test_seam(
1629    project_root: PathBuf,
1630    delay: Duration,
1631    fail_refresh: bool,
1632) {
1633    REFRESH_WORKER_TEST_SEAMS
1634        .get_or_init(|| Mutex::new(HashMap::new()))
1635        .lock()
1636        .expect("callgraph refresh test seam mutex poisoned")
1637        .insert(
1638            project_root,
1639            RefreshWorkerTestSeam {
1640                delay,
1641                fail_refresh,
1642                ..RefreshWorkerTestSeam::default()
1643            },
1644        );
1645}
1646
1647#[doc(hidden)]
1648pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1649    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1650        if let Some(seam) = seams
1651            .lock()
1652            .expect("callgraph refresh test seam mutex poisoned")
1653            .get_mut(&project_root)
1654        {
1655            seam.fail_open = enabled;
1656        }
1657    }
1658}
1659
1660#[doc(hidden)]
1661pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1662    let seam = refresh_worker_test_seam(project_root);
1663    (seam.refresh_calls, seam.stale_marks)
1664}
1665
1666#[doc(hidden)]
1667pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1668    refresh_worker_test_seam(project_root).worker_calls
1669}
1670
1671#[doc(hidden)]
1672pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1673    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1674        seams
1675            .lock()
1676            .expect("callgraph refresh test seam mutex poisoned")
1677            .remove(project_root);
1678    }
1679}
1680
1681#[derive(Debug)]
1682pub struct CallGraphStore {
1683    project_root: PathBuf,
1684    project_key: String,
1685    /// The concrete on-disk DB file this store opened. With the generation
1686    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1687    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1688    sqlite_path: PathBuf,
1689    /// Root-keyed directory whose pointer controls this store. For a legacy
1690    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1691    /// newly published root-keyed generation invalidates the fallback reader.
1692    publication_dir: PathBuf,
1693    /// True only when the root-keyed read path opened data from a legacy
1694    /// harness partition. Writer-capable callers use this to schedule migration
1695    /// without making read-only/worktree callers acquire a writer lease.
1696    legacy_fallback: bool,
1697    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1698    /// or `None` when it opened the legacy single-file DB. Used to detect when
1699    /// another process has published a newer generation so this process can
1700    /// drop its connection and reopen (see `current_generation`).
1701    generation: Option<String>,
1702    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1703    read_marker: Option<crate::root_cache::ReadMarker>,
1704    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1705    // Failed validations are not cached, so a later successful build remains visible.
1706    database_ready: AtomicBool,
1707    write_metrics: Arc<CallgraphWriteMetrics>,
1708    conn: Mutex<Connection>,
1709}
1710
1711#[derive(Debug)]
1712pub struct ReadonlyCallGraphStore {
1713    inner: CallGraphStore,
1714}
1715
1716pub trait CallGraphRead {
1717    fn project_root(&self) -> &Path;
1718    fn project_key(&self) -> &str;
1719    fn sqlite_path(&self) -> &Path;
1720    fn is_current(&self) -> bool;
1721    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1722    fn indexed_file_count(&self) -> Result<usize>;
1723    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1724    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1725    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1726    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1727    fn direct_callers_for_symbols(
1728        &self,
1729        targets: &[(String, String)],
1730    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1731        targets
1732            .iter()
1733            .cloned()
1734            .map(|target| {
1735                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1736                Ok((target, callers))
1737            })
1738            .collect()
1739    }
1740    fn direct_caller_counts_of(
1741        &self,
1742        targets: &[(String, String)],
1743    ) -> Result<HashMap<(String, String), usize>>;
1744    fn outgoing_calls_for_symbols(
1745        &self,
1746        sources: &[(String, String)],
1747    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1748    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1749        -> Result<StoreCallersResult>;
1750    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1751    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1752    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1753    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1754    fn call_tree(
1755        &self,
1756        file_rel: &Path,
1757        symbol: &str,
1758        depth: usize,
1759    ) -> Result<callgraph::CallTreeNode>;
1760    fn trace_to(
1761        &self,
1762        file_rel: &Path,
1763        symbol: &str,
1764        max_depth: usize,
1765    ) -> Result<callgraph::TraceToResult>;
1766    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1767    fn trace_to_symbol(
1768        &self,
1769        file_rel: &Path,
1770        symbol: &str,
1771        to_symbol: &str,
1772        to_file: Option<&Path>,
1773        max_depth: usize,
1774    ) -> Result<callgraph::TraceToSymbolResult>;
1775}
1776
1777#[derive(Debug, Clone, PartialEq, Eq)]
1778enum OpenRootRepair {
1779    None,
1780    ReRooted,
1781    NeedsRebuild {
1782        previous_roots: Vec<String>,
1783        current_root: String,
1784        reason: String,
1785    },
1786}
1787
1788struct OpenedStore {
1789    store: CallGraphStore,
1790    root_repair: OpenRootRepair,
1791}
1792
1793#[derive(Clone, Debug)]
1794struct LegacyCallgraphPartition {
1795    harness: String,
1796    dir: PathBuf,
1797    key: String,
1798    bytes: u64,
1799    freshness: Option<SystemTime>,
1800}
1801
1802#[derive(Clone, Debug)]
1803struct LegacyCallgraphTarget {
1804    partition: LegacyCallgraphPartition,
1805    sqlite_path: PathBuf,
1806    generation: Option<String>,
1807    source_bytes: u64,
1808    source_blake3: String,
1809}
1810
1811#[derive(Clone, Debug)]
1812struct SourceFingerprint {
1813    bytes: u64,
1814    blake3: String,
1815}
1816
1817#[derive(Clone, Debug)]
1818struct PublishedLegacyMigration {
1819    generation: String,
1820    migrated_bytes: u64,
1821}
1822
1823#[derive(Debug, Clone)]
1824pub struct ColdBuildStats {
1825    pub files: usize,
1826    pub nodes: usize,
1827    pub refs: usize,
1828    pub edges: usize,
1829    pub failed_files: Vec<String>,
1830    pub elapsed_ms: u128,
1831}
1832
1833#[derive(Debug, Clone)]
1834pub struct IncrementalStats {
1835    pub changed_files: Vec<String>,
1836    pub surface_changed: Vec<String>,
1837    pub deleted_files: Vec<String>,
1838    pub dependency_selected_refs: usize,
1839    pub refreshed_own_files: usize,
1840    pub unchanged_extract_files: usize,
1841}
1842
1843/// Phase timings for the copy-based incremental refresh benchmark.
1844#[doc(hidden)]
1845#[derive(Debug, Clone, Default, PartialEq, Eq)]
1846pub struct RefreshFilesProfile {
1847    pub parse: Duration,
1848    pub dependency_selection: Duration,
1849    pub row_deletes: Duration,
1850    pub row_inserts: Duration,
1851    pub dependent_parse: Duration,
1852    pub index_load: Duration,
1853    pub ref_resolution: Duration,
1854    pub method_dispatch: Duration,
1855    pub commit: Duration,
1856    pub total: Duration,
1857}
1858
1859impl RefreshFilesProfile {
1860    pub fn report(&self) -> String {
1861        format!(
1862            "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",
1863            self.parse.as_millis(),
1864            self.dependency_selection.as_millis(),
1865            self.row_deletes.as_millis(),
1866            self.row_inserts.as_millis(),
1867            self.dependent_parse.as_millis(),
1868            self.index_load.as_millis(),
1869            self.ref_resolution.as_millis(),
1870            self.method_dispatch.as_millis(),
1871            self.commit.as_millis(),
1872            self.total.as_millis(),
1873        )
1874    }
1875}
1876
1877#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1878pub struct StoredEdge {
1879    pub source_file: String,
1880    pub source_symbol: String,
1881    pub target_file: String,
1882    pub target_symbol: String,
1883    pub kind: String,
1884    pub line: u32,
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq)]
1888pub struct StoreNode {
1889    node_id: String,
1890    pub file: String,
1891    pub symbol: String,
1892    pub name: String,
1893    pub kind: String,
1894    pub line: u32,
1895    pub end_line: u32,
1896    pub signature: Option<String>,
1897    pub exported: bool,
1898    pub is_entry_point: bool,
1899    pub lang: LangId,
1900}
1901
1902#[cfg(test)]
1903impl StoreNode {
1904    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1905        Self {
1906            node_id: format!("{file}:{symbol}"),
1907            file: file.to_string(),
1908            symbol: symbol.to_string(),
1909            name: symbol.to_string(),
1910            kind: "function".to_string(),
1911            line: 1,
1912            end_line: 1,
1913            signature: None,
1914            exported: is_entry_point,
1915            is_entry_point,
1916            lang: LangId::TypeScript,
1917        }
1918    }
1919}
1920
1921#[derive(Debug, Clone, PartialEq, Eq)]
1922pub struct StoreCallSite {
1923    pub caller: StoreNode,
1924    pub target_file: String,
1925    pub target_symbol: String,
1926    pub target: Option<StoreNode>,
1927    pub line: u32,
1928    pub byte_start: usize,
1929    pub byte_end: usize,
1930    pub resolved: bool,
1931    pub provenance: String,
1932}
1933
1934impl StoreCallSite {
1935    pub fn approximate(&self) -> bool {
1936        self.provenance == PROVENANCE_NAME_MATCH
1937    }
1938
1939    pub fn resolved_by(&self) -> &str {
1940        &self.provenance
1941    }
1942
1943    pub fn supplemental_resolution(&self) -> Option<&str> {
1944        match self.provenance.as_str() {
1945            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1946            _ => None,
1947        }
1948    }
1949}
1950
1951#[derive(Debug, Clone, PartialEq, Eq)]
1952pub struct StoreUnresolvedCall {
1953    pub caller: StoreNode,
1954    pub symbol: String,
1955    pub full_ref: Option<String>,
1956    pub line: u32,
1957    pub byte_start: usize,
1958    pub byte_end: usize,
1959}
1960
1961#[derive(Debug, Clone, PartialEq, Eq)]
1962pub struct StoreCallersResult {
1963    pub target: StoreNode,
1964    pub callers: Vec<StoreCallSite>,
1965    pub scanned_files: usize,
1966    pub depth_limited: bool,
1967    pub truncated: usize,
1968}
1969
1970#[derive(Debug, Clone, PartialEq, Eq)]
1971pub struct StoreImpactCaller {
1972    pub site: StoreCallSite,
1973    pub signature: Option<String>,
1974    pub is_entry_point: bool,
1975    pub call_expression: Option<String>,
1976    pub parameters: Vec<String>,
1977}
1978
1979#[derive(Debug, Clone, PartialEq, Eq)]
1980pub struct StoreImpactResult {
1981    pub target: StoreNode,
1982    pub parameters: Vec<String>,
1983    pub callers: Vec<StoreImpactCaller>,
1984    pub depth_limited: bool,
1985    pub truncated: usize,
1986}
1987
1988#[derive(Debug, Clone)]
1989struct ExtractFailure {
1990    rel_path: String,
1991    freshness: Option<FileFreshness>,
1992}
1993
1994#[derive(Debug, Clone)]
1995struct BuildExtractsResult {
1996    extracts: Vec<FileExtract>,
1997    failures: Vec<ExtractFailure>,
1998}
1999
2000#[derive(Debug, Clone)]
2001enum StoreForwardCall {
2002    Resolved(StoreCallSite),
2003    Unresolved(StoreUnresolvedCall),
2004}
2005
2006impl StoreForwardCall {
2007    fn byte_start(&self) -> usize {
2008        match self {
2009            Self::Resolved(site) => site.byte_start,
2010            Self::Unresolved(call) => call.byte_start,
2011        }
2012    }
2013
2014    fn line(&self) -> u32 {
2015        match self {
2016            Self::Resolved(site) => site.line,
2017            Self::Unresolved(call) => call.line,
2018        }
2019    }
2020}
2021
2022#[derive(Debug, Clone)]
2023struct FileExtract {
2024    rel_path: String,
2025    freshness: FileFreshness,
2026    lang: LangId,
2027    data: FileCallData,
2028    nodes: Vec<NodeRecord>,
2029    raw_refs: Vec<RawRef>,
2030    dispatch_hints: Vec<DispatchHint>,
2031    surface_fingerprint: String,
2032}
2033
2034#[derive(Debug, Clone)]
2035struct NodeRecord {
2036    id: String,
2037    file_path: String,
2038    name: String,
2039    scoped_name: String,
2040    kind: String,
2041    range: Range,
2042    range_ordinal: u32,
2043    signature: Option<String>,
2044    exported: bool,
2045    is_default_export: bool,
2046    is_type_like: bool,
2047    is_callgraph_entry_point: bool,
2048}
2049
2050#[derive(Debug, Clone)]
2051struct RawRef {
2052    ref_id: String,
2053    caller_node: Option<String>,
2054    caller_symbol: Option<String>,
2055    caller_file: String,
2056    kind: String,
2057    short_name: Option<String>,
2058    full_ref: Option<String>,
2059    module_path: Option<String>,
2060    import_kind: Option<String>,
2061    local_name: Option<String>,
2062    requested_name: Option<String>,
2063    namespace_alias: Option<String>,
2064    wildcard: bool,
2065    line: u32,
2066    byte_start: usize,
2067    byte_end: usize,
2068    dependencies: BTreeSet<String>,
2069}
2070
2071/// A raw reference read from the durable staging table with its SQLite ordering
2072/// key. The ordering key is advanced only in the same transaction that writes
2073/// the resolved result, so a crash resumes at a committed window boundary.
2074#[derive(Debug)]
2075struct StagedRef {
2076    rowid: u64,
2077    raw: RawRef,
2078}
2079
2080#[derive(Debug, Clone)]
2081struct ResolvedRef {
2082    raw: RawRef,
2083    status: String,
2084    target_node: Option<String>,
2085    target_file: Option<String>,
2086    target_symbol: Option<String>,
2087    dependencies: BTreeSet<String>,
2088    edge: Option<EdgeRecord>,
2089}
2090
2091#[derive(Debug, Clone)]
2092struct EdgeRecord {
2093    edge_id: String,
2094    source_node: String,
2095    target_node: Option<String>,
2096    target_file: String,
2097    target_symbol: String,
2098    kind: String,
2099    line: u32,
2100}
2101
2102#[derive(Debug, Clone)]
2103struct DispatchHint {
2104    id: String,
2105    method_name: String,
2106    caller_node: String,
2107    file: String,
2108    line: u32,
2109    byte_start: usize,
2110    byte_end: usize,
2111}
2112
2113#[derive(Debug, Clone)]
2114struct NameMatchRef {
2115    ref_id: String,
2116    caller_node: String,
2117    caller_file: String,
2118    caller_symbol: String,
2119    caller_signature: Option<String>,
2120    receiver_expression: String,
2121    receiver: String,
2122    method_name: String,
2123    colon_dispatch: bool,
2124    line: u32,
2125    lang: String,
2126}
2127
2128#[derive(Debug, Clone)]
2129struct NameMatchCandidate {
2130    node_id: String,
2131    file_path: String,
2132    scoped_name: String,
2133    kind: String,
2134    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2135    start_line: u32,
2136}
2137
2138#[derive(Debug, Clone)]
2139struct FileRow {
2140    surface_fingerprint: String,
2141    freshness: FileFreshness,
2142}
2143
2144#[derive(Debug, Clone)]
2145struct DbFileIndex {
2146    lang: Option<LangId>,
2147    exports: HashSet<String>,
2148    default_export: Option<String>,
2149    export_aliases: HashMap<String, String>,
2150    node_by_scoped: HashMap<String, String>,
2151    node_by_bare: HashMap<String, String>,
2152    node_kind_by_id: HashMap<String, String>,
2153    module_targets: HashMap<String, Option<String>>,
2154    reexports: Vec<ReexportIndex>,
2155}
2156
2157#[derive(Debug, Clone)]
2158struct ReexportIndex {
2159    target_file: Option<String>,
2160    named: HashMap<String, String>,
2161    wildcard: bool,
2162}
2163
2164#[derive(Debug, Clone)]
2165struct ProjectIndex<'a> {
2166    project_root: PathBuf,
2167    files: HashMap<String, DbFileIndex>,
2168    caller_data: HashMap<String, &'a FileCallData>,
2169    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2170    /// watcher events replace the cache before another batch can resolve refs.
2171    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2172    /// its own workspace mapping.
2173    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2174}
2175
2176/// Resolution reads symbols and exports through one interface. Incremental
2177/// refreshes use the in-memory index, while cold builds query only the rows
2178/// needed by the active caller from SQLite.
2179trait ResolverIndex {
2180    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2181    fn lang_for(&self, file: &str) -> Option<LangId>;
2182    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2183    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2184    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2185    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2186    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2187    fn has_export(&self, file: &str, symbol: &str) -> bool;
2188    fn default_export(&self, file: &str) -> Option<String>;
2189    fn contains_file(&self, file: &str) -> bool;
2190    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2191    fn inline_scoped_target(
2192        &self,
2193        caller_file: &str,
2194        module_segments: &[String],
2195        short_name: &str,
2196    ) -> Option<(String, String)>;
2197}
2198
2199impl ResolverIndex for ProjectIndex<'_> {
2200    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2201        self.caller_data.get(file).copied()
2202    }
2203
2204    fn lang_for(&self, file: &str) -> Option<LangId> {
2205        self.lang_for(file)
2206    }
2207
2208    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2209        self.module_target(caller_file, module_path)
2210    }
2211
2212    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2213        self.reexports_for(file).to_vec()
2214    }
2215
2216    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2217        self.node_for_symbol(file, symbol)
2218    }
2219
2220    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2221        self.node_is_callable(file, node_id)
2222    }
2223
2224    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2225        self.files
2226            .get(file)
2227            .and_then(|item| item.export_aliases.get(symbol))
2228            .cloned()
2229    }
2230
2231    fn has_export(&self, file: &str, symbol: &str) -> bool {
2232        self.files
2233            .get(file)
2234            .is_some_and(|item| item.exports.contains(symbol))
2235    }
2236
2237    fn default_export(&self, file: &str) -> Option<String> {
2238        self.files
2239            .get(file)
2240            .and_then(|item| item.default_export.clone())
2241    }
2242
2243    fn contains_file(&self, file: &str) -> bool {
2244        self.files.contains_key(file)
2245    }
2246
2247    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2248        self.workspace_crate_prefixes
2249            .0
2250            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2251            .get(crate_name)
2252            .cloned()
2253    }
2254
2255    fn inline_scoped_target(
2256        &self,
2257        caller_file: &str,
2258        module_segments: &[String],
2259        short_name: &str,
2260    ) -> Option<(String, String)> {
2261        let src_prefix = rust_src_prefix(caller_file);
2262        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2263        file_paths.sort();
2264        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2265            let caller = file_paths.remove(position);
2266            file_paths.insert(0, caller);
2267        }
2268        for file_path in file_paths {
2269            if self.lang_for(&file_path) != Some(LangId::Rust)
2270                || rust_src_prefix(&file_path) != src_prefix
2271            {
2272                continue;
2273            }
2274            let file_module_segments = rust_module_segments_for_rel(&file_path);
2275            if !module_segments.starts_with(&file_module_segments) {
2276                continue;
2277            }
2278            let scoped_segments = &module_segments[file_module_segments.len()..];
2279            if scoped_segments.is_empty() {
2280                continue;
2281            }
2282            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2283            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2284                return Some((file_path, scoped_symbol));
2285            }
2286        }
2287        None
2288    }
2289}
2290
2291/// A cold-build resolver view that loads one file's index at a time. Keeping the
2292/// complete staged corpus in SQLite makes the heap proportional to the active
2293/// reference window rather than to the number of project files.
2294struct DiskProjectIndex<'a> {
2295    project_root: &'a Path,
2296    conn: &'a Connection,
2297    caller_file: &'a str,
2298    caller_data: &'a FileCallData,
2299    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2300}
2301
2302impl DiskProjectIndex<'_> {
2303    fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2304        let lang: String = self
2305            .conn
2306            .query_row(
2307                "SELECT lang FROM files WHERE path = ?1",
2308                params![rel_path],
2309                |row| row.get(0),
2310            )
2311            .optional()
2312            .ok()??;
2313        let mut index = DbFileIndex {
2314            lang: lang_from_label(&lang),
2315            exports: HashSet::new(),
2316            default_export: None,
2317            export_aliases: HashMap::new(),
2318            node_by_scoped: HashMap::new(),
2319            node_by_bare: HashMap::new(),
2320            node_kind_by_id: HashMap::new(),
2321            module_targets: HashMap::new(),
2322            reexports: Vec::new(),
2323        };
2324        let mut nodes = self
2325            .conn
2326            .prepare(
2327                "SELECT id, name, scoped_name, kind, exported, is_default_export
2328                 FROM nodes WHERE file_path = ?1",
2329            )
2330            .ok()?;
2331        let rows = nodes
2332            .query_map(params![rel_path], |row| {
2333                Ok((
2334                    row.get::<_, String>(0)?,
2335                    row.get::<_, String>(1)?,
2336                    row.get::<_, String>(2)?,
2337                    row.get::<_, String>(3)?,
2338                    row.get::<_, i64>(4)? != 0,
2339                    row.get::<_, i64>(5)? != 0,
2340                ))
2341            })
2342            .ok()?
2343            .collect::<std::result::Result<Vec<_>, _>>()
2344            .ok()?;
2345        drop(nodes);
2346        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2347            if exported {
2348                index.exports.insert(name.clone());
2349                index.exports.insert(scoped_name.clone());
2350            }
2351            if is_default_export {
2352                index.default_export = Some(scoped_name.clone());
2353            }
2354            index.node_by_scoped.insert(scoped_name, id.clone());
2355            index.node_by_bare.entry(name).or_insert(id.clone());
2356            index.node_kind_by_id.insert(id, kind);
2357        }
2358
2359        let mut refs = self
2360            .conn
2361            .prepare(
2362                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2363                 FROM refs
2364                 WHERE caller_file = ?1 AND kind IN ('import', 'reexport', 'export_alias')",
2365            )
2366            .ok()?;
2367        let rows = refs
2368            .query_map(params![rel_path], |row| {
2369                Ok((
2370                    row.get::<_, String>(0)?,
2371                    row.get::<_, String>(1)?,
2372                    row.get::<_, Option<String>>(2)?,
2373                    row.get::<_, Option<String>>(3)?,
2374                    row.get::<_, i64>(4)? != 0,
2375                    row.get::<_, Option<String>>(5)?,
2376                    row.get::<_, Option<String>>(6)?,
2377                ))
2378            })
2379            .ok()?
2380            .collect::<std::result::Result<Vec<_>, _>>()
2381            .ok()?;
2382        drop(refs);
2383        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2384            if kind == "export_alias" {
2385                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2386                    index.export_aliases.insert(exported, source);
2387                }
2388                continue;
2389            }
2390            let Some(module_path) = module_path else {
2391                continue;
2392            };
2393            let target_file = self.disk_module_target(rel_path, &module_path).or_else(|| {
2394                self.conn
2395                    .query_row(
2396                        "SELECT d.dep_file
2397                         FROM file_dependencies d
2398                         JOIN files f ON f.path = d.dep_file
2399                         WHERE d.file_path = ?1
2400                         ORDER BY d.dep_file
2401                         LIMIT 1",
2402                        params![rel_path],
2403                        |row| row.get::<_, String>(0),
2404                    )
2405                    .optional()
2406                    .ok()
2407                    .flatten()
2408            });
2409            index
2410                .module_targets
2411                .entry(module_path.clone())
2412                .or_insert_with(|| target_file.clone());
2413            if kind == "reexport" {
2414                let raw = RawRef {
2415                    ref_id,
2416                    caller_node: None,
2417                    caller_symbol: None,
2418                    caller_file: rel_path.to_string(),
2419                    kind,
2420                    short_name: None,
2421                    full_ref,
2422                    module_path: Some(module_path),
2423                    import_kind: Some("reexport".to_string()),
2424                    local_name: None,
2425                    requested_name: None,
2426                    namespace_alias: None,
2427                    wildcard,
2428                    line: 0,
2429                    byte_start: 0,
2430                    byte_end: 0,
2431                    dependencies: BTreeSet::new(),
2432                };
2433                index
2434                    .reexports
2435                    .push(reexport_index_from_raw(&raw, target_file));
2436            }
2437        }
2438        Some(index)
2439    }
2440
2441    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2442        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2443        let candidate = callgraph::resolve_module_path(&caller_dir, module_path)?;
2444        let rel_path = relative_path(self.project_root, &canonicalize_path(&candidate));
2445        self.contains_file(&rel_path).then_some(rel_path)
2446    }
2447}
2448
2449impl ResolverIndex for DiskProjectIndex<'_> {
2450    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2451        (file == self.caller_file).then_some(self.caller_data)
2452    }
2453
2454    fn lang_for(&self, file: &str) -> Option<LangId> {
2455        self.file_index(file).and_then(|index| index.lang)
2456    }
2457
2458    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2459        self.file_index(caller_file)
2460            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2461    }
2462
2463    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2464        self.file_index(file)
2465            .map(|index| index.reexports)
2466            .unwrap_or_default()
2467    }
2468
2469    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2470        self.file_index(file).and_then(|index| {
2471            index
2472                .node_by_scoped
2473                .get(symbol)
2474                .cloned()
2475                .or_else(|| index.node_by_bare.get(symbol).cloned())
2476        })
2477    }
2478
2479    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2480        self.file_index(file)
2481            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2482            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2483    }
2484
2485    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2486        self.file_index(file)
2487            .and_then(|index| index.export_aliases.get(symbol).cloned())
2488    }
2489
2490    fn has_export(&self, file: &str, symbol: &str) -> bool {
2491        self.file_index(file)
2492            .is_some_and(|index| index.exports.contains(symbol))
2493    }
2494
2495    fn default_export(&self, file: &str) -> Option<String> {
2496        self.file_index(file).and_then(|index| index.default_export)
2497    }
2498
2499    fn contains_file(&self, file: &str) -> bool {
2500        self.conn
2501            .query_row(
2502                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2503                params![file],
2504                |_| Ok(()),
2505            )
2506            .is_ok()
2507    }
2508
2509    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2510        self.workspace_crate_prefixes
2511            .0
2512            .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2513            .get(crate_name)
2514            .cloned()
2515    }
2516
2517    fn inline_scoped_target(
2518        &self,
2519        caller_file: &str,
2520        module_segments: &[String],
2521        short_name: &str,
2522    ) -> Option<(String, String)> {
2523        let src_prefix = rust_src_prefix(caller_file);
2524        let check = |file_path: String| {
2525            let file_module_segments = rust_module_segments_for_rel(&file_path);
2526            if rust_src_prefix(&file_path) != src_prefix
2527                || !module_segments.starts_with(&file_module_segments)
2528            {
2529                return None;
2530            }
2531            let scoped_segments = &module_segments[file_module_segments.len()..];
2532            if scoped_segments.is_empty() {
2533                return None;
2534            }
2535            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2536            self.node_for_symbol(&file_path, &scoped_symbol)
2537                .map(|_| (file_path, scoped_symbol))
2538        };
2539        if let Some(target) = check(caller_file.to_string()) {
2540            return Some(target);
2541        }
2542        let mut statement = self
2543            .conn
2544            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2545            .ok()?;
2546        let rows = statement
2547            .query_map(params![caller_file], |row| row.get::<_, String>(0))
2548            .ok()?;
2549        for path in rows.flatten() {
2550            if let Some(target) = check(path) {
2551                return Some(target);
2552            }
2553        }
2554        None
2555    }
2556}
2557
2558impl CallGraphStore {
2559    pub fn open_if_enabled(
2560        options: CallGraphStoreOptions,
2561        callgraph_dir: PathBuf,
2562        project_root: PathBuf,
2563    ) -> Result<Option<Self>> {
2564        if !options.enabled {
2565            return Ok(None);
2566        }
2567        Self::open(callgraph_dir, project_root).map(Some)
2568    }
2569
2570    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2571        let project_key = crate::search_index::artifact_cache_key(&project_root);
2572        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2573        else {
2574            return Err(CallGraphStoreError::Unavailable(
2575                "writer capability denied; use the read-only callgraph opener".to_string(),
2576            ));
2577        };
2578        std::fs::create_dir_all(&callgraph_dir)?;
2579        // Resolve the current generation via the pointer (falling back to the
2580        // legacy single-file DB). If nothing is published yet, open the legacy
2581        // path so a brand-new store still gets a writable DB + schema.
2582        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2583            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2584        let OpenedStore { store, root_repair } = Self::open_at_path(
2585            project_root.clone(),
2586            project_key,
2587            sqlite_path,
2588            generation,
2589            true,
2590            Some(Arc::clone(&writer_lease)),
2591            None,
2592        )?;
2593        match root_repair {
2594            OpenRootRepair::NeedsRebuild { .. } => {
2595                log_root_repair_rebuild(&root_repair);
2596                drop(store);
2597                drop(writer_lease);
2598                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2599                let (store, _stats) =
2600                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2601                Ok(store)
2602            }
2603            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2604        }
2605    }
2606
2607    pub fn open_readonly(
2608        callgraph_dir: PathBuf,
2609        project_root: PathBuf,
2610    ) -> Result<Option<ReadonlyCallGraphStore>> {
2611        let project_key = crate::search_index::artifact_cache_key(&project_root);
2612        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2613        {
2614            let conn = open_readonly_connection(&sqlite_path)?;
2615            if !database_ready(&conn).unwrap_or(false) {
2616                return Ok(None);
2617            }
2618            let marker_label = generation.as_deref().unwrap_or("legacy");
2619            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2620            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2621                Self::from_connection(
2622                    project_root,
2623                    project_key,
2624                    sqlite_path,
2625                    callgraph_dir,
2626                    false,
2627                    generation,
2628                    None,
2629                    Some(read_marker),
2630                    conn,
2631                ),
2632            )));
2633        }
2634
2635        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2636            return Ok(None);
2637        };
2638        crate::slog_warn!(
2639            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2640            target.partition.harness,
2641            target.sqlite_path.display()
2642        );
2643        let conn = open_readonly_connection(&target.sqlite_path)?;
2644        if !database_ready(&conn).unwrap_or(false) {
2645            return Ok(None);
2646        }
2647        let marker_label =
2648            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2649        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2650        Ok(Some(ReadonlyCallGraphStore::from_inner(
2651            Self::from_connection(
2652                project_root,
2653                project_key,
2654                target.sqlite_path,
2655                callgraph_dir,
2656                true,
2657                target.generation,
2658                None,
2659                Some(read_marker),
2660                conn,
2661            ),
2662        )))
2663    }
2664
2665    /// Open the currently-published ready store with write access so moved-root
2666    /// metadata can be repaired before projection readers consume it. Unlike
2667    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2668    /// ready generation exists, it returns `Ok(None)` instead of creating an
2669    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2670    pub fn open_ready_repairing(
2671        callgraph_dir: PathBuf,
2672        project_root: PathBuf,
2673    ) -> Result<Option<Self>> {
2674        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2675    }
2676
2677    /// Open a ready store for bounded maintenance work without repairing root
2678    /// metadata or starting a cold rebuild. A store that needs either action is
2679    /// reported as unavailable so a background build can own that work.
2680    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2681        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2682    }
2683
2684    pub fn open_ready_no_rebuild(
2685        callgraph_dir: PathBuf,
2686        project_root: PathBuf,
2687    ) -> Result<Option<Self>> {
2688        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2689    }
2690
2691    fn open_ready_with_rebuild_policy(
2692        callgraph_dir: PathBuf,
2693        project_root: PathBuf,
2694        allow_cold_build: bool,
2695        allow_root_repair: bool,
2696    ) -> Result<Option<Self>> {
2697        let project_key = crate::search_index::artifact_cache_key(&project_root);
2698        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2699        else {
2700            return Ok(None);
2701        };
2702        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2703        else {
2704            return Ok(None);
2705        };
2706        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2707            project_root.clone(),
2708            project_key.clone(),
2709            sqlite_path,
2710            generation,
2711            true,
2712            Some(Arc::clone(&writer_lease)),
2713            None,
2714            allow_root_repair,
2715        )?;
2716        match root_repair {
2717            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2718                log_root_repair_rebuild(&root_repair);
2719                drop(store);
2720                drop(writer_lease);
2721                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2722                let (store, _stats) =
2723                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2724                Ok(Some(store))
2725            }
2726            OpenRootRepair::NeedsRebuild { .. } => {
2727                if let Some(message) = note_repair_entry(&project_key) {
2728                    crate::slog_warn!("{message}");
2729                }
2730                Ok(None)
2731            }
2732            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2733        }
2734    }
2735
2736    pub fn cold_build_with_lease(
2737        callgraph_dir: PathBuf,
2738        project_root: PathBuf,
2739        files: &[PathBuf],
2740    ) -> Result<(Self, ColdBuildStats)> {
2741        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2742    }
2743
2744    pub fn cold_build_with_lease_chunked(
2745        callgraph_dir: PathBuf,
2746        project_root: PathBuf,
2747        files: &[PathBuf],
2748        chunk_size: usize,
2749    ) -> Result<(Self, ColdBuildStats)> {
2750        Self::cold_build_with_lease_chunked_inner(
2751            callgraph_dir,
2752            project_root,
2753            files,
2754            chunk_size,
2755            false,
2756        )
2757    }
2758
2759    pub(crate) fn force_cold_build_with_lease_chunked(
2760        callgraph_dir: PathBuf,
2761        project_root: PathBuf,
2762        files: &[PathBuf],
2763        chunk_size: usize,
2764    ) -> Result<(Self, ColdBuildStats)> {
2765        Self::cold_build_with_lease_chunked_inner(
2766            callgraph_dir,
2767            project_root,
2768            files,
2769            chunk_size,
2770            true,
2771        )
2772    }
2773
2774    fn cold_build_with_lease_chunked_inner(
2775        callgraph_dir: PathBuf,
2776        project_root: PathBuf,
2777        files: &[PathBuf],
2778        chunk_size: usize,
2779        require_new_publication: bool,
2780    ) -> Result<(Self, ColdBuildStats)> {
2781        let project_key = crate::search_index::artifact_cache_key(&project_root);
2782        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2783        else {
2784            let operation = if require_new_publication {
2785                "forced rebuild"
2786            } else {
2787                "cold build"
2788            };
2789            return Err(CallGraphStoreError::Unavailable(format!(
2790                "{operation} could not acquire writer capability"
2791            )));
2792        };
2793        std::fs::create_dir_all(&callgraph_dir)?;
2794        let (stats, generation) = Self::cold_build_publish_locked(
2795            &callgraph_dir,
2796            &project_root,
2797            &project_key,
2798            files,
2799            chunk_size,
2800            Arc::clone(&writer_lease),
2801        )?;
2802        let store = Self::open_generation(
2803            &callgraph_dir,
2804            project_root,
2805            project_key,
2806            generation,
2807            writer_lease,
2808        )?;
2809        Ok((store, stats))
2810    }
2811
2812    pub fn ensure_built_with_lease(
2813        callgraph_dir: PathBuf,
2814        project_root: PathBuf,
2815        files: &[PathBuf],
2816    ) -> Result<(Self, Option<ColdBuildStats>)> {
2817        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2818    }
2819
2820    pub fn ensure_built_with_lease_chunked(
2821        callgraph_dir: PathBuf,
2822        project_root: PathBuf,
2823        files: &[PathBuf],
2824        chunk_size: usize,
2825    ) -> Result<(Self, Option<ColdBuildStats>)> {
2826        let project_key = crate::search_index::artifact_cache_key(&project_root);
2827        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2828        else {
2829            return Err(CallGraphStoreError::Unavailable(
2830                "callgraph ensure could not acquire writer capability".to_string(),
2831            ));
2832        };
2833        std::fs::create_dir_all(&callgraph_dir)?;
2834        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2835        // Another process may have published a ready generation while we waited
2836        // for the lock — open it instead of rebuilding. If that generation is
2837        // from this same project at an older filesystem root, repair the root
2838        // metadata in-place while still holding the build lease. If data rows
2839        // contain absolute paths, publish a fresh generation under this lease
2840        // rather than recursively reacquiring the same lock.
2841        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2842        {
2843            let OpenedStore { store, root_repair } = Self::open_at_path(
2844                project_root.clone(),
2845                project_key.clone(),
2846                sqlite_path,
2847                generation,
2848                true,
2849                Some(Arc::clone(&writer_lease)),
2850                None,
2851            )?;
2852            match root_repair {
2853                OpenRootRepair::NeedsRebuild { .. } => {
2854                    log_root_repair_rebuild(&root_repair);
2855                    drop(store);
2856                    let (stats, generation) = Self::cold_build_publish_locked(
2857                        &callgraph_dir,
2858                        &project_root,
2859                        &project_key,
2860                        files,
2861                        chunk_size,
2862                        Arc::clone(&writer_lease),
2863                    )?;
2864                    let store = Self::open_generation(
2865                        &callgraph_dir,
2866                        project_root,
2867                        project_key,
2868                        generation,
2869                        writer_lease,
2870                    )?;
2871                    return Ok((store, Some(stats)));
2872                }
2873                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2874                    return Ok((store, None));
2875                }
2876            }
2877        }
2878        if let Some(store) = try_legacy_migration_or_fallback(
2879            &callgraph_dir,
2880            &project_root,
2881            &project_key,
2882            Arc::clone(&writer_lease),
2883        )? {
2884            return Ok((store, None));
2885        }
2886        let (stats, generation) = Self::cold_build_publish_locked(
2887            &callgraph_dir,
2888            &project_root,
2889            &project_key,
2890            files,
2891            chunk_size,
2892            Arc::clone(&writer_lease),
2893        )?;
2894        let store = Self::open_generation(
2895            &callgraph_dir,
2896            project_root,
2897            project_key,
2898            generation,
2899            writer_lease,
2900        )?;
2901        Ok((store, Some(stats)))
2902    }
2903
2904    /// Migrate a legacy harness-partition store without falling through to a
2905    /// cold build. This is used after a query has already opened a read-only
2906    /// fallback: the caller runs it on the same limited background lane as cold
2907    /// builds while queries continue using that fallback. Public so crash/retry
2908    /// tests can drive the migration synchronously on a thread where the
2909    /// thread-local failure seams apply.
2910    pub fn migrate_legacy_with_lease(
2911        callgraph_dir: PathBuf,
2912        project_root: PathBuf,
2913    ) -> Result<Option<Self>> {
2914        let project_key = crate::search_index::artifact_cache_key(&project_root);
2915        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2916        else {
2917            return Ok(None);
2918        };
2919        std::fs::create_dir_all(&callgraph_dir)?;
2920        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2921
2922        // Another writer may have completed the migration while this worker was
2923        // waiting for the lease. Adopt its root-keyed generation rather than
2924        // copying the legacy source a second time.
2925        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2926        {
2927            let OpenedStore { store, root_repair } = Self::open_at_path(
2928                project_root,
2929                project_key,
2930                sqlite_path,
2931                generation,
2932                true,
2933                Some(writer_lease),
2934                None,
2935            )?;
2936            return match root_repair {
2937                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2938                OpenRootRepair::NeedsRebuild { reason, .. } => {
2939                    Err(CallGraphStoreError::Unavailable(format!(
2940                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2941                    )))
2942                }
2943            };
2944        }
2945
2946        let store = try_legacy_migration_or_fallback(
2947            &callgraph_dir,
2948            &project_root,
2949            &project_key,
2950            writer_lease,
2951        )?;
2952        // A disk-floor or backup-budget failure returns a readable legacy store.
2953        // Keep the already-resident fallback instead of sending this duplicate
2954        // reader through the background-install channel.
2955        Ok(store.filter(|store| !store.is_legacy_fallback()))
2956    }
2957
2958    /// Build a fresh DB and publish it as a new generation, then atomically flip
2959    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2960    /// succeeds even when other processes hold an older generation open (the
2961    /// multi-TUI Windows case). The builder owns the temp + generation files
2962    /// exclusively (unique pid+nanos names), so it can rename/replace them
2963    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2964    ///
2965    /// Returns the published generation file name so callers open exactly the
2966    /// generation they built (avoiding a race where a concurrent build's flip
2967    /// would otherwise reopen a different generation).
2968    fn cold_build_publish_locked(
2969        callgraph_dir: &Path,
2970        project_root: &Path,
2971        project_key: &str,
2972        files: &[PathBuf],
2973        chunk_size: usize,
2974        writer_lease: Arc<crate::root_cache::WriterLease>,
2975    ) -> Result<(ColdBuildStats, String)> {
2976        if let Some((previous_root, remaining)) =
2977            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2978        {
2979            return Err(CallGraphStoreError::Unavailable(format!(
2980                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2981                previous_root.display(),
2982                remaining.as_millis()
2983            )));
2984        }
2985        let breaker = crate::build_breaker::BuildDeathBreaker::open(
2986            callgraph_dir.join("build-breaker.sqlite"),
2987        )
2988        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
2989
2990        let generation = generation_file_name(project_key);
2991        let gen_path = callgraph_dir.join(&generation);
2992        // A writer lease makes this root/domain's staging generation exclusive.
2993        // Keep its identity stable so a replacement process adopts committed
2994        // batches instead of minting a second temp and starting from zero.
2995        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
2996        let adopting_staging = temp_path.exists();
2997        if !adopting_staging {
2998            remove_sqlite_file_set(&temp_path);
2999        }
3000
3001        let (stats, breaker_key) = {
3002            if adopting_staging {
3003                crate::slog_info!(
3004                    "resuming callgraph cold build from staged generation {}",
3005                    temp_path.display()
3006                );
3007            }
3008            let temp_store = Self::open_at_path(
3009                project_root.to_path_buf(),
3010                project_key.to_string(),
3011                temp_path.clone(),
3012                None,
3013                false,
3014                Some(Arc::clone(&writer_lease)),
3015                None,
3016            )?
3017            .store;
3018            // Admission must precede every expensive build phase and every
3019            // staging write: a suspended root is refused before the process
3020            // spends anything, and a death during enumeration is attributable
3021            // to an admitted attempt. The breaker key needs the corpus
3022            // fingerprint, so that one input is resolved by a standalone
3023            // streaming walk first (sanctioned pre-admission work) - the
3024            // inventory pass below recomputes it while staging; the staged
3025            // value governs resume cursors, while the admission key stays
3026            // pinned to the admitted fingerprint so a file racing the walk
3027            // cannot detach the attempt from its breaker record.
3028            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3029            let breaker_key = crate::build_breaker::BreakerKey::new(
3030                project_root.display().to_string(),
3031                crate::build_breaker::BuildDomain::CallgraphCold,
3032                admission_fingerprint,
3033            );
3034            match breaker
3035                .admit(&breaker_key, 0)
3036                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3037            {
3038                crate::build_breaker::BreakerAdmission::Admitted(_) => {}
3039                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3040                    return Err(CallGraphStoreError::Suspended(suspension));
3041                }
3042            }
3043            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3044            let stats = temp_store
3045                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3046            let _ = temp_store.checkpoint_wal_truncate();
3047            temp_store.prepare_for_atomic_swap()?;
3048            (stats, breaker_key)
3049        };
3050
3051        notify_cold_build_before_publish_observer();
3052        let publication = publish_if_current(|| {
3053            verify_writer_lease(&writer_lease)?;
3054            // Move the finished build to its final generation path. This target is
3055            // brand-new and owned by us, so the rename never hits an open file.
3056            remove_sqlite_file_set(&gen_path);
3057            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3058            crate::fs_lock::sync_parent(&gen_path);
3059            remove_sqlite_sidecars(&gen_path);
3060
3061            notify_cold_build_swap_observer(&temp_path, &gen_path);
3062
3063            // Atomically publish the new generation, then best-effort GC old ones.
3064            verify_writer_lease(&writer_lease)?;
3065            publish_pointer(callgraph_dir, project_key, &generation)?;
3066            gc_old_generations(callgraph_dir, project_key, &generation);
3067            // Store-wide orphan sweep on the same cadence: reclaims aged build
3068            // temps for roots that no longer build here, which the per-root GC
3069            // above never reaches.
3070            sweep_orphaned_build_temps_store_wide(callgraph_dir);
3071            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3072                let inspect_root =
3073                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3074                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3075                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3076            }
3077            Ok(())
3078        });
3079        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
3080            remove_sqlite_file_set(&temp_path);
3081        }
3082        publication?;
3083        // Pointer publication is the only automatic breaker reset. The staging
3084        // batches above never reset history because a process can die after them.
3085        breaker
3086            .record_ready_publication(&breaker_key)
3087            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3088        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3089        Ok((stats, generation))
3090    }
3091
3092    /// Open a specific just-published generation (read-write, WAL) so a builder
3093    /// returns a store pinned to exactly what it built.
3094    fn open_generation(
3095        callgraph_dir: &Path,
3096        project_root: PathBuf,
3097        project_key: String,
3098        generation: String,
3099        writer_lease: Arc<crate::root_cache::WriterLease>,
3100    ) -> Result<Self> {
3101        let gen_path = callgraph_dir.join(&generation);
3102        Ok(Self::open_at_path(
3103            project_root,
3104            project_key,
3105            gen_path,
3106            Some(generation),
3107            true,
3108            Some(writer_lease),
3109            None,
3110        )?
3111        .store)
3112    }
3113
3114    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3115        let project_key = crate::search_index::artifact_cache_key(project_root);
3116        // A cold build is needed unless a ready generation (or ready legacy DB)
3117        // is currently published.
3118        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3119    }
3120
3121    /// Check the durable callgraph-domain breaker before a query starts a cold
3122    /// worker. This only runs while no ready generation exists; it never builds
3123    /// inline and lets a tripped root return a terminal answer instead of an
3124    /// endless `Building` response.
3125    pub fn cold_build_suspension(
3126        callgraph_dir: &Path,
3127        project_root: &Path,
3128    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3129        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3130        if !breaker_path.exists() {
3131            return Ok(None);
3132        }
3133        let key = crate::build_breaker::BreakerKey::new(
3134            project_root.display().to_string(),
3135            crate::build_breaker::BuildDomain::CallgraphCold,
3136            callgraph_corpus_fingerprint(project_root)?,
3137        );
3138        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3139            .and_then(|breaker| breaker.suspension(&key))
3140            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3141    }
3142
3143    fn open_at_path(
3144        project_root: PathBuf,
3145        project_key: String,
3146        sqlite_path: PathBuf,
3147        generation: Option<String>,
3148        use_wal: bool,
3149        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3150        read_marker: Option<crate::root_cache::ReadMarker>,
3151    ) -> Result<OpenedStore> {
3152        Self::open_at_path_with_root_repair(
3153            project_root,
3154            project_key,
3155            sqlite_path,
3156            generation,
3157            use_wal,
3158            writer_lease,
3159            read_marker,
3160            true,
3161        )
3162    }
3163
3164    fn open_at_path_with_root_repair(
3165        project_root: PathBuf,
3166        project_key: String,
3167        sqlite_path: PathBuf,
3168        generation: Option<String>,
3169        use_wal: bool,
3170        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3171        read_marker: Option<crate::root_cache::ReadMarker>,
3172        allow_root_repair: bool,
3173    ) -> Result<OpenedStore> {
3174        if let Some(lease) = writer_lease.as_ref() {
3175            verify_writer_lease(lease)?;
3176        }
3177        if let Some(parent) = sqlite_path.parent() {
3178            std::fs::create_dir_all(parent)?;
3179        }
3180        let mut conn = Connection::open(&sqlite_path)?;
3181        if use_wal {
3182            configure_connection(&conn)?;
3183        } else {
3184            configure_build_connection(&conn)?;
3185        }
3186        if let Some(lease) = writer_lease.as_ref() {
3187            verify_writer_lease(lease)?;
3188        }
3189        initialize_schema(&conn)?;
3190        if let Some(lease) = writer_lease.as_ref() {
3191            verify_writer_lease(lease)?;
3192        }
3193        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3194        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3195            (Some(marker), _, _) => Some(marker),
3196            (None, Some(label), Some(cache_dir)) => {
3197                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3198            }
3199            (None, _, _) => None,
3200        };
3201        let publication_dir = sqlite_path
3202            .parent()
3203            .map(Path::to_path_buf)
3204            .unwrap_or_default();
3205        let store = Self::from_connection(
3206            project_root,
3207            project_key,
3208            sqlite_path,
3209            publication_dir,
3210            false,
3211            generation,
3212            writer_lease,
3213            read_marker,
3214            conn,
3215        );
3216        Ok(OpenedStore { store, root_repair })
3217    }
3218
3219    fn prepare_for_atomic_swap(&self) -> Result<()> {
3220        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3221        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3222        Ok(())
3223    }
3224
3225    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3226        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3227            self.sqlite_path
3228                .parent()
3229                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3230        });
3231        if protected_reader {
3232            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3233        } else {
3234            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3235        }
3236    }
3237
3238    fn from_connection(
3239        project_root: PathBuf,
3240        project_key: String,
3241        sqlite_path: PathBuf,
3242        publication_dir: PathBuf,
3243        legacy_fallback: bool,
3244        generation: Option<String>,
3245        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3246        read_marker: Option<crate::root_cache::ReadMarker>,
3247        conn: Connection,
3248    ) -> Self {
3249        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3250        Self {
3251            project_root,
3252            project_key,
3253            sqlite_path,
3254            publication_dir,
3255            legacy_fallback,
3256            generation,
3257            writer_lease,
3258            read_marker,
3259            database_ready: AtomicBool::new(false),
3260            write_metrics,
3261            conn: Mutex::new(conn),
3262        }
3263    }
3264
3265    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3266        if self.database_ready.load(AtomicOrdering::Acquire) {
3267            return Ok(());
3268        }
3269        ensure_database_ready(conn)?;
3270        self.database_ready.store(true, AtomicOrdering::Release);
3271        Ok(())
3272    }
3273
3274    pub fn project_root(&self) -> &Path {
3275        &self.project_root
3276    }
3277
3278    pub fn project_key(&self) -> &str {
3279        &self.project_key
3280    }
3281
3282    pub fn sqlite_path(&self) -> &Path {
3283        &self.sqlite_path
3284    }
3285
3286    /// The generation file named by the publication pointer when this store opened.
3287    pub(crate) fn projection_generation(&self) -> Option<&str> {
3288        self.generation.as_deref()
3289    }
3290
3291    /// Read the durable revision that changes in the same transaction as graph writes.
3292    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3293        self.refresh_read_marker()?;
3294        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3295        self.ensure_ready(&conn)?;
3296        projection_write_revision(&conn)
3297    }
3298
3299    /// Whether this store is reading from a legacy harness partition because
3300    /// the root-keyed store has not published a generation yet.
3301    pub fn is_legacy_fallback(&self) -> bool {
3302        self.legacy_fallback
3303    }
3304
3305    pub(crate) fn is_legacy_migration(&self) -> bool {
3306        self.generation.as_deref().is_some_and(|generation| {
3307            migration_generation_requires_manifest(generation)
3308                && migration_manifest_valid(&self.publication_dir, generation)
3309        })
3310    }
3311
3312    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3313        self.writer_lease.as_ref().map(|lease| lease.epoch())
3314    }
3315
3316    fn verify_writer_lease(&self) -> Result<()> {
3317        let Some(lease) = self.writer_lease.as_ref() else {
3318            return Err(CallGraphStoreError::Unavailable(
3319                "callgraph store opened read-only; write API is unavailable".to_string(),
3320            ));
3321        };
3322        verify_writer_lease(lease)
3323    }
3324
3325    fn refresh_read_marker(&self) -> Result<()> {
3326        if let Some(marker) = self.read_marker.as_ref() {
3327            marker.touch_if_due()?;
3328        }
3329        Ok(())
3330    }
3331
3332    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3333        self.write_metrics
3334            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3335    }
3336
3337    fn checkpoint_wal_truncate(&self) -> bool {
3338        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3339        checkpoint_wal_truncate(&conn)
3340    }
3341
3342    /// True if this store still reflects the currently-published generation.
3343    /// Cheap (one small pointer-file read). When false, another process (or a
3344    /// local cold rebuild) has published a newer generation and the holder
3345    /// should drop this store and reopen via the pointer to converge. A missing
3346    /// pointer keeps the current store (legacy DB still valid, or transient).
3347    pub fn is_current(&self) -> bool {
3348        let _ = self.refresh_read_marker();
3349        match (
3350            read_pointer(&self.publication_dir, &self.project_key),
3351            &self.generation,
3352        ) {
3353            // Even when both generations happen to have the same filename, the
3354            // root-keyed pointer names a different directory from the fallback.
3355            (Some(_), _) if self.legacy_fallback => false,
3356            (Some(published), Some(opened)) => &published == opened,
3357            // A generation now supersedes the legacy single-file DB we opened.
3358            (Some(_), None) => false,
3359            // No pointer: keep serving (legacy DB, or an anomalous pointer
3360            // removal where our open generation file is still valid).
3361            (None, _) => true,
3362        }
3363    }
3364
3365    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3366        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3367    }
3368
3369    /// Build in two durable passes. Discovery first commits a disk-backed file
3370    /// inventory, extraction consumes bounded batches from that inventory, and
3371    /// resolution pages through staged raw references after all symbols exist.
3372    pub fn cold_build_chunked(
3373        &self,
3374        files: &[PathBuf],
3375        chunk_size: usize,
3376    ) -> Result<ColdBuildStats> {
3377        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3378        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3379    }
3380
3381    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3382        note_cold_build_phase("enumeration");
3383        if files.is_empty() {
3384            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3385                &self.project_root,
3386            ))
3387        } else {
3388            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3389        }
3390    }
3391
3392    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3393    where
3394        I: IntoIterator<Item = PathBuf>,
3395    {
3396        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3397        self.verify_writer_lease()?;
3398        let total_changes_before = conn.total_changes();
3399        let tx = conn.transaction()?;
3400        tx.execute("DELETE FROM staging_file_inventory", [])?;
3401        tx.commit()?;
3402        self.record_commit(total_changes_before, &conn);
3403
3404        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3405        for path in paths {
3406            let path = normalize_file_path(&self.project_root, &path)?;
3407            let rel_path = relative_path(&self.project_root, &path);
3408            let size = std::fs::metadata(&path)
3409                .map(|metadata| metadata.len())
3410                .unwrap_or(0);
3411            batch.push((rel_path, size));
3412            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3413                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3414                batch.clear();
3415            }
3416        }
3417        if !batch.is_empty() {
3418            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3419        }
3420
3421        staged_corpus_fingerprint(&conn, &self.project_root)
3422    }
3423
3424    fn insert_staged_file_inventory_batch(
3425        &self,
3426        conn: &mut Connection,
3427        batch: &[(String, u64)],
3428    ) -> Result<()> {
3429        self.verify_writer_lease()?;
3430        let total_changes_before = conn.total_changes();
3431        let tx = conn.transaction()?;
3432        {
3433            let mut insert = tx.prepare(
3434                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3435            )?;
3436            for (path, size) in batch {
3437                insert.execute(params![path, *size as i64])?;
3438            }
3439        }
3440        tx.commit()?;
3441        self.record_commit(total_changes_before, conn);
3442        Ok(())
3443    }
3444
3445    fn cold_build_chunked_from_staged_inventory(
3446        &self,
3447        chunk_size: usize,
3448        corpus_fingerprint: &str,
3449    ) -> Result<ColdBuildStats> {
3450        let started = Instant::now();
3451        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3452        let workspace_root = self.project_root.display().to_string();
3453        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3454
3455        self.verify_writer_lease()?;
3456        let mut phase = staged_build_phase(&conn)?;
3457        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3458        if phase.as_deref().is_none_or(|phase| phase == "ready")
3459            || staged_fingerprint.as_deref() != Some(corpus_fingerprint)
3460        {
3461            let total_changes_before = conn.total_changes();
3462            let tx = conn.transaction()?;
3463            clear_tables(&tx)?;
3464            tx.execute("DELETE FROM staging_ref_context", [])?;
3465            insert_meta(&tx)?;
3466            drop_cold_build_secondary_indexes(&tx)?;
3467            set_meta_ready(&tx, false)?;
3468            set_staged_build_phase(&tx, "extracting")?;
3469            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3470            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3471            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3472            tx.commit()?;
3473            self.record_commit(total_changes_before, &conn);
3474            phase = Some("extracting".to_string());
3475        }
3476
3477        // A crashed extraction pass has already committed complete batches. Compare the
3478        // staged content identity with the current file before parsing so unchanged
3479        // committed files are not restarted from zero after adoption.
3480        note_cold_build_phase("extraction");
3481        if phase.as_deref() == Some("extracting") {
3482            prune_staged_files_not_in_inventory(&mut conn)?;
3483
3484            let mut after_path = String::new();
3485            loop {
3486                let Some(batch) = load_staged_file_batch(
3487                    &conn,
3488                    &self.project_root,
3489                    &after_path,
3490                    batch_files,
3491                    COLD_BUILD_EXTRACT_BATCH_BYTES,
3492                )?
3493                else {
3494                    break;
3495                };
3496                after_path = batch.last_path;
3497
3498                let mut needs_extract = Vec::with_capacity(batch.paths.len());
3499                for path in batch.paths {
3500                    if !staged_content_matches(&conn, &self.project_root, &path)? {
3501                        needs_extract.push(path);
3502                    }
3503                }
3504                if needs_extract.is_empty() {
3505                    continue;
3506                }
3507
3508                let build = build_extracts_parallel(&self.project_root, &needs_extract);
3509                self.verify_writer_lease()?;
3510                let total_changes_before = conn.total_changes();
3511                let tx = conn.transaction()?;
3512                let mut extracted_bytes = 0u64;
3513                {
3514                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3515                    for extract in &build.extracts {
3516                        delete_staged_file_rows(&tx, &extract.rel_path)?;
3517                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3518                        for raw in &extract.raw_refs {
3519                            insert_staged_ref_prepared(&mut inserts, raw)?;
3520                        }
3521                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3522                    }
3523                    for failure in &build.failures {
3524                        insert_backend_state_prepared(
3525                            &mut inserts.backend_state,
3526                            &workspace_root,
3527                            &failure.rel_path,
3528                            failure
3529                                .freshness
3530                                .as_ref()
3531                                .map(|freshness| &freshness.content_hash),
3532                            "stale",
3533                        )?;
3534                    }
3535                }
3536                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3537                note_cold_build_commit_barrier("extraction_batch_before_commit");
3538                tx.commit()?;
3539                note_cold_build_commit_barrier("extraction_batch_committed");
3540                self.record_commit(total_changes_before, &conn);
3541            }
3542
3543            let total_changes_before = conn.total_changes();
3544            let tx = conn.transaction()?;
3545            set_staged_build_phase(&tx, "indexing")?;
3546            tx.commit()?;
3547            self.record_commit(total_changes_before, &conn);
3548            phase = Some("indexing".to_string());
3549        }
3550
3551        // Secondary indexes are intentionally created only after every extract is
3552        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
3553        // corpus-wide symbol/export table.
3554        note_cold_build_phase("symbol_export_index");
3555        if phase.as_deref() == Some("indexing") {
3556            self.verify_writer_lease()?;
3557            let total_changes_before = conn.total_changes();
3558            let tx = conn.transaction()?;
3559            create_cold_build_secondary_indexes(&tx)?;
3560            set_staged_build_phase(&tx, "resolving")?;
3561            tx.commit()?;
3562            self.record_commit(total_changes_before, &conn);
3563        }
3564
3565        note_cold_build_phase("resolution");
3566        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3567        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3568        loop {
3569            let staged = load_staged_ref_window(&conn, resolve_cursor, COLD_BUILD_RESOLVE_WINDOW)?;
3570            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3571                break;
3572            };
3573
3574            self.verify_writer_lease()?;
3575            let total_changes_before = conn.total_changes();
3576            let tx = conn.transaction()?;
3577            {
3578                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3579                let mut offset = 0;
3580                while offset < staged.len() {
3581                    let caller_file = staged[offset].raw.caller_file.clone();
3582                    let end = staged[offset..]
3583                        .iter()
3584                        .position(|entry| entry.raw.caller_file != caller_file)
3585                        .map(|relative| offset + relative)
3586                        .unwrap_or(staged.len());
3587                    let caller_extract = build_file_extract(
3588                        &self.project_root,
3589                        &self.project_root.join(&caller_file),
3590                    );
3591                    if let Ok(caller_extract) = caller_extract {
3592                        let index = DiskProjectIndex {
3593                            project_root: &self.project_root,
3594                            conn: &tx,
3595                            caller_file: &caller_file,
3596                            caller_data: &caller_extract.data,
3597                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3598                        };
3599                        for staged_ref in &staged[offset..end] {
3600                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3601                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3602                        }
3603                    } else {
3604                        for staged_ref in &staged[offset..end] {
3605                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3606                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3607                        }
3608                    }
3609                    offset = end;
3610                }
3611            }
3612            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3613            tx.commit()?;
3614            self.record_commit(total_changes_before, &conn);
3615            resolve_cursor = last_rowid;
3616        }
3617
3618        note_cold_build_phase("publication");
3619        self.verify_writer_lease()?;
3620        let total_changes_before = conn.total_changes();
3621        let tx = conn.transaction()?;
3622        let _supplemental_edge_count =
3623            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3624        set_meta_ready(&tx, true)?;
3625        set_staged_build_phase(&tx, "ready")?;
3626        tx.execute("DELETE FROM staging_file_inventory", [])?;
3627        tx.execute("DELETE FROM staging_ref_context", [])?;
3628        bump_projection_write_revision(&tx)?;
3629        tx.commit()?;
3630        self.record_commit(total_changes_before, &conn);
3631
3632        let files = query_count(&conn, "SELECT COUNT(*) FROM files")? as usize;
3633        let nodes = query_count(&conn, "SELECT COUNT(*) FROM nodes")? as usize;
3634        let refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3635        let edges = query_count(&conn, "SELECT COUNT(*) FROM edges")? as usize;
3636        let failed_files = staged_failed_files(&conn)?;
3637        let elapsed_ms = started.elapsed().as_millis();
3638        crate::slog_info!(
3639            "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
3640            files,
3641            nodes,
3642            refs,
3643            edges,
3644            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
3645            elapsed_ms
3646        );
3647        Ok(ColdBuildStats {
3648            files,
3649            nodes,
3650            refs,
3651            edges,
3652            failed_files,
3653            elapsed_ms,
3654        })
3655    }
3656
3657    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3658        self.refresh_files_with_workspace_crate_prefix_cache(
3659            changed_files,
3660            WorkspaceCratePrefixCache::default(),
3661        )
3662    }
3663
3664    fn refresh_files_with_workspace_crate_prefix_cache(
3665        &self,
3666        changed_files: &[PathBuf],
3667        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3668    ) -> Result<IncrementalStats> {
3669        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3670            changed_files,
3671            workspace_crate_prefixes,
3672        )?;
3673        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3674            eprintln!("refresh_files phases: {}", profile.report());
3675        }
3676        Ok(stats)
3677    }
3678
3679    /// Run an incremental refresh and return phase timings for an offline store copy.
3680    #[doc(hidden)]
3681    pub fn refresh_files_profiled(
3682        &self,
3683        changed_files: &[PathBuf],
3684    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3685        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3686            changed_files,
3687            WorkspaceCratePrefixCache::default(),
3688        )
3689    }
3690
3691    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3692        &self,
3693        changed_files: &[PathBuf],
3694        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3695    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3696        let total_started = Instant::now();
3697        let mut profile = RefreshFilesProfile::default();
3698        self.verify_writer_lease()?;
3699        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3700        ensure_database_ready(&conn)?;
3701        let total_changes_before = conn.total_changes();
3702        let mut changed = Vec::new();
3703        let mut surface_changed = BTreeSet::new();
3704        let mut deleted = BTreeSet::new();
3705        let mut own_refresh = BTreeSet::new();
3706        let mut candidate_own_refresh = BTreeSet::new();
3707        let mut confirmed_fresh = BTreeSet::new();
3708        let mut unchanged_extracts = 0usize;
3709        let mut selected_ref_ids = BTreeSet::new();
3710        let mut selected_refs_by_caller = BTreeMap::new();
3711        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3712        let mut fresh_metadata = BTreeMap::new();
3713
3714        for input in changed_files {
3715            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3716            {
3717                Ok(path) => path,
3718                Err(error) => {
3719                    record_path_identity_mismatch(&conn, &error)?;
3720                    return Err(error);
3721                }
3722            };
3723            changed.push(rel_path.clone());
3724            let old_row = load_file_row(&conn, &rel_path)?;
3725            if !abs_path.exists() {
3726                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3727                    surface_changed.insert(rel_path.clone());
3728                    let started = Instant::now();
3729                    let dependent_refs =
3730                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3731                    profile.dependency_selection += started.elapsed();
3732                    record_dependent_refs(
3733                        &mut selected_ref_ids,
3734                        &mut selected_refs_by_caller,
3735                        dependent_refs,
3736                    );
3737                }
3738                continue;
3739            }
3740
3741            if let Some(row) = &old_row {
3742                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3743                    FreshnessVerdict::HotFresh => {
3744                        // Content still matches the stored graph. A prior failed
3745                        // refresh may have left backend_file_state='stale' without
3746                        // changing bytes; skip the extract but still clear that
3747                        // leftover so dead-code projection can use this store.
3748                        confirmed_fresh.insert(rel_path.clone());
3749                        continue;
3750                    }
3751                    FreshnessVerdict::ContentFresh {
3752                        new_mtime,
3753                        new_size,
3754                    } => {
3755                        fresh_metadata.insert(
3756                            rel_path.clone(),
3757                            FileFreshness {
3758                                content_hash: row.freshness.content_hash,
3759                                mtime: new_mtime,
3760                                size: new_size,
3761                            },
3762                        );
3763                        continue;
3764                    }
3765                    FreshnessVerdict::Deleted => {
3766                        if deleted.insert(rel_path.clone()) {
3767                            surface_changed.insert(rel_path.clone());
3768                            let started = Instant::now();
3769                            let dependent_refs =
3770                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3771                            profile.dependency_selection += started.elapsed();
3772                            record_dependent_refs(
3773                                &mut selected_ref_ids,
3774                                &mut selected_refs_by_caller,
3775                                dependent_refs,
3776                            );
3777                        }
3778                        continue;
3779                    }
3780                    FreshnessVerdict::Stale => {}
3781                }
3782            }
3783
3784            let started = Instant::now();
3785            let extract = build_file_extract(&self.project_root, &abs_path)?;
3786            profile.parse += started.elapsed();
3787            let surface_is_changed = old_row
3788                .as_ref()
3789                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3790                .unwrap_or(true);
3791            if surface_is_changed {
3792                surface_changed.insert(rel_path.clone());
3793                let started = Instant::now();
3794                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3795                profile.dependency_selection += started.elapsed();
3796                record_dependent_refs(
3797                    &mut selected_ref_ids,
3798                    &mut selected_refs_by_caller,
3799                    dependent_refs,
3800                );
3801            }
3802            candidate_own_refresh.insert(rel_path.clone());
3803            changed_extracts.insert(rel_path, extract);
3804        }
3805
3806        let dependency_selected_refs = selected_ref_ids.len();
3807        let mut touched_callers: BTreeSet<String> =
3808            selected_refs_by_caller.keys().cloned().collect();
3809        touched_callers.extend(candidate_own_refresh.iter().cloned());
3810
3811        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3812        for rel_path in &touched_callers {
3813            if deleted.contains(rel_path) {
3814                continue;
3815            }
3816            if let Some(extract) = changed_extracts.get(rel_path) {
3817                caller_extracts.insert(rel_path.clone(), extract.clone());
3818                continue;
3819            }
3820            let abs_path = self.project_root.join(rel_path);
3821            if abs_path.exists() {
3822                let started = Instant::now();
3823                let extract = build_file_extract(&self.project_root, &abs_path)?;
3824                profile.dependent_parse += started.elapsed();
3825                caller_extracts.insert(rel_path.clone(), extract);
3826            }
3827        }
3828
3829        let tx = conn.transaction()?;
3830        for (rel_path, freshness) in fresh_metadata {
3831            update_file_fresh_metadata(
3832                &tx,
3833                &self.project_root,
3834                &rel_path,
3835                &freshness.content_hash,
3836                freshness.mtime,
3837                freshness.size,
3838            )?;
3839        }
3840        for rel_path in &confirmed_fresh {
3841            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
3842        }
3843        for rel_path in &deleted {
3844            let started = Instant::now();
3845            delete_file_rows(&tx, rel_path)?;
3846            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3847            profile.row_deletes += started.elapsed();
3848        }
3849
3850        let started = Instant::now();
3851        let index = ProjectIndex::from_db_and_callers(
3852            &tx,
3853            &self.project_root,
3854            &caller_extracts,
3855            workspace_crate_prefixes,
3856        )?;
3857        profile.index_load += started.elapsed();
3858
3859        let workspace_root = self.project_root.display().to_string();
3860        {
3861            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3862            for rel_path in &candidate_own_refresh {
3863                let Some(extract) = changed_extracts.get(rel_path) else {
3864                    continue;
3865                };
3866                if !write_amplification_baseline_enabled()
3867                    && stored_extract_matches(&tx, rel_path, extract, &index)?
3868                {
3869                    unchanged_extracts += 1;
3870                    update_file_fresh_metadata(
3871                        &tx,
3872                        &self.project_root,
3873                        rel_path,
3874                        &extract.freshness.content_hash,
3875                        extract.freshness.mtime,
3876                        extract.freshness.size,
3877                    )?;
3878                    continue;
3879                }
3880
3881                own_refresh.insert(rel_path.clone());
3882                let started = Instant::now();
3883                delete_file_rows(&tx, rel_path)?;
3884                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
3885                profile.row_deletes += started.elapsed();
3886                let started = Instant::now();
3887                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3888                profile.row_inserts += started.elapsed();
3889            }
3890
3891            let dependency_callers = touched_callers
3892                .iter()
3893                .filter(|rel_path| {
3894                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3895                })
3896                .cloned()
3897                .collect::<Vec<_>>();
3898            for rel_path in dependency_callers {
3899                let Some(extract) = caller_extracts.get(&rel_path) else {
3900                    continue;
3901                };
3902                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3903                    continue;
3904                }
3905
3906                own_refresh.insert(rel_path.clone());
3907                let started = Instant::now();
3908                delete_file_rows(&tx, &rel_path)?;
3909                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3910                profile.row_deletes += started.elapsed();
3911                let started = Instant::now();
3912                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3913                profile.row_inserts += started.elapsed();
3914            }
3915            let started = Instant::now();
3916            for rel_path in &touched_callers {
3917                if deleted.contains(rel_path) {
3918                    continue;
3919                }
3920                let Some(extract) = caller_extracts.get(rel_path) else {
3921                    continue;
3922                };
3923                if own_refresh.contains(rel_path) {
3924                    delete_refs_for_caller(&tx, rel_path)?;
3925                    for raw_ref in &extract.raw_refs {
3926                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3927                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3928                    }
3929                    continue;
3930                }
3931
3932                let selected_for_caller = selected_refs_by_caller
3933                    .get(rel_path)
3934                    .cloned()
3935                    .unwrap_or_default();
3936                delete_ref_ids(&tx, &selected_for_caller)?;
3937                for raw_ref in &extract.raw_refs {
3938                    if selected_for_caller.contains(&raw_ref.ref_id) {
3939                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
3940                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3941                    }
3942                }
3943            }
3944            profile.ref_resolution += started.elapsed();
3945        }
3946
3947        let started = Instant::now();
3948        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3949        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3950        profile.method_dispatch += started.elapsed();
3951
3952        bump_projection_write_revision(&tx)?;
3953        let started = Instant::now();
3954        commit_incremental_if_current(tx)?;
3955        self.record_commit(total_changes_before, &conn);
3956        profile.commit += started.elapsed();
3957        profile.total = total_started.elapsed();
3958        Ok((
3959            IncrementalStats {
3960                changed_files: changed,
3961                surface_changed: surface_changed.into_iter().collect(),
3962                deleted_files: deleted.into_iter().collect(),
3963                dependency_selected_refs,
3964                refreshed_own_files: own_refresh.len(),
3965                unchanged_extract_files: unchanged_extracts,
3966            },
3967            profile,
3968        ))
3969    }
3970
3971    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3972        self.cold_build(current_files)
3973    }
3974
3975    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3976        self.verify_writer_lease()?;
3977        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3978        let total_changes_before = conn.total_changes();
3979        let tx = conn.transaction()?;
3980        let mut marked = Vec::new();
3981        for path in files {
3982            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
3983                Ok(path) => path,
3984                Err(error) => {
3985                    drop(tx);
3986                    record_path_identity_mismatch(&conn, &error)?;
3987                    return Err(error);
3988                }
3989            };
3990            let freshness = cache_freshness::collect(&abs_path).ok();
3991            mark_backend_state(
3992                &tx,
3993                &self.project_root,
3994                &rel_path,
3995                freshness.as_ref().map(|freshness| &freshness.content_hash),
3996                "stale",
3997            )?;
3998            marked.push(rel_path);
3999        }
4000        bump_projection_write_revision(&tx)?;
4001        tx.commit()?;
4002        self.record_commit(total_changes_before, &conn);
4003        marked.sort();
4004        marked.dedup();
4005        Ok(marked)
4006    }
4007
4008    pub fn stale_files(&self) -> Result<Vec<String>> {
4009        self.refresh_read_marker()?;
4010        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4011        let mut stmt = conn.prepare(
4012            "SELECT DISTINCT file_path FROM backend_file_state
4013             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4014             ORDER BY file_path",
4015        )?;
4016        let rows = stmt.query_map(
4017            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4018            |row| row.get::<_, String>(0),
4019        )?;
4020        rows.collect::<std::result::Result<Vec<_>, _>>()
4021            .map_err(Into::into)
4022    }
4023
4024    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4025        self.refresh_read_marker()?;
4026        let rel_path = relative_path(
4027            &self.project_root,
4028            &normalize_file_path(&self.project_root, file)?,
4029        );
4030        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4031        conn.query_row(
4032            "SELECT status FROM backend_file_state
4033             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4034             ORDER BY updated_at DESC LIMIT 1",
4035            params![
4036                BACKEND_TREESITTER,
4037                self.project_root.display().to_string(),
4038                rel_path
4039            ],
4040            |row| row.get(0),
4041        )
4042        .optional()
4043        .map_err(Into::into)
4044    }
4045
4046    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4047        self.refresh_read_marker()?;
4048        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4049        self.ensure_ready(&conn)?;
4050        edge_snapshot_with_conn(&conn)
4051    }
4052
4053    pub fn indexed_file_count(&self) -> Result<usize> {
4054        self.refresh_read_marker()?;
4055        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4056        self.ensure_ready(&conn)?;
4057        indexed_file_count(&conn)
4058    }
4059
4060    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4061        self.refresh_read_marker()?;
4062        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4063        let rel_path = relative_path(&self.project_root, &abs_path);
4064        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4065        self.ensure_ready(&conn)?;
4066        resolve_node_for_rel(&conn, &rel_path, symbol)
4067    }
4068
4069    /// Return all positional nodes matching a legacy symbol query in a file.
4070    ///
4071    /// Consumers that need legacy compatibility can collapse these by
4072    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
4073    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4074        self.refresh_read_marker()?;
4075        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4076        let rel_path = relative_path(&self.project_root, &abs_path);
4077        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4078        self.ensure_ready(&conn)?;
4079        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4080    }
4081
4082    /// Return all positional nodes matching a symbol query anywhere in the store.
4083    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4084        self.refresh_read_marker()?;
4085        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4086        self.ensure_ready(&conn)?;
4087        nodes_matching_symbol(&conn, symbol)
4088    }
4089
4090    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
4091    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4092        self.refresh_read_marker()?;
4093        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4094        let rel_path = relative_path(&self.project_root, &abs_path);
4095        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4096        self.ensure_ready(&conn)?;
4097        direct_callers_for_tuple(&conn, &rel_path, symbol)
4098    }
4099
4100    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4101    pub fn direct_callers_for_symbols(
4102        &self,
4103        targets: &[(String, String)],
4104    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4105        if targets.is_empty() {
4106            return Ok(HashMap::new());
4107        }
4108        self.refresh_read_marker()?;
4109        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4110        self.ensure_ready(&conn)?;
4111        direct_callers_for_tuples(&conn, targets)
4112    }
4113
4114    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4115    pub fn direct_caller_counts_of(
4116        &self,
4117        targets: &[(String, String)],
4118    ) -> Result<HashMap<(String, String), usize>> {
4119        if targets.is_empty() {
4120            return Ok(HashMap::new());
4121        }
4122        self.refresh_read_marker()?;
4123        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4124        self.ensure_ready(&conn)?;
4125        direct_caller_counts_for_tuples(&conn, targets)
4126    }
4127
4128    pub fn callers_of(
4129        &self,
4130        file_rel: &Path,
4131        symbol: &str,
4132        depth: usize,
4133    ) -> Result<StoreCallersResult> {
4134        let target = self.node_for(file_rel, symbol)?;
4135        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4136        self.ensure_ready(&conn)?;
4137        let effective_depth = depth.max(1);
4138        let mut visited = HashSet::new();
4139        let mut callers = Vec::new();
4140        let mut depth_limited = false;
4141        let mut truncated = 0usize;
4142        collect_callers_recursive(
4143            &conn,
4144            &target.file,
4145            &target.symbol,
4146            effective_depth,
4147            0,
4148            &mut visited,
4149            &mut callers,
4150            &mut depth_limited,
4151            &mut truncated,
4152        )?;
4153        Ok(StoreCallersResult {
4154            target,
4155            callers,
4156            scanned_files: indexed_file_count(&conn)?,
4157            depth_limited,
4158            truncated,
4159        })
4160    }
4161
4162    pub fn impact_of(
4163        &self,
4164        file_rel: &Path,
4165        symbol: &str,
4166        depth: usize,
4167    ) -> Result<StoreImpactResult> {
4168        let callers = self.callers_of(file_rel, symbol, depth)?;
4169        let target_parameters = callers
4170            .target
4171            .signature
4172            .as_deref()
4173            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4174            .unwrap_or_default();
4175        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4176        for site in &callers.callers {
4177            source_lines_by_file
4178                .entry(site.caller.file.clone())
4179                .or_insert_with(|| {
4180                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4181                });
4182        }
4183        let enriched = callers
4184            .callers
4185            .iter()
4186            .map(|site| StoreImpactCaller {
4187                site: site.clone(),
4188                signature: site.caller.signature.clone(),
4189                is_entry_point: site.caller.is_entry_point,
4190                call_expression: source_lines_by_file
4191                    .get(&site.caller.file)
4192                    .and_then(|lines| lines.as_ref())
4193                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4194                    .cloned(),
4195                parameters: site
4196                    .caller
4197                    .signature
4198                    .as_deref()
4199                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4200                    .unwrap_or_default(),
4201            })
4202            .collect();
4203        Ok(StoreImpactResult {
4204            target: callers.target,
4205            parameters: target_parameters,
4206            callers: enriched,
4207            depth_limited: callers.depth_limited,
4208            truncated: callers.truncated,
4209        })
4210    }
4211
4212    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4213        self.refresh_read_marker()?;
4214        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4215        self.ensure_ready(&conn)?;
4216        outgoing_calls_for_node(&conn, node)
4217    }
4218
4219    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4220    pub fn outgoing_calls_for_symbols(
4221        &self,
4222        sources: &[(String, String)],
4223    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4224        if sources.is_empty() {
4225            return Ok(HashMap::new());
4226        }
4227        self.refresh_read_marker()?;
4228        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4229        self.ensure_ready(&conn)?;
4230        outgoing_calls_for_symbol_tuples(&conn, sources)
4231    }
4232
4233    /// Return resolved direct self-call refs suppressed from the general edge table.
4234    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4235        self.refresh_read_marker()?;
4236        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4237        self.ensure_ready(&conn)?;
4238        resolved_self_calls_for_node(&conn, node)
4239    }
4240
4241    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4242        self.refresh_read_marker()?;
4243        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4244        self.ensure_ready(&conn)?;
4245        unresolved_calls_for_node(&conn, node)
4246    }
4247
4248    pub fn call_tree(
4249        &self,
4250        file_rel: &Path,
4251        symbol: &str,
4252        max_depth: usize,
4253    ) -> Result<callgraph::CallTreeNode> {
4254        let node = self.node_for(file_rel, symbol)?;
4255        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4256        self.ensure_ready(&conn)?;
4257        let mut visited = HashSet::new();
4258        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4259    }
4260
4261    pub fn trace_to(
4262        &self,
4263        file_rel: &Path,
4264        symbol: &str,
4265        max_depth: usize,
4266    ) -> Result<callgraph::TraceToResult> {
4267        let target = self.node_for(file_rel, symbol)?;
4268        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4269        self.ensure_ready(&conn)?;
4270        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4271
4272        #[derive(Clone)]
4273        struct PathElem {
4274            node: StoreNode,
4275        }
4276
4277        let initial = vec![PathElem {
4278            node: target.clone(),
4279        }];
4280        let mut complete_paths = Vec::new();
4281        if target.is_entry_point {
4282            complete_paths.push(initial.clone());
4283        }
4284
4285        let mut queue = vec![(initial, 0usize)];
4286        let mut max_depth_reached = false;
4287        let mut truncated_paths = 0usize;
4288
4289        while let Some((path, depth)) = queue.pop() {
4290            if depth >= effective_max {
4291                max_depth_reached = true;
4292                continue;
4293            }
4294            let Some(current) = path.last() else {
4295                continue;
4296            };
4297            let callers =
4298                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4299            if callers.is_empty() {
4300                if path.len() > 1 {
4301                    truncated_paths += 1;
4302                }
4303                continue;
4304            }
4305
4306            let mut has_new_path = false;
4307            for site in callers {
4308                if path.iter().any(|elem| {
4309                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4310                }) {
4311                    continue;
4312                }
4313                has_new_path = true;
4314                let mut new_path = path.clone();
4315                new_path.push(PathElem {
4316                    node: site.caller.clone(),
4317                });
4318                if site.caller.is_entry_point {
4319                    complete_paths.push(new_path.clone());
4320                }
4321                queue.push((new_path, depth + 1));
4322            }
4323            if !has_new_path && path.len() > 1 {
4324                truncated_paths += 1;
4325            }
4326        }
4327
4328        let mut paths: Vec<callgraph::TracePath> = complete_paths
4329            .into_iter()
4330            .map(|mut elems| {
4331                elems.reverse();
4332                let hops = elems
4333                    .iter()
4334                    .enumerate()
4335                    .map(|(index, elem)| callgraph::TraceHop {
4336                        symbol: elem.node.symbol.clone(),
4337                        file: elem.node.file.clone(),
4338                        line: elem.node.line,
4339                        signature: elem.node.signature.clone(),
4340                        is_entry_point: index == 0 && elem.node.is_entry_point,
4341                    })
4342                    .collect();
4343                callgraph::TracePath { hops }
4344            })
4345            .collect();
4346        paths.sort_by(|left, right| {
4347            let left_entry = left
4348                .hops
4349                .first()
4350                .map(|hop| hop.symbol.as_str())
4351                .unwrap_or("");
4352            let right_entry = right
4353                .hops
4354                .first()
4355                .map(|hop| hop.symbol.as_str())
4356                .unwrap_or("");
4357            left_entry
4358                .cmp(right_entry)
4359                .then(left.hops.len().cmp(&right.hops.len()))
4360        });
4361        let entry_points_found = paths
4362            .iter()
4363            .filter_map(|path| path.hops.first())
4364            .filter(|hop| hop.is_entry_point)
4365            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4366            .collect::<HashSet<_>>()
4367            .len();
4368
4369        Ok(callgraph::TraceToResult {
4370            target_symbol: target.symbol,
4371            target_file: target.file,
4372            total_paths: paths.len(),
4373            paths,
4374            entry_points_found,
4375            max_depth_reached,
4376            truncated_paths,
4377        })
4378    }
4379
4380    pub fn trace_to_symbol_candidates(
4381        &self,
4382        to_symbol: &str,
4383    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4384        self.refresh_read_marker()?;
4385        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4386        self.ensure_ready(&conn)?;
4387        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4388        for node in nodes_matching_symbol(&conn, to_symbol)? {
4389            candidates_by_file
4390                .entry(node.file)
4391                .and_modify(|line| *line = (*line).min(node.line))
4392                .or_insert(node.line);
4393        }
4394        let mut candidates: Vec<_> = candidates_by_file
4395            .into_iter()
4396            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4397            .collect();
4398        candidates
4399            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4400        Ok(candidates)
4401    }
4402
4403    pub fn trace_to_symbol(
4404        &self,
4405        file_rel: &Path,
4406        symbol: &str,
4407        to_symbol: &str,
4408        to_file: Option<&Path>,
4409        max_depth: usize,
4410    ) -> Result<callgraph::TraceToSymbolResult> {
4411        let origin = self.node_for(file_rel, symbol)?;
4412        let target_file = to_file
4413            .map(|path| normalize_file_path(&self.project_root, path))
4414            .transpose()?
4415            .map(|path| relative_path(&self.project_root, &path));
4416        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4417        self.ensure_ready(&conn)?;
4418        let effective_max = if max_depth == 0 {
4419            10
4420        } else {
4421            max_depth.min(16)
4422        };
4423
4424        let start_hop = trace_to_symbol_hop(&origin);
4425        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4426            return Ok(callgraph::TraceToSymbolResult {
4427                path: Some(vec![start_hop]),
4428                complete: true,
4429                reason: None,
4430            });
4431        }
4432
4433        let mut queue = VecDeque::new();
4434        queue.push_back((origin.clone(), vec![start_hop], 0usize));
4435        let mut visited = HashSet::new();
4436        visited.insert((origin.file.clone(), origin.symbol.clone()));
4437        let mut max_depth_exhausted = false;
4438
4439        while let Some((current, path, depth)) = queue.pop_front() {
4440            let callees = outgoing_calls_for_node(&conn, &current)?
4441                .into_iter()
4442                .filter_map(|site| site.target)
4443                .collect::<Vec<_>>();
4444
4445            if depth >= effective_max {
4446                if callees
4447                    .iter()
4448                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4449                {
4450                    max_depth_exhausted = true;
4451                }
4452                continue;
4453            }
4454
4455            for callee in callees {
4456                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4457                    continue;
4458                }
4459                let mut next_path = path.clone();
4460                next_path.push(trace_to_symbol_hop(&callee));
4461                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4462                    return Ok(callgraph::TraceToSymbolResult {
4463                        path: Some(next_path),
4464                        complete: true,
4465                        reason: None,
4466                    });
4467                }
4468                queue.push_back((callee, next_path, depth + 1));
4469            }
4470        }
4471
4472        if max_depth_exhausted {
4473            Ok(callgraph::TraceToSymbolResult {
4474                path: None,
4475                complete: false,
4476                reason: Some("max_depth_exhausted".to_string()),
4477            })
4478        } else {
4479            Ok(callgraph::TraceToSymbolResult {
4480                path: None,
4481                complete: true,
4482                reason: Some("no_path_found".to_string()),
4483            })
4484        }
4485    }
4486}
4487
4488impl ReadonlyCallGraphStore {
4489    fn from_inner(inner: CallGraphStore) -> Self {
4490        Self { inner }
4491    }
4492
4493    pub fn project_root(&self) -> &Path {
4494        self.inner.project_root()
4495    }
4496
4497    pub fn project_key(&self) -> &str {
4498        self.inner.project_key()
4499    }
4500
4501    pub fn sqlite_path(&self) -> &Path {
4502        self.inner.sqlite_path()
4503    }
4504
4505    pub fn stale_files(&self) -> Result<Vec<String>> {
4506        self.inner.stale_files()
4507    }
4508
4509    pub(crate) fn projection_generation(&self) -> Option<&str> {
4510        self.inner.projection_generation()
4511    }
4512
4513    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4514        self.inner.projection_write_revision()
4515    }
4516
4517    /// Report the open generation handle. SQLite-owned allocations are measured
4518    /// once by the process-wide SQLite allocator counters.
4519    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4520        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4521    }
4522
4523    /// Whether this reader is temporarily serving a legacy harness partition.
4524    pub fn is_legacy_fallback(&self) -> bool {
4525        self.inner.is_legacy_fallback()
4526    }
4527
4528    pub fn is_current(&self) -> bool {
4529        self.inner.is_current()
4530    }
4531
4532    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4533        self.inner.edge_snapshot()
4534    }
4535
4536    pub fn indexed_file_count(&self) -> Result<usize> {
4537        self.inner.indexed_file_count()
4538    }
4539
4540    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4541        self.inner.node_for(file_rel, symbol)
4542    }
4543
4544    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4545        self.inner.nodes_for(file_rel, symbol)
4546    }
4547
4548    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4549        self.inner.nodes_matching(symbol)
4550    }
4551
4552    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4553        self.inner.direct_callers_of(file_rel, symbol)
4554    }
4555
4556    pub fn direct_callers_for_symbols(
4557        &self,
4558        targets: &[(String, String)],
4559    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4560        self.inner.direct_callers_for_symbols(targets)
4561    }
4562
4563    pub fn direct_caller_counts_of(
4564        &self,
4565        targets: &[(String, String)],
4566    ) -> Result<HashMap<(String, String), usize>> {
4567        self.inner.direct_caller_counts_of(targets)
4568    }
4569
4570    pub fn callers_of(
4571        &self,
4572        file_rel: &Path,
4573        symbol: &str,
4574        depth: usize,
4575    ) -> Result<StoreCallersResult> {
4576        self.inner.callers_of(file_rel, symbol, depth)
4577    }
4578
4579    pub fn impact_of(
4580        &self,
4581        file_rel: &Path,
4582        symbol: &str,
4583        depth: usize,
4584    ) -> Result<StoreImpactResult> {
4585        self.inner.impact_of(file_rel, symbol, depth)
4586    }
4587
4588    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4589        self.inner.outgoing_calls_of(node)
4590    }
4591
4592    pub fn outgoing_calls_for_symbols(
4593        &self,
4594        sources: &[(String, String)],
4595    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4596        self.inner.outgoing_calls_for_symbols(sources)
4597    }
4598
4599    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4600        self.inner.resolved_self_calls_of(node)
4601    }
4602
4603    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4604        self.inner.unresolved_calls_of(node)
4605    }
4606
4607    pub fn call_tree(
4608        &self,
4609        file_rel: &Path,
4610        symbol: &str,
4611        depth: usize,
4612    ) -> Result<callgraph::CallTreeNode> {
4613        self.inner.call_tree(file_rel, symbol, depth)
4614    }
4615
4616    pub fn trace_to(
4617        &self,
4618        file_rel: &Path,
4619        symbol: &str,
4620        max_depth: usize,
4621    ) -> Result<callgraph::TraceToResult> {
4622        self.inner.trace_to(file_rel, symbol, max_depth)
4623    }
4624
4625    pub fn trace_to_symbol_candidates(
4626        &self,
4627        to_symbol: &str,
4628    ) -> Result<Vec<TraceToSymbolCandidate>> {
4629        self.inner.trace_to_symbol_candidates(to_symbol)
4630    }
4631
4632    pub fn trace_to_symbol(
4633        &self,
4634        file_rel: &Path,
4635        symbol: &str,
4636        to_symbol: &str,
4637        to_file: Option<&Path>,
4638        max_depth: usize,
4639    ) -> Result<callgraph::TraceToSymbolResult> {
4640        self.inner
4641            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4642    }
4643}
4644
4645impl CallGraphRead for CallGraphStore {
4646    fn project_root(&self) -> &Path {
4647        CallGraphStore::project_root(self)
4648    }
4649    fn project_key(&self) -> &str {
4650        CallGraphStore::project_key(self)
4651    }
4652    fn sqlite_path(&self) -> &Path {
4653        CallGraphStore::sqlite_path(self)
4654    }
4655    fn is_current(&self) -> bool {
4656        CallGraphStore::is_current(self)
4657    }
4658    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4659        CallGraphStore::edge_snapshot(self)
4660    }
4661    fn indexed_file_count(&self) -> Result<usize> {
4662        CallGraphStore::indexed_file_count(self)
4663    }
4664    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4665        CallGraphStore::node_for(self, file_rel, symbol)
4666    }
4667    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4668        CallGraphStore::nodes_for(self, file_rel, symbol)
4669    }
4670    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4671        CallGraphStore::nodes_matching(self, symbol)
4672    }
4673    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4674        CallGraphStore::direct_callers_of(self, file_rel, symbol)
4675    }
4676    fn direct_callers_for_symbols(
4677        &self,
4678        targets: &[(String, String)],
4679    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4680        CallGraphStore::direct_callers_for_symbols(self, targets)
4681    }
4682    fn direct_caller_counts_of(
4683        &self,
4684        targets: &[(String, String)],
4685    ) -> Result<HashMap<(String, String), usize>> {
4686        CallGraphStore::direct_caller_counts_of(self, targets)
4687    }
4688    fn callers_of(
4689        &self,
4690        file_rel: &Path,
4691        symbol: &str,
4692        depth: usize,
4693    ) -> Result<StoreCallersResult> {
4694        CallGraphStore::callers_of(self, file_rel, symbol, depth)
4695    }
4696    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4697        CallGraphStore::impact_of(self, file_rel, symbol, depth)
4698    }
4699    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4700        CallGraphStore::outgoing_calls_of(self, node)
4701    }
4702    fn outgoing_calls_for_symbols(
4703        &self,
4704        sources: &[(String, String)],
4705    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4706        CallGraphStore::outgoing_calls_for_symbols(self, sources)
4707    }
4708    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4709        CallGraphStore::resolved_self_calls_of(self, node)
4710    }
4711    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4712        CallGraphStore::unresolved_calls_of(self, node)
4713    }
4714    fn call_tree(
4715        &self,
4716        file_rel: &Path,
4717        symbol: &str,
4718        depth: usize,
4719    ) -> Result<callgraph::CallTreeNode> {
4720        CallGraphStore::call_tree(self, file_rel, symbol, depth)
4721    }
4722    fn trace_to(
4723        &self,
4724        file_rel: &Path,
4725        symbol: &str,
4726        max_depth: usize,
4727    ) -> Result<callgraph::TraceToResult> {
4728        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4729    }
4730    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4731        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4732    }
4733    fn trace_to_symbol(
4734        &self,
4735        file_rel: &Path,
4736        symbol: &str,
4737        to_symbol: &str,
4738        to_file: Option<&Path>,
4739        max_depth: usize,
4740    ) -> Result<callgraph::TraceToSymbolResult> {
4741        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4742    }
4743}
4744
4745impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4746    fn project_root(&self) -> &Path {
4747        (**self).project_root()
4748    }
4749    fn project_key(&self) -> &str {
4750        (**self).project_key()
4751    }
4752    fn sqlite_path(&self) -> &Path {
4753        (**self).sqlite_path()
4754    }
4755    fn is_current(&self) -> bool {
4756        (**self).is_current()
4757    }
4758    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4759        (**self).edge_snapshot()
4760    }
4761    fn indexed_file_count(&self) -> Result<usize> {
4762        (**self).indexed_file_count()
4763    }
4764    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4765        (**self).node_for(file_rel, symbol)
4766    }
4767    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4768        (**self).nodes_for(file_rel, symbol)
4769    }
4770    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4771        (**self).nodes_matching(symbol)
4772    }
4773    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4774        (**self).direct_callers_of(file_rel, symbol)
4775    }
4776    fn direct_callers_for_symbols(
4777        &self,
4778        targets: &[(String, String)],
4779    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4780        (**self).direct_callers_for_symbols(targets)
4781    }
4782    fn direct_caller_counts_of(
4783        &self,
4784        targets: &[(String, String)],
4785    ) -> Result<HashMap<(String, String), usize>> {
4786        (**self).direct_caller_counts_of(targets)
4787    }
4788    fn callers_of(
4789        &self,
4790        file_rel: &Path,
4791        symbol: &str,
4792        depth: usize,
4793    ) -> Result<StoreCallersResult> {
4794        (**self).callers_of(file_rel, symbol, depth)
4795    }
4796    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4797        (**self).impact_of(file_rel, symbol, depth)
4798    }
4799    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4800        (**self).outgoing_calls_of(node)
4801    }
4802    fn outgoing_calls_for_symbols(
4803        &self,
4804        sources: &[(String, String)],
4805    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4806        (**self).outgoing_calls_for_symbols(sources)
4807    }
4808    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4809        (**self).resolved_self_calls_of(node)
4810    }
4811    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4812        (**self).unresolved_calls_of(node)
4813    }
4814    fn call_tree(
4815        &self,
4816        file_rel: &Path,
4817        symbol: &str,
4818        depth: usize,
4819    ) -> Result<callgraph::CallTreeNode> {
4820        (**self).call_tree(file_rel, symbol, depth)
4821    }
4822    fn trace_to(
4823        &self,
4824        file_rel: &Path,
4825        symbol: &str,
4826        max_depth: usize,
4827    ) -> Result<callgraph::TraceToResult> {
4828        (**self).trace_to(file_rel, symbol, max_depth)
4829    }
4830    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4831        (**self).trace_to_symbol_candidates(to_symbol)
4832    }
4833    fn trace_to_symbol(
4834        &self,
4835        file_rel: &Path,
4836        symbol: &str,
4837        to_symbol: &str,
4838        to_file: Option<&Path>,
4839        max_depth: usize,
4840    ) -> Result<callgraph::TraceToSymbolResult> {
4841        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4842    }
4843}
4844
4845impl CallGraphRead for ReadonlyCallGraphStore {
4846    fn project_root(&self) -> &Path {
4847        self.project_root()
4848    }
4849    fn project_key(&self) -> &str {
4850        self.project_key()
4851    }
4852    fn sqlite_path(&self) -> &Path {
4853        self.sqlite_path()
4854    }
4855    fn is_current(&self) -> bool {
4856        self.is_current()
4857    }
4858    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4859        self.edge_snapshot()
4860    }
4861    fn indexed_file_count(&self) -> Result<usize> {
4862        self.indexed_file_count()
4863    }
4864    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4865        self.node_for(file_rel, symbol)
4866    }
4867    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4868        self.nodes_for(file_rel, symbol)
4869    }
4870    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4871        self.nodes_matching(symbol)
4872    }
4873    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4874        self.direct_callers_of(file_rel, symbol)
4875    }
4876    fn direct_callers_for_symbols(
4877        &self,
4878        targets: &[(String, String)],
4879    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4880        self.direct_callers_for_symbols(targets)
4881    }
4882    fn direct_caller_counts_of(
4883        &self,
4884        targets: &[(String, String)],
4885    ) -> Result<HashMap<(String, String), usize>> {
4886        self.direct_caller_counts_of(targets)
4887    }
4888    fn callers_of(
4889        &self,
4890        file_rel: &Path,
4891        symbol: &str,
4892        depth: usize,
4893    ) -> Result<StoreCallersResult> {
4894        self.callers_of(file_rel, symbol, depth)
4895    }
4896    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4897        self.impact_of(file_rel, symbol, depth)
4898    }
4899    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4900        self.outgoing_calls_of(node)
4901    }
4902    fn outgoing_calls_for_symbols(
4903        &self,
4904        sources: &[(String, String)],
4905    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4906        self.outgoing_calls_for_symbols(sources)
4907    }
4908    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4909        self.resolved_self_calls_of(node)
4910    }
4911    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4912        self.unresolved_calls_of(node)
4913    }
4914    fn call_tree(
4915        &self,
4916        file_rel: &Path,
4917        symbol: &str,
4918        depth: usize,
4919    ) -> Result<callgraph::CallTreeNode> {
4920        self.call_tree(file_rel, symbol, depth)
4921    }
4922    fn trace_to(
4923        &self,
4924        file_rel: &Path,
4925        symbol: &str,
4926        max_depth: usize,
4927    ) -> Result<callgraph::TraceToResult> {
4928        self.trace_to(file_rel, symbol, max_depth)
4929    }
4930    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4931        self.trace_to_symbol_candidates(to_symbol)
4932    }
4933    fn trace_to_symbol(
4934        &self,
4935        file_rel: &Path,
4936        symbol: &str,
4937        to_symbol: &str,
4938        to_file: Option<&Path>,
4939        max_depth: usize,
4940    ) -> Result<callgraph::TraceToSymbolResult> {
4941        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4942    }
4943}
4944
4945fn indexed_file_count(conn: &Connection) -> Result<usize> {
4946    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4947    Ok(count.max(0) as usize)
4948}
4949
4950fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4951    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4952    match candidates.as_slice() {
4953        [candidate] => Ok(candidate.clone()),
4954        [] => Err(AftError::SymbolNotFound {
4955            name: symbol.to_string(),
4956            file: rel_path.to_string(),
4957        }
4958        .into()),
4959        _ => Err(AftError::AmbiguousSymbol {
4960            name: symbol.to_string(),
4961            candidates: candidates
4962                .iter()
4963                .map(|candidate| candidate.symbol.clone())
4964                .collect(),
4965        }
4966        .into()),
4967    }
4968}
4969
4970fn nodes_for_file_matching_symbol(
4971    conn: &Connection,
4972    rel_path: &str,
4973    symbol: &str,
4974) -> Result<Vec<StoreNode>> {
4975    let qualified_query = symbol.contains("::");
4976    let sql = if qualified_query {
4977        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4978                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4979         FROM nodes n JOIN files f ON f.path = n.file_path
4980         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4981         ORDER BY n.scoped_name, n.start_line, n.start_col"
4982    } else {
4983        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4984                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4985         FROM nodes n JOIN files f ON f.path = n.file_path
4986         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4987         ORDER BY n.scoped_name, n.start_line, n.start_col"
4988    };
4989    let mut stmt = conn.prepare(sql)?;
4990    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4991    rows.collect::<std::result::Result<Vec<_>, _>>()
4992        .map_err(Into::into)
4993}
4994
4995fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4996    let qualified_query = symbol.contains("::");
4997    let sql = if qualified_query {
4998        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4999                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5000         FROM nodes n JOIN files f ON f.path = n.file_path
5001         WHERE n.scoped_name = ?1
5002         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5003    } else {
5004        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5005                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5006         FROM nodes n JOIN files f ON f.path = n.file_path
5007         WHERE n.scoped_name = ?1 OR n.name = ?1
5008         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5009    };
5010    let mut stmt = conn.prepare(sql)?;
5011    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5012    rows.collect::<std::result::Result<Vec<_>, _>>()
5013        .map_err(Into::into)
5014}
5015
5016fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5017    store_node_from_row_at(row, 0)
5018}
5019
5020fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5021    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5022    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5023    let lang_label_value: String = row.get(offset + 10)?;
5024    Ok(StoreNode {
5025        node_id: row.get(offset)?,
5026        file: row.get(offset + 1)?,
5027        symbol: row.get(offset + 2)?,
5028        name: row.get(offset + 3)?,
5029        kind: row.get(offset + 4)?,
5030        line: start_line.saturating_add(1),
5031        end_line: end_line.saturating_add(1),
5032        signature: row.get(offset + 7)?,
5033        exported: row.get::<_, i64>(offset + 8)? != 0,
5034        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5035        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5036    })
5037}
5038
5039fn optional_store_node_from_row_at(
5040    row: &rusqlite::Row<'_>,
5041    offset: usize,
5042) -> rusqlite::Result<Option<StoreNode>> {
5043    if row.get::<_, Option<String>>(offset)?.is_some() {
5044        store_node_from_row_at(row, offset).map(Some)
5045    } else {
5046        Ok(None)
5047    }
5048}
5049
5050#[allow(clippy::too_many_arguments)]
5051fn collect_callers_recursive(
5052    conn: &Connection,
5053    file: &str,
5054    symbol: &str,
5055    max_depth: usize,
5056    current_depth: usize,
5057    visited: &mut HashSet<(String, String)>,
5058    result: &mut Vec<StoreCallSite>,
5059    depth_limited: &mut bool,
5060    truncated: &mut usize,
5061) -> Result<()> {
5062    if current_depth >= max_depth {
5063        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5064        if omitted > 0 {
5065            *depth_limited = true;
5066            *truncated += omitted;
5067        }
5068        return Ok(());
5069    }
5070
5071    if !visited.insert((file.to_string(), symbol.to_string())) {
5072        return Ok(());
5073    }
5074
5075    let sites = direct_callers_for_tuple(conn, file, symbol)?;
5076    for site in sites {
5077        result.push(site.clone());
5078        if current_depth + 1 < max_depth {
5079            collect_callers_recursive(
5080                conn,
5081                &site.caller.file,
5082                &site.caller.symbol,
5083                max_depth,
5084                current_depth + 1,
5085                visited,
5086                result,
5087                depth_limited,
5088                truncated,
5089            )?;
5090        } else {
5091            let omitted =
5092                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5093            if omitted > 0 {
5094                *depth_limited = true;
5095                *truncated += omitted;
5096            }
5097        }
5098    }
5099    Ok(())
5100}
5101
5102// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5103const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5104
5105fn direct_caller_counts_for_tuples(
5106    conn: &Connection,
5107    targets: &[(String, String)],
5108) -> Result<HashMap<(String, String), usize>> {
5109    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5110    let mut counts = unique_targets
5111        .iter()
5112        .cloned()
5113        .map(|target| (target, 0usize))
5114        .collect::<HashMap<_, _>>();
5115
5116    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5117    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5118        let requested_values = (0..chunk.len())
5119            .map(|_| "(?, ?)")
5120            .collect::<Vec<_>>()
5121            .join(", ");
5122        let sql = format!(
5123            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5124             deduped AS (
5125                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5126                 FROM requested requested
5127                 JOIN edges e
5128                   ON e.target_file = requested.target_file
5129                  AND e.target_symbol = requested.target_symbol
5130                  AND e.kind = 'call'
5131                 JOIN refs r ON r.ref_id = e.ref_id
5132                 JOIN nodes src ON src.id = e.source_node
5133                 JOIN files src_file ON src_file.path = src.file_path
5134                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5135             )
5136             SELECT target_file, target_symbol, COUNT(*)
5137             FROM deduped
5138             GROUP BY target_file, target_symbol"
5139        );
5140        let bindings = chunk
5141            .iter()
5142            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5143        let mut stmt = conn.prepare(&sql)?;
5144        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5145            Ok((
5146                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5147                row.get::<_, i64>(2)?,
5148            ))
5149        })?;
5150        for row in rows {
5151            let (target, count) = row?;
5152            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5153        }
5154    }
5155
5156    Ok(counts)
5157}
5158
5159fn direct_caller_count_for_tuple(
5160    conn: &Connection,
5161    target_file: &str,
5162    target_symbol: &str,
5163) -> Result<usize> {
5164    let count: i64 = conn.query_row(
5165        "SELECT COUNT(*)
5166         FROM edges e
5167         JOIN refs r ON r.ref_id = e.ref_id
5168         JOIN nodes src ON src.id = e.source_node
5169         JOIN files src_file ON src_file.path = src.file_path
5170         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5171        params![target_file, target_symbol],
5172        |row| row.get(0),
5173    )?;
5174    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5175}
5176
5177fn direct_callers_for_tuple(
5178    conn: &Connection,
5179    target_file: &str,
5180    target_symbol: &str,
5181) -> Result<Vec<StoreCallSite>> {
5182    let mut stmt = conn.prepare(
5183        "SELECT e.target_file, e.target_symbol, e.line,
5184                r.byte_start, r.byte_end, r.status, e.provenance,
5185                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5186                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5187                src_file.lang,
5188                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5189                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5190                tgt_file.lang
5191         FROM edges e
5192         JOIN refs r ON r.ref_id = e.ref_id
5193         JOIN nodes src ON src.id = e.source_node
5194         JOIN files src_file ON src_file.path = src.file_path
5195         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5196             ON tgt.id = e.target_node
5197         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5198         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5199    )?;
5200    let rows = stmt.query_map(
5201        params![target_file, target_symbol],
5202        direct_call_site_from_row,
5203    )?;
5204    rows.collect::<std::result::Result<Vec<_>, _>>()
5205        .map_err(Into::into)
5206}
5207
5208fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5209    let caller = store_node_from_row_at(row, 7)?;
5210    let target = optional_store_node_from_row_at(row, 18)?;
5211    Ok(StoreCallSite {
5212        caller,
5213        target_file: row.get(0)?,
5214        target_symbol: row.get(1)?,
5215        target,
5216        line: row.get::<_, i64>(2)?.max(0) as u32,
5217        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5218        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5219        resolved: row.get::<_, String>(5)? == "resolved",
5220        provenance: row.get(6)?,
5221    })
5222}
5223
5224fn direct_callers_for_tuples(
5225    conn: &Connection,
5226    targets: &[(String, String)],
5227) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5228    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5229    let mut callers_by_target = unique_targets
5230        .iter()
5231        .cloned()
5232        .map(|target| (target, Vec::new()))
5233        .collect::<HashMap<_, _>>();
5234    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5235
5236    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5237        let requested_values = (0..chunk.len())
5238            .map(|_| "(?, ?)")
5239            .collect::<Vec<_>>()
5240            .join(", ");
5241        let sql = format!(
5242            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5243             SELECT e.target_file, e.target_symbol, e.line,
5244                    r.byte_start, r.byte_end, r.status, e.provenance,
5245                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5246                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5247                    src_file.lang,
5248                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5249                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5250                    tgt_file.lang
5251             FROM requested requested
5252             JOIN edges e
5253               ON e.target_file = requested.target_file
5254              AND e.target_symbol = requested.target_symbol
5255              AND e.kind = 'call'
5256             JOIN refs r ON r.ref_id = e.ref_id
5257             JOIN nodes src ON src.id = e.source_node
5258             JOIN files src_file ON src_file.path = src.file_path
5259             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5260                 ON tgt.id = e.target_node
5261             ORDER BY e.target_file, e.target_symbol, e.source_node,
5262                      r.byte_start, r.line, r.ref_id"
5263        );
5264        let bindings = chunk
5265            .iter()
5266            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5267        let mut stmt = conn.prepare(&sql)?;
5268        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5269            let call = direct_call_site_from_row(row)?;
5270            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5271            Ok((target_key, call))
5272        })?;
5273        for row in rows {
5274            let (target, call) = row?;
5275            callers_by_target
5276                .get_mut(&target)
5277                .expect("batched caller row belongs to a requested target")
5278                .push(call);
5279        }
5280    }
5281
5282    Ok(callers_by_target)
5283}
5284
5285// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5286const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5287// Outgoing-edge batches bind one source node per parameter.
5288const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5289
5290fn outgoing_calls_for_symbol_tuples(
5291    conn: &Connection,
5292    sources: &[(String, String)],
5293) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5294    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5295    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5296    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5297    let source_nodes = unique_sources
5298        .iter()
5299        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5300        .cloned()
5301        .collect::<Vec<_>>();
5302    let source_nodes_by_id = source_nodes
5303        .iter()
5304        .cloned()
5305        .map(|node| (node.node_id.clone(), node))
5306        .collect::<HashMap<_, _>>();
5307    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5308
5309    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5310        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5311        let sql = format!(
5312            "SELECT e.source_node,
5313                    e.target_file, e.target_symbol, e.line,
5314                    r.byte_start, r.byte_end, r.status, e.provenance,
5315                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5316                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5317                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5318                    tgt_file.lang
5319             FROM edges e
5320             JOIN refs r ON r.ref_id = e.ref_id
5321             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5322             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5323             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5324             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5325        );
5326        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5327        let mut stmt = conn.prepare(&sql)?;
5328        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5329            let source_node_id = row.get::<_, String>(0)?;
5330            let caller = source_nodes_by_id
5331                .get(&source_node_id)
5332                .expect("batched outgoing row belongs to a requested source node")
5333                .clone();
5334            let target = optional_store_node_from_row_at(row, 8)?;
5335            Ok((
5336                source_node_id,
5337                StoreCallSite {
5338                    caller,
5339                    target_file: row.get(1)?,
5340                    target_symbol: row.get(2)?,
5341                    target,
5342                    line: row.get::<_, i64>(3)?.max(0) as u32,
5343                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5344                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5345                    resolved: row.get::<_, String>(6)? == "resolved",
5346                    provenance: row.get(7)?,
5347                },
5348            ))
5349        })?;
5350        for row in rows {
5351            let (source_node_id, call) = row?;
5352            calls_by_node.entry(source_node_id).or_default().push(call);
5353        }
5354    }
5355
5356    let mut calls_by_source = HashMap::new();
5357    for source in &unique_sources {
5358        let mut calls = Vec::new();
5359        if let Some(nodes) = source_nodes_by_symbol.get(source) {
5360            for node in nodes {
5361                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5362                    calls.extend(node_calls);
5363                }
5364            }
5365        }
5366        calls_by_source.insert(source.clone(), calls);
5367    }
5368
5369    // Resolve each logical target once for the whole frontier. Keeping this separate
5370    // preserves positional-symbol representatives without a correlated lookup per edge.
5371    let target_tuples = calls_by_source
5372        .values()
5373        .flatten()
5374        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5375        .collect::<Vec<_>>();
5376    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5377    for calls in calls_by_source.values_mut() {
5378        for call in calls {
5379            if let Some(target) = target_nodes
5380                .get(&(call.target_file.clone(), call.target_symbol.clone()))
5381                .and_then(|nodes| nodes.first())
5382            {
5383                call.target = Some(target.clone());
5384            }
5385        }
5386    }
5387
5388    Ok(calls_by_source)
5389}
5390
5391fn nodes_for_symbol_tuples(
5392    conn: &Connection,
5393    symbols: &[(String, String)],
5394) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5395    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5396    let mut nodes_by_symbol = unique_symbols
5397        .iter()
5398        .cloned()
5399        .map(|symbol| (symbol, Vec::new()))
5400        .collect::<HashMap<_, _>>();
5401    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5402
5403    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5404        let requested_values = (0..chunk.len())
5405            .map(|_| "(?, ?)")
5406            .collect::<Vec<_>>()
5407            .join(", ");
5408        let sql = format!(
5409            "WITH requested(file, symbol) AS (VALUES {requested_values})
5410             SELECT requested.file, requested.symbol,
5411                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
5412                    node.start_line, node.end_line, node.signature, node.exported,
5413                    node.is_callgraph_entry_point, node_file.lang
5414             FROM requested
5415             JOIN nodes node INDEXED BY idx_nodes_file
5416               ON node.file_path = requested.file
5417              AND node.scoped_name = requested.symbol
5418             JOIN files node_file ON node_file.path = node.file_path
5419             ORDER BY requested.file, requested.symbol,
5420                      node.scoped_name, node.start_line, node.end_line,
5421                      node.start_col, node.range_ordinal"
5422        );
5423        let bindings = chunk
5424            .iter()
5425            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5426        let mut stmt = conn.prepare(&sql)?;
5427        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5428            Ok((
5429                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5430                store_node_from_row_at(row, 2)?,
5431            ))
5432        })?;
5433        for row in rows {
5434            let (symbol, node) = row?;
5435            nodes_by_symbol.entry(symbol).or_default().push(node);
5436        }
5437    }
5438
5439    Ok(nodes_by_symbol)
5440}
5441
5442fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5443    let mut stmt = conn.prepare(
5444        "SELECT e.target_file, e.target_symbol, e.line,
5445                r.byte_start, r.byte_end, r.status, e.provenance,
5446                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5447                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5448                tgt_file.lang
5449         FROM edges e
5450         JOIN refs r ON r.ref_id = e.ref_id
5451         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5452             ON tgt.id = e.target_node
5453         WHERE e.kind = 'call' AND e.source_node = ?1
5454         ORDER BY r.byte_start, r.line, r.ref_id",
5455    )?;
5456    let rows = stmt.query_map(params![node.node_id], |row| {
5457        let target = optional_store_node_from_row_at(row, 7)?;
5458        Ok(StoreCallSite {
5459            caller: node.clone(),
5460            target_file: row.get(0)?,
5461            target_symbol: row.get(1)?,
5462            target,
5463            line: row.get::<_, i64>(2)?.max(0) as u32,
5464            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5465            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5466            resolved: row.get::<_, String>(5)? == "resolved",
5467            provenance: row.get(6)?,
5468        })
5469    })?;
5470    rows.collect::<std::result::Result<Vec<_>, _>>()
5471        .map_err(Into::into)
5472}
5473
5474fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5475    let mut stmt = conn.prepare(
5476        "SELECT r.target_file, r.target_symbol, r.line,
5477                r.byte_start, r.byte_end, r.status, r.provenance,
5478                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5479                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5480                tgt_file.lang
5481         FROM refs r
5482         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5483             ON tgt.id = r.target_node
5484         WHERE r.caller_node = ?1
5485           AND r.kind = 'call'
5486           AND r.status <> 'unresolved'
5487           AND r.target_file = ?2
5488           AND r.target_symbol = ?3
5489           AND r.provenance = ?4
5490           AND NOT EXISTS (
5491               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5492           )
5493         ORDER BY r.byte_start, r.line, r.ref_id",
5494    )?;
5495    let rows = stmt.query_map(
5496        params![
5497            &node.node_id,
5498            &node.file,
5499            &node.symbol,
5500            PROVENANCE_TREESITTER
5501        ],
5502        |row| {
5503            let target = optional_store_node_from_row_at(row, 7)?;
5504            Ok(StoreCallSite {
5505                caller: node.clone(),
5506                target_file: row.get(0)?,
5507                target_symbol: row.get(1)?,
5508                target,
5509                line: row.get::<_, i64>(2)?.max(0) as u32,
5510                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5511                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5512                resolved: row.get::<_, String>(5)? == "resolved",
5513                provenance: row.get(6)?,
5514            })
5515        },
5516    )?;
5517    rows.collect::<std::result::Result<Vec<_>, _>>()
5518        .map_err(Into::into)
5519}
5520
5521fn unresolved_calls_for_node(
5522    conn: &Connection,
5523    node: &StoreNode,
5524) -> Result<Vec<StoreUnresolvedCall>> {
5525    let mut stmt = conn.prepare(
5526        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5527         FROM refs
5528         WHERE caller_node = ?1
5529           AND kind = 'call'
5530           AND status = 'unresolved'
5531           AND NOT EXISTS (
5532               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5533           )
5534         ORDER BY byte_start, line, ref_id",
5535    )?;
5536    let rows = stmt.query_map(params![node.node_id], |row| {
5537        Ok(StoreUnresolvedCall {
5538            caller: node.clone(),
5539            symbol: row.get(0)?,
5540            full_ref: row.get(1)?,
5541            line: row.get::<_, i64>(2)?.max(0) as u32,
5542            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5543            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5544        })
5545    })?;
5546    rows.collect::<std::result::Result<Vec<_>, _>>()
5547        .map_err(Into::into)
5548}
5549
5550fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5551    let mut calls = Vec::new();
5552    calls.extend(
5553        outgoing_calls_for_node(conn, node)?
5554            .into_iter()
5555            .map(StoreForwardCall::Resolved),
5556    );
5557    calls.extend(
5558        unresolved_calls_for_node(conn, node)?
5559            .into_iter()
5560            .map(StoreForwardCall::Unresolved),
5561    );
5562    calls.sort_by(|left, right| {
5563        left.byte_start()
5564            .cmp(&right.byte_start())
5565            .then(left.line().cmp(&right.line()))
5566    });
5567    Ok(calls)
5568}
5569
5570fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5571    let resolved_count: i64 = conn.query_row(
5572        "SELECT COUNT(*)
5573         FROM edges e
5574         JOIN refs r ON r.ref_id = e.ref_id
5575         WHERE e.kind = 'call' AND e.source_node = ?1",
5576        params![&node.node_id],
5577        |row| row.get(0),
5578    )?;
5579    let unresolved_count: i64 = conn.query_row(
5580        "SELECT COUNT(*)
5581         FROM refs
5582         WHERE caller_node = ?1
5583           AND kind = 'call'
5584           AND status = 'unresolved'
5585           AND NOT EXISTS (
5586               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5587           )",
5588        params![&node.node_id],
5589        |row| row.get(0),
5590    )?;
5591    let total = resolved_count.saturating_add(unresolved_count);
5592    Ok(usize::try_from(total).unwrap_or(usize::MAX))
5593}
5594
5595fn call_tree_inner(
5596    conn: &Connection,
5597    node: &StoreNode,
5598    max_depth: usize,
5599    current_depth: usize,
5600    visited: &mut HashSet<(String, String)>,
5601) -> Result<callgraph::CallTreeNode> {
5602    let visit_key = (node.file.clone(), node.symbol.clone());
5603    if visited.contains(&visit_key) {
5604        return Ok(callgraph::CallTreeNode {
5605            name: node.symbol.clone(),
5606            file: node.file.clone(),
5607            line: node.line,
5608            signature: node.signature.clone(),
5609            resolved: true,
5610            children: Vec::new(),
5611            depth_limited: false,
5612            truncated: 0,
5613        });
5614    }
5615    visited.insert(visit_key.clone());
5616
5617    let mut children = Vec::new();
5618    let mut depth_limited = false;
5619    let mut truncated = 0usize;
5620
5621    if current_depth < max_depth {
5622        let calls = forward_calls_for_node(conn, node)?;
5623        for call in calls {
5624            match call {
5625                StoreForwardCall::Resolved(site) => {
5626                    if let Some(target) = site.target {
5627                        let child =
5628                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5629                        depth_limited |= child.depth_limited;
5630                        truncated += child.truncated;
5631                        children.push(child);
5632                    } else {
5633                        children.push(callgraph::CallTreeNode {
5634                            name: site.target_symbol,
5635                            file: site.target_file,
5636                            line: site.line,
5637                            signature: None,
5638                            resolved: false,
5639                            children: Vec::new(),
5640                            depth_limited: false,
5641                            truncated: 0,
5642                        });
5643                    }
5644                }
5645                StoreForwardCall::Unresolved(call) => {
5646                    children.push(callgraph::CallTreeNode {
5647                        name: call.symbol,
5648                        file: call.caller.file,
5649                        line: call.line,
5650                        signature: None,
5651                        resolved: false,
5652                        children: Vec::new(),
5653                        depth_limited: false,
5654                        truncated: 0,
5655                    });
5656                }
5657            }
5658        }
5659    } else {
5660        truncated = forward_call_count_for_node(conn, node)?;
5661        depth_limited = truncated > 0;
5662    }
5663
5664    visited.remove(&visit_key);
5665    Ok(callgraph::CallTreeNode {
5666        name: node.symbol.clone(),
5667        file: node.file.clone(),
5668        line: node.line,
5669        signature: node.signature.clone(),
5670        resolved: true,
5671        children,
5672        depth_limited,
5673        truncated,
5674    })
5675}
5676
5677fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5678    callgraph::TraceToSymbolHop {
5679        symbol: node.symbol.clone(),
5680        file: node.file.clone(),
5681        line: node.line,
5682    }
5683}
5684
5685fn trace_to_symbol_matches_target(
5686    node: &StoreNode,
5687    to_symbol: &str,
5688    to_file: Option<&str>,
5689) -> bool {
5690    if !symbol_query_matches(&node.symbol, to_symbol) {
5691        return false;
5692    }
5693    match to_file {
5694        Some(file) => node.file == file,
5695        None => true,
5696    }
5697}
5698
5699fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5700    symbol == query || unqualified_name(symbol) == query
5701}
5702
5703fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5704    let source = std::fs::read_to_string(path).ok()?;
5705    Some(source.lines().map(|line| line.trim().to_string()).collect())
5706}
5707
5708#[doc(hidden)]
5709pub fn live_callgraph_edge_snapshot(
5710    project_root: &Path,
5711    files: &[PathBuf],
5712) -> Result<BTreeSet<StoredEdge>> {
5713    let files = normalize_file_list(project_root, files)?;
5714    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5715    let mut file_data = Vec::new();
5716    for file in &files {
5717        let canon = canonicalize_path(file);
5718        let data = graph.build_file(&canon)?.clone();
5719        file_data.push((canon, data));
5720    }
5721
5722    let mut edges = BTreeSet::new();
5723    for (caller_file, data) in &file_data {
5724        for (caller_symbol, call_sites) in &data.calls_by_symbol {
5725            for call_site in call_sites {
5726                let resolution = graph.resolve_cross_file_edge(
5727                    &call_site.full_callee,
5728                    &call_site.callee_name,
5729                    caller_file,
5730                    &data.import_block,
5731                );
5732                let (target_file, target_symbol) = match resolution {
5733                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5734                    EdgeResolution::Unresolved { callee_name } => {
5735                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5736                            continue;
5737                        }
5738                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5739                            data,
5740                            caller_file,
5741                            &callee_name,
5742                        ) else {
5743                            continue;
5744                        };
5745                        (caller_file.clone(), target_symbol)
5746                    }
5747                };
5748                if target_file == *caller_file && target_symbol == *caller_symbol {
5749                    continue;
5750                }
5751                edges.insert(StoredEdge {
5752                    source_file: relative_path(project_root, caller_file),
5753                    source_symbol: caller_symbol.clone(),
5754                    target_file: relative_path(project_root, &target_file),
5755                    target_symbol,
5756                    kind: "call".to_string(),
5757                    line: call_site.line,
5758                });
5759            }
5760        }
5761    }
5762    Ok(edges)
5763}
5764
5765fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5766{
5767    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5768}
5769
5770fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5771    RebuildCooldownKey {
5772        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5773            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5774        project_key: project_key.to_string(),
5775    }
5776}
5777
5778fn rebuild_cooldown_denial(
5779    callgraph_dir: &Path,
5780    project_key: &str,
5781    project_root: &Path,
5782    now: Instant,
5783) -> Option<(PathBuf, Duration)> {
5784    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5785    let records = rebuild_cooldown_records()
5786        .lock()
5787        .unwrap_or_else(std::sync::PoisonError::into_inner);
5788    let record = records.get(&key)?;
5789    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5790        return None;
5791    }
5792    let elapsed = now.saturating_duration_since(record.published_at);
5793    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5794}
5795
5796fn record_successful_rebuild(
5797    callgraph_dir: &Path,
5798    project_key: &str,
5799    project_root: &Path,
5800    published_at: Instant,
5801) {
5802    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5803    let mut records = rebuild_cooldown_records()
5804        .lock()
5805        .unwrap_or_else(std::sync::PoisonError::into_inner);
5806    if records.len() >= 4_096 && !records.contains_key(&key) {
5807        if let Some(evict) = records.keys().next().cloned() {
5808            records.remove(&evict);
5809        }
5810    }
5811    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5812        previous.cross_root_cooldown_armed || previous.project_root != project_root
5813    });
5814    records.insert(
5815        key,
5816        RebuildCooldownRecord {
5817            project_root: project_root.to_path_buf(),
5818            published_at,
5819            cross_root_cooldown_armed,
5820        },
5821    );
5822}
5823
5824fn acquire_writer_lease(
5825    callgraph_dir: &Path,
5826    project_key: &str,
5827    project_root: &Path,
5828) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
5829    crate::root_cache::WriterLease::acquire_shared(
5830        crate::root_cache::RootCacheDomain::Callgraph,
5831        callgraph_dir,
5832        project_key,
5833        project_root,
5834    )
5835    .map_err(CallGraphStoreError::from)
5836}
5837
5838fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
5839    if lease.verify()? {
5840        Ok(())
5841    } else {
5842        Err(CallGraphStoreError::Unavailable(format!(
5843            "callgraph writer lease for key {} lost epoch {}; aborting write",
5844            lease.key(),
5845            lease.epoch()
5846        )))
5847    }
5848}
5849
5850fn legacy_migration_completion_line(
5851    project_key: &str,
5852    method: &str,
5853    legacy_bytes: u64,
5854    migrated_bytes: u64,
5855) -> String {
5856    format!(
5857        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
5858    )
5859}
5860
5861fn log_legacy_migration_completion(
5862    project_key: &str,
5863    method: &str,
5864    legacy_bytes: u64,
5865    migrated_bytes: u64,
5866) {
5867    crate::slog_info!(
5868        "{}",
5869        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
5870    );
5871}
5872
5873fn try_legacy_migration_or_fallback(
5874    callgraph_dir: &Path,
5875    project_root: &Path,
5876    project_key: &str,
5877    writer_lease: Arc<crate::root_cache::WriterLease>,
5878) -> Result<Option<CallGraphStore>> {
5879    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5880    if partitions.is_empty() {
5881        return Ok(None);
5882    }
5883
5884    for partition in &partitions {
5885        if let Some(source) = newest_superseded_legacy_generation(partition)? {
5886            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5887                return open_legacy_fallback_store(
5888                    callgraph_dir,
5889                    project_root,
5890                    project_key,
5891                    &partitions,
5892                );
5893            }
5894            match publish_generation_copy_migration(
5895                callgraph_dir,
5896                project_key,
5897                &source,
5898                Arc::clone(&writer_lease),
5899            ) {
5900                Ok(published) => {
5901                    log_legacy_migration_completion(
5902                        project_key,
5903                        "generation_copy",
5904                        source.source_bytes,
5905                        published.migrated_bytes,
5906                    );
5907                    return CallGraphStore::open_generation(
5908                        callgraph_dir,
5909                        project_root.to_path_buf(),
5910                        project_key.to_string(),
5911                        published.generation,
5912                        writer_lease,
5913                    )
5914                    .map(Some);
5915                }
5916                Err(error) => {
5917                    crate::slog_warn!(
5918                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5919                        source.sqlite_path.display(),
5920                        error
5921                    );
5922                    return open_legacy_fallback_store(
5923                        callgraph_dir,
5924                        project_root,
5925                        project_key,
5926                        &partitions,
5927                    );
5928                }
5929            }
5930        }
5931
5932        if let Some(source) = current_legacy_generation(partition)? {
5933            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5934                return open_legacy_fallback_store(
5935                    callgraph_dir,
5936                    project_root,
5937                    project_key,
5938                    &partitions,
5939                );
5940            }
5941            match publish_backup_migration(
5942                callgraph_dir,
5943                project_key,
5944                &source,
5945                Arc::clone(&writer_lease),
5946            ) {
5947                Ok(published) => {
5948                    log_legacy_migration_completion(
5949                        project_key,
5950                        "sqlite_backup",
5951                        source.source_bytes,
5952                        published.migrated_bytes,
5953                    );
5954                    return CallGraphStore::open_generation(
5955                        callgraph_dir,
5956                        project_root.to_path_buf(),
5957                        project_key.to_string(),
5958                        published.generation,
5959                        writer_lease,
5960                    )
5961                    .map(Some);
5962                }
5963                Err(error) => {
5964                    crate::slog_warn!(
5965                        "root-keyed callgraph backup migration failed from {}: {}",
5966                        source.sqlite_path.display(),
5967                        error
5968                    );
5969                    return open_legacy_fallback_store(
5970                        callgraph_dir,
5971                        project_root,
5972                        project_key,
5973                        &partitions,
5974                    );
5975                }
5976            }
5977        }
5978    }
5979
5980    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5981}
5982
5983fn open_legacy_fallback_store(
5984    callgraph_dir: &Path,
5985    project_root: &Path,
5986    project_key: &str,
5987    partitions: &[LegacyCallgraphPartition],
5988) -> Result<Option<CallGraphStore>> {
5989    let Some(target) = first_ready_legacy_target(partitions)? else {
5990        return Ok(None);
5991    };
5992    crate::slog_warn!(
5993        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5994        target.partition.harness,
5995        target.sqlite_path.display()
5996    );
5997    let conn = open_readonly_connection(&target.sqlite_path)?;
5998    if !database_ready(&conn).unwrap_or(false) {
5999        return Ok(None);
6000    }
6001    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6002    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6003    Ok(Some(CallGraphStore::from_connection(
6004        project_root.to_path_buf(),
6005        project_key.to_string(),
6006        target.sqlite_path,
6007        callgraph_dir.to_path_buf(),
6008        true,
6009        target.generation,
6010        None,
6011        Some(read_marker),
6012        conn,
6013    )))
6014}
6015
6016fn migration_disk_floor_allows(
6017    source: &LegacyCallgraphTarget,
6018    callgraph_dir: &Path,
6019) -> Result<bool> {
6020    let available = migration_available_disk(callgraph_dir)?;
6021    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6022        source.source_bytes,
6023        available,
6024    );
6025    if decision.should_skip_copy() {
6026        crate::slog_warn!(
6027            "{}",
6028            decision.warning_message(&source.sqlite_path, callgraph_dir)
6029        );
6030        return Ok(false);
6031    }
6032    Ok(true)
6033}
6034
6035fn migration_available_disk(path: &Path) -> Result<u64> {
6036    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6037        return Ok(bytes);
6038    }
6039    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6040}
6041
6042fn legacy_callgraph_partitions(
6043    callgraph_dir: &Path,
6044    project_key: &str,
6045) -> Result<Vec<LegacyCallgraphPartition>> {
6046    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6047        return Ok(Vec::new());
6048    };
6049    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6050    let mut partitions = inventory
6051        .into_iter()
6052        .filter(|entry| {
6053            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6054                && entry.key == project_key
6055        })
6056        .map(|entry| {
6057            let dir = if entry.path.is_dir() {
6058                entry.path.clone()
6059            } else {
6060                entry
6061                    .path
6062                    .parent()
6063                    .map(Path::to_path_buf)
6064                    .unwrap_or_else(|| entry.path.clone())
6065            };
6066            LegacyCallgraphPartition {
6067                harness: entry.harness,
6068                dir,
6069                key: entry.key,
6070                bytes: entry.bytes,
6071                freshness: entry.callgraph_pointer_mtime,
6072            }
6073        })
6074        .collect::<Vec<_>>();
6075    partitions.sort_by(|left, right| {
6076        right
6077            .freshness
6078            .cmp(&left.freshness)
6079            .then_with(|| right.bytes.cmp(&left.bytes))
6080            .then_with(|| left.harness.cmp(&right.harness))
6081    });
6082    Ok(partitions)
6083}
6084
6085fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6086    let domain_dir = callgraph_dir.parent()?;
6087    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6088        return None;
6089    }
6090    domain_dir.parent().map(Path::to_path_buf)
6091}
6092
6093pub(crate) fn all_legacy_partitions_migrated_for_keys(
6094    callgraph_dir: &Path,
6095    configured_keys: &BTreeSet<String>,
6096) -> Result<bool> {
6097    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6098        return Ok(false);
6099    };
6100    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6101        .into_iter()
6102        .filter(|entry| {
6103            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6104                && configured_keys.contains(&entry.key)
6105        })
6106        .map(|entry| entry.key)
6107        .collect::<BTreeSet<_>>();
6108    if legacy_keys.is_empty() {
6109        return Ok(false);
6110    }
6111
6112    for key in legacy_keys {
6113        let migrated_dir = storage_root.join("callgraph").join(&key);
6114        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6115            return Ok(false);
6116        };
6117        if !migration_generation_requires_manifest(&generation)
6118            || !migration_manifest_valid(&migrated_dir, &generation)
6119        {
6120            return Ok(false);
6121        }
6122    }
6123    Ok(true)
6124}
6125
6126fn newest_superseded_legacy_generation(
6127    partition: &LegacyCallgraphPartition,
6128) -> Result<Option<LegacyCallgraphTarget>> {
6129    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6130        return Ok(None);
6131    };
6132    let prefix = format!("{}.g", partition.key);
6133    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6134        return Ok(None);
6135    };
6136    let mut candidates = Vec::new();
6137    for entry in entries.flatten() {
6138        let name = entry.file_name().to_string_lossy().to_string();
6139        if name == current
6140            || name.contains(".tmp.")
6141            || !name.starts_with(&prefix)
6142            || !name.ends_with(".sqlite")
6143        {
6144            continue;
6145        }
6146        let path = entry.path();
6147        if !db_path_ready(&path) {
6148            continue;
6149        }
6150        let modified = entry
6151            .metadata()
6152            .and_then(|metadata| metadata.modified())
6153            .unwrap_or(SystemTime::UNIX_EPOCH);
6154        candidates.push((modified, path, name));
6155    }
6156    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6157    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6158        return Ok(None);
6159    };
6160    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6161    Ok(Some(LegacyCallgraphTarget {
6162        partition: partition.clone(),
6163        sqlite_path,
6164        generation: Some(generation),
6165        source_bytes,
6166        source_blake3: String::new(),
6167    }))
6168}
6169
6170fn current_legacy_generation(
6171    partition: &LegacyCallgraphPartition,
6172) -> Result<Option<LegacyCallgraphTarget>> {
6173    let Some(target) = ready_legacy_target(partition)? else {
6174        return Ok(None);
6175    };
6176    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6177    if has_superseded {
6178        return Ok(None);
6179    }
6180    Ok(Some(target))
6181}
6182
6183fn freshest_legacy_fallback_target(
6184    callgraph_dir: &Path,
6185    project_key: &str,
6186) -> Result<Option<LegacyCallgraphTarget>> {
6187    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6188    first_ready_legacy_target(&partitions)
6189}
6190
6191fn first_ready_legacy_target(
6192    partitions: &[LegacyCallgraphPartition],
6193) -> Result<Option<LegacyCallgraphTarget>> {
6194    for partition in partitions {
6195        if let Some(target) = ready_legacy_target(partition)? {
6196            return Ok(Some(target));
6197        }
6198    }
6199    Ok(None)
6200}
6201
6202fn ready_legacy_target(
6203    partition: &LegacyCallgraphPartition,
6204) -> Result<Option<LegacyCallgraphTarget>> {
6205    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6206        let sqlite_path = partition.dir.join(&generation);
6207        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6208            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6209            return Ok(Some(LegacyCallgraphTarget {
6210                partition: partition.clone(),
6211                sqlite_path,
6212                generation: Some(generation),
6213                source_bytes,
6214                source_blake3: String::new(),
6215            }));
6216        }
6217    }
6218
6219    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6220    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6221        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6222        return Ok(Some(LegacyCallgraphTarget {
6223            partition: partition.clone(),
6224            sqlite_path,
6225            generation: None,
6226            source_bytes,
6227            source_blake3: String::new(),
6228        }));
6229    }
6230    Ok(None)
6231}
6232
6233fn publish_generation_copy_migration(
6234    callgraph_dir: &Path,
6235    project_key: &str,
6236    source: &LegacyCallgraphTarget,
6237    writer_lease: Arc<crate::root_cache::WriterLease>,
6238) -> Result<PublishedLegacyMigration> {
6239    let generation = migration_generation_file_name(project_key, "copy");
6240    let temp_path = migration_temp_path(callgraph_dir, &generation);
6241    remove_sqlite_file_set(&temp_path);
6242    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6243    fail_after_temp_copy_for_test()?;
6244
6245    let mut source = source.clone();
6246    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6247    source.source_blake3 = fingerprint.blake3;
6248    let generation = publish_migrated_generation(
6249        callgraph_dir,
6250        project_key,
6251        &generation,
6252        &temp_path,
6253        &source,
6254        fingerprint.bytes,
6255        writer_lease,
6256        "generation_copy",
6257    )?;
6258    Ok(PublishedLegacyMigration {
6259        generation,
6260        migrated_bytes: fingerprint.bytes,
6261    })
6262}
6263
6264fn publish_backup_migration(
6265    callgraph_dir: &Path,
6266    project_key: &str,
6267    source: &LegacyCallgraphTarget,
6268    writer_lease: Arc<crate::root_cache::WriterLease>,
6269) -> Result<PublishedLegacyMigration> {
6270    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6271        return Err(CallGraphStoreError::Unavailable(
6272            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6273        ));
6274    }
6275
6276    let generation = migration_generation_file_name(project_key, "backup");
6277    let temp_path = migration_temp_path(callgraph_dir, &generation);
6278    remove_sqlite_file_set(&temp_path);
6279
6280    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6281    let mut destination = Connection::open(&temp_path)?;
6282    destination.busy_timeout(Duration::from_secs(5))?;
6283    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6284    let started = Instant::now();
6285    let mut retries = 0;
6286    loop {
6287        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6288            rusqlite::backup::StepResult::Done => break,
6289            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6290            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6291                retries += 1;
6292                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6293                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6294                {
6295                    return Err(CallGraphStoreError::Unavailable(format!(
6296                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6297                    )));
6298                }
6299                std::thread::sleep(Duration::from_millis(20));
6300            }
6301            _ => {
6302                return Err(CallGraphStoreError::Unavailable(
6303                    "legacy callgraph backup returned an unknown step result".to_string(),
6304                ));
6305            }
6306        }
6307    }
6308    drop(backup);
6309
6310    let integrity: String =
6311        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6312    if integrity != "ok" {
6313        return Err(CallGraphStoreError::Unavailable(format!(
6314            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6315        )));
6316    }
6317    if !database_ready(&destination)? {
6318        return Err(CallGraphStoreError::Unavailable(
6319            "legacy callgraph backup produced a database without ready metadata".to_string(),
6320        ));
6321    }
6322    destination.execute_batch("PRAGMA optimize;")?;
6323    drop(destination);
6324    sync_file(&temp_path)?;
6325    fail_after_temp_copy_for_test()?;
6326
6327    let mut source = source.clone();
6328    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6329    source.source_blake3 = fingerprint.blake3;
6330    let generation = publish_migrated_generation(
6331        callgraph_dir,
6332        project_key,
6333        &generation,
6334        &temp_path,
6335        &source,
6336        fingerprint.bytes,
6337        writer_lease,
6338        "sqlite_backup",
6339    )?;
6340    Ok(PublishedLegacyMigration {
6341        generation,
6342        migrated_bytes: fingerprint.bytes,
6343    })
6344}
6345
6346fn publish_migrated_generation(
6347    callgraph_dir: &Path,
6348    project_key: &str,
6349    generation: &str,
6350    temp_path: &Path,
6351    source: &LegacyCallgraphTarget,
6352    migrated_bytes: u64,
6353    writer_lease: Arc<crate::root_cache::WriterLease>,
6354    method: &str,
6355) -> Result<String> {
6356    let gen_path = callgraph_dir.join(generation);
6357    checkpoint_sqlite_before_publication(temp_path);
6358    let publication = publish_if_current(|| {
6359        verify_writer_lease(&writer_lease)?;
6360        remove_sqlite_file_set(&gen_path);
6361        rename_sqlite_file_set(temp_path, &gen_path)?;
6362        crate::fs_lock::sync_parent(&gen_path);
6363
6364        verify_writer_lease(&writer_lease)?;
6365        publish_pointer(callgraph_dir, project_key, generation)?;
6366        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6367        Ok(generation.to_string())
6368    });
6369    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6370        remove_sqlite_file_set(temp_path);
6371    }
6372    publication
6373}
6374
6375fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6376    if let Some(parent) = destination.parent() {
6377        std::fs::create_dir_all(parent)?;
6378    }
6379    for suffix in SQLITE_FILE_SET_SUFFIXES {
6380        let source_path = sqlite_file_set_path(source, suffix);
6381        if !source_path.is_file() {
6382            continue;
6383        }
6384        let destination_path = sqlite_file_set_path(destination, suffix);
6385        std::fs::copy(&source_path, &destination_path)?;
6386        sync_file(&destination_path)?;
6387    }
6388    Ok(())
6389}
6390
6391fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6392    for suffix in SQLITE_FILE_SET_SUFFIXES {
6393        let source_path = sqlite_file_set_path(source, suffix);
6394        if !source_path.exists() {
6395            continue;
6396        }
6397        let destination_path = sqlite_file_set_path(destination, suffix);
6398        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6399            let _ = std::fs::remove_file(&source_path);
6400            return Err(error.into());
6401        }
6402    }
6403    Ok(())
6404}
6405
6406fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6407    let mut bytes = 0_u64;
6408    for suffix in SQLITE_FILE_SET_SUFFIXES {
6409        let member = sqlite_file_set_path(path, suffix);
6410        if !member.is_file() {
6411            continue;
6412        }
6413        bytes = bytes.saturating_add(member.metadata()?.len());
6414    }
6415    Ok(bytes)
6416}
6417
6418fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6419    let mut hasher = blake3::Hasher::new();
6420    let mut bytes = 0_u64;
6421    let mut buffer = [0_u8; 64 * 1024];
6422    for suffix in SQLITE_FILE_SET_SUFFIXES {
6423        let member = sqlite_file_set_path(path, suffix);
6424        if !member.is_file() {
6425            continue;
6426        }
6427        hasher.update(suffix.as_bytes());
6428        let mut file = std::fs::File::open(&member)?;
6429        loop {
6430            let read = file.read(&mut buffer)?;
6431            if read == 0 {
6432                break;
6433            }
6434            bytes = bytes.saturating_add(read as u64);
6435            hasher.update(&buffer[..read]);
6436        }
6437    }
6438    Ok(SourceFingerprint {
6439        bytes,
6440        blake3: hash_to_hex(hasher.finalize()),
6441    })
6442}
6443
6444fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6445    if suffix.is_empty() {
6446        path.to_path_buf()
6447    } else {
6448        PathBuf::from(format!("{}{suffix}", path.display()))
6449    }
6450}
6451
6452fn sync_file(path: &Path) -> Result<()> {
6453    let file = std::fs::OpenOptions::new()
6454        .read(true)
6455        .write(true)
6456        .open(path)?;
6457    file.sync_all()?;
6458    Ok(())
6459}
6460
6461fn fail_after_temp_copy_for_test() -> Result<()> {
6462    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6463        return Err(CallGraphStoreError::Unavailable(
6464            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6465        ));
6466    }
6467    Ok(())
6468}
6469
6470fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6471    format!(
6472        "{project_key}.g{}.{}{}{}.sqlite",
6473        now_nanos(),
6474        std::process::id(),
6475        MIGRATION_GENERATION_TAG,
6476        method
6477    )
6478}
6479
6480fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6481    callgraph_dir.join(format!(
6482        "{generation}.tmp.{}.{}",
6483        std::process::id(),
6484        now_nanos()
6485    ))
6486}
6487
6488fn write_migration_manifest(
6489    callgraph_dir: &Path,
6490    generation: &str,
6491    source: &LegacyCallgraphTarget,
6492    migrated_bytes: u64,
6493    method: &str,
6494) -> Result<()> {
6495    let manifest_path = migration_manifest_path(callgraph_dir, generation);
6496    let temp_path = manifest_path.with_extension(format!(
6497        "migration.json.tmp.{}.{}",
6498        std::process::id(),
6499        now_nanos()
6500    ));
6501    let manifest = serde_json::json!({
6502        "version": MIGRATION_MANIFEST_VERSION,
6503        "method": method,
6504        "target_generation": generation,
6505        "source_harness": source.partition.harness,
6506        "source_path": source.sqlite_path.display().to_string(),
6507        "source_generation": source.generation,
6508        "source_bytes": source.source_bytes,
6509        "source_blake3": source.source_blake3,
6510        "migrated_bytes": migrated_bytes,
6511    });
6512    {
6513        use std::io::Write as _;
6514        let mut file = std::fs::File::create(&temp_path)?;
6515        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6516        file.write_all(b"\n")?;
6517        file.sync_all()?;
6518    }
6519    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6520        let _ = std::fs::remove_file(&temp_path);
6521        return Err(error.into());
6522    }
6523    crate::fs_lock::sync_parent(&manifest_path);
6524    Ok(())
6525}
6526
6527fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6528    callgraph_dir.join(format!("{generation}.migration.json"))
6529}
6530
6531fn migration_generation_requires_manifest(generation: &str) -> bool {
6532    generation.contains(MIGRATION_GENERATION_TAG)
6533}
6534
6535fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6536    if !migration_generation_requires_manifest(generation) {
6537        return true;
6538    }
6539    let path = migration_manifest_path(callgraph_dir, generation);
6540    let Ok(bytes) = std::fs::read(path) else {
6541        return false;
6542    };
6543    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6544        return false;
6545    };
6546    value.get("version").and_then(serde_json::Value::as_u64)
6547        == Some(MIGRATION_MANIFEST_VERSION as u64)
6548        && value
6549            .get("target_generation")
6550            .and_then(serde_json::Value::as_str)
6551            == Some(generation)
6552        && value
6553            .get("source_bytes")
6554            .and_then(serde_json::Value::as_u64)
6555            .is_some_and(|bytes| bytes > 0)
6556        && value
6557            .get("source_blake3")
6558            .and_then(serde_json::Value::as_str)
6559            .is_some_and(|hash| hash.len() == 64)
6560}
6561
6562fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6563    let pointer_generation = read_pointer(callgraph_dir, project_key);
6564    if let Some(generation) = pointer_generation.as_deref() {
6565        if migration_generation_requires_manifest(generation)
6566            && !migration_manifest_valid(callgraph_dir, generation)
6567        {
6568            let path = callgraph_dir.join(generation);
6569            remove_sqlite_file_set(&path);
6570            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6571            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6572        }
6573    }
6574
6575    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6576        return;
6577    };
6578    for entry in entries.flatten() {
6579        let name = entry.file_name().to_string_lossy().to_string();
6580        let path = entry.path();
6581        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6582            let _ = std::fs::remove_file(path);
6583            continue;
6584        }
6585        if name.starts_with(&format!("{project_key}.g"))
6586            && name.ends_with(".sqlite")
6587            && name.contains(MIGRATION_GENERATION_TAG)
6588            && pointer_generation.as_deref() != Some(&name)
6589            && !migration_manifest_valid(callgraph_dir, &name)
6590        {
6591            remove_sqlite_file_set(&path);
6592            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6593        }
6594    }
6595    crate::fs_lock::sync_parent(callgraph_dir);
6596}
6597
6598fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6599    let mut hasher = blake3::Hasher::new();
6600    hasher.update(path.to_string_lossy().as_bytes());
6601    if let Some(generation) = generation {
6602        hasher.update(generation.as_bytes());
6603    }
6604    let digest = hash_to_hex(hasher.finalize());
6605    format!("legacy-{}", &digest[..16])
6606}
6607
6608fn open_readonly_connection(path: &Path) -> Result<Connection> {
6609    let uri = sqlite_readonly_uri(path);
6610    let conn = Connection::open_with_flags(
6611        &uri,
6612        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6613    )?;
6614    conn.pragma_update(
6615        None,
6616        "synchronous",
6617        if write_amplification_baseline_enabled() {
6618            "FULL"
6619        } else {
6620            "NORMAL"
6621        },
6622    )?;
6623    conn.busy_timeout(reader_busy_timeout())?;
6624    conn.execute_batch("PRAGMA query_only=ON;")?;
6625    Ok(conn)
6626}
6627
6628fn reader_busy_timeout() -> Duration {
6629    let jitter = (now_nanos() % 500) as u64;
6630    Duration::from_millis(250 + jitter)
6631}
6632
6633fn sqlite_readonly_uri(path: &Path) -> String {
6634    let raw = path.to_string_lossy().replace('\\', "/");
6635    let encoded = percent_encode_sqlite_uri_path(&raw);
6636    if raw.starts_with('/') {
6637        format!("file://{encoded}?mode=ro")
6638    } else if raw.as_bytes().get(1) == Some(&b':') {
6639        format!("file:///{encoded}?mode=ro")
6640    } else {
6641        format!("file:{encoded}?mode=ro")
6642    }
6643}
6644
6645fn percent_encode_sqlite_uri_path(path: &str) -> String {
6646    let mut encoded = String::with_capacity(path.len());
6647    for byte in path.bytes() {
6648        match byte {
6649            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6650                encoded.push(byte as char)
6651            }
6652            _ => encoded.push_str(&format!("%{byte:02X}")),
6653        }
6654    }
6655    encoded
6656}
6657
6658fn configure_connection(conn: &Connection) -> Result<()> {
6659    // Changing journal mode takes a database lock. Install the busy handler
6660    // first so concurrent cold-build and refresh connections wait rather than
6661    // failing immediately, especially under Windows byte-range locking.
6662    conn.busy_timeout(Duration::from_secs(5))?;
6663    conn.pragma_update(None, "journal_mode", "WAL")?;
6664    let baseline = write_amplification_baseline_enabled();
6665    conn.pragma_update(
6666        None,
6667        "synchronous",
6668        if baseline { "FULL" } else { "NORMAL" },
6669    )?;
6670    conn.pragma_update(
6671        None,
6672        "wal_autocheckpoint",
6673        if baseline {
6674            1_000
6675        } else {
6676            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6677        },
6678    )?;
6679    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6680    Ok(())
6681}
6682
6683fn configure_build_connection(conn: &Connection) -> Result<()> {
6684    // The staging database commits independently recoverable batches. WAL keeps
6685    // those commits durable without forcing a rollback journal rewrite per batch.
6686    // Set the busy handler before WAL because selecting the journal mode itself
6687    // can contend with a connection finishing an earlier staged transaction.
6688    conn.busy_timeout(Duration::from_secs(5))?;
6689    conn.pragma_update(None, "journal_mode", "WAL")?;
6690    conn.pragma_update(
6691        None,
6692        "synchronous",
6693        if write_amplification_baseline_enabled() {
6694            "FULL"
6695        } else {
6696            "NORMAL"
6697        },
6698    )?;
6699    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6700    Ok(())
6701}
6702
6703/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
6704/// private temporary copy before publishing it; a busy reader is harmless because
6705/// the next publication or cleanup pass can retry without affecting the source.
6706fn checkpoint_sqlite_before_publication(path: &Path) {
6707    let Ok(conn) = Connection::open(path) else {
6708        return;
6709    };
6710    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6711    let _ = conn.busy_timeout(Duration::from_secs(5));
6712    let _ = checkpoint_wal_truncate(&conn);
6713}
6714
6715fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6716    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6717        row.get::<_, i64>(0)
6718    }) {
6719        Ok(0) => true,
6720        Ok(_) => false,
6721        Err(rusqlite::Error::SqliteFailure(error, _))
6722            if matches!(
6723                error.code,
6724                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6725            ) =>
6726        {
6727            false
6728        }
6729        Err(error) => {
6730            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6731            false
6732        }
6733    }
6734}
6735
6736fn initialize_schema(conn: &Connection) -> Result<()> {
6737    conn.execute_batch(
6738        "CREATE TABLE IF NOT EXISTS files (
6739            path                TEXT PRIMARY KEY,
6740            content_hash        TEXT NOT NULL,
6741            mtime_ns            INTEGER NOT NULL,
6742            size                INTEGER NOT NULL,
6743            lang                TEXT NOT NULL,
6744            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
6745            is_public_api       INTEGER NOT NULL DEFAULT 0,
6746            surface_fingerprint TEXT NOT NULL,
6747            indexed_at          INTEGER NOT NULL
6748        );
6749
6750        CREATE TABLE IF NOT EXISTS nodes (
6751            id                         TEXT PRIMARY KEY,
6752            file_path                  TEXT NOT NULL,
6753            name                       TEXT NOT NULL,
6754            scoped_name                TEXT NOT NULL,
6755            kind                       TEXT NOT NULL,
6756            start_line                 INTEGER NOT NULL,
6757            start_col                  INTEGER NOT NULL,
6758            end_line                   INTEGER NOT NULL,
6759            end_col                    INTEGER NOT NULL,
6760            range_ordinal              INTEGER NOT NULL,
6761            signature                  TEXT,
6762            exported                   INTEGER NOT NULL,
6763            is_default_export          INTEGER NOT NULL,
6764            is_type_like               INTEGER NOT NULL,
6765            is_callgraph_entry_point   INTEGER NOT NULL,
6766            provenance                 TEXT NOT NULL,
6767            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6768        );
6769        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6770        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6771        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6772
6773        CREATE TABLE IF NOT EXISTS refs (
6774            ref_id          TEXT PRIMARY KEY,
6775            caller_node     TEXT,
6776            caller_file     TEXT NOT NULL,
6777            kind            TEXT NOT NULL,
6778            short_name      TEXT,
6779            full_ref        TEXT,
6780            module_path     TEXT,
6781            import_kind     TEXT,
6782            local_name      TEXT,
6783            requested_name  TEXT,
6784            namespace_alias TEXT,
6785            wildcard        INTEGER NOT NULL DEFAULT 0,
6786            line            INTEGER NOT NULL,
6787            byte_start      INTEGER NOT NULL,
6788            byte_end        INTEGER NOT NULL,
6789            status          TEXT NOT NULL,
6790            target_node     TEXT,
6791            target_file     TEXT,
6792            target_symbol   TEXT,
6793            provenance      TEXT NOT NULL
6794        );
6795        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6796        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6797        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6798        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6799        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6800
6801        CREATE TABLE IF NOT EXISTS file_dependencies (
6802            file_path   TEXT NOT NULL,
6803            dep_file    TEXT NOT NULL,
6804            PRIMARY KEY(file_path, dep_file)
6805        );
6806        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6807
6808        CREATE TABLE IF NOT EXISTS edges (
6809            edge_id       TEXT PRIMARY KEY,
6810            ref_id        TEXT NOT NULL,
6811            source_node   TEXT NOT NULL,
6812            target_node   TEXT,
6813            target_file   TEXT NOT NULL,
6814            target_symbol TEXT NOT NULL,
6815            kind          TEXT NOT NULL,
6816            line          INTEGER NOT NULL,
6817            provenance    TEXT NOT NULL
6818        );
6819        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6820        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6821        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6822        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6823
6824        CREATE TABLE IF NOT EXISTS dispatch_hints (
6825            id           TEXT PRIMARY KEY,
6826            method_name  TEXT NOT NULL,
6827            caller_node  TEXT NOT NULL,
6828            file         TEXT NOT NULL,
6829            line         INTEGER NOT NULL,
6830            byte_start   INTEGER NOT NULL,
6831            byte_end     INTEGER NOT NULL,
6832            provenance   TEXT NOT NULL
6833        );
6834        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6835        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
6836
6837        CREATE TABLE IF NOT EXISTS type_ref_names (
6838            name TEXT PRIMARY KEY
6839        );
6840
6841        CREATE TABLE IF NOT EXISTS backend_file_state (
6842            backend        TEXT NOT NULL,
6843            workspace_root TEXT NOT NULL,
6844            file_path      TEXT NOT NULL,
6845            content_hash   TEXT NOT NULL,
6846            status         TEXT NOT NULL,
6847            updated_at     INTEGER NOT NULL,
6848            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
6849        );
6850        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
6851
6852        CREATE TABLE IF NOT EXISTS meta (
6853            k TEXT PRIMARY KEY,
6854            v TEXT NOT NULL
6855        );
6856
6857        -- The file walk is staged on disk so extraction can page through a
6858        -- deterministic inventory without retaining every source path in heap.
6859        CREATE TABLE IF NOT EXISTS staging_file_inventory (
6860            path TEXT PRIMARY KEY,
6861            size INTEGER NOT NULL
6862        ) WITHOUT ROWID;
6863
6864        -- Context needed only while a generation is staged. Raw refs live in
6865        -- `refs` with status `staged`; this table preserves the caller symbol
6866        -- needed to avoid inventing self edges during the later resolve pass.
6867        CREATE TABLE IF NOT EXISTS staging_ref_context (
6868            ref_id        TEXT PRIMARY KEY,
6869            caller_symbol TEXT
6870        );",
6871    )?;
6872    insert_meta(conn)?;
6873    Ok(())
6874}
6875
6876fn insert_meta(conn: &Connection) -> Result<()> {
6877    conn.execute(
6878        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
6879        params![SCHEMA_VERSION.to_string()],
6880    )?;
6881    conn.execute(
6882        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
6883        params![schema_fingerprint()],
6884    )?;
6885    conn.execute(
6886        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
6887        [],
6888    )?;
6889    Ok(())
6890}
6891
6892/// Return the durable revision paired atomically with graph mutations. Stores
6893/// created by older binaries lack the revision row, so callers cannot detect
6894/// in-place graph changes and must not cache their snapshots.
6895const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
6896
6897fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
6898    let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
6899        return Ok(());
6900    };
6901    conn.execute(
6902        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
6903        params![
6904            PATH_IDENTITY_MISMATCH_META_KEY,
6905            format!(
6906                "callgraph_path_identity_mismatch path={} project_root={}",
6907                path.display(),
6908                project_root.display()
6909            )
6910        ],
6911    )?;
6912    Ok(())
6913}
6914
6915pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
6916    conn.query_row(
6917        "SELECT v FROM meta WHERE k = ?1",
6918        [PATH_IDENTITY_MISMATCH_META_KEY],
6919        |row| row.get(0),
6920    )
6921    .optional()
6922    .map_err(Into::into)
6923}
6924
6925fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
6926    let revision: Option<String> = conn
6927        .query_row(
6928            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
6929            [],
6930            |row| row.get(0),
6931        )
6932        .optional()?;
6933    revision
6934        .map(|revision| {
6935            revision.parse::<u64>().map_err(|error| {
6936                CallGraphStoreError::Unavailable(format!(
6937                    "callgraph projection write revision is invalid: {error}"
6938                ))
6939            })
6940        })
6941        .transpose()
6942}
6943
6944/// Advance the projection revision inside the graph mutation transaction so a
6945/// cached snapshot never survives an in-place refresh.
6946fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
6947    tx.execute(
6948        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
6949         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
6950        [],
6951    )?;
6952    Ok(())
6953}
6954
6955fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
6956    conn.execute(
6957        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
6958        params![if ready { "1" } else { "0" }],
6959    )?;
6960    Ok(())
6961}
6962
6963fn database_ready(conn: &Connection) -> Result<bool> {
6964    let schema_version: Option<String> = conn
6965        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
6966            row.get(0)
6967        })
6968        .optional()?;
6969    let fingerprint: Option<String> = conn
6970        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
6971            row.get(0)
6972        })
6973        .optional()?;
6974    let ready: Option<String> = conn
6975        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
6976        .optional()?;
6977
6978    let expected_schema = SCHEMA_VERSION.to_string();
6979    let expected_fingerprint = schema_fingerprint();
6980    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6981        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6982        && ready.as_deref() == Some("1"))
6983}
6984
6985fn ensure_database_ready(conn: &Connection) -> Result<()> {
6986    if database_ready(conn)? {
6987        Ok(())
6988    } else {
6989        Err(CallGraphStoreError::Unavailable(
6990            "database is missing, stale, or mid-build".to_string(),
6991        ))
6992    }
6993}
6994
6995fn schema_fingerprint() -> String {
6996    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6997    // edge sources, broader call extraction) even if the table SHAPE is
6998    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6999    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
7000    let input =
7001        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7002    hash_to_hex(blake3::hash(input.as_bytes()))
7003}
7004
7005fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7006    tx.execute_batch(
7007        "DELETE FROM staging_ref_context;
7008         DELETE FROM edges;
7009         DELETE FROM file_dependencies;
7010         DELETE FROM refs;
7011         DELETE FROM dispatch_hints;
7012         DELETE FROM type_ref_names;
7013         DELETE FROM backend_file_state;
7014         DELETE FROM nodes;
7015         DELETE FROM files;",
7016    )?;
7017    Ok(())
7018}
7019
7020fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7021    conn.query_row(
7022        "SELECT v FROM meta WHERE k = ?1",
7023        params![STAGED_BUILD_PHASE],
7024        |row| row.get(0),
7025    )
7026    .optional()
7027    .map_err(Into::into)
7028}
7029
7030fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7031    let value = staged_string(conn, key)?;
7032    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7033}
7034
7035fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7036    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7037        row.get::<_, String>(0)
7038    })
7039    .optional()
7040    .map_err(Into::into)
7041}
7042
7043fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7044    tx.execute(
7045        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7046        params![STAGED_BUILD_PHASE, phase],
7047    )?;
7048    Ok(())
7049}
7050
7051fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7052    set_staged_string(tx, key, &value.to_string())
7053}
7054
7055fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7056    tx.execute(
7057        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7058        params![key, value],
7059    )?;
7060    Ok(())
7061}
7062
7063/// The extract rows and this counter update share a SQLite transaction. This is
7064/// intentionally not inferred from file/page growth: rollback removes both the
7065/// rows and the claimed credit, while page reuse cannot fabricate credit.
7066fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7067    tx.execute(
7068        "INSERT INTO meta(k, v) VALUES(?1, ?2)
7069         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7070        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7071    )?;
7072    Ok(())
7073}
7074
7075fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7076    let Ok(source) = std::fs::read_to_string(path) else {
7077        return Ok(false);
7078    };
7079    let Ok(freshness) = collect_source_freshness(path, &source) else {
7080        return Ok(false);
7081    };
7082    let rel_path = relative_path(project_root, path);
7083    let staged_hash = conn
7084        .query_row(
7085            "SELECT content_hash FROM files WHERE path = ?1",
7086            params![rel_path],
7087            |row| row.get::<_, String>(0),
7088        )
7089        .optional()?;
7090    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7091}
7092
7093fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7094    tx.execute(
7095        "DELETE FROM staging_ref_context
7096         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7097        params![rel_path],
7098    )?;
7099    delete_file_rows(tx, rel_path)
7100}
7101
7102fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7103    loop {
7104        let removed = {
7105            let mut statement = conn.prepare(
7106                "SELECT path
7107                 FROM files
7108                 WHERE NOT EXISTS (
7109                     SELECT 1 FROM staging_file_inventory inventory
7110                     WHERE inventory.path = files.path
7111                 )
7112                 ORDER BY path
7113                 LIMIT ?1",
7114            )?;
7115            let paths = statement
7116                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7117                    row.get::<_, String>(0)
7118                })?
7119                .collect::<std::result::Result<Vec<_>, _>>()?;
7120            paths
7121        };
7122        if removed.is_empty() {
7123            return Ok(());
7124        }
7125        let tx = conn.transaction()?;
7126        for path in removed {
7127            delete_staged_file_rows(&tx, &path)?;
7128        }
7129        tx.commit()?;
7130    }
7131}
7132
7133struct StagedFileBatch {
7134    paths: Vec<PathBuf>,
7135    last_path: String,
7136}
7137
7138fn load_staged_file_batch(
7139    conn: &Connection,
7140    project_root: &Path,
7141    after_path: &str,
7142    max_files: usize,
7143    max_bytes: u64,
7144) -> Result<Option<StagedFileBatch>> {
7145    let mut statement = conn.prepare(
7146        "SELECT path, size
7147         FROM staging_file_inventory
7148         WHERE path > ?1
7149         ORDER BY path
7150         LIMIT ?2",
7151    )?;
7152    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7153    let mut paths = Vec::with_capacity(max_files.max(1));
7154    let mut last_path = String::new();
7155    let mut batch_bytes = 0u64;
7156    while let Some(row) = rows.next()? {
7157        let rel_path = row.get::<_, String>(0)?;
7158        let size = row.get::<_, i64>(1)?.max(0) as u64;
7159        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7160            break;
7161        }
7162        batch_bytes = batch_bytes.saturating_add(size);
7163        last_path.clone_from(&rel_path);
7164        paths.push(project_root.join(rel_path));
7165    }
7166    if paths.is_empty() {
7167        Ok(None)
7168    } else {
7169        Ok(Some(StagedFileBatch { paths, last_path }))
7170    }
7171}
7172
7173fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7174    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7175    let mut rows = statement.query([])?;
7176    let mut fingerprint = CorpusFingerprint::default();
7177    while let Some(row) = rows.next()? {
7178        let rel_path = row.get::<_, String>(0)?;
7179        fingerprint.add_path(project_root, &project_root.join(rel_path));
7180    }
7181    Ok(fingerprint.finish(project_root))
7182}
7183
7184fn load_staged_ref_window(
7185    conn: &Connection,
7186    after_rowid: u64,
7187    limit: usize,
7188) -> Result<Vec<StagedRef>> {
7189    let mut statement = conn.prepare(
7190        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7191                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7192                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7193                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7194         FROM refs
7195         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7196         WHERE refs.status = 'staged' AND refs.rowid > ?1
7197         ORDER BY refs.rowid
7198         LIMIT ?2",
7199    )?;
7200    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7201        Ok(StagedRef {
7202            rowid: row.get::<_, i64>(0)? as u64,
7203            raw: RawRef {
7204                ref_id: row.get(1)?,
7205                caller_node: row.get(2)?,
7206                caller_file: row.get(3)?,
7207                kind: row.get(4)?,
7208                short_name: row.get(5)?,
7209                full_ref: row.get(6)?,
7210                module_path: row.get(7)?,
7211                import_kind: row.get(8)?,
7212                local_name: row.get(9)?,
7213                requested_name: row.get(10)?,
7214                namespace_alias: row.get(11)?,
7215                wildcard: row.get::<_, i64>(12)? != 0,
7216                line: row.get::<_, i64>(13)? as u32,
7217                byte_start: row.get::<_, i64>(14)? as usize,
7218                byte_end: row.get::<_, i64>(15)? as usize,
7219                caller_symbol: row.get(16)?,
7220                dependencies: BTreeSet::new(),
7221            },
7222        })
7223    })?;
7224    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7225    drop(statement);
7226
7227    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7228    let mut dependency_statement = conn
7229        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7230    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7231        if !dependencies.contains_key(&raw.caller_file) {
7232            let rows =
7233                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7234            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7235            dependencies.insert(raw.caller_file.clone(), values);
7236        }
7237        raw.dependencies = dependencies
7238            .get(&raw.caller_file)
7239            .cloned()
7240            .unwrap_or_default();
7241    }
7242    Ok(refs)
7243}
7244
7245fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7246    ResolvedRef {
7247        dependencies: raw.dependencies.clone(),
7248        raw,
7249        status: "unresolved".to_string(),
7250        target_node: None,
7251        target_file: None,
7252        target_symbol: None,
7253        edge: None,
7254    }
7255}
7256
7257fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7258    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7259        .map(|count| count.max(0) as u64)
7260        .map_err(Into::into)
7261}
7262
7263fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7264    let mut statement = conn.prepare(
7265        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7266    )?;
7267    let rows = statement.query_map([], |row| row.get(0))?;
7268    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7269}
7270
7271fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7272    tx.execute_batch(
7273        "DROP INDEX IF EXISTS idx_nodes_file;
7274         DROP INDEX IF EXISTS idx_nodes_name;
7275         DROP INDEX IF EXISTS idx_nodes_scoped;
7276         DROP INDEX IF EXISTS idx_refs_short_name;
7277         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7278         DROP INDEX IF EXISTS idx_refs_caller_file;
7279         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7280         DROP INDEX IF EXISTS idx_refs_target_file;
7281         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7282         DROP INDEX IF EXISTS idx_edges_source_kind;
7283         DROP INDEX IF EXISTS idx_edges_target_kind;
7284         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7285         DROP INDEX IF EXISTS idx_edges_ref_id;
7286         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7287         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7288         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7289    )?;
7290    Ok(())
7291}
7292
7293fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7294    tx.execute_batch(
7295        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7296         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7297         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7298         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7299         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7300         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7301         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7302         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7303         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7304         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7305         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7306         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7307         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7308         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7309         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7310         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7311    )?;
7312    Ok(())
7313}
7314
7315const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7316    ("files", "path"),
7317    ("nodes", "file_path"),
7318    ("refs", "caller_file"),
7319    ("refs", "target_file"),
7320    ("file_dependencies", "file_path"),
7321    ("file_dependencies", "dep_file"),
7322    ("edges", "target_file"),
7323    ("dispatch_hints", "file"),
7324    ("backend_file_state", "file_path"),
7325];
7326
7327/// Reconcile `backend_file_state.workspace_root` when the opener's project root
7328/// differs from what is stored. The store key is the git-root commit hash, so
7329/// multiple live checkouts/clones share one on-disk generation.
7330///
7331/// Cheap in-place re-root is only safe when every previously stored root path is
7332/// gone from disk (true move/rename). If any stale root still exists, another
7333/// clone is still alive and rewriting metadata would ping-pong relative rows
7334/// between trees (possibly on different branches). We then return
7335/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
7336/// opener. That can make each clone rebuild on open when they alternate — bounded
7337/// by open frequency — but each rebuild is correct for its opener, unlike silent
7338/// cross-clone corruption.
7339fn reconcile_workspace_roots(
7340    conn: &mut Connection,
7341    project_root: &Path,
7342    allow_repair: bool,
7343) -> Result<OpenRootRepair> {
7344    let roots = stored_workspace_roots(conn)?;
7345    let current_root = project_root.display().to_string();
7346    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7347        return Ok(OpenRootRepair::None);
7348    }
7349
7350    if let Some(sample) = sample_absolute_data_path(conn)? {
7351        return Ok(OpenRootRepair::NeedsRebuild {
7352            previous_roots: roots,
7353            current_root,
7354            reason: format!("absolute store data path row {sample}"),
7355        });
7356    }
7357
7358    for stored_root in roots.iter() {
7359        if stored_root == &current_root {
7360            continue;
7361        }
7362        if Path::new(stored_root).exists() {
7363            let reason = format!(
7364                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7365            );
7366            return Ok(OpenRootRepair::NeedsRebuild {
7367                previous_roots: roots,
7368                current_root,
7369                reason,
7370            });
7371        }
7372    }
7373
7374    if !allow_repair {
7375        return Ok(OpenRootRepair::NeedsRebuild {
7376            previous_roots: roots,
7377            current_root,
7378            reason: "workspace root metadata requires deferred repair".to_string(),
7379        });
7380    }
7381
7382    publish_if_current(|| {
7383        let tx = conn.transaction()?;
7384        tx.execute(
7385            "UPDATE OR IGNORE backend_file_state
7386             SET workspace_root = ?1
7387             WHERE workspace_root <> ?1",
7388            params![&current_root],
7389        )?;
7390        tx.execute(
7391            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7392            params![&current_root],
7393        )?;
7394        tx.commit()?;
7395        Ok(())
7396    })?;
7397
7398    crate::slog_info!(
7399        "callgraph store re-rooted from {} to {}",
7400        roots.join(", "),
7401        current_root
7402    );
7403    Ok(OpenRootRepair::ReRooted)
7404}
7405
7406fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7407    let mut stmt = conn.prepare(
7408        "SELECT DISTINCT workspace_root
7409         FROM backend_file_state
7410         ORDER BY workspace_root",
7411    )?;
7412    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7413    rows.collect::<std::result::Result<Vec<_>, _>>()
7414        .map_err(Into::into)
7415}
7416
7417fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7418    for (table, column) in STORE_DATA_PATH_COLUMNS {
7419        let sql = format!(
7420            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7421        );
7422        let mut stmt = conn.prepare(&sql)?;
7423        let mut rows = stmt.query([])?;
7424        while let Some(row) = rows.next()? {
7425            let value: String = row.get(0)?;
7426            if stored_path_is_absolute(&value) {
7427                return Ok(Some(format!("{table}.{column}={value}")));
7428            }
7429        }
7430    }
7431    Ok(None)
7432}
7433
7434fn stored_path_is_absolute(value: &str) -> bool {
7435    if value.is_empty() {
7436        return false;
7437    }
7438    if Path::new(value).is_absolute() || value.starts_with('/') {
7439        return true;
7440    }
7441    let bytes = value.as_bytes();
7442    if bytes.len() >= 3
7443        && bytes[1] == b':'
7444        && (bytes[2] == b'/' || bytes[2] == b'\\')
7445        && bytes[0].is_ascii_alphabetic()
7446    {
7447        return true;
7448    }
7449    value.starts_with("\\\\") || value.starts_with("//")
7450}
7451
7452fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7453    if let OpenRootRepair::NeedsRebuild {
7454        previous_roots,
7455        current_root,
7456        reason,
7457    } = repair
7458    {
7459        crate::slog_info!(
7460            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
7461            previous_roots.join(", "),
7462            current_root,
7463            reason
7464        );
7465    }
7466}
7467
7468/// Nanosecond clock used to make temp/generation file names unique.
7469fn now_nanos() -> u128 {
7470    SystemTime::now()
7471        .duration_since(UNIX_EPOCH)
7472        .unwrap_or(Duration::ZERO)
7473        .as_nanos()
7474}
7475
7476/// The pointer file `<dir>/<key>.current`. Its single line names the current
7477/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
7478/// can always be atomically replaced via rename even on Windows — Rust opens
7479/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
7480fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7481    callgraph_dir.join(format!("{project_key}.current"))
7482}
7483
7484/// The legacy single-file DB path used before the generation scheme. Still read
7485/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
7486/// build publishes a generation.
7487fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7488    callgraph_dir.join(format!("{project_key}.sqlite"))
7489}
7490
7491/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
7492/// cold build writes a brand-new generation file, so publishing NEVER replaces
7493/// a file another process holds open (the root Windows fix).
7494fn generation_file_name(project_key: &str) -> String {
7495    format!(
7496        "{project_key}.g{}.{}.sqlite",
7497        now_nanos(),
7498        std::process::id()
7499    )
7500}
7501
7502/// Read the pointer; returns the generation file name if present and non-empty.
7503fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7504    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7505    let name = text.trim();
7506    if name.is_empty() {
7507        None
7508    } else {
7509        Some(name.to_string())
7510    }
7511}
7512
7513/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
7514/// `ready` flag). Uses a throwaway read-only connection.
7515fn db_path_ready(path: &Path) -> bool {
7516    (|| -> Result<bool> {
7517        let conn = open_readonly_connection(path)?;
7518        database_ready(&conn)
7519    })()
7520    .unwrap_or(false)
7521}
7522
7523/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
7524/// where `generation` is `Some(name)` for a pointer-published generation or
7525/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
7526/// published (caller treats that as "needs cold build").
7527///
7528/// Handles the GC race (the pointer names a generation that was just deleted) by
7529/// re-reading the pointer and retrying a few times.
7530fn resolve_ready_target(
7531    callgraph_dir: &Path,
7532    project_key: &str,
7533) -> Option<(PathBuf, Option<String>)> {
7534    for _ in 0..5 {
7535        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7536            let gen_path = callgraph_dir.join(&generation);
7537            if gen_path.is_file() {
7538                return (migration_manifest_valid(callgraph_dir, &generation)
7539                    && db_path_ready(&gen_path))
7540                .then_some((gen_path, Some(generation)));
7541            }
7542            // Pointer names a missing generation (a GC/publish race): re-read the
7543            // pointer and retry rather than failing the reader.
7544            std::thread::sleep(Duration::from_millis(5));
7545            continue;
7546        }
7547        // No pointer: fall back to the legacy single-file DB if it is ready.
7548        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7549        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7550    }
7551    None
7552}
7553
7554/// Atomically publish `generation` as the current store by flipping the pointer
7555/// file. Writes a temp file, fsyncs, then renames over the pointer — never
7556/// replacing an open DB file, so it succeeds cross-platform.
7557fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7558    let pointer = pointer_path(callgraph_dir, project_key);
7559    let tmp = callgraph_dir.join(format!(
7560        "{project_key}.current.tmp.{}.{}",
7561        std::process::id(),
7562        now_nanos()
7563    ));
7564    {
7565        use std::io::Write as _;
7566        let mut file = std::fs::File::create(&tmp)?;
7567        file.write_all(generation.as_bytes())?;
7568        file.write_all(b"\n")?;
7569        file.sync_all()?;
7570    }
7571    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7572        let _ = std::fs::remove_file(&tmp);
7573        return Err(error.into());
7574    }
7575    crate::fs_lock::sync_parent(&pointer);
7576    Ok(())
7577}
7578
7579#[derive(Clone, Debug)]
7580struct GenerationGcCandidate {
7581    name: String,
7582    path: PathBuf,
7583    modified: SystemTime,
7584}
7585
7586/// Best-effort GC of superseded generation files. The current pointer target and
7587/// newest previous generation are always retained. Older generations are removed
7588/// when they have no protected read marker, or after the absolute retention TTL
7589/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
7590/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
7591fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7592    let temp_grace = Duration::from_secs(60);
7593    let now = SystemTime::now();
7594    let pointer_current =
7595        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7596    let gen_prefix = format!("{project_key}.g");
7597    let tmp_prefixes = [
7598        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
7599        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
7600        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
7601    ];
7602    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7603        return;
7604    };
7605    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7606    for entry in entries.flatten() {
7607        let name = entry.file_name();
7608        let name = name.to_string_lossy().to_string();
7609        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7610        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7611
7612        // Orphaned temp files from a crashed build/publish: remove once aged out.
7613        if name.contains(".tmp.") {
7614            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7615                let _ = std::fs::remove_file(entry.path());
7616            }
7617            continue;
7618        }
7619
7620        // Superseded legacy single-file DB: best-effort delete once a generation
7621        // is published (ignored if another process still holds it open).
7622        if name == format!("{project_key}.sqlite") {
7623            remove_sqlite_file_set(&entry.path());
7624            continue;
7625        }
7626
7627        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7628            gens.push(GenerationGcCandidate {
7629                name,
7630                path: entry.path(),
7631                modified: mtime,
7632            });
7633        }
7634    }
7635
7636    let mut superseded = gens
7637        .iter()
7638        .filter(|generation| generation.name != pointer_current)
7639        .collect::<Vec<_>>();
7640    superseded.sort_by(|left, right| {
7641        right
7642            .modified
7643            .cmp(&left.modified)
7644            .then_with(|| right.name.cmp(&left.name))
7645    });
7646    let previous = superseded.first().map(|generation| generation.name.clone());
7647
7648    for generation in gens {
7649        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7650        if generation.name == pointer_current
7651            || Some(generation.name.as_str()) == previous.as_deref()
7652        {
7653            continue;
7654        }
7655
7656        let age = now
7657            .duration_since(generation.modified)
7658            .unwrap_or(Duration::ZERO);
7659        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7660            continue;
7661        }
7662
7663        remove_sqlite_file_set(&generation.path);
7664        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7665        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7666            callgraph_dir,
7667            &generation.name,
7668        ));
7669    }
7670}
7671
7672fn remove_sqlite_file_set(path: &Path) {
7673    let _ = std::fs::remove_file(path);
7674    remove_sqlite_sidecars(path);
7675}
7676
7677fn remove_sqlite_sidecars(path: &Path) {
7678    let path_text = path.to_string_lossy();
7679    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7680    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7681    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7682}
7683
7684/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
7685///
7686/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
7687/// place on success; a build that dies (process kill, crash, host restart) leaves
7688/// the temporary behind. The largest observed cold build finishes well under a
7689/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
7690/// never rename. A live build's temporary is minutes old at most.
7691///
7692/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
7693/// liveness check read false-positive on exactly the oldest files — the ones most
7694/// worth deleting: in production an orphan's embedded pid had been recycled to an
7695/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
7696/// cannot lie that way, so it is the honest orphan predicate.
7697const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
7698
7699/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
7700/// same cadence as [`gc_old_generations`] (after a generation is published) but,
7701/// unlike it, is not scoped to the building root: it covers every directory in the
7702/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
7703///
7704/// That last case is the production hole this fixes. The per-root cleanup in
7705/// [`gc_old_generations`] only fires when a root actually builds, so when activity
7706/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
7707/// store's orphans become permanent — gigabytes accumulated in a legacy store
7708/// whose roots no longer built there, while the active store stayed clean. A
7709/// sibling root that still builds triggers this pass and cleans both layouts.
7710fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
7711    sweep_orphaned_build_temps(callgraph_dir);
7712    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7713        return;
7714    };
7715    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
7716    // A vanished mounted child can make ReadDir::drop panic after closedir
7717    // returns ENXIO, aborting the daemon. Keep the store-wide background sweep
7718    // on the storage root's filesystem before opening child directories.
7719    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
7720        crate::slog_warn!(
7721            "cannot establish filesystem boundary for callgraph sweep {}",
7722            storage_root.display()
7723        );
7724        return;
7725    };
7726    let mut skipped_foreign_mounts = 0usize;
7727
7728    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
7729    let root_keyed_dir = storage_root.join(domain);
7730    if root_keyed_dir.is_dir() {
7731        if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
7732            if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
7733                for entry in entries.flatten() {
7734                    let path = entry.path();
7735                    if path.is_dir() {
7736                        if boundary.should_descend(&path).unwrap_or(false) {
7737                            sweep_orphaned_build_temps(&path);
7738                        } else {
7739                            skipped_foreign_mounts += 1;
7740                        }
7741                    }
7742                }
7743            }
7744        } else {
7745            skipped_foreign_mounts += 1;
7746        }
7747    }
7748
7749    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
7750    if let Ok(entries) = std::fs::read_dir(&storage_root) {
7751        for entry in entries.flatten() {
7752            let harness_dir = entry.path();
7753            if !harness_dir.is_dir() {
7754                continue;
7755            }
7756            if !boundary.should_descend(&harness_dir).unwrap_or(false) {
7757                skipped_foreign_mounts += 1;
7758                continue;
7759            }
7760            let legacy_dir = harness_dir.join(domain);
7761            if legacy_dir.is_dir() {
7762                if boundary.should_descend(&legacy_dir).unwrap_or(false) {
7763                    sweep_orphaned_build_temps(&legacy_dir);
7764                } else {
7765                    skipped_foreign_mounts += 1;
7766                }
7767            }
7768        }
7769    }
7770    if skipped_foreign_mounts > 0 {
7771        crate::slog_warn!(
7772            "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
7773            skipped_foreign_mounts,
7774            storage_root.display()
7775        );
7776    }
7777}
7778
7779/// Sweep one callgraph directory, removing build temporaries older than
7780/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
7781fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
7782    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
7783}
7784
7785/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
7786/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
7787fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
7788    let now = SystemTime::now();
7789    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7790        return;
7791    };
7792    let mut removed_any = false;
7793    for entry in entries.flatten() {
7794        let name = entry.file_name().to_string_lossy().to_string();
7795        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
7796        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
7797        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
7798        // without that substring — a completed `.sqlite` generation, a pointer, a
7799        // read-marker dir — is left alone: those belong to generation GC.
7800        if !name.contains(".sqlite.tmp.") {
7801            continue;
7802        }
7803        let mtime = entry
7804            .metadata()
7805            .and_then(|meta| meta.modified())
7806            .unwrap_or(now);
7807        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
7808            continue;
7809        }
7810        // Deletion races a concurrent build finishing: that build renames the temp
7811        // into place, so the file is gone by the time we unlink. The 24h age makes
7812        // this overlap practically impossible, but treat a missing file as success
7813        // (the rename won) rather than an error, and never touch a path that does
7814        // not match the temporary shape above.
7815        match std::fs::remove_file(entry.path()) {
7816            Ok(()) => removed_any = true,
7817            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
7818            Err(_) => {}
7819        }
7820    }
7821    if removed_any {
7822        crate::fs_lock::sync_parent(callgraph_dir);
7823    }
7824}
7825
7826/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
7827/// the global all-cores rayon pool. The store cold-build is the heaviest
7828/// background pass (parse-dominated) and runs on a separate thread off the
7829/// single-threaded request loop; left unbounded it monopolizes every core and
7830/// starves the bridge so interactive tools time out (the same starvation the
7831/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
7832/// match the main thread, since the extract walks tree-sitter ASTs.
7833fn build_pool_size() -> usize {
7834    std::thread::available_parallelism()
7835        .map(|parallelism| parallelism.get())
7836        .unwrap_or(1)
7837        .div_ceil(2)
7838        .clamp(1, 8)
7839}
7840
7841fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
7842    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
7843        Ok(extract) => Ok(extract),
7844        Err(error) => {
7845            let abs_path =
7846                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
7847            let rel_path = relative_path(project_root, &abs_path);
7848            let freshness = cache_freshness::collect(&abs_path).ok();
7849            log::debug!(
7850                "callgraph store: skipping {} during cold build: {}",
7851                abs_path.display(),
7852                error
7853            );
7854            Err(ExtractFailure {
7855                rel_path,
7856                freshness,
7857            })
7858        }
7859    };
7860
7861    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
7862        files.par_iter().map(extract_one).collect()
7863    };
7864
7865    // Run inside a dedicated bounded pool when one builds; fall back to the
7866    // global pool only if the bounded pool can't be constructed.
7867    let results = match rayon::ThreadPoolBuilder::new()
7868        .num_threads(build_pool_size())
7869        .thread_name(|index| format!("aft-callgraph-build-{index}"))
7870        .stack_size(8 * 1024 * 1024)
7871        .build()
7872    {
7873        Ok(pool) => pool.install(run),
7874        Err(error) => {
7875            log::warn!(
7876                "callgraph store: bounded build pool unavailable ({error}); using global pool"
7877            );
7878            run()
7879        }
7880    };
7881
7882    let mut extracts = Vec::new();
7883    let mut failures = Vec::new();
7884    for result in results {
7885        match result {
7886            Ok(extract) => extracts.push(extract),
7887            Err(failure) => failures.push(failure),
7888        }
7889    }
7890    BuildExtractsResult { extracts, failures }
7891}
7892
7893fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
7894    let metadata = std::fs::metadata(path)?;
7895    let size = metadata.len();
7896    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
7897        cache_freshness::zero_hash()
7898    } else if source.len() as u64 == size {
7899        cache_freshness::hash_bytes(source.as_bytes())
7900    } else {
7901        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
7902    };
7903    Ok(FileFreshness {
7904        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
7905        size,
7906        content_hash,
7907    })
7908}
7909
7910fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
7911    let abs_path = normalize_file_path(project_root, path)?;
7912    let rel_path = relative_path(project_root, &abs_path);
7913    let source = std::fs::read_to_string(&abs_path)?;
7914    let freshness = collect_source_freshness(&abs_path, &source)?;
7915    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
7916    let lang = data.lang;
7917    if lang == LangId::Rust {
7918        extend_rust_imports_with_nested_uses(&source, &mut data);
7919    }
7920    let mut nodes = build_node_records(&rel_path, &source, &data)?;
7921    let node_by_scoped: HashMap<String, String> = nodes
7922        .iter()
7923        .map(|node| (node.scoped_name.clone(), node.id.clone()))
7924        .collect();
7925    let import_dependencies =
7926        import_dependencies(project_root, &abs_path, &data.import_block.imports);
7927    let line_index = LineIndex::new(&source);
7928    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
7929    let rust_reexports = if lang == LangId::Rust {
7930        collect_rust_pub_use_reexport_refs(
7931            project_root,
7932            &abs_path,
7933            &rel_path,
7934            &data.import_block.imports,
7935            &line_index,
7936        )
7937    } else {
7938        ReexportRefs {
7939            raw_refs: Vec::new(),
7940            surface_parts: Vec::new(),
7941        }
7942    };
7943    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
7944    let mut raw_refs = Vec::new();
7945    raw_refs.extend(build_call_refs(
7946        &rel_path,
7947        &data,
7948        &node_by_scoped,
7949        &import_dependencies,
7950    ));
7951    raw_refs.extend(build_value_ref_refs(
7952        &rel_path,
7953        &data,
7954        &node_by_scoped,
7955        &import_dependencies,
7956    ));
7957    raw_refs.extend(build_import_refs(
7958        project_root,
7959        &abs_path,
7960        &rel_path,
7961        &data.import_block.imports,
7962        &line_index,
7963    ));
7964    let mut surface_parts = reexports.surface_parts;
7965    surface_parts.extend(rust_reexports.surface_parts);
7966    surface_parts.extend(source_less_exports.surface_parts);
7967    raw_refs.extend(reexports.raw_refs);
7968    raw_refs.extend(rust_reexports.raw_refs);
7969    raw_refs.extend(source_less_exports.raw_refs);
7970    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
7971    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
7972
7973    Ok(FileExtract {
7974        rel_path,
7975        freshness,
7976        lang,
7977        data,
7978        nodes,
7979        raw_refs,
7980        dispatch_hints,
7981        surface_fingerprint,
7982    })
7983}
7984
7985fn build_node_records(
7986    rel_path: &str,
7987    source: &str,
7988    data: &FileCallData,
7989) -> Result<Vec<NodeRecord>> {
7990    let mut records = Vec::new();
7991    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
7992    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
7993    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
7994
7995    for (scoped_name, meta) in metadata {
7996        let name = unqualified_name(scoped_name).to_string();
7997        let range = selection_range(source, scoped_name, &name, &meta.range);
7998        let range_key = (
7999            range.start_line,
8000            range.start_col,
8001            range.end_line,
8002            range.end_col,
8003        );
8004        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8005        let range_ordinal = *ordinal;
8006        *ordinal += 1;
8007        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8008        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8009        let is_default_export = data
8010            .default_export_symbol
8011            .as_deref()
8012            .map(|default| default == scoped_name || default == name)
8013            .unwrap_or(false);
8014        records.push(NodeRecord {
8015            id,
8016            file_path: rel_path.to_string(),
8017            name: name.clone(),
8018            scoped_name: scoped_name.clone(),
8019            kind: symbol_kind_label(&meta.kind).to_string(),
8020            range,
8021            range_ordinal,
8022            signature: meta.signature.clone(),
8023            exported,
8024            is_default_export,
8025            is_type_like: is_type_like(&meta.kind),
8026            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8027                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8028        });
8029    }
8030
8031    Ok(records)
8032}
8033
8034fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8035    if scoped_name == TOP_LEVEL_SYMBOL {
8036        return Range {
8037            start_line: 0,
8038            start_col: 0,
8039            end_line: 0,
8040            end_col: 0,
8041        };
8042    }
8043    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8044        return fallback.clone();
8045    };
8046    let start_col = fallback.start_col as usize;
8047    let search_start = start_col.min(line.len());
8048    if let Some(offset) = line[search_start..].find(name) {
8049        let col = search_start + offset;
8050        return Range {
8051            start_line: fallback.start_line,
8052            start_col: col as u32,
8053            end_line: fallback.start_line,
8054            end_col: (col + name.len()) as u32,
8055        };
8056    }
8057    if let Some(offset) = line.find(name) {
8058        return Range {
8059            start_line: fallback.start_line,
8060            start_col: offset as u32,
8061            end_line: fallback.start_line,
8062            end_col: (offset + name.len()) as u32,
8063        };
8064    }
8065    Range {
8066        start_line: fallback.start_line,
8067        start_col: fallback.start_col,
8068        end_line: fallback.start_line,
8069        end_col: fallback.start_col.saturating_add(name.len() as u32),
8070    }
8071}
8072
8073fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8074    if scoped_name == TOP_LEVEL_SYMBOL {
8075        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8076    }
8077    let input = format!(
8078        "{rel_path}:{}:{}:{}:{}:{ordinal}",
8079        range.start_line, range.start_col, range.end_line, range.end_col
8080    );
8081    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8082}
8083
8084fn build_call_refs(
8085    rel_path: &str,
8086    data: &FileCallData,
8087    node_by_scoped: &HashMap<String, String>,
8088    import_dependencies: &BTreeSet<String>,
8089) -> Vec<RawRef> {
8090    build_callable_refs(
8091        rel_path,
8092        &data.calls_by_symbol,
8093        node_by_scoped,
8094        import_dependencies,
8095        "call",
8096    )
8097}
8098
8099fn build_value_ref_refs(
8100    rel_path: &str,
8101    data: &FileCallData,
8102    node_by_scoped: &HashMap<String, String>,
8103    import_dependencies: &BTreeSet<String>,
8104) -> Vec<RawRef> {
8105    build_callable_refs(
8106        rel_path,
8107        &data.value_refs_by_symbol,
8108        node_by_scoped,
8109        import_dependencies,
8110        "value_ref",
8111    )
8112}
8113
8114fn build_callable_refs(
8115    rel_path: &str,
8116    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8117    node_by_scoped: &HashMap<String, String>,
8118    import_dependencies: &BTreeSet<String>,
8119    kind: &str,
8120) -> Vec<RawRef> {
8121    let mut refs = Vec::new();
8122    let mut ordinal = 0usize;
8123    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8124    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8125    for (caller_symbol, call_sites) in symbols {
8126        let caller_node = node_by_scoped.get(caller_symbol).cloned();
8127        for call_site in call_sites {
8128            ordinal += 1;
8129            let ref_id = ref_id(&[
8130                rel_path,
8131                kind,
8132                caller_symbol,
8133                &call_site.line.to_string(),
8134                &call_site.byte_start.to_string(),
8135                &call_site.byte_end.to_string(),
8136                &call_site.full_callee,
8137                &ordinal.to_string(),
8138            ]);
8139            refs.push(RawRef {
8140                ref_id,
8141                caller_node: caller_node.clone(),
8142                caller_symbol: Some(caller_symbol.clone()),
8143                caller_file: rel_path.to_string(),
8144                kind: kind.to_string(),
8145                short_name: Some(call_site.callee_name.clone()),
8146                full_ref: Some(call_site.full_callee.clone()),
8147                module_path: None,
8148                import_kind: None,
8149                local_name: Some(call_site.callee_name.clone()),
8150                requested_name: Some(call_site.callee_name.clone()),
8151                namespace_alias: namespace_alias(&call_site.full_callee),
8152                wildcard: false,
8153                line: call_site.line,
8154                byte_start: call_site.byte_start,
8155                byte_end: call_site.byte_end,
8156                dependencies: import_dependencies.clone(),
8157            });
8158        }
8159    }
8160    refs
8161}
8162
8163fn build_import_refs(
8164    project_root: &Path,
8165    abs_path: &Path,
8166    rel_path: &str,
8167    imports: &[ImportStatement],
8168    line_index: &LineIndex,
8169) -> Vec<RawRef> {
8170    let mut refs = Vec::new();
8171    for (index, import) in imports.iter().enumerate() {
8172        let import_kind = import_kind_label(import.kind).to_string();
8173        let local_name = import_local_names(import).join(",");
8174        let requested_name = import_requested_names(import).join(",");
8175        let ref_id = ref_id(&[
8176            rel_path,
8177            "import",
8178            &import.byte_range.start.to_string(),
8179            &import.byte_range.end.to_string(),
8180            &import.module_path,
8181            &index.to_string(),
8182        ]);
8183        refs.push(RawRef {
8184            ref_id,
8185            caller_node: None,
8186            caller_symbol: None,
8187            caller_file: rel_path.to_string(),
8188            kind: "import".to_string(),
8189            short_name: None,
8190            full_ref: Some(import.raw_text.clone()),
8191            module_path: Some(import.module_path.clone()),
8192            import_kind: Some(import_kind),
8193            local_name: empty_to_none(local_name),
8194            requested_name: empty_to_none(requested_name),
8195            namespace_alias: import.namespace_import.clone(),
8196            wildcard: import_is_wildcard(import),
8197            line: line_index.byte_to_line(import.byte_range.start),
8198            byte_start: import.byte_range.start,
8199            byte_end: import.byte_range.end,
8200            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8201        });
8202    }
8203    refs
8204}
8205
8206fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8207    let grammar = grammar_for(LangId::Rust);
8208    let mut parser = Parser::new();
8209    if parser.set_language(&grammar).is_err() {
8210        return;
8211    }
8212    let Some(tree) = parser.parse(source, None) else {
8213        return;
8214    };
8215
8216    let mut seen = data
8217        .import_block
8218        .imports
8219        .iter()
8220        .map(|import| (import.byte_range.start, import.byte_range.end))
8221        .collect::<HashSet<_>>();
8222    let mut nested_imports = Vec::new();
8223    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
8224    if nested_imports.is_empty() {
8225        return;
8226    }
8227
8228    data.import_block.imports.extend(nested_imports);
8229    data.import_block
8230        .imports
8231        .sort_by_key(|import| import.byte_range.start);
8232    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
8233}
8234
8235fn collect_rust_use_imports(
8236    source: &str,
8237    node: Node<'_>,
8238    seen: &mut HashSet<(usize, usize)>,
8239    imports: &mut Vec<ImportStatement>,
8240) {
8241    if node.kind() == "use_declaration" {
8242        let range = node.byte_range();
8243        if seen.insert((range.start, range.end)) {
8244            if let Some(import) = rust_import_from_use_node(source, node) {
8245                imports.push(import);
8246            }
8247        }
8248    }
8249
8250    let mut cursor = node.walk();
8251    if !cursor.goto_first_child() {
8252        return;
8253    }
8254    loop {
8255        collect_rust_use_imports(source, cursor.node(), seen, imports);
8256        if !cursor.goto_next_sibling() {
8257            break;
8258        }
8259    }
8260}
8261
8262fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
8263    let raw_text = source[node.byte_range()].to_string();
8264    let body = rust_use_body(&raw_text)?.to_string();
8265    let visibility = rust_use_visibility(&raw_text);
8266    let names = rust_use_list_names(&body);
8267    let group = classify_rust_import_group(&body);
8268    let byte_range = node.byte_range();
8269
8270    Some(ImportStatement {
8271        module_path: body,
8272        names: names.clone(),
8273        default_import: visibility.clone(),
8274        namespace_import: None,
8275        kind: ImportKind::Value,
8276        group,
8277        byte_range,
8278        raw_text,
8279        form: ImportForm::RustUse {
8280            visibility,
8281            named: names,
8282        },
8283    })
8284}
8285
8286fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
8287    let start = imports.iter().map(|import| import.byte_range.start).min()?;
8288    let end = imports.iter().map(|import| import.byte_range.end).max()?;
8289    Some(start..end)
8290}
8291
8292fn rust_use_visibility(raw_text: &str) -> Option<String> {
8293    let use_pos = raw_text.find("use ")?;
8294    let prefix = raw_text[..use_pos].trim();
8295    if prefix.is_empty() {
8296        None
8297    } else {
8298        Some(prefix.to_string())
8299    }
8300}
8301
8302fn rust_use_body(raw_text: &str) -> Option<&str> {
8303    let use_pos = raw_text.find("use ")?;
8304    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
8305}
8306
8307fn rust_use_list_names(body: &str) -> Vec<String> {
8308    let Some(open) = body.find("::{") else {
8309        return Vec::new();
8310    };
8311    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
8312        return Vec::new();
8313    };
8314    body[open + 3..close]
8315        .split(',')
8316        .filter_map(|spec| {
8317            let spec = spec.trim();
8318            if spec.is_empty() {
8319                None
8320            } else {
8321                Some(spec.to_string())
8322            }
8323        })
8324        .collect()
8325}
8326
8327fn classify_rust_import_group(body: &str) -> ImportGroup {
8328    let first = body
8329        .split("::")
8330        .next()
8331        .unwrap_or(body)
8332        .split_whitespace()
8333        .next()
8334        .unwrap_or(body);
8335    match first.trim() {
8336        "std" | "core" | "alloc" => ImportGroup::Stdlib,
8337        "crate" | "self" | "super" => ImportGroup::Internal,
8338        _ => ImportGroup::External,
8339    }
8340}
8341
8342#[derive(Debug, Clone)]
8343struct ReexportRefs {
8344    raw_refs: Vec<RawRef>,
8345    surface_parts: Vec<String>,
8346}
8347
8348fn collect_reexport_refs(
8349    project_root: &Path,
8350    abs_path: &Path,
8351    rel_path: &str,
8352    source: &str,
8353) -> ReexportRefs {
8354    let mut raw_refs = Vec::new();
8355    let mut surface_parts = Vec::new();
8356    let mut search_start = 0usize;
8357    let mut ordinal = 0usize;
8358    while let Some(export_offset) = source[search_start..].find("export") {
8359        let start = search_start + export_offset;
8360        let Some(statement_end_offset) = source[start..].find(';') else {
8361            break;
8362        };
8363        let end = start + statement_end_offset + 1;
8364        let statement = &source[start..end];
8365        search_start = end;
8366        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
8367            continue;
8368        }
8369        let Some(module_path) = quoted_module_path(statement) else {
8370            continue;
8371        };
8372        ordinal += 1;
8373        let wildcard = statement.contains('*');
8374        let line = source[..start]
8375            .bytes()
8376            .filter(|byte| *byte == b'\n')
8377            .count() as u32
8378            + 1;
8379        let ref_id = ref_id(&[
8380            rel_path,
8381            "reexport",
8382            &start.to_string(),
8383            &end.to_string(),
8384            &module_path,
8385            &ordinal.to_string(),
8386        ]);
8387        surface_parts.push(format!("reexport\t{statement}"));
8388        raw_refs.push(RawRef {
8389            ref_id,
8390            caller_node: None,
8391            caller_symbol: None,
8392            caller_file: rel_path.to_string(),
8393            kind: "reexport".to_string(),
8394            short_name: None,
8395            full_ref: Some(statement.to_string()),
8396            module_path: Some(module_path.clone()),
8397            import_kind: Some("reexport".to_string()),
8398            local_name: None,
8399            requested_name: None,
8400            namespace_alias: None,
8401            wildcard,
8402            line,
8403            byte_start: start,
8404            byte_end: end,
8405            dependencies: module_dependencies(project_root, abs_path, &module_path),
8406        });
8407    }
8408    ReexportRefs {
8409        raw_refs,
8410        surface_parts,
8411    }
8412}
8413
8414fn collect_rust_pub_use_reexport_refs(
8415    project_root: &Path,
8416    abs_path: &Path,
8417    rel_path: &str,
8418    imports: &[ImportStatement],
8419    line_index: &LineIndex,
8420) -> ReexportRefs {
8421    let mut raw_refs = Vec::new();
8422    let mut surface_parts = Vec::new();
8423    let mut ordinal = 0usize;
8424
8425    for import in imports {
8426        let Some(visibility) = &import.default_import else {
8427            continue;
8428        };
8429        if !visibility.starts_with("pub") {
8430            continue;
8431        }
8432        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
8433            continue;
8434        };
8435        ordinal += 1;
8436        let ref_id = ref_id(&[
8437            rel_path,
8438            "rust_reexport",
8439            &import.byte_range.start.to_string(),
8440            &import.byte_range.end.to_string(),
8441            &module_path,
8442            &ordinal.to_string(),
8443        ]);
8444        surface_parts.push(format!("reexport\t{}", import.raw_text));
8445        raw_refs.push(RawRef {
8446            ref_id,
8447            caller_node: None,
8448            caller_symbol: None,
8449            caller_file: rel_path.to_string(),
8450            kind: "reexport".to_string(),
8451            short_name: None,
8452            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
8453            module_path: Some(module_path.clone()),
8454            import_kind: Some("reexport".to_string()),
8455            local_name: None,
8456            requested_name: None,
8457            namespace_alias: None,
8458            wildcard,
8459            line: line_index.byte_to_line(import.byte_range.start),
8460            byte_start: import.byte_range.start,
8461            byte_end: import.byte_range.end,
8462            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
8463        });
8464    }
8465
8466    ReexportRefs {
8467        raw_refs,
8468        surface_parts,
8469    }
8470}
8471
8472fn rust_pub_use_reexport_parts(
8473    import: &ImportStatement,
8474) -> Option<(String, HashMap<String, String>, bool)> {
8475    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
8476    let body = body.trim();
8477    if let Some(module_path) = body.strip_suffix("::*") {
8478        return Some((module_path.trim().to_string(), HashMap::new(), true));
8479    }
8480
8481    if let Some(brace_start) = body.find("::{") {
8482        let module_path = body[..brace_start].trim().to_string();
8483        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
8484        if names.is_empty() {
8485            return None;
8486        }
8487        return Some((module_path, names, false));
8488    }
8489
8490    let (module_path, spec) = body.rsplit_once("::")?;
8491    let names = rust_reexport_names_from_specs(spec);
8492    if names.is_empty() {
8493        return None;
8494    }
8495    Some((module_path.trim().to_string(), names, false))
8496}
8497
8498fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
8499    let mut names = HashMap::new();
8500    for spec in specs.split(',') {
8501        let spec = spec.trim();
8502        if spec.is_empty() || spec == "self" {
8503            continue;
8504        }
8505        if let Some((source, local)) = spec.split_once(" as ") {
8506            let source = source.trim();
8507            let local = local.trim();
8508            if !source.is_empty() && !local.is_empty() && source != "self" {
8509                names.insert(local.to_string(), source.to_string());
8510            }
8511        } else {
8512            names.insert(spec.to_string(), spec.to_string());
8513        }
8514    }
8515    names
8516}
8517
8518fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
8519    if named.is_empty() {
8520        return fallback.to_string();
8521    }
8522    let mut specs = named
8523        .iter()
8524        .map(|(local, source)| {
8525            if local == source {
8526                source.clone()
8527            } else {
8528                format!("{source} as {local}")
8529            }
8530        })
8531        .collect::<Vec<_>>();
8532    specs.sort();
8533    format!("pub use {{{}}};", specs.join(", "))
8534}
8535
8536fn quoted_module_path(statement: &str) -> Option<String> {
8537    let quote = match (statement.find('\''), statement.find('"')) {
8538        (Some(single), Some(double)) if single < double => '\'',
8539        (Some(_), Some(_)) => '"',
8540        (Some(_), None) => '\'',
8541        (None, Some(_)) => '"',
8542        (None, None) => return None,
8543    };
8544    let start = statement.find(quote)? + 1;
8545    let end = statement[start..].find(quote)? + start;
8546    Some(statement[start..end].to_string())
8547}
8548
8549#[derive(Debug, Clone)]
8550struct SourceLessExportRefs {
8551    raw_refs: Vec<RawRef>,
8552    surface_parts: Vec<String>,
8553}
8554
8555fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
8556    let mut raw_refs = Vec::new();
8557    let mut surface_parts = Vec::new();
8558    let mut search_start = 0usize;
8559    let mut ordinal = 0usize;
8560    while let Some(export_offset) = source[search_start..].find("export") {
8561        let start = search_start + export_offset;
8562        let Some(statement_end_offset) = source[start..].find(';') else {
8563            break;
8564        };
8565        let end = start + statement_end_offset + 1;
8566        let statement = &source[start..end];
8567        search_start = end;
8568        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
8569            continue;
8570        }
8571        let aliases = parse_reexport_names(statement);
8572        if aliases.is_empty() {
8573            continue;
8574        }
8575        let line = source[..start]
8576            .bytes()
8577            .filter(|byte| *byte == b'\n')
8578            .count() as u32
8579            + 1;
8580        for (exported, source_symbol) in aliases {
8581            ordinal += 1;
8582            let ref_id = ref_id(&[
8583                rel_path,
8584                "export_alias",
8585                &start.to_string(),
8586                &end.to_string(),
8587                &exported,
8588                &source_symbol,
8589                &ordinal.to_string(),
8590            ]);
8591            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
8592            raw_refs.push(RawRef {
8593                ref_id,
8594                caller_node: None,
8595                caller_symbol: None,
8596                caller_file: rel_path.to_string(),
8597                kind: "export_alias".to_string(),
8598                short_name: None,
8599                full_ref: Some(statement.to_string()),
8600                module_path: None,
8601                import_kind: Some("export_alias".to_string()),
8602                local_name: Some(exported),
8603                requested_name: Some(source_symbol),
8604                namespace_alias: None,
8605                wildcard: false,
8606                line,
8607                byte_start: start,
8608                byte_end: end,
8609                dependencies: BTreeSet::new(),
8610            });
8611        }
8612    }
8613    SourceLessExportRefs {
8614        raw_refs,
8615        surface_parts,
8616    }
8617}
8618
8619fn build_dispatch_hints(
8620    rel_path: &str,
8621    data: &FileCallData,
8622    node_by_scoped: &HashMap<String, String>,
8623) -> Vec<DispatchHint> {
8624    let mut hints = Vec::new();
8625    let mut ordinal = 0usize;
8626    for (caller_symbol, call_sites) in &data.calls_by_symbol {
8627        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
8628            continue;
8629        };
8630        for call_site in call_sites {
8631            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
8632                continue;
8633            }
8634            ordinal += 1;
8635            hints.push(DispatchHint {
8636                id: ref_id(&[
8637                    rel_path,
8638                    "dispatch",
8639                    caller_symbol,
8640                    &call_site.line.to_string(),
8641                    &call_site.byte_start.to_string(),
8642                    &call_site.byte_end.to_string(),
8643                    &ordinal.to_string(),
8644                ]),
8645                method_name: call_site.callee_name.clone(),
8646                caller_node: caller_node.clone(),
8647                file: rel_path.to_string(),
8648                line: call_site.line,
8649                byte_start: call_site.byte_start,
8650                byte_end: call_site.byte_end,
8651            });
8652        }
8653    }
8654    hints
8655}
8656
8657fn surface_fingerprint(
8658    nodes: &mut [NodeRecord],
8659    data: &FileCallData,
8660    reexport_parts: &[String],
8661) -> String {
8662    nodes.sort_by(|left, right| {
8663        (left.file_path.as_str(), left.scoped_name.as_str())
8664            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
8665    });
8666    let mut parts = Vec::new();
8667    for node in nodes.iter() {
8668        parts.push(format!(
8669            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
8670            node.scoped_name,
8671            node.name,
8672            node.kind,
8673            node.exported,
8674            node.range.start_line,
8675            node.range.start_col,
8676            node.range.end_line,
8677            node.range.end_col,
8678            node.range_ordinal,
8679            node.signature.as_deref().unwrap_or("")
8680        ));
8681    }
8682    let mut exports = data.exported_symbols.clone();
8683    exports.sort();
8684    for export in exports {
8685        parts.push(format!("export\t{export}"));
8686    }
8687    if let Some(default_export) = &data.default_export_symbol {
8688        parts.push(format!("default\t{default_export}"));
8689    }
8690    let mut imports: Vec<String> = data
8691        .import_block
8692        .imports
8693        .iter()
8694        .map(|import| {
8695            format!(
8696                "import\t{}\t{:?}\t{}",
8697                import.module_path, import.form, import.raw_text
8698            )
8699        })
8700        .collect();
8701    imports.sort();
8702    parts.extend(imports);
8703    parts.extend(reexport_parts.iter().cloned());
8704    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
8705}
8706
8707fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
8708    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
8709        return Ok(ResolvedRef {
8710            dependencies: raw.dependencies.clone(),
8711            raw,
8712            status: "unresolved".to_string(),
8713            target_node: None,
8714            target_file: None,
8715            target_symbol: None,
8716            edge: None,
8717        });
8718    }
8719
8720    let caller_file = raw.caller_file.clone();
8721    let caller_data =
8722        index
8723            .caller_data(&caller_file)
8724            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
8725                file: caller_file.clone(),
8726            })?;
8727    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
8728    let short_name = raw.short_name.as_deref().unwrap_or_default();
8729    let mut dependencies = raw.dependencies.clone();
8730
8731    let resolved = match index.lang_for(&caller_file) {
8732        Some(LangId::Rust) => {
8733            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
8734        }
8735        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
8736            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
8737        }
8738        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
8739    };
8740
8741    let Some((status, target_file, target_symbol)) = resolved else {
8742        return Ok(ResolvedRef {
8743            raw,
8744            status: "unresolved".to_string(),
8745            target_node: None,
8746            target_file: None,
8747            target_symbol: None,
8748            dependencies,
8749            edge: None,
8750        });
8751    };
8752
8753    dependencies.insert(target_file.clone());
8754    let target_node = index.node_for_symbol(&target_file, &target_symbol);
8755    if raw.kind == "value_ref"
8756        && !target_node
8757            .as_deref()
8758            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
8759    {
8760        return Ok(ResolvedRef {
8761            raw,
8762            status: "unresolved".to_string(),
8763            target_node: None,
8764            target_file: None,
8765            target_symbol: None,
8766            dependencies,
8767            edge: None,
8768        });
8769    }
8770    let source_node = raw.caller_node.clone();
8771    let edge = if let Some(source_node) = source_node {
8772        if target_file == caller_file
8773            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
8774        {
8775            None
8776        } else {
8777            Some(EdgeRecord {
8778                edge_id: ref_id(&[&raw.ref_id, "edge"]),
8779                source_node,
8780                target_node: target_node.clone(),
8781                target_file: target_file.clone(),
8782                target_symbol: target_symbol.clone(),
8783                kind: raw.kind.clone(),
8784                line: raw.line,
8785            })
8786        }
8787    } else {
8788        None
8789    };
8790
8791    Ok(ResolvedRef {
8792        raw,
8793        status,
8794        target_node,
8795        target_file: Some(target_file),
8796        target_symbol: Some(target_symbol),
8797        dependencies,
8798        edge,
8799    })
8800}
8801
8802fn resolve_js_ts_target<I: ResolverIndex>(
8803    index: &I,
8804    caller_file: &str,
8805    full_ref: &str,
8806    short_name: &str,
8807    caller_data: &FileCallData,
8808) -> Option<(String, String, String)> {
8809    if let Some((namespace, member)) = full_ref.split_once('.') {
8810        for import in &caller_data.import_block.imports {
8811            if import.namespace_import.as_deref() == Some(namespace) {
8812                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8813                    if let Some((file, symbol)) =
8814                        resolve_exported_symbol(index, &target_file, member, 0)
8815                    {
8816                        return Some(("resolved".to_string(), file, symbol));
8817                    }
8818                }
8819            }
8820        }
8821    }
8822
8823    for import in &caller_data.import_block.imports {
8824        for spec in &import.names {
8825            if crate::imports::specifier_local_name(spec) == short_name {
8826                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8827                    let requested = crate::imports::specifier_imported_name(spec);
8828                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
8829                        .unwrap_or_else(|| (target_file, requested.to_string()));
8830                    return Some(("resolved".to_string(), file, symbol));
8831                }
8832            }
8833        }
8834
8835        if import.default_import.as_deref() == Some(short_name) {
8836            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8837                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
8838                    .or_else(|| {
8839                        index
8840                            .default_export(&target_file)
8841                            .map(|symbol| (target_file.clone(), symbol))
8842                    })
8843                    .unwrap_or_else(|| {
8844                        let file_name = Path::new(&target_file)
8845                            .file_name()
8846                            .and_then(|name| name.to_str())
8847                            .unwrap_or("unknown")
8848                            .to_string();
8849                        (target_file, format!("<default:{file_name}>"))
8850                    });
8851                return Some(("resolved".to_string(), file, symbol));
8852            }
8853        }
8854    }
8855
8856    for import in &caller_data.import_block.imports {
8857        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
8858            if index.has_export(&target_file, short_name) {
8859                return Some(("resolved".to_string(), target_file, short_name.to_string()));
8860            }
8861        }
8862    }
8863
8864    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8865}
8866
8867fn resolve_exported_symbol<I: ResolverIndex>(
8868    index: &I,
8869    file: &str,
8870    requested: &str,
8871    depth: usize,
8872) -> Option<(String, String)> {
8873    let mut visited = std::collections::HashMap::new();
8874    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
8875}
8876
8877/// Re-export graphs are frequently cyclic (barrel files re-exporting each
8878/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
8879/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
8880/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
8881/// (file, symbol) pair — but only when the earlier visit had at least as much
8882/// remaining depth budget (a shallower re-visit can reach leaves the deeper
8883/// first visit had to cut off at the cap, so plain visited-set pruning would
8884/// lose resolutions the capped walk finds).
8885fn resolve_exported_symbol_inner<I: ResolverIndex>(
8886    index: &I,
8887    file: &str,
8888    requested: &str,
8889    depth: usize,
8890    visited: &mut std::collections::HashMap<(String, String), usize>,
8891) -> Option<(String, String)> {
8892    if depth > 16 {
8893        return None;
8894    }
8895    if requested != "default" {
8896        if let Some(source_symbol) = index.export_alias(file, requested) {
8897            return Some((file.to_string(), source_symbol));
8898        }
8899        if index.has_export(file, requested) {
8900            return Some((file.to_string(), requested.to_string()));
8901        }
8902    } else if let Some(default) = index.default_export(file) {
8903        return Some((file.to_string(), default));
8904    }
8905
8906    // Memo check sits after the local-export fast paths: the common direct
8907    // hit never allocates the key, and a hit through the memo would have
8908    // returned above anyway.
8909    match visited.entry((file.to_string(), requested.to_string())) {
8910        std::collections::hash_map::Entry::Occupied(mut seen) => {
8911            if *seen.get() <= depth {
8912                return None;
8913            }
8914            seen.insert(depth);
8915        }
8916        std::collections::hash_map::Entry::Vacant(slot) => {
8917            slot.insert(depth);
8918        }
8919    }
8920
8921    for reexport in index.reexports_for(file) {
8922        let mut next_requested = requested.to_string();
8923        let matches = if reexport.wildcard {
8924            true
8925        } else if let Some(source_name) = reexport.named.get(requested) {
8926            next_requested = source_name.clone();
8927            true
8928        } else {
8929            false
8930        };
8931        if !matches {
8932            continue;
8933        }
8934        if let Some(target_file) = &reexport.target_file {
8935            if let Some(target) = resolve_exported_symbol_inner(
8936                index,
8937                target_file,
8938                &next_requested,
8939                depth + 1,
8940                visited,
8941            ) {
8942                return Some(target);
8943            }
8944        }
8945    }
8946    None
8947}
8948
8949fn resolve_rust_target<I: ResolverIndex>(
8950    index: &I,
8951    caller_file: &str,
8952    full_ref: &str,
8953    short_name: &str,
8954    caller_data: &FileCallData,
8955    raw: &RawRef,
8956) -> Option<(String, String, String)> {
8957    if full_ref.contains("::") {
8958        if let Some((target_file, target_symbol)) =
8959            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
8960        {
8961            return Some(("resolved".to_string(), target_file, target_symbol));
8962        }
8963    }
8964
8965    for import in &caller_data.import_block.imports {
8966        if let Some((target_file, target_symbol)) =
8967            rust_target_for_use(index, caller_file, import, short_name)
8968        {
8969            return Some(("resolved".to_string(), target_file, target_symbol));
8970        }
8971    }
8972
8973    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
8974}
8975
8976fn rust_target_for_qualified<I: ResolverIndex>(
8977    index: &I,
8978    caller_file: &str,
8979    full_ref: &str,
8980    short_name: &str,
8981    caller_data: &FileCallData,
8982    raw: &RawRef,
8983) -> Option<(String, String)> {
8984    let mut segments: Vec<&str> = full_ref.split("::").collect();
8985    if segments.len() < 2 {
8986        return None;
8987    }
8988    segments.pop();
8989    let requested_symbol = rust_target_symbol(full_ref, short_name);
8990
8991    for path in rust_module_path_candidates(&segments, caller_data, raw) {
8992        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
8993        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
8994            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
8995                return Some(rust_resolve_reexport_if_symbol_missing(
8996                    index,
8997                    target_file,
8998                    requested_symbol.clone(),
8999                ));
9000            }
9001        }
9002
9003        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
9004        if let Some(target) =
9005            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9006        {
9007            return Some(target);
9008        }
9009        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9010            return Some(rust_resolve_reexport_if_symbol_missing(
9011                index,
9012                target_file,
9013                requested_symbol.clone(),
9014            ));
9015        }
9016    }
9017    None
9018}
9019
9020fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9021    full_ref
9022        .rsplit("::")
9023        .next()
9024        .filter(|name| !name.is_empty())
9025        .unwrap_or(short_name)
9026        .to_string()
9027}
9028
9029fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9030    index: &I,
9031    target_file: String,
9032    target_symbol: String,
9033) -> (String, String) {
9034    if index
9035        .node_for_symbol(&target_file, &target_symbol)
9036        .is_some()
9037    {
9038        return (target_file, target_symbol);
9039    }
9040    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9041        resolved
9042    } else {
9043        (target_file, target_symbol)
9044    }
9045}
9046
9047fn rust_module_path_candidates(
9048    segments: &[&str],
9049    caller_data: &FileCallData,
9050    raw: &RawRef,
9051) -> Vec<Vec<String>> {
9052    let mut candidates = Vec::new();
9053    if let Some(first) = segments.first().copied() {
9054        for import in &caller_data.import_block.imports {
9055            if !rust_import_is_visible_to_call(import, raw) {
9056                continue;
9057            }
9058            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9059                continue;
9060            };
9061            if local_name == first {
9062                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9063                rust_push_unique_path_candidate(&mut candidates, path_segments);
9064            }
9065        }
9066    }
9067    rust_push_unique_path_candidate(
9068        &mut candidates,
9069        segments
9070            .iter()
9071            .map(|segment| (*segment).to_string())
9072            .collect(),
9073    );
9074    candidates
9075}
9076
9077fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9078    if !candidates.iter().any(|existing| existing == &candidate) {
9079        candidates.push(candidate);
9080    }
9081}
9082
9083fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9084    import.byte_range.start <= raw.byte_start
9085}
9086
9087fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9088    let path = import.module_path.trim().trim_end_matches(';').trim();
9089    if path.contains("::{") || path.contains('{') || path.contains('*') {
9090        return None;
9091    }
9092    let (path_without_alias, alias) = path
9093        .split_once(" as ")
9094        .map(|(left, right)| (left.trim(), Some(right.trim())))
9095        .unwrap_or((path, None));
9096    let segments = path_without_alias
9097        .split("::")
9098        .map(str::trim)
9099        .filter(|segment| !segment.is_empty())
9100        .collect::<Vec<_>>();
9101    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9102    if local_name.chars().next().is_some_and(char::is_uppercase) {
9103        return None;
9104    }
9105    Some((
9106        local_name,
9107        segments
9108            .into_iter()
9109            .map(|segment| segment.to_string())
9110            .collect(),
9111    ))
9112}
9113
9114fn rust_inline_scoped_target<I: ResolverIndex>(
9115    index: &I,
9116    caller_file: &str,
9117    module_segments: &[String],
9118    short_name: &str,
9119) -> Option<(String, String)> {
9120    index.inline_scoped_target(caller_file, module_segments, short_name)
9121}
9122
9123fn rust_target_for_use<I: ResolverIndex>(
9124    index: &I,
9125    caller_file: &str,
9126    import: &ImportStatement,
9127    short_name: &str,
9128) -> Option<(String, String)> {
9129    let path = import.module_path.trim().trim_end_matches(';');
9130    if let Some(brace_start) = path.find("::{") {
9131        let prefix = &path[..brace_start];
9132        if import.names.iter().any(|name| name == short_name) {
9133            let prefix_segments: Vec<&str> = prefix.split("::").collect();
9134            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
9135            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9136            return Some((file, short_name.to_string()));
9137        }
9138        return None;
9139    }
9140
9141    let (path_without_alias, alias) = path
9142        .split_once(" as ")
9143        .map(|(left, right)| (left.trim(), Some(right.trim())))
9144        .unwrap_or((path, None));
9145    let segments: Vec<&str> = path_without_alias.split("::").collect();
9146    let imported = alias.or_else(|| segments.last().copied())?;
9147    if imported != short_name {
9148        return None;
9149    }
9150    if segments.len() < 2 {
9151        return None;
9152    }
9153    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
9154    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9155    Some((file, segments.last().unwrap_or(&short_name).to_string()))
9156}
9157
9158fn rust_workspace_file_for_segments<I: ResolverIndex>(
9159    index: &I,
9160    segments: &[&str],
9161) -> Option<String> {
9162    let crate_name = segments.first().copied()?;
9163    let src_prefix = index.crate_src_prefix(crate_name)?;
9164    let module_segments = segments[1..]
9165        .iter()
9166        .map(|segment| segment.to_string())
9167        .collect::<Vec<_>>();
9168    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
9169}
9170
9171#[cfg(test)]
9172static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9173    OnceLock::new();
9174
9175#[cfg(test)]
9176fn note_workspace_crate_prefix_build(project_root: &Path) {
9177    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9178        .get_or_init(|| Mutex::new(HashMap::new()))
9179        .lock()
9180        .expect("workspace crate prefix build counts mutex poisoned");
9181    *counts.entry(project_root.to_path_buf()).or_default() += 1;
9182}
9183
9184#[cfg(not(test))]
9185fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9186
9187#[cfg(test)]
9188fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9189    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9190        .get_or_init(|| Mutex::new(HashMap::new()))
9191        .lock()
9192        .expect("workspace crate prefix build counts mutex poisoned")
9193        .remove(project_root);
9194}
9195
9196#[cfg(test)]
9197fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9198    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9199        .get_or_init(|| Mutex::new(HashMap::new()))
9200        .lock()
9201        .expect("workspace crate prefix build counts mutex poisoned")
9202        .get(project_root)
9203        .copied()
9204        .unwrap_or(0)
9205}
9206
9207/// Walk the project tree once and map every Rust crate name (package name with
9208/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
9209/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
9210/// longer re-walks the filesystem once per ref.
9211fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9212    note_workspace_crate_prefix_build(project_root);
9213    let mut prefixes = HashMap::new();
9214    let mut stack = vec![project_root.to_path_buf()];
9215    while let Some(dir) = stack.pop() {
9216        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9217        if matches!(name, "target" | "node_modules" | ".git") {
9218            continue;
9219        }
9220        let manifest = dir.join("Cargo.toml");
9221        if manifest.is_file() {
9222            let crate_names = rust_manifest_crate_names(&manifest);
9223            if !crate_names.is_empty() {
9224                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
9225                for crate_name in crate_names {
9226                    prefixes
9227                        .entry(crate_name)
9228                        .or_insert_with(|| src_prefix.clone());
9229                }
9230            }
9231        }
9232        let Ok(entries) = std::fs::read_dir(&dir) else {
9233            continue;
9234        };
9235        for entry in entries.flatten() {
9236            let path = entry.path();
9237            if path.is_dir() {
9238                stack.push(path);
9239            }
9240        }
9241    }
9242    prefixes
9243}
9244
9245/// Extract the crate names a manifest defines: the normalized package name
9246/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
9247/// reachable by either spelling, matching the previous match semantics.
9248fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
9249    let Ok(source) = std::fs::read_to_string(manifest) else {
9250        return Vec::new();
9251    };
9252    let mut in_lib = false;
9253    let mut package_name = None;
9254    let mut lib_name = None;
9255    for line in source.lines() {
9256        let trimmed = line.trim();
9257        if trimmed.starts_with('[') {
9258            in_lib = trimmed == "[lib]";
9259            continue;
9260        }
9261        let Some((key, value)) = trimmed.split_once('=') else {
9262            continue;
9263        };
9264        let key = key.trim();
9265        let value = value.trim().trim_matches('"');
9266        if in_lib && key == "name" {
9267            lib_name = Some(value.to_string());
9268        } else if !in_lib && key == "name" && package_name.is_none() {
9269            package_name = Some(value.to_string());
9270        }
9271    }
9272    let mut names = Vec::new();
9273    if let Some(lib) = lib_name {
9274        names.push(lib);
9275    }
9276    if let Some(package) = package_name {
9277        let normalized = package.replace('-', "_");
9278        if !names.contains(&normalized) {
9279            names.push(normalized);
9280        }
9281    }
9282    names
9283}
9284
9285fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
9286    if segments.is_empty() {
9287        return Some(Vec::new());
9288    }
9289    let caller_segments = rust_module_segments_for_rel(caller_file);
9290    match segments[0] {
9291        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
9292        "self" => {
9293            let mut resolved = caller_segments;
9294            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9295            Some(resolved)
9296        }
9297        "super" => {
9298            let mut resolved = caller_segments;
9299            resolved.pop();
9300            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
9301            Some(resolved)
9302        }
9303        _ => {
9304            let mut resolved = caller_segments;
9305            resolved.pop();
9306            resolved.extend(segments.iter().map(|item| item.to_string()));
9307            Some(resolved)
9308        }
9309    }
9310}
9311
9312fn rust_file_for_segments<I: ResolverIndex>(
9313    index: &I,
9314    caller_file: &str,
9315    segments: &[String],
9316) -> Option<String> {
9317    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
9318}
9319
9320fn rust_file_for_src_prefix<I: ResolverIndex>(
9321    index: &I,
9322    src_prefix: &str,
9323    segments: &[String],
9324) -> Option<String> {
9325    let candidate = if segments.is_empty() {
9326        [src_prefix, "lib.rs"].join("/")
9327    } else {
9328        format!("{}/{}.rs", src_prefix, segments.join("/"))
9329    };
9330    if index.contains_file(&candidate) {
9331        return Some(candidate);
9332    }
9333    if !segments.is_empty() {
9334        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
9335        if index.contains_file(&mod_candidate) {
9336            return Some(mod_candidate);
9337        }
9338    }
9339    None
9340}
9341
9342fn rust_src_prefix(rel_path: &str) -> String {
9343    rel_path
9344        .split_once("/src/")
9345        .map(|(prefix, _)| format!("{prefix}/src"))
9346        .unwrap_or_else(|| "src".to_string())
9347}
9348
9349fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
9350    let after_src = rel_path
9351        .split_once("/src/")
9352        .map(|(_, rest)| rest)
9353        .or_else(|| rel_path.strip_prefix("src/"))
9354        .unwrap_or(rel_path);
9355    if matches!(after_src, "lib.rs" | "main.rs") {
9356        return Vec::new();
9357    }
9358    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
9359        return prefix.split('/').map(|item| item.to_string()).collect();
9360    }
9361    after_src
9362        .strip_suffix(".rs")
9363        .unwrap_or(after_src)
9364        .split('/')
9365        .map(|item| item.to_string())
9366        .collect()
9367}
9368
9369fn resolve_local_target<I: ResolverIndex>(
9370    _index: &I,
9371    caller_file: &str,
9372    full_ref: &str,
9373    short_name: &str,
9374    caller_data: &FileCallData,
9375) -> Option<(String, String, String)> {
9376    if !callgraph::is_bare_callee(full_ref, short_name) {
9377        return None;
9378    }
9379    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
9380        .ok()
9381        .map(|symbol| {
9382            (
9383                "resolved_local".to_string(),
9384                caller_file.to_string(),
9385                symbol,
9386            )
9387        })
9388}
9389
9390impl<'a> ProjectIndex<'a> {
9391    fn from_parts(
9392        project_root: &Path,
9393        files: HashMap<String, DbFileIndex>,
9394        caller_data: HashMap<String, &'a FileCallData>,
9395        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9396    ) -> Self {
9397        Self {
9398            project_root: project_root.to_path_buf(),
9399            files,
9400            caller_data,
9401            workspace_crate_prefixes,
9402        }
9403    }
9404
9405    fn from_db_and_callers(
9406        tx: &Transaction<'_>,
9407        project_root: &Path,
9408        caller_extracts: &'a HashMap<String, FileExtract>,
9409        workspace_crate_prefixes: WorkspaceCratePrefixCache,
9410    ) -> Result<Self> {
9411        let mut files = load_db_file_indexes(tx, project_root)?;
9412        let mut caller_data = HashMap::new();
9413        for (rel_path, extract) in caller_extracts {
9414            files.insert(
9415                rel_path.clone(),
9416                DbFileIndex::from_extract(project_root, extract),
9417            );
9418            caller_data.insert(rel_path.clone(), &extract.data);
9419        }
9420        Ok(Self::from_parts(
9421            project_root,
9422            files,
9423            caller_data,
9424            workspace_crate_prefixes,
9425        ))
9426    }
9427
9428    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
9429        self.files.get(rel_path).and_then(|file| file.lang)
9430    }
9431
9432    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
9433        self.files
9434            .get(caller_file)
9435            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
9436    }
9437
9438    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
9439        self.files
9440            .get(rel_path)
9441            .map(|file| file.reexports.as_slice())
9442            .unwrap_or(&[])
9443    }
9444
9445    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
9446        self.files.get(rel_path).and_then(|file| {
9447            file.node_by_scoped
9448                .get(symbol)
9449                .cloned()
9450                .or_else(|| file.node_by_bare.get(symbol).cloned())
9451        })
9452    }
9453
9454    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
9455        self.files
9456            .get(rel_path)
9457            .and_then(|file| file.node_kind_by_id.get(node_id))
9458            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
9459    }
9460}
9461
9462impl DbFileIndex {
9463    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
9464        let mut node_by_scoped = HashMap::new();
9465        let mut node_by_bare = HashMap::new();
9466        for node in &extract.nodes {
9467            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
9468            node_by_bare
9469                .entry(node.name.clone())
9470                .or_insert(node.id.clone());
9471        }
9472        let node_kind_by_id = extract
9473            .nodes
9474            .iter()
9475            .map(|node| (node.id.clone(), node.kind.clone()))
9476            .collect();
9477        let mut export_aliases = HashMap::new();
9478        for raw_ref in &extract.raw_refs {
9479            if raw_ref.kind == "export_alias" {
9480                if let (Some(exported), Some(source_symbol)) =
9481                    (&raw_ref.local_name, &raw_ref.requested_name)
9482                {
9483                    export_aliases.insert(exported.clone(), source_symbol.clone());
9484                }
9485            }
9486        }
9487        let mut module_targets = HashMap::new();
9488        let mut reexports = Vec::new();
9489        for raw_ref in &extract.raw_refs {
9490            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
9491                continue;
9492            }
9493            let Some(module_path) = &raw_ref.module_path else {
9494                continue;
9495            };
9496            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
9497            module_targets
9498                .entry(module_path.clone())
9499                .or_insert_with(|| target_file.clone());
9500            if raw_ref.kind == "reexport" {
9501                reexports.push(reexport_index_from_raw(raw_ref, target_file));
9502            }
9503        }
9504        Self {
9505            lang: Some(extract.lang),
9506            exports: extract.data.exported_symbols.iter().cloned().collect(),
9507            default_export: extract.data.default_export_symbol.clone(),
9508            export_aliases,
9509            node_by_scoped,
9510            node_by_bare,
9511            node_kind_by_id,
9512            module_targets,
9513            reexports,
9514        }
9515    }
9516}
9517
9518fn load_db_file_indexes(
9519    tx: &Transaction<'_>,
9520    project_root: &Path,
9521) -> Result<HashMap<String, DbFileIndex>> {
9522    let mut files = HashMap::new();
9523    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
9524    let rows = stmt.query_map([], |row| {
9525        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9526    })?;
9527    for row in rows {
9528        let (rel_path, lang) = row?;
9529        files.insert(
9530            rel_path.clone(),
9531            DbFileIndex {
9532                lang: lang_from_label(&lang),
9533                exports: HashSet::new(),
9534                default_export: None,
9535                export_aliases: HashMap::new(),
9536                node_by_scoped: HashMap::new(),
9537                node_by_bare: HashMap::new(),
9538                node_kind_by_id: HashMap::new(),
9539                module_targets: HashMap::new(),
9540                reexports: Vec::new(),
9541            },
9542        );
9543    }
9544
9545    let mut node_stmt = tx.prepare(
9546        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
9547    )?;
9548    let nodes = node_stmt.query_map([], |row| {
9549        Ok((
9550            row.get::<_, String>(0)?,
9551            row.get::<_, String>(1)?,
9552            row.get::<_, String>(2)?,
9553            row.get::<_, String>(3)?,
9554            row.get::<_, String>(4)?,
9555            row.get::<_, i64>(5)? != 0,
9556            row.get::<_, i64>(6)? != 0,
9557        ))
9558    })?;
9559    for row in nodes {
9560        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
9561        let file = files
9562            .entry(file_path.clone())
9563            .or_insert_with(|| DbFileIndex {
9564                lang: None,
9565                exports: HashSet::new(),
9566                default_export: None,
9567                export_aliases: HashMap::new(),
9568                node_by_scoped: HashMap::new(),
9569                node_by_bare: HashMap::new(),
9570                node_kind_by_id: HashMap::new(),
9571                module_targets: HashMap::new(),
9572                reexports: Vec::new(),
9573            });
9574        if exported {
9575            file.exports.insert(name.clone());
9576            file.exports.insert(scoped_name.clone());
9577        }
9578        if is_default_export {
9579            file.default_export = Some(scoped_name.clone());
9580        }
9581        file.node_by_scoped.insert(scoped_name, id.clone());
9582        file.node_by_bare.entry(name).or_insert(id.clone());
9583        file.node_kind_by_id.insert(id, kind);
9584    }
9585    let file_keys: HashSet<String> = files.keys().cloned().collect();
9586    // Persisted caller extracts supply import targets. Only reexports from other
9587    // files need dependency reconstruction, and their caller dependencies are
9588    // loaded once instead of issuing repeated SQLite queries per reference.
9589    let dependencies_by_file = load_file_dependencies_index(tx)?;
9590    let mut ref_stmt = tx.prepare(
9591        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
9592         FROM refs WHERE kind IN ('reexport', 'export_alias')",
9593    )?;
9594    let ref_rows = ref_stmt.query_map([], |row| {
9595        Ok((
9596            row.get::<_, String>(0)?,
9597            row.get::<_, String>(1)?,
9598            row.get::<_, String>(2)?,
9599            row.get::<_, Option<String>>(3)?,
9600            row.get::<_, Option<String>>(4)?,
9601            row.get::<_, i64>(5)? != 0,
9602            row.get::<_, Option<String>>(6)?,
9603            row.get::<_, Option<String>>(7)?,
9604        ))
9605    })?;
9606    for row in ref_rows {
9607        let (
9608            ref_id,
9609            caller_file,
9610            kind,
9611            module_path,
9612            full_ref,
9613            wildcard,
9614            local_name,
9615            requested_name,
9616        ) = row?;
9617        if kind == "export_alias" {
9618            if let (Some(exported), Some(source_symbol), Some(file)) =
9619                (local_name, requested_name, files.get_mut(&caller_file))
9620            {
9621                file.export_aliases.insert(exported, source_symbol);
9622            }
9623            continue;
9624        }
9625        let Some(module_path) = module_path else {
9626            continue;
9627        };
9628        let file_deps = dependencies_by_file
9629            .get(&caller_file)
9630            .cloned()
9631            .unwrap_or_default();
9632        let deps = stored_dependencies_for_module(
9633            project_root,
9634            &caller_file,
9635            &module_path,
9636            &file_deps,
9637            &file_keys,
9638        );
9639        let target_file = deps
9640            .iter()
9641            .find(|dep| file_keys.contains(*dep))
9642            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
9643        if let Some(file) = files.get_mut(&caller_file) {
9644            file.module_targets
9645                .entry(module_path.clone())
9646                .or_insert_with(|| target_file.clone());
9647            if kind == "reexport" {
9648                let raw = RawRef {
9649                    ref_id,
9650                    caller_node: None,
9651                    caller_symbol: None,
9652                    caller_file,
9653                    kind,
9654                    short_name: None,
9655                    full_ref,
9656                    module_path: Some(module_path),
9657                    import_kind: Some("reexport".to_string()),
9658                    local_name: None,
9659                    requested_name: None,
9660                    namespace_alias: None,
9661                    wildcard,
9662                    line: 0,
9663                    byte_start: 0,
9664                    byte_end: 0,
9665                    dependencies: deps,
9666                };
9667                file.reexports
9668                    .push(reexport_index_from_raw(&raw, target_file));
9669            }
9670        }
9671    }
9672
9673    Ok(files)
9674}
9675
9676fn stored_dependencies_for_module(
9677    project_root: &Path,
9678    caller_file: &str,
9679    module_path: &str,
9680    caller_dependencies: &BTreeSet<String>,
9681    indexed_files: &HashSet<String>,
9682) -> BTreeSet<String> {
9683    let caller_path = project_root.join(caller_file);
9684    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
9685    if module_path.starts_with('.') {
9686        let caller_dir = caller_path.parent().unwrap_or(project_root);
9687        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
9688            let normalized = if candidate.is_file() {
9689                canonicalize_path(&candidate)
9690            } else {
9691                candidate
9692            };
9693            candidates.insert(relative_path(project_root, &normalized));
9694        }
9695    }
9696    let exact = candidates
9697        .intersection(caller_dependencies)
9698        .filter(|dependency| indexed_files.contains(*dependency))
9699        .cloned()
9700        .collect::<BTreeSet<_>>();
9701    if !exact.is_empty() || module_path.starts_with('.') {
9702        return exact;
9703    }
9704
9705    let module_path = rust_module_path_without_alias_or_use_list(module_path)
9706        .trim_matches(|character| matches!(character, '\'' | '"'));
9707    let package_name = module_path
9708        .split('/')
9709        .next_back()
9710        .unwrap_or(module_path)
9711        .replace('_', "-");
9712    let matched = caller_dependencies
9713        .iter()
9714        .filter(|dependency| indexed_files.contains(*dependency))
9715        .filter(|dependency| {
9716            dependency.as_str() == module_path
9717                || dependency.ends_with(&format!("/{module_path}"))
9718                || Path::new(dependency).components().any(|component| {
9719                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
9720                })
9721        })
9722        .cloned()
9723        .collect::<BTreeSet<_>>();
9724    if matched.len() == 1 {
9725        matched
9726    } else {
9727        BTreeSet::new()
9728    }
9729}
9730
9731fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
9732    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
9733    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
9734    let rows = stmt.query_map([], |row| {
9735        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9736    })?;
9737    for row in rows {
9738        let (file_path, dependency) = row?;
9739        by_file.entry(file_path).or_default().insert(dependency);
9740    }
9741    Ok(by_file)
9742}
9743
9744struct ColdBuildInsertStatements<'stmt> {
9745    file: Statement<'stmt>,
9746    node: Statement<'stmt>,
9747    file_dependency: Statement<'stmt>,
9748    dispatch_hint: Statement<'stmt>,
9749    backend_state: Statement<'stmt>,
9750    reference: Statement<'stmt>,
9751    staging_ref_context: Statement<'stmt>,
9752    edge: Statement<'stmt>,
9753}
9754
9755impl<'stmt> ColdBuildInsertStatements<'stmt> {
9756    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
9757        Ok(Self {
9758            file: tx.prepare(
9759                "INSERT OR REPLACE INTO files(
9760                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9761                    is_public_api, surface_fingerprint, indexed_at
9762                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9763            )?,
9764            node: tx.prepare(
9765                "INSERT OR REPLACE INTO nodes(
9766                    id, file_path, name, scoped_name, kind, start_line, start_col,
9767                    end_line, end_col, range_ordinal, signature, exported,
9768                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
9769                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9770            )?,
9771            file_dependency: tx.prepare(
9772                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
9773            )?,
9774            dispatch_hint: tx.prepare(
9775                "INSERT OR REPLACE INTO dispatch_hints(
9776                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
9777                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
9778            )?,
9779            backend_state: tx.prepare(
9780                "INSERT OR REPLACE INTO backend_file_state(
9781                    backend, workspace_root, file_path, content_hash, status, updated_at
9782                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9783            )?,
9784            reference: tx.prepare(
9785                "INSERT OR REPLACE INTO refs(
9786                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
9787                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
9788                    byte_start, byte_end, status, target_node, target_file, target_symbol,
9789                    provenance
9790                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
9791            )?,
9792            staging_ref_context: tx.prepare(
9793                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
9794            )?,
9795            edge: tx.prepare(
9796                "INSERT OR REPLACE INTO edges(
9797                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
9798                    kind, line, provenance
9799                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
9800            )?,
9801        })
9802    }
9803}
9804
9805fn insert_file_extract_prepared(
9806    statements: &mut ColdBuildInsertStatements<'_>,
9807    workspace_root: &str,
9808    extract: &FileExtract,
9809) -> Result<()> {
9810    statements.file.execute(params![
9811        extract.rel_path,
9812        hash_to_hex(extract.freshness.content_hash),
9813        system_time_to_ns(extract.freshness.mtime),
9814        extract.freshness.size as i64,
9815        lang_label(extract.lang),
9816        extract.surface_fingerprint,
9817        unix_seconds_now(),
9818    ])?;
9819    for node in &extract.nodes {
9820        statements.node.execute(params![
9821            node.id,
9822            node.file_path,
9823            node.name,
9824            node.scoped_name,
9825            node.kind,
9826            node.range.start_line as i64,
9827            node.range.start_col as i64,
9828            node.range.end_line as i64,
9829            node.range.end_col as i64,
9830            node.range_ordinal as i64,
9831            node.signature,
9832            bool_int(node.exported),
9833            bool_int(node.is_default_export),
9834            bool_int(node.is_type_like),
9835            bool_int(node.is_callgraph_entry_point),
9836            PROVENANCE_TREESITTER,
9837        ])?;
9838    }
9839
9840    let mut dependencies = BTreeSet::new();
9841    for raw_ref in &extract.raw_refs {
9842        dependencies.extend(raw_ref.dependencies.iter().cloned());
9843    }
9844    for dep_file in &dependencies {
9845        statements
9846            .file_dependency
9847            .execute(params![extract.rel_path, dep_file])?;
9848    }
9849
9850    for hint in &extract.dispatch_hints {
9851        statements.dispatch_hint.execute(params![
9852            hint.id,
9853            hint.method_name,
9854            hint.caller_node,
9855            hint.file,
9856            hint.line as i64,
9857            hint.byte_start as i64,
9858            hint.byte_end as i64,
9859            PROVENANCE_TREESITTER,
9860        ])?;
9861    }
9862    insert_backend_state_prepared(
9863        &mut statements.backend_state,
9864        workspace_root,
9865        &extract.rel_path,
9866        Some(&extract.freshness.content_hash),
9867        "fresh",
9868    )?;
9869    Ok(())
9870}
9871
9872fn insert_backend_state_prepared(
9873    stmt: &mut Statement<'_>,
9874    workspace_root: &str,
9875    rel_path: &str,
9876    content_hash: Option<&blake3::Hash>,
9877    status: &str,
9878) -> Result<()> {
9879    let hash = content_hash
9880        .map(|hash| hash_to_hex(*hash))
9881        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9882    stmt.execute(params![
9883        BACKEND_TREESITTER,
9884        workspace_root,
9885        rel_path,
9886        hash,
9887        status,
9888        unix_seconds_now(),
9889    ])?;
9890    Ok(())
9891}
9892
9893fn insert_staged_ref_prepared(
9894    statements: &mut ColdBuildInsertStatements<'_>,
9895    raw: &RawRef,
9896) -> Result<()> {
9897    statements.reference.execute(params![
9898        raw.ref_id,
9899        raw.caller_node,
9900        raw.caller_file,
9901        raw.kind,
9902        raw.short_name,
9903        raw.full_ref,
9904        raw.module_path,
9905        raw.import_kind,
9906        raw.local_name,
9907        raw.requested_name,
9908        raw.namespace_alias,
9909        bool_int(raw.wildcard),
9910        raw.line as i64,
9911        raw.byte_start as i64,
9912        raw.byte_end as i64,
9913        "staged",
9914        Option::<String>::None,
9915        Option::<String>::None,
9916        Option::<String>::None,
9917        ref_provenance(raw),
9918    ])?;
9919    statements
9920        .staging_ref_context
9921        .execute(params![raw.ref_id, raw.caller_symbol])?;
9922    Ok(())
9923}
9924
9925fn insert_resolved_ref_prepared(
9926    statements: &mut ColdBuildInsertStatements<'_>,
9927    resolved: &ResolvedRef,
9928) -> Result<()> {
9929    let raw = &resolved.raw;
9930    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
9931    statements.reference.execute(params![
9932        raw.ref_id,
9933        raw.caller_node,
9934        raw.caller_file,
9935        raw.kind,
9936        raw.short_name,
9937        raw.full_ref,
9938        raw.module_path,
9939        raw.import_kind,
9940        raw.local_name,
9941        raw.requested_name,
9942        raw.namespace_alias,
9943        bool_int(raw.wildcard),
9944        raw.line as i64,
9945        raw.byte_start as i64,
9946        raw.byte_end as i64,
9947        resolved.status,
9948        resolved.target_node,
9949        resolved.target_file,
9950        resolved.target_symbol,
9951        ref_provenance(raw),
9952    ])?;
9953    if let Some(edge) = &resolved.edge {
9954        statements.edge.execute(params![
9955            edge.edge_id,
9956            raw.ref_id,
9957            edge.source_node,
9958            edge.target_node,
9959            edge.target_file,
9960            edge.target_symbol,
9961            edge.kind,
9962            edge.line as i64,
9963            ref_provenance(raw),
9964        ])?;
9965    }
9966    Ok(())
9967}
9968
9969#[cfg(test)]
9970fn insert_file_extract(
9971    tx: &Transaction<'_>,
9972    project_root: &Path,
9973    extract: &FileExtract,
9974) -> Result<()> {
9975    tx.execute(
9976        "INSERT OR REPLACE INTO files(
9977            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
9978            is_public_api, surface_fingerprint, indexed_at
9979        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
9980        params![
9981            extract.rel_path,
9982            hash_to_hex(extract.freshness.content_hash),
9983            system_time_to_ns(extract.freshness.mtime),
9984            extract.freshness.size as i64,
9985            lang_label(extract.lang),
9986            extract.surface_fingerprint,
9987            unix_seconds_now(),
9988        ],
9989    )?;
9990    for node in &extract.nodes {
9991        tx.execute(
9992            "INSERT OR REPLACE INTO nodes(
9993                id, file_path, name, scoped_name, kind, start_line, start_col,
9994                end_line, end_col, range_ordinal, signature, exported,
9995                is_default_export, is_type_like, is_callgraph_entry_point, provenance
9996            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
9997            params![
9998                node.id,
9999                node.file_path,
10000                node.name,
10001                node.scoped_name,
10002                node.kind,
10003                node.range.start_line as i64,
10004                node.range.start_col as i64,
10005                node.range.end_line as i64,
10006                node.range.end_col as i64,
10007                node.range_ordinal as i64,
10008                node.signature,
10009                bool_int(node.exported),
10010                bool_int(node.is_default_export),
10011                bool_int(node.is_type_like),
10012                bool_int(node.is_callgraph_entry_point),
10013                PROVENANCE_TREESITTER,
10014            ],
10015        )?;
10016    }
10017    let mut dependencies = BTreeSet::new();
10018    for raw_ref in &extract.raw_refs {
10019        dependencies.extend(raw_ref.dependencies.iter().cloned());
10020    }
10021    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10022
10023    for hint in &extract.dispatch_hints {
10024        tx.execute(
10025            "INSERT OR REPLACE INTO dispatch_hints(
10026                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10027            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10028            params![
10029                hint.id,
10030                hint.method_name,
10031                hint.caller_node,
10032                hint.file,
10033                hint.line as i64,
10034                hint.byte_start as i64,
10035                hint.byte_end as i64,
10036                PROVENANCE_TREESITTER,
10037            ],
10038        )?;
10039    }
10040    mark_backend_state(
10041        tx,
10042        project_root,
10043        &extract.rel_path,
10044        Some(&extract.freshness.content_hash),
10045        "fresh",
10046    )?;
10047    Ok(())
10048}
10049
10050#[cfg(test)]
10051fn insert_file_dependencies(
10052    tx: &Transaction<'_>,
10053    file_path: &str,
10054    dependencies: &BTreeSet<String>,
10055) -> Result<()> {
10056    for dep_file in dependencies {
10057        tx.execute(
10058            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10059            params![file_path, dep_file],
10060        )?;
10061    }
10062    Ok(())
10063}
10064
10065fn ref_provenance(raw: &RawRef) -> &'static str {
10066    if raw.kind == "value_ref" {
10067        PROVENANCE_VALUE_REF
10068    } else {
10069        PROVENANCE_TREESITTER
10070    }
10071}
10072
10073#[cfg(test)]
10074fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
10075    let raw = &resolved.raw;
10076    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10077    tx.execute(
10078        "INSERT OR REPLACE INTO refs(
10079            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10080            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10081            byte_start, byte_end, status, target_node, target_file, target_symbol,
10082            provenance
10083        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10084        params![
10085            raw.ref_id,
10086            raw.caller_node,
10087            raw.caller_file,
10088            raw.kind,
10089            raw.short_name,
10090            raw.full_ref,
10091            raw.module_path,
10092            raw.import_kind,
10093            raw.local_name,
10094            raw.requested_name,
10095            raw.namespace_alias,
10096            bool_int(raw.wildcard),
10097            raw.line as i64,
10098            raw.byte_start as i64,
10099            raw.byte_end as i64,
10100            resolved.status,
10101            resolved.target_node,
10102            resolved.target_file,
10103            resolved.target_symbol,
10104            ref_provenance(raw),
10105        ],
10106    )?;
10107    if let Some(edge) = &resolved.edge {
10108        tx.execute(
10109            "INSERT OR REPLACE INTO edges(
10110                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10111                kind, line, provenance
10112            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10113            params![
10114                edge.edge_id,
10115                raw.ref_id,
10116                edge.source_node,
10117                edge.target_node,
10118                edge.target_file,
10119                edge.target_symbol,
10120                edge.kind,
10121                edge.line as i64,
10122                ref_provenance(raw),
10123            ],
10124        )?;
10125    }
10126    Ok(())
10127}
10128
10129fn insert_method_dispatch_edges(
10130    tx: &Transaction<'_>,
10131    project_root: &Path,
10132    caller_files: Option<&BTreeSet<String>>,
10133) -> Result<usize> {
10134    let references = load_name_match_refs(tx, caller_files)?;
10135    if references.is_empty() {
10136        return Ok(0);
10137    }
10138
10139    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
10140    let mut source_cache: DispatchSourceCache = HashMap::new();
10141    let mut inserted = 0usize;
10142    for reference in references {
10143        let key = (reference.method_name.clone(), reference.lang.clone());
10144        let candidates = match candidates_by_name.entry(key) {
10145            Entry::Occupied(entry) => entry.into_mut(),
10146            Entry::Vacant(entry) => {
10147                let candidates =
10148                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
10149                entry.insert(candidates)
10150            }
10151        };
10152
10153        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
10154            ReceiverTypeInference::Known(receiver_type) => {
10155                let Some(candidate) =
10156                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
10157                else {
10158                    continue;
10159                };
10160                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10161                inserted += 1;
10162                continue;
10163            }
10164            ReceiverTypeInference::RustDirectSelfField {
10165                receiver_type,
10166                declaration_file,
10167                module_scope,
10168            } => {
10169                let Some(candidate) = select_rust_direct_self_field_candidate(
10170                    project_root,
10171                    &reference,
10172                    candidates.as_slice(),
10173                    &receiver_type,
10174                    &declaration_file,
10175                    &module_scope,
10176                    &mut source_cache,
10177                ) else {
10178                    continue;
10179                };
10180                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
10181                inserted += 1;
10182                continue;
10183            }
10184            ReceiverTypeInference::KnownButUnresolved => continue,
10185            ReceiverTypeInference::Unknown => {}
10186        }
10187
10188        if method_name_match_denylisted(&reference.method_name) {
10189            continue;
10190        }
10191
10192        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
10193            continue;
10194        };
10195        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
10196        inserted += 1;
10197    }
10198    Ok(inserted)
10199}
10200
10201fn insert_method_dispatch_edges_chunked(
10202    tx: &Transaction<'_>,
10203    project_root: &Path,
10204    chunk_size: usize,
10205) -> Result<usize> {
10206    let mut inserted = 0usize;
10207    let mut after_file = String::new();
10208    loop {
10209        let caller_files = {
10210            let mut statement = tx.prepare(
10211                "SELECT DISTINCT caller_file
10212                 FROM refs
10213                 WHERE caller_file > ?1
10214                 ORDER BY caller_file
10215                 LIMIT ?2",
10216            )?;
10217            let rows = statement
10218                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
10219                    row.get::<_, String>(0)
10220                })?;
10221            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
10222        };
10223        let Some(last_file) = caller_files.last().cloned() else {
10224            break;
10225        };
10226        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
10227        after_file = last_file;
10228    }
10229    Ok(inserted)
10230}
10231
10232fn insert_method_dispatch_edge(
10233    tx: &Transaction<'_>,
10234    reference: &NameMatchRef,
10235    candidate: &NameMatchCandidate,
10236    provenance: &str,
10237) -> Result<()> {
10238    tx.execute(
10239        "INSERT OR REPLACE INTO edges(
10240            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10241            kind, line, provenance
10242        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
10243        params![
10244            ref_id(&[&reference.ref_id, provenance, "edge"]),
10245            &reference.ref_id,
10246            &reference.caller_node,
10247            &candidate.node_id,
10248            &candidate.file_path,
10249            &candidate.scoped_name,
10250            reference.line as i64,
10251            provenance,
10252        ],
10253    )?;
10254    Ok(())
10255}
10256
10257fn delete_method_dispatch_edges_for_callers(
10258    tx: &Transaction<'_>,
10259    caller_files: &BTreeSet<String>,
10260) -> Result<()> {
10261    if caller_files.is_empty() {
10262        return Ok(());
10263    }
10264
10265    let mut stmt = tx.prepare(
10266        "DELETE FROM edges
10267         WHERE provenance IN (?1, ?2)
10268           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
10269    )?;
10270    for caller_file in caller_files {
10271        stmt.execute(params![
10272            PROVENANCE_NAME_MATCH,
10273            PROVENANCE_TYPE_MATCH,
10274            caller_file
10275        ])?;
10276    }
10277    Ok(())
10278}
10279
10280fn load_name_match_refs(
10281    tx: &Transaction<'_>,
10282    caller_files: Option<&BTreeSet<String>>,
10283) -> Result<Vec<NameMatchRef>> {
10284    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
10285                           n.signature, r.short_name, r.full_ref, r.line, f.lang
10286                    FROM refs r
10287                    JOIN files f ON f.path = r.caller_file
10288                    JOIN nodes n ON n.id = r.caller_node
10289                    WHERE r.kind = 'call'
10290                      AND r.status = 'unresolved'
10291                      AND r.caller_node IS NOT NULL
10292                      AND r.full_ref IS NOT NULL
10293                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
10294                      AND NOT EXISTS (
10295                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
10296                      )";
10297    let mut references = Vec::new();
10298
10299    if let Some(caller_files) = caller_files {
10300        if caller_files.is_empty() {
10301            return Ok(references);
10302        }
10303        let sql = format!(
10304            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
10305        );
10306        let mut stmt = tx.prepare(&sql)?;
10307        for caller_file in caller_files {
10308            let rows = stmt.query_map(params![caller_file], |row| {
10309                Ok((
10310                    row.get::<_, String>(0)?,
10311                    row.get::<_, Option<String>>(1)?,
10312                    row.get::<_, String>(2)?,
10313                    row.get::<_, String>(3)?,
10314                    row.get::<_, Option<String>>(4)?,
10315                    row.get::<_, Option<String>>(5)?,
10316                    row.get::<_, Option<String>>(6)?,
10317                    row.get::<_, i64>(7)?,
10318                    row.get::<_, String>(8)?,
10319                ))
10320            })?;
10321            for row in rows {
10322                let (
10323                    ref_id,
10324                    caller_node,
10325                    caller_file,
10326                    caller_symbol,
10327                    caller_signature,
10328                    short_name,
10329                    full_ref,
10330                    line,
10331                    lang,
10332                ) = row?;
10333                if let Some(reference) = name_match_ref_from_parts(
10334                    ref_id,
10335                    caller_node,
10336                    caller_file,
10337                    caller_symbol,
10338                    caller_signature,
10339                    short_name,
10340                    full_ref,
10341                    line,
10342                    lang,
10343                ) {
10344                    references.push(reference);
10345                }
10346            }
10347        }
10348        return Ok(references);
10349    }
10350
10351    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
10352    let mut stmt = tx.prepare(&sql)?;
10353    let rows = stmt.query_map([], |row| {
10354        Ok((
10355            row.get::<_, String>(0)?,
10356            row.get::<_, Option<String>>(1)?,
10357            row.get::<_, String>(2)?,
10358            row.get::<_, String>(3)?,
10359            row.get::<_, Option<String>>(4)?,
10360            row.get::<_, Option<String>>(5)?,
10361            row.get::<_, Option<String>>(6)?,
10362            row.get::<_, i64>(7)?,
10363            row.get::<_, String>(8)?,
10364        ))
10365    })?;
10366    for row in rows {
10367        let (
10368            ref_id,
10369            caller_node,
10370            caller_file,
10371            caller_symbol,
10372            caller_signature,
10373            short_name,
10374            full_ref,
10375            line,
10376            lang,
10377        ) = row?;
10378        if let Some(reference) = name_match_ref_from_parts(
10379            ref_id,
10380            caller_node,
10381            caller_file,
10382            caller_symbol,
10383            caller_signature,
10384            short_name,
10385            full_ref,
10386            line,
10387            lang,
10388        ) {
10389            references.push(reference);
10390        }
10391    }
10392    Ok(references)
10393}
10394
10395#[allow(clippy::too_many_arguments)]
10396fn name_match_ref_from_parts(
10397    ref_id: String,
10398    caller_node: Option<String>,
10399    caller_file: String,
10400    caller_symbol: String,
10401    caller_signature: Option<String>,
10402    short_name: Option<String>,
10403    full_ref: Option<String>,
10404    line: i64,
10405    lang: String,
10406) -> Option<NameMatchRef> {
10407    let caller_node = caller_node?;
10408    let full_ref = full_ref?;
10409    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
10410    let method_name = if member.is_empty() {
10411        short_name.as_deref()?.to_string()
10412    } else {
10413        member
10414    };
10415    Some(NameMatchRef {
10416        ref_id,
10417        caller_node,
10418        caller_file,
10419        caller_symbol,
10420        caller_signature,
10421        receiver_expression,
10422        receiver,
10423        method_name,
10424        colon_dispatch,
10425        line: line.max(0) as u32,
10426        lang,
10427    })
10428}
10429
10430fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
10431    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
10432    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
10433    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
10434    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
10435        .into_iter()
10436        .flatten()
10437        .max_by_key(|(index, _, _)| *index)?;
10438    if delimiter == 0 {
10439        return None;
10440    }
10441    let member_start = delimiter + delimiter_len;
10442    if member_start >= full_ref.len() {
10443        return None;
10444    }
10445    let receiver_expression = full_ref[..delimiter].trim();
10446    let receiver = last_name_segment(receiver_expression).trim();
10447    let member = &full_ref[member_start..];
10448    if receiver.is_empty() || member.is_empty() {
10449        return None;
10450    }
10451    Some((
10452        receiver_expression.to_string(),
10453        receiver.to_string(),
10454        member.to_string(),
10455        colon_dispatch,
10456    ))
10457}
10458
10459fn last_name_segment(value: &str) -> &str {
10460    value
10461        .rsplit(['.', ':', '/', '\\', '-', '>'])
10462        .find(|segment| !segment.is_empty())
10463        .unwrap_or(value)
10464}
10465
10466fn load_name_match_candidates(
10467    tx: &Transaction<'_>,
10468    method_name: &str,
10469    lang: &str,
10470) -> Result<Vec<NameMatchCandidate>> {
10471    let mut stmt = tx.prepare(
10472        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
10473         FROM nodes n JOIN files f ON f.path = n.file_path
10474         WHERE n.name = ?1
10475           AND f.lang = ?2
10476           AND n.kind IN ('method', 'function')
10477         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
10478    )?;
10479    let rows = stmt.query_map(params![method_name, lang], |row| {
10480        Ok(NameMatchCandidate {
10481            node_id: row.get(0)?,
10482            file_path: row.get(1)?,
10483            scoped_name: row.get(2)?,
10484            kind: row.get(3)?,
10485            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
10486        })
10487    })?;
10488    rows.collect::<std::result::Result<Vec<_>, _>>()
10489        .map_err(Into::into)
10490}
10491
10492struct ParsedDispatchSource {
10493    source: String,
10494    tree: tree_sitter::Tree,
10495}
10496
10497type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
10498
10499#[derive(Debug, Clone, PartialEq, Eq)]
10500enum ReceiverTypeInference {
10501    Unknown,
10502    Known(String),
10503    RustDirectSelfField {
10504        receiver_type: String,
10505        declaration_file: String,
10506        module_scope: Vec<(usize, usize)>,
10507    },
10508    KnownButUnresolved,
10509}
10510
10511#[cfg(test)]
10512fn infer_receiver_type(
10513    project_root: &Path,
10514    reference: &NameMatchRef,
10515    source_cache: &mut DispatchSourceCache,
10516) -> Option<String> {
10517    match infer_receiver_type_state(project_root, reference, source_cache) {
10518        ReceiverTypeInference::Known(receiver_type)
10519        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
10520        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
10521    }
10522}
10523
10524fn infer_receiver_type_state(
10525    project_root: &Path,
10526    reference: &NameMatchRef,
10527    source_cache: &mut DispatchSourceCache,
10528) -> ReceiverTypeInference {
10529    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
10530    match reference.lang.as_str() {
10531        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
10532        "java" => {
10533            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
10534                .map(known)
10535                .unwrap_or(ReceiverTypeInference::Unknown)
10536        }
10537        "kotlin" => {
10538            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
10539                .map(known)
10540                .unwrap_or(ReceiverTypeInference::Unknown)
10541        }
10542        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
10543            .map(known)
10544            .unwrap_or(ReceiverTypeInference::Unknown),
10545        _ => ReceiverTypeInference::Unknown,
10546    }
10547}
10548
10549fn parse_dispatch_source(
10550    project_root: &Path,
10551    caller_file: &str,
10552    lang: LangId,
10553) -> Option<ParsedDispatchSource> {
10554    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
10555    let grammar = crate::parser::grammar_for(lang);
10556    let mut parser = tree_sitter::Parser::new();
10557    parser.set_language(&grammar).ok()?;
10558    let tree = parser.parse(&source, None)?;
10559    Some(ParsedDispatchSource { source, tree })
10560}
10561
10562fn parsed_dispatch_source<'a>(
10563    project_root: &Path,
10564    reference: &NameMatchRef,
10565    lang: LangId,
10566    source_cache: &'a mut DispatchSourceCache,
10567) -> Option<&'a ParsedDispatchSource> {
10568    parsed_dispatch_source_for_file(
10569        project_root,
10570        &reference.caller_file,
10571        &reference.lang,
10572        lang,
10573        source_cache,
10574    )
10575}
10576
10577fn parsed_dispatch_source_for_file<'a>(
10578    project_root: &Path,
10579    file_path: &str,
10580    lang_label: &str,
10581    lang: LangId,
10582    source_cache: &'a mut DispatchSourceCache,
10583) -> Option<&'a ParsedDispatchSource> {
10584    let key = (file_path.to_string(), lang_label.to_string());
10585    source_cache
10586        .entry(key)
10587        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
10588        .as_ref()
10589}
10590
10591fn infer_java_like_receiver_type(
10592    project_root: &Path,
10593    reference: &NameMatchRef,
10594    lang: LangId,
10595    source_cache: &mut DispatchSourceCache,
10596) -> Option<String> {
10597    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10598        return None;
10599    }
10600
10601    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
10602    let root = parsed.tree.root_node();
10603    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
10604
10605    let callable_scope = type_node
10606        .and_then(|node| {
10607            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
10608        })
10609        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
10610
10611    if let Some(callable_scope) = callable_scope {
10612        if let Some(receiver_type) = infer_java_like_local_receiver_type(
10613            callable_scope,
10614            &parsed.source,
10615            &reference.receiver,
10616            reference.line.max(1),
10617            lang,
10618        ) {
10619            return Some(receiver_type);
10620        }
10621    }
10622
10623    type_node.and_then(|node| {
10624        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
10625    })
10626}
10627
10628fn infer_cpp_receiver_type(
10629    project_root: &Path,
10630    reference: &NameMatchRef,
10631    source_cache: &mut DispatchSourceCache,
10632) -> Option<String> {
10633    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
10634        return None;
10635    }
10636
10637    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
10638    let root = parsed.tree.root_node();
10639    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
10640    infer_cpp_receiver_type_from_scope(
10641        scope,
10642        &parsed.source,
10643        &reference.receiver,
10644        reference.line.max(1),
10645    )
10646}
10647
10648fn find_enclosing_java_like_type_node<'tree>(
10649    root: tree_sitter::Node<'tree>,
10650    source: &str,
10651    reference: &NameMatchRef,
10652    lang: LangId,
10653) -> Option<tree_sitter::Node<'tree>> {
10654    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
10655        .and_then(|name| simple_type_name(&name));
10656    let line = reference.line.max(1);
10657    let mut best = None;
10658    let mut stack = vec![root];
10659    while let Some(node) = stack.pop() {
10660        if !node_contains_line(node, line) {
10661            continue;
10662        }
10663        if is_java_like_type_kind(node.kind(), lang) {
10664            let name = declaration_name(node, source);
10665            if expected_type
10666                .as_deref()
10667                .is_none_or(|expected| name == Some(expected))
10668            {
10669                best = tighter_node(best, node);
10670            }
10671        }
10672        push_named_children(node, &mut stack);
10673    }
10674    best
10675}
10676
10677fn find_enclosing_java_like_callable_node<'tree>(
10678    root: tree_sitter::Node<'tree>,
10679    source: &str,
10680    reference: &NameMatchRef,
10681    lang: LangId,
10682) -> Option<tree_sitter::Node<'tree>> {
10683    let expected_name = reference.caller_symbol.rsplit("::").next();
10684    let line = reference.line.max(1);
10685    let mut best = None;
10686    let mut stack = vec![root];
10687    while let Some(node) = stack.pop() {
10688        if !node_contains_line(node, line) {
10689            continue;
10690        }
10691        if is_java_like_callable_kind(node.kind(), lang) {
10692            let name = declaration_name(node, source);
10693            if expected_name.is_none_or(|expected| name == Some(expected)) {
10694                best = tighter_node(best, node);
10695            }
10696        }
10697        push_named_children(node, &mut stack);
10698    }
10699    best
10700}
10701
10702fn find_enclosing_cpp_callable_node<'tree>(
10703    root: tree_sitter::Node<'tree>,
10704    _source: &str,
10705    reference: &NameMatchRef,
10706) -> Option<tree_sitter::Node<'tree>> {
10707    let line = reference.line.max(1);
10708    let mut best = None;
10709    let mut stack = vec![root];
10710    while let Some(node) = stack.pop() {
10711        if !node_contains_line(node, line) {
10712            continue;
10713        }
10714        if node.kind() == "function_definition" {
10715            best = tighter_node(best, node);
10716        }
10717        push_named_children(node, &mut stack);
10718    }
10719    best
10720}
10721
10722fn tighter_node<'tree>(
10723    current: Option<tree_sitter::Node<'tree>>,
10724    candidate: tree_sitter::Node<'tree>,
10725) -> Option<tree_sitter::Node<'tree>> {
10726    match current {
10727        Some(current)
10728            if current.start_byte() > candidate.start_byte()
10729                || (current.start_byte() == candidate.start_byte()
10730                    && current.end_byte() <= candidate.end_byte()) =>
10731        {
10732            Some(current)
10733        }
10734        _ => Some(candidate),
10735    }
10736}
10737
10738fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
10739    let start = node.start_position().row as u32 + 1;
10740    let end = node.end_position().row as u32 + 1;
10741    start <= line && line <= end
10742}
10743
10744fn push_named_children<'tree>(
10745    node: tree_sitter::Node<'tree>,
10746    stack: &mut Vec<tree_sitter::Node<'tree>>,
10747) {
10748    for index in 0..node.named_child_count() {
10749        if let Some(child) = node.named_child(index as u32) {
10750            stack.push(child);
10751        }
10752    }
10753}
10754
10755fn declaration_name<'source>(
10756    node: tree_sitter::Node<'_>,
10757    source: &'source str,
10758) -> Option<&'source str> {
10759    node.child_by_field_name("name")
10760        .map(|name| node_text(name, source))
10761        .or_else(|| {
10762            first_named_child_text(
10763                node,
10764                source,
10765                &["identifier", "type_identifier", "simple_identifier"],
10766            )
10767        })
10768}
10769
10770fn first_named_child_text<'source>(
10771    node: tree_sitter::Node<'_>,
10772    source: &'source str,
10773    kinds: &[&str],
10774) -> Option<&'source str> {
10775    for index in 0..node.named_child_count() {
10776        let child = node.named_child(index as u32)?;
10777        if kinds.contains(&child.kind()) {
10778            return Some(node_text(child, source));
10779        }
10780    }
10781    None
10782}
10783
10784fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
10785    &source[node.byte_range()]
10786}
10787
10788fn infer_java_like_field_receiver_type(
10789    type_node: tree_sitter::Node<'_>,
10790    source: &str,
10791    receiver: &str,
10792    lang: LangId,
10793) -> Option<String> {
10794    let mut stack = Vec::new();
10795    push_named_children(type_node, &mut stack);
10796    while let Some(node) = stack.pop() {
10797        if is_java_like_field_kind(node.kind(), lang) {
10798            if let Some(receiver_type) =
10799                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10800            {
10801                return Some(receiver_type);
10802            }
10803        }
10804        if is_java_like_type_kind(node.kind(), lang)
10805            || is_java_like_callable_kind(node.kind(), lang)
10806        {
10807            continue;
10808        }
10809        push_named_children(node, &mut stack);
10810    }
10811    None
10812}
10813
10814fn infer_java_like_local_receiver_type(
10815    callable_node: tree_sitter::Node<'_>,
10816    source: &str,
10817    receiver: &str,
10818    call_line: u32,
10819    lang: LangId,
10820) -> Option<String> {
10821    let mut best: Option<(u32, String)> = None;
10822    let mut stack = Vec::new();
10823    push_named_children(callable_node, &mut stack);
10824    while let Some(node) = stack.pop() {
10825        let start_line = node.start_position().row as u32 + 1;
10826        if start_line > call_line {
10827            continue;
10828        }
10829        if is_java_like_local_kind(node.kind(), lang) {
10830            if let Some(receiver_type) =
10831                extract_java_like_declared_type(node_text(node, source), receiver, lang)
10832            {
10833                if best
10834                    .as_ref()
10835                    .is_none_or(|(best_line, _)| start_line >= *best_line)
10836                {
10837                    best = Some((start_line, receiver_type));
10838                }
10839            }
10840        }
10841        if is_java_like_type_kind(node.kind(), lang)
10842            || is_java_like_callable_kind(node.kind(), lang)
10843        {
10844            continue;
10845        }
10846        push_named_children(node, &mut stack);
10847    }
10848    best.map(|(_, receiver_type)| receiver_type)
10849}
10850
10851fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
10852    match lang {
10853        LangId::Java => matches!(
10854            kind,
10855            "class_declaration"
10856                | "interface_declaration"
10857                | "enum_declaration"
10858                | "record_declaration"
10859                | "annotation_type_declaration"
10860        ),
10861        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
10862        _ => false,
10863    }
10864}
10865
10866fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
10867    match lang {
10868        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
10869        LangId::Kotlin => kind == "function_declaration",
10870        _ => false,
10871    }
10872}
10873
10874fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
10875    match lang {
10876        LangId::Java => kind == "field_declaration",
10877        LangId::Kotlin => kind == "property_declaration",
10878        _ => false,
10879    }
10880}
10881
10882fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
10883    match lang {
10884        LangId::Java => kind == "local_variable_declaration",
10885        LangId::Kotlin => kind == "property_declaration",
10886        _ => false,
10887    }
10888}
10889
10890fn extract_java_like_declared_type(
10891    declaration: &str,
10892    receiver: &str,
10893    lang: LangId,
10894) -> Option<String> {
10895    match lang {
10896        LangId::Java => extract_java_declared_type(declaration, receiver),
10897        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
10898        _ => None,
10899    }
10900}
10901
10902fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10903    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10904    let after = declaration[receiver_start + receiver.len()..].trim_start();
10905    if after
10906        .chars()
10907        .next()
10908        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
10909    {
10910        return None;
10911    }
10912
10913    let before = declaration[..receiver_start].trim_end();
10914    if before.contains(',') {
10915        return None;
10916    }
10917    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
10918}
10919
10920fn strip_java_declaration_prefixes(mut value: &str) -> &str {
10921    loop {
10922        value = value.trim_start();
10923        if let Some(stripped) = strip_leading_java_annotation(value) {
10924            value = stripped;
10925            continue;
10926        }
10927        if let Some(stripped) = strip_leading_java_modifier(value) {
10928            value = stripped;
10929            continue;
10930        }
10931        return value.trim();
10932    }
10933}
10934
10935fn strip_leading_java_annotation(value: &str) -> Option<&str> {
10936    let value = value.trim_start();
10937    let mut chars = value.char_indices();
10938    let (_, first) = chars.next()?;
10939    if first != '@' {
10940        return None;
10941    }
10942    let mut end = first.len_utf8();
10943    for (index, ch) in chars {
10944        if !(is_code_ident_char(ch) || ch == '.') {
10945            end = index;
10946            break;
10947        }
10948        end = index + ch.len_utf8();
10949    }
10950    let rest = value[end..].trim_start();
10951    if let Some(stripped) = rest.strip_prefix('(') {
10952        let mut depth = 1usize;
10953        for (index, ch) in stripped.char_indices() {
10954            match ch {
10955                '(' => depth += 1,
10956                ')' => {
10957                    depth = depth.saturating_sub(1);
10958                    if depth == 0 {
10959                        return Some(stripped[index + ch.len_utf8()..].trim_start());
10960                    }
10961                }
10962                _ => {}
10963            }
10964        }
10965        return Some("");
10966    }
10967    Some(rest)
10968}
10969
10970fn strip_leading_java_modifier(value: &str) -> Option<&str> {
10971    const MODIFIERS: &[&str] = &[
10972        "public",
10973        "protected",
10974        "private",
10975        "abstract",
10976        "static",
10977        "final",
10978        "transient",
10979        "volatile",
10980        "synchronized",
10981        "native",
10982        "strictfp",
10983    ];
10984    MODIFIERS
10985        .iter()
10986        .find_map(|modifier| strip_leading_word(value, modifier))
10987}
10988
10989fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
10990    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
10991    let before = &declaration[..receiver_start];
10992    if find_identifier_occurrence(before, "val").is_none()
10993        && find_identifier_occurrence(before, "var").is_none()
10994    {
10995        return None;
10996    }
10997
10998    let after = declaration[receiver_start + receiver.len()..].trim_start();
10999    if let Some(type_text) = after.strip_prefix(':') {
11000        return normalize_receiver_type_name(read_type_prefix(type_text));
11001    }
11002    after
11003        .strip_prefix('=')
11004        .and_then(infer_kotlin_constructor_type)
11005}
11006
11007fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11008    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11009    if rest.trim_start().starts_with('(') {
11010        normalize_receiver_type_name(head)
11011    } else {
11012        None
11013    }
11014}
11015
11016fn read_type_prefix(value: &str) -> &str {
11017    let mut angle_depth = 0usize;
11018    for (index, ch) in value.char_indices() {
11019        match ch {
11020            '<' => angle_depth += 1,
11021            '>' => angle_depth = angle_depth.saturating_sub(1),
11022            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11023                return value[..index].trim();
11024            }
11025            _ => {}
11026        }
11027    }
11028    value.trim()
11029}
11030
11031fn infer_cpp_receiver_type_from_scope(
11032    scope: tree_sitter::Node<'_>,
11033    source: &str,
11034    receiver: &str,
11035    call_line: u32,
11036) -> Option<String> {
11037    let lines = source.lines().collect::<Vec<_>>();
11038    if lines.is_empty() {
11039        return None;
11040    }
11041    let scope_start = scope.start_position().row as usize;
11042    let call_index = (call_line as usize)
11043        .saturating_sub(1)
11044        .min(lines.len().saturating_sub(1));
11045    for index in (scope_start..=call_index).rev() {
11046        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
11047            return Some(receiver_type);
11048        }
11049    }
11050    None
11051}
11052
11053fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
11054    for receiver_start in identifier_occurrences(line, receiver) {
11055        let after = line[receiver_start + receiver.len()..].trim_start();
11056        if after
11057            .chars()
11058            .next()
11059            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
11060        {
11061            continue;
11062        }
11063        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
11064        let normalized = normalize_cpp_type_name(type_text)?;
11065        if normalized == "auto" {
11066            if let Some(rhs) = after.strip_prefix('=') {
11067                return infer_cpp_auto_receiver_type(rhs);
11068            }
11069            continue;
11070        }
11071        return Some(normalized);
11072    }
11073    None
11074}
11075
11076fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
11077    let candidate = prefix
11078        .rsplit([';', '{', '}', '('])
11079        .next()
11080        .unwrap_or(prefix)
11081        .trim();
11082    if candidate.is_empty() || candidate.ends_with(',') {
11083        None
11084    } else {
11085        Some(candidate)
11086    }
11087}
11088
11089fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
11090    let without_templates = strip_angle_groups(type_text);
11091    let mut cleaned = String::with_capacity(without_templates.len());
11092    for token in without_templates.split_whitespace() {
11093        if matches!(
11094            token,
11095            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
11096        ) {
11097            continue;
11098        }
11099        if !cleaned.is_empty() {
11100            cleaned.push(' ');
11101        }
11102        cleaned.push_str(token);
11103    }
11104    let token = cleaned
11105        .split_whitespace()
11106        .last()
11107        .unwrap_or(cleaned.trim())
11108        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
11109        .trim_matches(['*', '&']);
11110    let simple = token.rsplit("::").next().unwrap_or(token).trim();
11111    if simple.is_empty() || cpp_non_type_token(simple) {
11112        None
11113    } else {
11114        Some(simple.to_string())
11115    }
11116}
11117
11118fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
11119    let rhs = rhs.trim_start();
11120    if let Some(after_new) = rhs.strip_prefix("new ") {
11121        return infer_cpp_constructor_type(after_new);
11122    }
11123    infer_cpp_make_template_type(rhs)
11124        .or_else(|| infer_cpp_constructor_type(rhs))
11125        .or_else(|| infer_cpp_factory_type(rhs))
11126}
11127
11128fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
11129    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11130    let normalized = normalize_cpp_type_name(head)?;
11131    if !normalized
11132        .chars()
11133        .next()
11134        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11135    {
11136        return None;
11137    }
11138    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
11139        Some(normalized)
11140    } else {
11141        None
11142    }
11143}
11144
11145fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
11146    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11147    if !rest.trim_start().starts_with('(') {
11148        return None;
11149    }
11150    let base = head.split('<').next().unwrap_or(head);
11151    let base_simple = base.rsplit("::").next().unwrap_or(base);
11152    if !matches!(base_simple, "make_unique" | "make_shared") {
11153        return None;
11154    }
11155    first_angle_arg(head).and_then(normalize_cpp_type_name)
11156}
11157
11158fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
11159    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11160    if !rest.trim_start().starts_with('(') {
11161        return None;
11162    }
11163    let simple = head
11164        .split('<')
11165        .next()
11166        .unwrap_or(head)
11167        .rsplit("::")
11168        .next()
11169        .unwrap_or(head);
11170    for prefix in ["make", "create", "build"] {
11171        if let Some(suffix) = simple.strip_prefix(prefix) {
11172            if suffix
11173                .chars()
11174                .next()
11175                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11176            {
11177                return normalize_cpp_type_name(suffix);
11178            }
11179        }
11180    }
11181    None
11182}
11183
11184#[derive(Debug, Clone, Copy)]
11185enum JavaLikeInvocation {
11186    Kotlin,
11187    Cpp,
11188}
11189
11190fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
11191    let value = value.trim_start();
11192    let mut end = 0usize;
11193    for (index, ch) in value.char_indices() {
11194        let allowed_separator = match flavor {
11195            JavaLikeInvocation::Kotlin => ch == '.',
11196            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
11197        };
11198        if is_code_ident_char(ch) || allowed_separator {
11199            end = index + ch.len_utf8();
11200            continue;
11201        }
11202        break;
11203    }
11204    if end == 0 {
11205        return None;
11206    }
11207    let mut rest = &value[end..];
11208    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
11209        let skipped = skip_balanced_angle(stripped)?;
11210        let rest_start = rest.len() - rest.trim_start().len();
11211        let angle_len = 1 + skipped;
11212        end += rest_start + angle_len;
11213        rest = &value[end..];
11214    }
11215    Some((value[..end].trim(), rest))
11216}
11217
11218fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
11219    let mut depth = 1usize;
11220    for (index, ch) in value_after_open.char_indices() {
11221        match ch {
11222            '<' => depth += 1,
11223            '>' => {
11224                depth = depth.saturating_sub(1);
11225                if depth == 0 {
11226                    return Some(index + ch.len_utf8());
11227                }
11228            }
11229            _ => {}
11230        }
11231    }
11232    None
11233}
11234
11235fn first_angle_arg(value: &str) -> Option<&str> {
11236    let open = value.find('<')?;
11237    let inner_len = skip_balanced_angle(&value[open + 1..])?;
11238    let inner = &value[open + 1..open + inner_len];
11239    split_top_level_commas(inner).into_iter().next()
11240}
11241
11242fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
11243    let without_generics = strip_angle_groups(type_text);
11244    let cleaned = without_generics
11245        .replace("[]", " ")
11246        .replace("...", " ")
11247        .replace(['?', '&', '*'], " ");
11248    let token = cleaned
11249        .split_whitespace()
11250        .last()
11251        .unwrap_or(cleaned.trim())
11252        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
11253    let token = token.rsplit("::").next().unwrap_or(token);
11254    let simple = token.rsplit('.').next().unwrap_or(token).trim();
11255    if simple.is_empty()
11256        || java_like_primitive_type(simple)
11257        || !simple
11258            .chars()
11259            .next()
11260            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
11261    {
11262        None
11263    } else {
11264        Some(simple.to_string())
11265    }
11266}
11267
11268fn simple_type_name(scoped_name: &str) -> Option<String> {
11269    scoped_name
11270        .rsplit("::")
11271        .find(|segment| !segment.is_empty())
11272        .and_then(normalize_receiver_type_name)
11273}
11274
11275fn strip_angle_groups(value: &str) -> String {
11276    let mut output = String::with_capacity(value.len());
11277    let mut depth = 0usize;
11278    for ch in value.chars() {
11279        match ch {
11280            '<' => {
11281                if depth == 0 {
11282                    output.push(' ');
11283                }
11284                depth += 1;
11285            }
11286            '>' => depth = depth.saturating_sub(1),
11287            _ if depth == 0 => output.push(ch),
11288            _ => {}
11289        }
11290    }
11291    output
11292}
11293
11294fn java_like_primitive_type(value: &str) -> bool {
11295    matches!(
11296        value,
11297        "boolean"
11298            | "byte"
11299            | "char"
11300            | "double"
11301            | "float"
11302            | "int"
11303            | "long"
11304            | "short"
11305            | "void"
11306            | "Boolean"
11307            | "Byte"
11308            | "Char"
11309            | "Double"
11310            | "Float"
11311            | "Int"
11312            | "Long"
11313            | "Short"
11314            | "Unit"
11315    )
11316}
11317
11318fn cpp_non_type_token(value: &str) -> bool {
11319    matches!(
11320        value,
11321        "return"
11322            | "if"
11323            | "else"
11324            | "for"
11325            | "while"
11326            | "do"
11327            | "switch"
11328            | "case"
11329            | "default"
11330            | "break"
11331            | "continue"
11332            | "goto"
11333            | "throw"
11334            | "new"
11335            | "delete"
11336            | "co_await"
11337            | "co_yield"
11338            | "co_return"
11339            | "static_cast"
11340            | "const_cast"
11341            | "dynamic_cast"
11342            | "reinterpret_cast"
11343            | "sizeof"
11344            | "alignof"
11345            | "typeid"
11346            | "and"
11347            | "or"
11348            | "not"
11349            | "xor"
11350    )
11351}
11352
11353fn receiver_is_bare_identifier(value: &str) -> bool {
11354    let mut chars = value.chars();
11355    let Some(first) = chars.next() else {
11356        return false;
11357    };
11358    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
11359}
11360
11361fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
11362    identifier_occurrences(value, needle).into_iter().next()
11363}
11364
11365fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
11366    value
11367        .match_indices(needle)
11368        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
11369        .collect()
11370}
11371
11372fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
11373    let before = value[..start].chars().next_back();
11374    let after = value[start + len..].chars().next();
11375    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
11376}
11377
11378fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
11379    let stripped = value.strip_prefix(word)?;
11380    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
11381        Some(stripped.trim_start())
11382    } else {
11383        None
11384    }
11385}
11386
11387fn is_code_ident_char(ch: char) -> bool {
11388    ch == '_' || ch.is_ascii_alphanumeric()
11389}
11390
11391fn infer_rust_receiver_type(
11392    project_root: &Path,
11393    reference: &NameMatchRef,
11394    source_cache: &mut DispatchSourceCache,
11395) -> ReceiverTypeInference {
11396    if matches!(reference.receiver.as_str(), "self" | "Self") {
11397        return enclosing_type_from_scoped_name(&reference.caller_symbol)
11398            .map(ReceiverTypeInference::Known)
11399            .unwrap_or(ReceiverTypeInference::Unknown);
11400    }
11401
11402    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
11403        return ReceiverTypeInference::Known(reference.receiver.clone());
11404    }
11405
11406    if let Some(receiver_type) = reference
11407        .caller_signature
11408        .as_deref()
11409        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
11410    {
11411        return ReceiverTypeInference::Known(receiver_type);
11412    }
11413
11414    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
11415}
11416
11417fn infer_rust_direct_self_field_receiver_type(
11418    project_root: &Path,
11419    reference: &NameMatchRef,
11420    source_cache: &mut DispatchSourceCache,
11421) -> ReceiverTypeInference {
11422    if reference.colon_dispatch {
11423        return ReceiverTypeInference::Unknown;
11424    }
11425    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
11426        return ReceiverTypeInference::Unknown;
11427    };
11428    if field_name != reference.receiver {
11429        return ReceiverTypeInference::Unknown;
11430    }
11431
11432    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
11433        return ReceiverTypeInference::Unknown;
11434    };
11435    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
11436        return ReceiverTypeInference::KnownButUnresolved;
11437    };
11438    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
11439    else {
11440        return ReceiverTypeInference::Unknown;
11441    };
11442    let Some(impl_node) =
11443        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
11444    else {
11445        return ReceiverTypeInference::Unknown;
11446    };
11447    if impl_node.child_by_field_name("trait").is_some()
11448        || impl_node.child_by_field_name("type_parameters").is_some()
11449    {
11450        return ReceiverTypeInference::KnownButUnresolved;
11451    }
11452    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11453        return ReceiverTypeInference::KnownButUnresolved;
11454    };
11455    if impl_target.kind() != "type_identifier"
11456        || node_text(impl_target, &parsed.source) != impl_type
11457    {
11458        return ReceiverTypeInference::KnownButUnresolved;
11459    }
11460
11461    let module_scope = rust_module_scope(impl_node);
11462    let Some(struct_node) = find_unique_rust_struct(
11463        parsed.tree.root_node(),
11464        &parsed.source,
11465        struct_name,
11466        &module_scope,
11467    ) else {
11468        return ReceiverTypeInference::KnownButUnresolved;
11469    };
11470    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
11471    else {
11472        return ReceiverTypeInference::KnownButUnresolved;
11473    };
11474    if field_type.kind() != "type_identifier" {
11475        return ReceiverTypeInference::KnownButUnresolved;
11476    }
11477    let field_type_name = node_text(field_type, &parsed.source);
11478    if find_unique_rust_struct(
11479        parsed.tree.root_node(),
11480        &parsed.source,
11481        field_type_name,
11482        &module_scope,
11483    )
11484    .is_none()
11485    {
11486        return ReceiverTypeInference::KnownButUnresolved;
11487    }
11488
11489    ReceiverTypeInference::RustDirectSelfField {
11490        receiver_type: field_type_name.to_string(),
11491        declaration_file: reference.caller_file.clone(),
11492        module_scope,
11493    }
11494}
11495
11496fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
11497    let (base, field) = receiver_expression.split_once('.')?;
11498    let base = base.trim();
11499    let field = field.trim();
11500    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
11501}
11502
11503fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
11504    let name = value.rsplit("::").next()?.trim();
11505    (!name.is_empty()
11506        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
11507        && name.chars().all(is_rust_ident_char))
11508    .then_some(name)
11509}
11510
11511fn find_enclosing_rust_impl_node<'tree>(
11512    root: tree_sitter::Node<'tree>,
11513    line: u32,
11514) -> Option<tree_sitter::Node<'tree>> {
11515    let mut best = None;
11516    let mut stack = vec![root];
11517    while let Some(node) = stack.pop() {
11518        if !node_contains_line(node, line) {
11519            continue;
11520        }
11521        if node.kind() == "impl_item" {
11522            best = tighter_node(best, node);
11523        }
11524        push_named_children(node, &mut stack);
11525    }
11526    best
11527}
11528
11529fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
11530    let mut scope = Vec::new();
11531    let mut current = node.parent();
11532    while let Some(parent) = current {
11533        if parent.kind() == "mod_item" {
11534            scope.push((parent.start_byte(), parent.end_byte()));
11535        }
11536        current = parent.parent();
11537    }
11538    scope.reverse();
11539    scope
11540}
11541
11542fn find_unique_rust_struct<'tree>(
11543    root: tree_sitter::Node<'tree>,
11544    source: &str,
11545    expected_name: &str,
11546    module_scope: &[(usize, usize)],
11547) -> Option<tree_sitter::Node<'tree>> {
11548    let mut found = None;
11549    let mut stack = vec![root];
11550    while let Some(node) = stack.pop() {
11551        if node.kind() == "struct_item"
11552            && rust_module_scope(node) == module_scope
11553            && node.child_by_field_name("type_parameters").is_none()
11554            && declaration_name(node, source) == Some(expected_name)
11555        {
11556            if found.is_some() {
11557                return None;
11558            }
11559            found = Some(node);
11560        }
11561        push_named_children(node, &mut stack);
11562    }
11563    found
11564}
11565
11566fn rust_struct_field_type_node<'tree>(
11567    struct_node: tree_sitter::Node<'tree>,
11568    source: &str,
11569    field_name: &str,
11570) -> Option<tree_sitter::Node<'tree>> {
11571    let fields = struct_node.child_by_field_name("body")?;
11572    if fields.kind() != "field_declaration_list" {
11573        return None;
11574    }
11575    for index in 0..fields.named_child_count() {
11576        let field = fields.named_child(index as u32)?;
11577        if field.kind() != "field_declaration"
11578            || declaration_name(field, source) != Some(field_name)
11579        {
11580            continue;
11581        }
11582        return field.child_by_field_name("type");
11583    }
11584    None
11585}
11586
11587fn rust_receiver_looks_type_like(receiver: &str) -> bool {
11588    receiver
11589        .chars()
11590        .next()
11591        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
11592}
11593
11594fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
11595    scoped_name
11596        .rsplit_once("::")
11597        .map(|(enclosing, _)| enclosing)
11598        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
11599        .map(ToString::to_string)
11600}
11601
11602fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
11603    let params = signature_parameter_text(signature)?;
11604    for param in split_top_level_commas(params) {
11605        let Some((pattern, type_text)) = param.split_once(':') else {
11606            continue;
11607        };
11608        let Some(name) = rust_parameter_name(pattern) else {
11609            continue;
11610        };
11611        if name == receiver {
11612            return normalize_rust_receiver_type(type_text);
11613        }
11614    }
11615    None
11616}
11617
11618fn signature_parameter_text(signature: &str) -> Option<&str> {
11619    let open = signature.find('(')?;
11620    let mut depth = 0usize;
11621    for (offset, ch) in signature[open..].char_indices() {
11622        match ch {
11623            '(' => depth += 1,
11624            ')' => {
11625                depth = depth.saturating_sub(1);
11626                if depth == 0 {
11627                    return Some(&signature[open + 1..open + offset]);
11628                }
11629            }
11630            _ => {}
11631        }
11632    }
11633    None
11634}
11635
11636fn split_top_level_commas(value: &str) -> Vec<&str> {
11637    let mut parts = Vec::new();
11638    let mut start = 0usize;
11639    let mut angle_depth = 0usize;
11640    let mut paren_depth = 0usize;
11641    let mut bracket_depth = 0usize;
11642    for (index, ch) in value.char_indices() {
11643        match ch {
11644            '<' => angle_depth += 1,
11645            '>' => angle_depth = angle_depth.saturating_sub(1),
11646            '(' => paren_depth += 1,
11647            ')' => paren_depth = paren_depth.saturating_sub(1),
11648            '[' => bracket_depth += 1,
11649            ']' => bracket_depth = bracket_depth.saturating_sub(1),
11650            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
11651                let part = value[start..index].trim();
11652                if !part.is_empty() {
11653                    parts.push(part);
11654                }
11655                start = index + ch.len_utf8();
11656            }
11657            _ => {}
11658        }
11659    }
11660    let part = value[start..].trim();
11661    if !part.is_empty() {
11662        parts.push(part);
11663    }
11664    parts
11665}
11666
11667fn rust_parameter_name(pattern: &str) -> Option<&str> {
11668    let mut pattern = pattern.trim();
11669    if let Some(stripped) = pattern.strip_prefix("mut ") {
11670        pattern = stripped.trim_start();
11671    }
11672    pattern
11673        .rsplit(|ch: char| !is_rust_ident_char(ch))
11674        .find(|part| !part.is_empty())
11675}
11676
11677fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
11678    let mut ty = strip_leading_rust_type_modifiers(type_text);
11679    let owned_inner;
11680    if let Some(inner) = single_outer_generic_arg(ty) {
11681        owned_inner = inner.trim().to_string();
11682        ty = strip_leading_rust_type_modifiers(&owned_inner);
11683    }
11684    rust_base_type_ident(ty)
11685}
11686
11687fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
11688    loop {
11689        ty = ty.trim_start();
11690        if let Some(stripped) = ty.strip_prefix('&') {
11691            ty = stripped.trim_start();
11692            if let Some(stripped) = strip_leading_lifetime(ty) {
11693                ty = stripped.trim_start();
11694            }
11695            if let Some(stripped) = ty.strip_prefix("mut ") {
11696                ty = stripped.trim_start();
11697            }
11698            continue;
11699        }
11700        if let Some(stripped) = ty.strip_prefix("mut ") {
11701            ty = stripped.trim_start();
11702            continue;
11703        }
11704        if let Some(stripped) = ty.strip_prefix("dyn ") {
11705            ty = stripped.trim_start();
11706            continue;
11707        }
11708        if let Some(stripped) = ty.strip_prefix("impl ") {
11709            ty = stripped.trim_start();
11710            continue;
11711        }
11712        break ty.trim();
11713    }
11714}
11715
11716fn strip_leading_lifetime(value: &str) -> Option<&str> {
11717    let mut chars = value.char_indices();
11718    let (_, first) = chars.next()?;
11719    if first != '\'' {
11720        return None;
11721    }
11722    for (index, ch) in chars {
11723        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
11724            return Some(&value[index..]);
11725        }
11726    }
11727    Some("")
11728}
11729
11730fn single_outer_generic_arg(ty: &str) -> Option<&str> {
11731    let ty = ty.trim();
11732    let open = ty.find('<')?;
11733    let mut depth = 0usize;
11734    let mut close = None;
11735    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
11736        match ch {
11737            '<' => depth += 1,
11738            '>' => {
11739                depth = depth.saturating_sub(1);
11740                if depth == 0 {
11741                    close = Some(index);
11742                    break;
11743                }
11744            }
11745            _ => {}
11746        }
11747    }
11748    let close = close?;
11749    if !ty[close + 1..].trim().is_empty() {
11750        return None;
11751    }
11752    let inner = &ty[open + 1..close];
11753    let args = split_top_level_commas(inner);
11754    match args.as_slice() {
11755        [arg] => Some(*arg),
11756        _ => None,
11757    }
11758}
11759
11760fn rust_base_type_ident(ty: &str) -> Option<String> {
11761    let ty = ty.trim();
11762    let head = ty
11763        .split([' ', '+', '='])
11764        .find(|part| !part.is_empty())
11765        .unwrap_or(ty);
11766    let head = head.split('<').next().unwrap_or(head).trim();
11767    let ident = head
11768        .rsplit("::")
11769        .next()
11770        .unwrap_or(head)
11771        .trim_matches(|ch: char| !is_rust_ident_char(ch));
11772    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
11773        None
11774    } else {
11775        Some(ident.to_string())
11776    }
11777}
11778
11779fn is_rust_ident_char(ch: char) -> bool {
11780    ch == '_' || ch.is_ascii_alphanumeric()
11781}
11782
11783fn select_rust_direct_self_field_candidate(
11784    project_root: &Path,
11785    reference: &NameMatchRef,
11786    candidates: &[NameMatchCandidate],
11787    receiver_type: &str,
11788    declaration_file: &str,
11789    declaration_scope: &[(usize, usize)],
11790    source_cache: &mut DispatchSourceCache,
11791) -> Option<NameMatchCandidate> {
11792    let eligible = candidates
11793        .iter()
11794        .filter(|candidate| candidate.node_id != reference.caller_node)
11795        .filter(|candidate| {
11796            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11797        })
11798        .filter(|candidate| {
11799            rust_direct_self_field_candidate_matches_scope(
11800                project_root,
11801                candidate,
11802                receiver_type,
11803                declaration_file,
11804                declaration_scope,
11805                source_cache,
11806            )
11807        })
11808        .collect::<Vec<_>>();
11809    match eligible.as_slice() {
11810        [candidate] => Some((**candidate).clone()),
11811        _ => None,
11812    }
11813}
11814
11815fn rust_direct_self_field_candidate_matches_scope(
11816    project_root: &Path,
11817    candidate: &NameMatchCandidate,
11818    receiver_type: &str,
11819    declaration_file: &str,
11820    declaration_scope: &[(usize, usize)],
11821    source_cache: &mut DispatchSourceCache,
11822) -> bool {
11823    if candidate.file_path != declaration_file {
11824        return false;
11825    }
11826    let Some(parsed) = parsed_dispatch_source_for_file(
11827        project_root,
11828        &candidate.file_path,
11829        "rust",
11830        LangId::Rust,
11831        source_cache,
11832    ) else {
11833        return false;
11834    };
11835    let Some(impl_node) =
11836        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
11837    else {
11838        return false;
11839    };
11840    if impl_node.child_by_field_name("trait").is_some()
11841        || impl_node.child_by_field_name("type_parameters").is_some()
11842    {
11843        return false;
11844    }
11845    let Some(impl_target) = impl_node.child_by_field_name("type") else {
11846        return false;
11847    };
11848    impl_target.kind() == "type_identifier"
11849        && node_text(impl_target, &parsed.source) == receiver_type
11850        && rust_module_scope(impl_node) == declaration_scope
11851}
11852
11853fn select_type_match_candidate(
11854    reference: &NameMatchRef,
11855    candidates: &[NameMatchCandidate],
11856    receiver_type: &str,
11857) -> Option<NameMatchCandidate> {
11858    let candidates = candidates
11859        .iter()
11860        .filter(|candidate| candidate.node_id != reference.caller_node)
11861        .filter(|candidate| {
11862            type_candidate_matches(candidate, receiver_type, &reference.method_name)
11863        })
11864        .collect::<Vec<_>>();
11865    match candidates.as_slice() {
11866        [candidate] => Some((**candidate).clone()),
11867        _ => None,
11868    }
11869}
11870
11871fn type_candidate_matches(
11872    candidate: &NameMatchCandidate,
11873    receiver_type: &str,
11874    method_name: &str,
11875) -> bool {
11876    let normalized_type = receiver_type.replace('.', "::");
11877    let suffix = format!("{normalized_type}::{method_name}");
11878    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
11879}
11880
11881fn select_name_match_candidate(
11882    reference: &NameMatchRef,
11883    candidates: &[NameMatchCandidate],
11884) -> Option<NameMatchCandidate> {
11885    let candidates = candidates
11886        .iter()
11887        .filter(|candidate| candidate.node_id != reference.caller_node)
11888        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
11889        .collect::<Vec<_>>();
11890    match candidates.as_slice() {
11891        [] => None,
11892        [candidate] => Some((**candidate).clone()),
11893        _ => select_scored_name_match_candidate(reference, &candidates),
11894    }
11895}
11896
11897fn candidate_allowed_for_reference(
11898    reference: &NameMatchRef,
11899    candidate: &NameMatchCandidate,
11900) -> bool {
11901    if !reference.colon_dispatch {
11902        return true;
11903    }
11904
11905    candidate.kind == "method"
11906        && candidate
11907            .scoped_name
11908            .split("::")
11909            .any(|segment| segment == reference.receiver)
11910}
11911
11912fn select_scored_name_match_candidate(
11913    reference: &NameMatchRef,
11914    candidates: &[&NameMatchCandidate],
11915) -> Option<NameMatchCandidate> {
11916    let receiver_words = split_camel_case(&reference.receiver);
11917    if receiver_words.is_empty() {
11918        return None;
11919    }
11920
11921    let mut best: Option<(&NameMatchCandidate, f64)> = None;
11922    let mut tied_best = false;
11923    for candidate in candidates {
11924        let candidate_words = split_camel_case(&candidate.scoped_name);
11925        let overlap = receiver_words
11926            .iter()
11927            .filter(|receiver_word| {
11928                candidate_words
11929                    .iter()
11930                    .any(|candidate_word| candidate_word == *receiver_word)
11931            })
11932            .count() as f64;
11933        let score =
11934            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
11935        match best {
11936            None => {
11937                best = Some((*candidate, score));
11938                tied_best = false;
11939            }
11940            Some((_, best_score)) if score > best_score => {
11941                best = Some((*candidate, score));
11942                tied_best = false;
11943            }
11944            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
11945                tied_best = true;
11946            }
11947            _ => {}
11948        }
11949    }
11950
11951    let (candidate, score) = best?;
11952    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
11953        Some(candidate.clone())
11954    } else {
11955        None
11956    }
11957}
11958
11959fn method_name_match_denylisted(method_name: &str) -> bool {
11960    matches!(
11961        method_name,
11962        "and_then"
11963            | "as_bytes"
11964            | "as_deref"
11965            | "as_mut"
11966            | "as_ref"
11967            | "as_str"
11968            | "borrow"
11969            | "borrow_mut"
11970            | "clear"
11971            | "clone"
11972            | "collect"
11973            | "contains"
11974            | "contains_key"
11975            | "count"
11976            | "dedup"
11977            | "default"
11978            | "drain"
11979            | "ends_with"
11980            | "entry"
11981            | "err"
11982            | "expect"
11983            | "extend"
11984            | "filter"
11985            | "filter_map"
11986            | "find"
11987            | "from"
11988            | "get"
11989            | "get_mut"
11990            | "insert"
11991            | "into"
11992            | "into_iter"
11993            | "is_empty"
11994            | "is_err"
11995            | "is_none"
11996            | "is_ok"
11997            | "is_some"
11998            | "iter"
11999            | "iter_mut"
12000            | "join"
12001            | "len"
12002            | "lock"
12003            | "map"
12004            | "map_err"
12005            | "max"
12006            | "min"
12007            | "new"
12008            | "next"
12009            | "ok"
12010            | "or_default"
12011            | "or_else"
12012            | "or_insert"
12013            | "or_insert_with"
12014            | "parse"
12015            | "pop"
12016            | "position"
12017            | "push"
12018            | "read"
12019            | "recv"
12020            | "remove"
12021            | "replace"
12022            | "retain"
12023            | "send"
12024            | "sort"
12025            | "sort_by"
12026            | "split"
12027            | "starts_with"
12028            | "sum"
12029            | "take"
12030            | "to_owned"
12031            | "to_string"
12032            | "trim"
12033            | "try_from"
12034            | "try_into"
12035            | "unwrap"
12036            | "unwrap_or"
12037            | "unwrap_or_default"
12038            | "unwrap_or_else"
12039            | "with_capacity"
12040            | "write"
12041    )
12042}
12043
12044fn split_camel_case(value: &str) -> Vec<String> {
12045    let chars = value.chars().collect::<Vec<_>>();
12046    let mut normalized = String::with_capacity(value.len() + 8);
12047    for (index, ch) in chars.iter().enumerate() {
12048        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
12049        let next = chars.get(index + 1);
12050        let is_separator = ch.is_whitespace()
12051            || matches!(
12052                ch,
12053                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
12054            );
12055        if is_separator {
12056            normalized.push(' ');
12057            continue;
12058        }
12059        let camel_boundary = previous.is_some_and(|prev| {
12060            (prev.is_lowercase() && ch.is_uppercase())
12061                || (prev.is_ascii_digit() && ch.is_alphabetic())
12062                || (prev.is_uppercase()
12063                    && ch.is_uppercase()
12064                    && next.is_some_and(|next| next.is_lowercase()))
12065        });
12066        if camel_boundary {
12067            normalized.push(' ');
12068        }
12069        normalized.push(*ch);
12070    }
12071
12072    normalized
12073        .split_whitespace()
12074        .filter(|word| word.len() > 1)
12075        .map(|word| word.to_ascii_lowercase())
12076        .collect()
12077}
12078
12079fn compute_path_proximity(left: &str, right: &str) -> f64 {
12080    let left_dirs = left
12081        .rsplit_once('/')
12082        .map(|(dir, _)| dir)
12083        .unwrap_or_default()
12084        .split('/')
12085        .filter(|part| !part.is_empty());
12086    let right_dirs = right
12087        .rsplit_once('/')
12088        .map(|(dir, _)| dir)
12089        .unwrap_or_default()
12090        .split('/')
12091        .filter(|part| !part.is_empty());
12092
12093    let shared = left_dirs
12094        .zip(right_dirs)
12095        .take_while(|(left, right)| left == right)
12096        .count();
12097    ((shared as f64) * 0.05).min(0.5)
12098}
12099
12100fn mark_backend_state(
12101    tx: &Transaction<'_>,
12102    project_root: &Path,
12103    rel_path: &str,
12104    content_hash: Option<&blake3::Hash>,
12105    status: &str,
12106) -> Result<()> {
12107    clear_backend_state_for_file(tx, project_root, rel_path)?;
12108    let hash = content_hash
12109        .map(|hash| hash_to_hex(*hash))
12110        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
12111    tx.execute(
12112        "INSERT OR REPLACE INTO backend_file_state(
12113            backend, workspace_root, file_path, content_hash, status, updated_at
12114        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
12115        params![
12116            BACKEND_TREESITTER,
12117            project_root.display().to_string(),
12118            rel_path,
12119            hash,
12120            status,
12121            unix_seconds_now(),
12122        ],
12123    )?;
12124    Ok(())
12125}
12126
12127fn clear_backend_state_for_file(
12128    tx: &Transaction<'_>,
12129    project_root: &Path,
12130    rel_path: &str,
12131) -> Result<()> {
12132    tx.execute(
12133        "DELETE FROM backend_file_state
12134         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
12135        params![
12136            BACKEND_TREESITTER,
12137            project_root.display().to_string(),
12138            rel_path
12139        ],
12140    )?;
12141    Ok(())
12142}
12143
12144/// Mark a file whose graph bytes were just confirmed current as fresh.
12145///
12146/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
12147/// leftover `status='stale'` row from a failed refresh would keep blocking
12148/// dead-code projection even though the graph still matches disk.
12149fn clear_stale_backend_status_for_file(
12150    tx: &Transaction<'_>,
12151    project_root: &Path,
12152    rel_path: &str,
12153) -> Result<()> {
12154    tx.execute(
12155        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
12156         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
12157        params![
12158            BACKEND_TREESITTER,
12159            project_root.display().to_string(),
12160            rel_path,
12161            unix_seconds_now(),
12162        ],
12163    )?;
12164    Ok(())
12165}
12166
12167fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
12168    conn.query_row(
12169        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
12170        params![rel_path],
12171        |row| {
12172            let hash_text: String = row.get(1)?;
12173            Ok(FileRow {
12174                surface_fingerprint: row.get(0)?,
12175                freshness: FileFreshness {
12176                    content_hash: hash_from_hex(&hash_text)
12177                        .unwrap_or_else(cache_freshness::zero_hash),
12178                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
12179                    size: row.get::<_, i64>(3)? as u64,
12180                },
12181            })
12182        },
12183    )
12184    .optional()
12185    .map_err(CallGraphStoreError::from)
12186}
12187
12188fn stored_node_ids_match_extract(
12189    tx: &Transaction<'_>,
12190    rel_path: &str,
12191    extract: &FileExtract,
12192) -> Result<bool> {
12193    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
12194    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12195    let mut stored = BTreeSet::new();
12196    for row in rows {
12197        stored.insert(row?);
12198    }
12199    let extracted = extract
12200        .nodes
12201        .iter()
12202        .map(|node| node.id.clone())
12203        .collect::<BTreeSet<_>>();
12204    Ok(stored == extracted)
12205}
12206
12207/// Compare every persisted graph row that comes from this file before rewriting it.
12208/// Ranges and reference byte offsets are part of the key because queries expose
12209/// source locations; equal names and edges are not enough after a body shift.
12210fn stored_extract_matches(
12211    tx: &Transaction<'_>,
12212    rel_path: &str,
12213    extract: &FileExtract,
12214    index: &ProjectIndex<'_>,
12215) -> Result<bool> {
12216    let stored_file = tx
12217        .query_row(
12218            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
12219            params![rel_path],
12220            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
12221        )
12222        .optional()?;
12223    if stored_file
12224        != Some((
12225            lang_label(extract.lang).to_string(),
12226            extract.surface_fingerprint.clone(),
12227        ))
12228    {
12229        return Ok(false);
12230    }
12231
12232    let mut stored_nodes_stmt = tx.prepare(
12233        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
12234                end_line, end_col, range_ordinal, signature, exported,
12235                is_default_export, is_type_like, is_callgraph_entry_point, provenance
12236         FROM nodes WHERE file_path = ?1",
12237    )?;
12238    let stored_nodes = stored_nodes_stmt
12239        .query_map(params![rel_path], |row| {
12240            Ok(serde_json::json!([
12241                row.get::<_, String>(0)?,
12242                row.get::<_, String>(1)?,
12243                row.get::<_, String>(2)?,
12244                row.get::<_, String>(3)?,
12245                row.get::<_, String>(4)?,
12246                row.get::<_, i64>(5)?,
12247                row.get::<_, i64>(6)?,
12248                row.get::<_, i64>(7)?,
12249                row.get::<_, i64>(8)?,
12250                row.get::<_, i64>(9)?,
12251                row.get::<_, Option<String>>(10)?,
12252                row.get::<_, i64>(11)?,
12253                row.get::<_, i64>(12)?,
12254                row.get::<_, i64>(13)?,
12255                row.get::<_, i64>(14)?,
12256                row.get::<_, String>(15)?,
12257            ])
12258            .to_string())
12259        })?
12260        .collect::<rusqlite::Result<Vec<_>>>()?;
12261    let expected_nodes = extract
12262        .nodes
12263        .iter()
12264        .map(|node| {
12265            serde_json::json!([
12266                node.id,
12267                node.file_path,
12268                node.name,
12269                node.scoped_name,
12270                node.kind,
12271                node.range.start_line,
12272                node.range.start_col,
12273                node.range.end_line,
12274                node.range.end_col,
12275                node.range_ordinal,
12276                node.signature,
12277                bool_int(node.exported),
12278                bool_int(node.is_default_export),
12279                bool_int(node.is_type_like),
12280                bool_int(node.is_callgraph_entry_point),
12281                PROVENANCE_TREESITTER,
12282            ])
12283            .to_string()
12284        })
12285        .collect::<Vec<_>>();
12286    let mut stored_nodes = stored_nodes;
12287    let mut expected_nodes = expected_nodes;
12288    stored_nodes.sort();
12289    expected_nodes.sort();
12290    if stored_nodes != expected_nodes {
12291        return Ok(false);
12292    }
12293
12294    let resolved_refs = extract
12295        .raw_refs
12296        .iter()
12297        .cloned()
12298        .map(|raw| resolve_ref(raw, index))
12299        .collect::<Result<Vec<_>>>()?;
12300    let mut stored_refs_stmt = tx.prepare(
12301        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
12302                module_path, import_kind, local_name, requested_name, namespace_alias,
12303                wildcard, line, byte_start, byte_end, status, target_node,
12304                target_file, target_symbol, provenance
12305         FROM refs WHERE caller_file = ?1",
12306    )?;
12307    let stored_refs = stored_refs_stmt
12308        .query_map(params![rel_path], |row| {
12309            Ok(serde_json::json!([
12310                row.get::<_, String>(0)?,
12311                row.get::<_, Option<String>>(1)?,
12312                row.get::<_, String>(2)?,
12313                row.get::<_, String>(3)?,
12314                row.get::<_, Option<String>>(4)?,
12315                row.get::<_, Option<String>>(5)?,
12316                row.get::<_, Option<String>>(6)?,
12317                row.get::<_, Option<String>>(7)?,
12318                row.get::<_, Option<String>>(8)?,
12319                row.get::<_, Option<String>>(9)?,
12320                row.get::<_, Option<String>>(10)?,
12321                row.get::<_, i64>(11)?,
12322                row.get::<_, i64>(12)?,
12323                row.get::<_, i64>(13)?,
12324                row.get::<_, i64>(14)?,
12325                row.get::<_, String>(15)?,
12326                row.get::<_, Option<String>>(16)?,
12327                row.get::<_, Option<String>>(17)?,
12328                row.get::<_, Option<String>>(18)?,
12329                row.get::<_, String>(19)?,
12330            ])
12331            .to_string())
12332        })?
12333        .collect::<rusqlite::Result<Vec<_>>>()?;
12334    let expected_refs = resolved_refs
12335        .iter()
12336        .map(|resolved| {
12337            let raw = &resolved.raw;
12338            serde_json::json!([
12339                raw.ref_id,
12340                raw.caller_node,
12341                raw.caller_file,
12342                raw.kind,
12343                raw.short_name,
12344                raw.full_ref,
12345                raw.module_path,
12346                raw.import_kind,
12347                raw.local_name,
12348                raw.requested_name,
12349                raw.namespace_alias,
12350                bool_int(raw.wildcard),
12351                raw.line,
12352                raw.byte_start,
12353                raw.byte_end,
12354                resolved.status,
12355                resolved.target_node,
12356                resolved.target_file,
12357                resolved.target_symbol,
12358                PROVENANCE_TREESITTER,
12359            ])
12360            .to_string()
12361        })
12362        .collect::<Vec<_>>();
12363    let mut stored_refs = stored_refs;
12364    let mut expected_refs = expected_refs;
12365    stored_refs.sort();
12366    expected_refs.sort();
12367    if stored_refs != expected_refs {
12368        return Ok(false);
12369    }
12370
12371    let mut stored_edges_stmt = tx.prepare(
12372        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
12373                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
12374         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
12375         WHERE r.caller_file = ?1 AND e.provenance = ?2",
12376    )?;
12377    let stored_edges = stored_edges_stmt
12378        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
12379            Ok(serde_json::json!([
12380                row.get::<_, String>(0)?,
12381                row.get::<_, String>(1)?,
12382                row.get::<_, String>(2)?,
12383                row.get::<_, Option<String>>(3)?,
12384                row.get::<_, String>(4)?,
12385                row.get::<_, String>(5)?,
12386                row.get::<_, String>(6)?,
12387                row.get::<_, i64>(7)?,
12388                row.get::<_, String>(8)?,
12389            ])
12390            .to_string())
12391        })?
12392        .collect::<rusqlite::Result<Vec<_>>>()?;
12393    let expected_edges = resolved_refs
12394        .iter()
12395        .filter_map(|resolved| {
12396            resolved.edge.as_ref().map(|edge| {
12397                serde_json::json!([
12398                    edge.edge_id,
12399                    resolved.raw.ref_id,
12400                    edge.source_node,
12401                    edge.target_node,
12402                    edge.target_file,
12403                    edge.target_symbol,
12404                    edge.kind,
12405                    edge.line,
12406                    PROVENANCE_TREESITTER,
12407                ])
12408                .to_string()
12409            })
12410        })
12411        .collect::<Vec<_>>();
12412    let mut stored_edges = stored_edges;
12413    let mut expected_edges = expected_edges;
12414    stored_edges.sort();
12415    expected_edges.sort();
12416    if stored_edges != expected_edges {
12417        return Ok(false);
12418    }
12419
12420    let mut stored_dependencies_stmt =
12421        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
12422    let stored_dependencies = stored_dependencies_stmt
12423        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
12424        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
12425    let expected_dependencies = extract
12426        .raw_refs
12427        .iter()
12428        .flat_map(|raw| raw.dependencies.iter().cloned())
12429        .collect::<BTreeSet<_>>();
12430    if stored_dependencies != expected_dependencies {
12431        return Ok(false);
12432    }
12433
12434    let mut stored_hints_stmt = tx.prepare(
12435        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
12436         FROM dispatch_hints WHERE file = ?1",
12437    )?;
12438    let stored_hints = stored_hints_stmt
12439        .query_map(params![rel_path], |row| {
12440            Ok(serde_json::json!([
12441                row.get::<_, String>(0)?,
12442                row.get::<_, String>(1)?,
12443                row.get::<_, String>(2)?,
12444                row.get::<_, String>(3)?,
12445                row.get::<_, i64>(4)?,
12446                row.get::<_, i64>(5)?,
12447                row.get::<_, i64>(6)?,
12448                row.get::<_, String>(7)?,
12449            ])
12450            .to_string())
12451        })?
12452        .collect::<rusqlite::Result<Vec<_>>>()?;
12453    let expected_hints = extract
12454        .dispatch_hints
12455        .iter()
12456        .map(|hint| {
12457            serde_json::json!([
12458                hint.id,
12459                hint.method_name,
12460                hint.caller_node,
12461                hint.file,
12462                hint.line,
12463                hint.byte_start,
12464                hint.byte_end,
12465                PROVENANCE_TREESITTER,
12466            ])
12467            .to_string()
12468        })
12469        .collect::<Vec<_>>();
12470    let mut stored_hints = stored_hints;
12471    let mut expected_hints = expected_hints;
12472    stored_hints.sort();
12473    expected_hints.sort();
12474    Ok(stored_hints == expected_hints)
12475}
12476
12477fn update_file_fresh_metadata(
12478    tx: &Transaction<'_>,
12479    project_root: &Path,
12480    rel_path: &str,
12481    hash: &blake3::Hash,
12482    mtime: SystemTime,
12483    size: u64,
12484) -> Result<()> {
12485    tx.execute(
12486        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
12487         WHERE path = ?1",
12488        params![
12489            rel_path,
12490            hash_to_hex(*hash),
12491            system_time_to_ns(mtime),
12492            size as i64,
12493            unix_seconds_now()
12494        ],
12495    )?;
12496    tx.execute(
12497        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
12498         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
12499        params![
12500            BACKEND_TREESITTER,
12501            rel_path,
12502            hash_to_hex(*hash),
12503            project_root.display().to_string(),
12504            unix_seconds_now(),
12505        ],
12506    )?;
12507    Ok(())
12508}
12509
12510#[derive(Debug, Clone, PartialEq, Eq)]
12511struct DependentRefSelection {
12512    ref_id: String,
12513    caller_file: String,
12514}
12515
12516fn ref_ids_depending_on(
12517    conn: &Connection,
12518    project_root: &Path,
12519    rel_path: &str,
12520) -> Result<Vec<DependentRefSelection>> {
12521    let mut stmt = conn.prepare(
12522        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
12523         FROM refs r
12524         WHERE r.caller_file IN (
12525             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
12526         )
12527            OR r.target_file = ?1
12528         ORDER BY r.ref_id",
12529    )?;
12530    let rows = stmt.query_map(params![rel_path], |row| {
12531        Ok(RefDependencyRow {
12532            ref_id: row.get(0)?,
12533            kind: row.get(1)?,
12534            caller_file: row.get(2)?,
12535            module_path: row.get(3)?,
12536            target_file: row.get(4)?,
12537        })
12538    })?;
12539    let mut ids = Vec::new();
12540    for row in rows {
12541        let row = row?;
12542        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
12543            ids.push(DependentRefSelection {
12544                ref_id: row.ref_id,
12545                caller_file: row.caller_file,
12546            });
12547        }
12548    }
12549    Ok(ids)
12550}
12551
12552fn record_dependent_refs(
12553    selected_ref_ids: &mut BTreeSet<String>,
12554    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
12555    dependent_refs: Vec<DependentRefSelection>,
12556) {
12557    for dependent_ref in dependent_refs {
12558        let DependentRefSelection {
12559            ref_id,
12560            caller_file,
12561        } = dependent_ref;
12562        selected_ref_ids.insert(ref_id.clone());
12563        selected_refs_by_caller
12564            .entry(caller_file)
12565            .or_default()
12566            .insert(ref_id);
12567    }
12568}
12569
12570#[cfg(test)]
12571fn refs_by_caller_for_ref_ids(
12572    tx: &Transaction<'_>,
12573    ref_ids: &BTreeSet<String>,
12574) -> Result<BTreeMap<String, BTreeSet<String>>> {
12575    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12576    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
12577    for ref_id in ref_ids {
12578        if let Some(caller) = stmt
12579            .query_row(params![ref_id], |row| row.get::<_, String>(0))
12580            .optional()?
12581        {
12582            by_caller.entry(caller).or_default().insert(ref_id.clone());
12583        }
12584    }
12585    Ok(by_caller)
12586}
12587
12588fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12589    tx.execute(
12590        "DELETE FROM file_dependencies WHERE file_path = ?1",
12591        params![rel_path],
12592    )?;
12593    delete_refs_for_caller(tx, rel_path)?;
12594    tx.execute(
12595        "DELETE FROM dispatch_hints WHERE file = ?1",
12596        params![rel_path],
12597    )?;
12598    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
12599    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
12600    Ok(())
12601}
12602
12603fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
12604    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
12605    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
12606    let mut ids = BTreeSet::new();
12607    for row in rows {
12608        ids.insert(row?);
12609    }
12610    delete_ref_ids(tx, &ids)
12611}
12612
12613fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
12614    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
12615    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
12616    for ref_id in ref_ids {
12617        delete_edges.execute(params![ref_id])?;
12618        delete_refs.execute(params![ref_id])?;
12619    }
12620    Ok(())
12621}
12622
12623fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
12624    let mut stmt = conn.prepare(
12625        "SELECT source.file_path, source.scoped_name, edges.target_file,
12626                edges.target_symbol, edges.kind, edges.line
12627         FROM edges
12628         JOIN nodes AS source ON source.id = edges.source_node
12629         ORDER BY source.file_path, source.scoped_name, edges.target_file,
12630                  edges.target_symbol, edges.kind, edges.line",
12631    )?;
12632    let rows = stmt.query_map([], |row| {
12633        Ok(StoredEdge {
12634            source_file: row.get(0)?,
12635            source_symbol: row.get(1)?,
12636            target_file: row.get(2)?,
12637            target_symbol: row.get(3)?,
12638            kind: row.get(4)?,
12639            line: row.get::<_, i64>(5)? as u32,
12640        })
12641    })?;
12642    let mut edges = BTreeSet::new();
12643    for row in rows {
12644        edges.insert(row?);
12645    }
12646    Ok(edges)
12647}
12648
12649fn module_target_from_dependencies(
12650    project_root: &Path,
12651    dependencies: &BTreeSet<String>,
12652) -> Option<String> {
12653    dependencies.iter().find_map(|dep| {
12654        let path = project_root.join(dep);
12655        if path.is_file() {
12656            Some(relative_path(project_root, &canonicalize_path(&path)))
12657        } else {
12658            None
12659        }
12660    })
12661}
12662
12663fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
12664    let mut named = HashMap::new();
12665    if let Some(full_ref) = &raw_ref.full_ref {
12666        named = parse_reexport_names(full_ref);
12667    }
12668    ReexportIndex {
12669        target_file,
12670        named,
12671        wildcard: raw_ref.wildcard,
12672    }
12673}
12674
12675fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
12676    let mut names = HashMap::new();
12677    let Some(open) = statement.find('{') else {
12678        return names;
12679    };
12680    let Some(close) = statement[open + 1..]
12681        .find('}')
12682        .map(|offset| open + 1 + offset)
12683    else {
12684        return names;
12685    };
12686    for spec in statement[open + 1..close].split(',') {
12687        let spec = spec.trim();
12688        if spec.is_empty() {
12689            continue;
12690        }
12691        if let Some((source, local)) = spec.split_once(" as ") {
12692            names.insert(local.trim().to_string(), source.trim().to_string());
12693        } else {
12694            names.insert(spec.to_string(), spec.to_string());
12695        }
12696    }
12697    names
12698}
12699
12700#[derive(Debug)]
12701struct RefDependencyRow {
12702    ref_id: String,
12703    kind: String,
12704    caller_file: String,
12705    module_path: Option<String>,
12706    target_file: Option<String>,
12707}
12708
12709fn ref_dependency_row_depends_on(
12710    project_root: &Path,
12711    row: &RefDependencyRow,
12712    rel_path: &str,
12713) -> bool {
12714    if row.target_file.as_deref() == Some(rel_path) {
12715        return true;
12716    }
12717
12718    match row.kind.as_str() {
12719        "call" => true,
12720        "import" | "reexport" => row
12721            .module_path
12722            .as_deref()
12723            .map(|module_path| {
12724                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
12725                    .contains(rel_path)
12726            })
12727            .unwrap_or(false),
12728        "export_alias" => false,
12729        _ => false,
12730    }
12731}
12732
12733fn module_dependencies_for_ref(
12734    project_root: &Path,
12735    caller_file: &str,
12736    module_path: &str,
12737) -> BTreeSet<String> {
12738    module_dependencies(project_root, &project_root.join(caller_file), module_path)
12739}
12740
12741fn import_dependencies(
12742    project_root: &Path,
12743    abs_path: &Path,
12744    imports: &[ImportStatement],
12745) -> BTreeSet<String> {
12746    let mut deps = BTreeSet::new();
12747    for import in imports {
12748        deps.extend(module_dependencies(
12749            project_root,
12750            abs_path,
12751            &import.module_path,
12752        ));
12753    }
12754    deps
12755}
12756
12757fn module_dependencies(
12758    project_root: &Path,
12759    abs_path: &Path,
12760    module_path: &str,
12761) -> BTreeSet<String> {
12762    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
12763    let caller_dir = abs_path.parent().unwrap_or(project_root);
12764    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
12765        deps.insert(relative_path(project_root, &resolved));
12766    }
12767    if module_path.starts_with('.') {
12768        let base = caller_dir.join(module_path);
12769        for candidate in relative_module_candidates(&base) {
12770            deps.insert(relative_path(project_root, &candidate));
12771        }
12772    }
12773    deps
12774}
12775
12776fn rust_module_dependencies(
12777    project_root: &Path,
12778    abs_path: &Path,
12779    module_path: &str,
12780) -> BTreeSet<String> {
12781    let mut deps = BTreeSet::new();
12782    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
12783    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
12784        return deps;
12785    };
12786    let src_prefix = rust_src_prefix(&rel_path);
12787    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
12788    if !path_segments.is_empty() {
12789        rust_push_module_dependency_candidate(
12790            project_root,
12791            &mut deps,
12792            &src_prefix,
12793            &path_segments[..path_segments.len() - 1],
12794        );
12795    }
12796    deps
12797}
12798
12799fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
12800    let path = rust_module_path_without_alias_or_use_list(module_path);
12801    let segments = path
12802        .split("::")
12803        .map(str::trim)
12804        .filter(|segment| !segment.is_empty())
12805        .collect::<Vec<_>>();
12806    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
12807        return None;
12808    }
12809    rust_resolve_segments(rel_path, &segments)
12810}
12811
12812fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
12813    let path = module_path
12814        .trim()
12815        .trim_end_matches(';')
12816        .split_once(" as ")
12817        .map(|(left, _)| left.trim())
12818        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
12819    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
12820}
12821
12822fn rust_push_module_dependency_candidate(
12823    project_root: &Path,
12824    deps: &mut BTreeSet<String>,
12825    src_prefix: &str,
12826    segments: &[String],
12827) {
12828    let candidates = if segments.is_empty() {
12829        vec![
12830            format!("{src_prefix}/lib.rs"),
12831            format!("{src_prefix}/main.rs"),
12832        ]
12833    } else {
12834        vec![
12835            format!("{}/{}.rs", src_prefix, segments.join("/")),
12836            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
12837        ]
12838    };
12839    for candidate in candidates {
12840        if project_root.join(&candidate).is_file() {
12841            deps.insert(candidate);
12842        }
12843    }
12844}
12845
12846fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
12847    let mut candidates = Vec::new();
12848    if base.extension().is_some() {
12849        candidates.push(base.to_path_buf());
12850        return candidates;
12851    }
12852    for ext in JS_TS_EXTENSIONS {
12853        candidates.push(base.with_extension(ext));
12854    }
12855    for ext in JS_TS_EXTENSIONS {
12856        candidates.push(base.join(format!("index.{ext}")));
12857    }
12858    candidates
12859}
12860
12861fn import_local_names(import: &ImportStatement) -> Vec<String> {
12862    let mut names = Vec::new();
12863    if let Some(default) = &import.default_import {
12864        names.push(default.clone());
12865    }
12866    if let Some(namespace) = &import.namespace_import {
12867        names.push(namespace.clone());
12868    }
12869    for name in &import.names {
12870        names.push(crate::imports::specifier_local_name(name).to_string());
12871    }
12872    names
12873}
12874
12875fn import_requested_names(import: &ImportStatement) -> Vec<String> {
12876    import
12877        .names
12878        .iter()
12879        .map(|name| crate::imports::specifier_imported_name(name).to_string())
12880        .collect()
12881}
12882
12883fn import_is_wildcard(import: &ImportStatement) -> bool {
12884    import.namespace_import.is_some() || import.raw_text.contains('*')
12885}
12886
12887fn namespace_alias(full_ref: &str) -> Option<String> {
12888    full_ref
12889        .split_once('.')
12890        .map(|(namespace, _)| namespace.to_string())
12891}
12892
12893fn import_kind_label(kind: ImportKind) -> &'static str {
12894    match kind {
12895        ImportKind::Value => "value",
12896        ImportKind::Type => "type",
12897        ImportKind::SideEffect => "side_effect",
12898    }
12899}
12900
12901fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
12902    match kind {
12903        SymbolKind::Function => "function",
12904        SymbolKind::Class => "class",
12905        SymbolKind::Method => "method",
12906        SymbolKind::Struct => "struct",
12907        SymbolKind::Interface => "interface",
12908        SymbolKind::Enum => "enum",
12909        SymbolKind::TypeAlias => "type_alias",
12910        SymbolKind::Variable => "variable",
12911        SymbolKind::Heading => "heading",
12912        SymbolKind::FileSummary => "file_summary",
12913    }
12914}
12915
12916fn is_type_like(kind: &SymbolKind) -> bool {
12917    matches!(
12918        kind,
12919        SymbolKind::Class
12920            | SymbolKind::Struct
12921            | SymbolKind::Interface
12922            | SymbolKind::Enum
12923            | SymbolKind::TypeAlias
12924    )
12925}
12926
12927fn lang_label(lang: LangId) -> &'static str {
12928    match lang {
12929        LangId::TypeScript => "typescript",
12930        LangId::Tsx => "tsx",
12931        LangId::JavaScript => "javascript",
12932        LangId::Python => "python",
12933        LangId::Rust => "rust",
12934        LangId::Go => "go",
12935        LangId::C => "c",
12936        LangId::Cpp => "cpp",
12937        LangId::Zig => "zig",
12938        LangId::CSharp => "csharp",
12939        LangId::Bash => "bash",
12940        LangId::Html => "html",
12941        LangId::Markdown => "markdown",
12942        LangId::Solidity => "solidity",
12943        LangId::Scss => "scss",
12944        LangId::Vue => "vue",
12945        LangId::Json => "json",
12946        LangId::Scala => "scala",
12947        LangId::Java => "java",
12948        LangId::Ruby => "ruby",
12949        LangId::Kotlin => "kotlin",
12950        LangId::Swift => "swift",
12951        LangId::Php => "php",
12952        LangId::Lua => "lua",
12953        LangId::Perl => "perl",
12954        LangId::Yaml => "yaml",
12955        LangId::Pascal => "pascal",
12956        LangId::R => "r",
12957        LangId::Groovy => "groovy",
12958        LangId::ObjC => "objc",
12959    }
12960}
12961
12962fn lang_from_label(label: &str) -> Option<LangId> {
12963    match label {
12964        "typescript" => Some(LangId::TypeScript),
12965        "tsx" => Some(LangId::Tsx),
12966        "javascript" => Some(LangId::JavaScript),
12967        "python" => Some(LangId::Python),
12968        "rust" => Some(LangId::Rust),
12969        "go" => Some(LangId::Go),
12970        "c" => Some(LangId::C),
12971        "cpp" => Some(LangId::Cpp),
12972        "zig" => Some(LangId::Zig),
12973        "csharp" => Some(LangId::CSharp),
12974        "bash" => Some(LangId::Bash),
12975        "html" => Some(LangId::Html),
12976        "markdown" => Some(LangId::Markdown),
12977        "solidity" => Some(LangId::Solidity),
12978        "scss" => Some(LangId::Scss),
12979        "vue" => Some(LangId::Vue),
12980        "json" => Some(LangId::Json),
12981        "scala" => Some(LangId::Scala),
12982        "java" => Some(LangId::Java),
12983        "ruby" => Some(LangId::Ruby),
12984        "kotlin" => Some(LangId::Kotlin),
12985        "swift" => Some(LangId::Swift),
12986        "php" => Some(LangId::Php),
12987        "lua" => Some(LangId::Lua),
12988        "perl" => Some(LangId::Perl),
12989        "yaml" => Some(LangId::Yaml),
12990        "pascal" => Some(LangId::Pascal),
12991        "r" => Some(LangId::R),
12992        "groovy" => Some(LangId::Groovy),
12993        "objc" => Some(LangId::ObjC),
12994        _ => None,
12995    }
12996}
12997
12998fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
12999    let mut normalized = if files.is_empty() {
13000        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13001    } else {
13002        files
13003            .iter()
13004            .map(|path| normalize_file_path(project_root, path))
13005            .collect::<Result<Vec<_>>>()?
13006    };
13007    normalized.sort();
13008    normalized.dedup();
13009    Ok(normalized)
13010}
13011
13012fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13013    let full_path = if path.is_relative() {
13014        project_root.join(path)
13015    } else {
13016        path.to_path_buf()
13017    };
13018    Ok(canonicalize_path(&full_path))
13019}
13020
13021/// Normalize a refresh path against the store root before assigning its durable
13022/// relative key. Deleted watcher paths need lenient canonicalization: their
13023/// parent can still reveal an alias such as a symlinked project root.
13024fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13025    let abs_path = normalize_file_path(project_root, path)?;
13026    let rel_path = relative_path(project_root, &abs_path);
13027    if Path::new(&rel_path).is_absolute() {
13028        return Err(CallGraphStoreError::PathIdentityMismatch {
13029            path: path.to_path_buf(),
13030            project_root: project_root.to_path_buf(),
13031        });
13032    }
13033    Ok((abs_path, rel_path))
13034}
13035
13036/// Canonicalize an existing path or the deepest existing ancestor of a deleted
13037/// one. This keeps watcher deletion events in the same identity domain as the
13038/// files indexed before the deletion.
13039fn canonicalize_path(path: &Path) -> PathBuf {
13040    if let Ok(canonical) = std::fs::canonicalize(path) {
13041        return canonical;
13042    }
13043
13044    let mut resolved = PathBuf::new();
13045    let mut missing = Vec::new();
13046    for component in path.components() {
13047        match component {
13048            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
13049                resolved.push(component.as_os_str());
13050                if let Ok(canonical) = std::fs::canonicalize(&resolved) {
13051                    resolved = canonical;
13052                }
13053            }
13054            std::path::Component::CurDir => {}
13055            std::path::Component::ParentDir => {
13056                if missing.pop().is_none() {
13057                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
13058                        return path.to_path_buf();
13059                    }
13060                    resolved.pop();
13061                }
13062            }
13063            std::path::Component::Normal(name) => {
13064                if missing.is_empty() {
13065                    let candidate = resolved.join(name);
13066                    match std::fs::canonicalize(&candidate) {
13067                        Ok(canonical) => resolved = canonical,
13068                        Err(_) => match std::fs::symlink_metadata(&candidate) {
13069                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13070                                missing.push(name.to_owned());
13071                            }
13072                            _ => return path.to_path_buf(),
13073                        },
13074                    }
13075                } else {
13076                    missing.push(name.to_owned());
13077                }
13078            }
13079        }
13080    }
13081    resolved.extend(missing);
13082    resolved
13083}
13084
13085fn relative_path(project_root: &Path, path: &Path) -> String {
13086    if let Ok(stripped) = path.strip_prefix(project_root) {
13087        return stripped.to_string_lossy().replace('\\', "/");
13088    }
13089    let canon_root = canonicalize_path(project_root);
13090    let canon_path = canonicalize_path(path);
13091    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
13092        return stripped.to_string_lossy().replace('\\', "/");
13093    }
13094    canon_path.to_string_lossy().replace('\\', "/")
13095}
13096
13097fn unqualified_name(scoped: &str) -> &str {
13098    if scoped == TOP_LEVEL_SYMBOL {
13099        return scoped;
13100    }
13101    scoped
13102        .rsplit("::")
13103        .next()
13104        .unwrap_or(scoped)
13105        .rsplit('.')
13106        .next()
13107        .unwrap_or(scoped)
13108        .rsplit('#')
13109        .next()
13110        .unwrap_or(scoped)
13111}
13112
13113fn ref_id(parts: &[&str]) -> String {
13114    let joined = parts.join("\0");
13115    hash_to_hex(blake3::hash(joined.as_bytes()))
13116}
13117
13118fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
13119    let mut fingerprint = CorpusFingerprint::default();
13120    for path in callgraph::walk_project_files(project_root) {
13121        fingerprint.add_path(project_root, &path);
13122    }
13123    Ok(fingerprint.finish(project_root))
13124}
13125
13126/// Pre-admission fingerprint over the same source set the staging inventory
13127/// will consume: the walk when no explicit list is supplied, the list
13128/// otherwise. Streaming accumulator - no staging writes, bounded memory.
13129fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
13130    if files.is_empty() {
13131        return callgraph_corpus_fingerprint(project_root);
13132    }
13133    let mut fingerprint = CorpusFingerprint::default();
13134    for path in files {
13135        fingerprint.add_path(project_root, path);
13136    }
13137    Ok(fingerprint.finish(project_root))
13138}
13139
13140#[derive(Default)]
13141struct CorpusFingerprint {
13142    xor: [u8; 32],
13143    sums: [u64; 4],
13144    files: u64,
13145}
13146
13147impl CorpusFingerprint {
13148    fn add_path(&mut self, project_root: &Path, path: &Path) {
13149        let mut record = blake3::Hasher::new();
13150        record.update(relative_path(project_root, path).as_bytes());
13151        record.update(&[0]);
13152        match hash_file_bounded(path) {
13153            Ok(content_hash) => record.update(content_hash.as_bytes()),
13154            // Encoding a missing file as a distinct record changes the corpus
13155            // fingerprint, so breaker state keyed to the previous corpus is not reused.
13156            Err(error) => record.update(format!("missing:{error}").as_bytes()),
13157        };
13158        record.update(&[0]);
13159        let record = record.finalize();
13160        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
13161            self.xor[index] ^= byte;
13162        }
13163        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
13164            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
13165            self.sums[index] = self.sums[index].wrapping_add(value);
13166        }
13167        self.files = self.files.saturating_add(1);
13168    }
13169
13170    fn finish(self, project_root: &Path) -> String {
13171        // Combining both xor and modular sums keeps the digest independent of
13172        // walk order while retaining duplicate sensitivity for generic callers.
13173        let mut hasher = blake3::Hasher::new();
13174        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
13175        hasher.update(&self.files.to_le_bytes());
13176        hasher.update(&self.xor);
13177        for sum in self.sums {
13178            hasher.update(&sum.to_le_bytes());
13179        }
13180        let ignore_rules = project_root.join(".gitignore");
13181        if let Ok(contents) = std::fs::read(ignore_rules) {
13182            hasher.update(b".gitignore\0");
13183            hasher.update(blake3::hash(&contents).as_bytes());
13184        }
13185        hash_to_hex(hasher.finalize())
13186    }
13187}
13188
13189fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
13190    let mut file = std::fs::File::open(path)?;
13191    let mut hasher = blake3::Hasher::new();
13192    let mut buffer = [0u8; 64 * 1024];
13193    loop {
13194        let read = file.read(&mut buffer)?;
13195        if read == 0 {
13196            break;
13197        }
13198        hasher.update(&buffer[..read]);
13199    }
13200    Ok(hasher.finalize())
13201}
13202
13203#[cfg(test)]
13204pub(crate) fn callgraph_corpus_fingerprint_for_test(
13205    project_root: &Path,
13206    _files: &[PathBuf],
13207) -> Result<String> {
13208    // The streaming fingerprint walks the corpus itself (order-independent
13209    // accumulator, no resident file list); the test seam keeps its historical
13210    // signature so callers need not thread a walk of their own.
13211    callgraph_corpus_fingerprint(project_root)
13212}
13213
13214fn hash_to_hex(hash: blake3::Hash) -> String {
13215    hash.to_hex().to_string()
13216}
13217
13218fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
13219    let bytes = hex_to_bytes(value)?;
13220    Some(blake3::Hash::from_bytes(bytes))
13221}
13222
13223fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
13224    if value.len() != 64 {
13225        return None;
13226    }
13227    let mut bytes = [0u8; 32];
13228    for (index, slot) in bytes.iter_mut().enumerate() {
13229        let start = index * 2;
13230        let end = start + 2;
13231        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
13232    }
13233    Some(bytes)
13234}
13235
13236#[derive(Debug, Clone)]
13237struct LineIndex {
13238    newline_offsets: Vec<usize>,
13239    source_len: usize,
13240}
13241
13242impl LineIndex {
13243    fn new(source: &str) -> Self {
13244        Self {
13245            newline_offsets: source
13246                .bytes()
13247                .enumerate()
13248                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
13249                .collect(),
13250            source_len: source.len(),
13251        }
13252    }
13253
13254    fn byte_to_line(&self, byte_offset: usize) -> u32 {
13255        let byte_offset = byte_offset.min(self.source_len);
13256        self.newline_offsets
13257            .partition_point(|offset| *offset < byte_offset) as u32
13258            + 1
13259    }
13260}
13261
13262fn empty_to_none(value: String) -> Option<String> {
13263    if value.is_empty() {
13264        None
13265    } else {
13266        Some(value)
13267    }
13268}
13269
13270fn bool_int(value: bool) -> i64 {
13271    if value {
13272        1
13273    } else {
13274        0
13275    }
13276}
13277
13278fn system_time_to_ns(time: SystemTime) -> i64 {
13279    time.duration_since(UNIX_EPOCH)
13280        .unwrap_or_default()
13281        .as_nanos()
13282        .min(i64::MAX as u128) as i64
13283}
13284
13285fn ns_to_system_time(value: i64) -> SystemTime {
13286    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
13287}
13288
13289pub(crate) fn unix_millis_now() -> u64 {
13290    SystemTime::now()
13291        .duration_since(UNIX_EPOCH)
13292        .unwrap_or_default()
13293        .as_millis()
13294        .min(u128::from(u64::MAX)) as u64
13295}
13296
13297fn unix_seconds_now() -> i64 {
13298    SystemTime::now()
13299        .duration_since(UNIX_EPOCH)
13300        .unwrap_or_default()
13301        .as_secs() as i64
13302}
13303
13304/// Serializes every test that drives the process-wide refresh worker
13305/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
13306/// worker down between another test's enqueue and its flush, deferring the
13307/// batch and zeroing that test's seam counts).
13308#[cfg(test)]
13309pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
13310
13311#[cfg(test)]
13312mod refresh_worker_tests {
13313    use super::*;
13314    use std::fs;
13315    use tempfile::tempdir;
13316
13317    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13318        let temp = tempdir().unwrap();
13319        let root = temp.path().join("root");
13320        fs::create_dir_all(&root).unwrap();
13321        let artifact_key = crate::search_index::artifact_cache_key(&root);
13322        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13323        let callgraph_dir = temp
13324            .path()
13325            .join("storage")
13326            .join("callgraph")
13327            .join(artifact_key);
13328        let source = root.join("main.rs");
13329        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
13330        let (store, _) = CallGraphStore::cold_build_with_lease(
13331            callgraph_dir.clone(),
13332            root.clone(),
13333            std::slice::from_ref(&source),
13334        )
13335        .unwrap();
13336        drop(store);
13337        (temp, root, callgraph_dir, source)
13338    }
13339
13340    fn pending_paths() -> PendingCallGraphStorePaths {
13341        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
13342    }
13343
13344    fn wait_for_refresh_calls(root: &Path, expected: usize) {
13345        let deadline = Instant::now() + Duration::from_secs(12);
13346        while callgraph_refresh_worker_test_counts(root).0 < expected {
13347            assert!(
13348                Instant::now() < deadline,
13349                "timed out waiting for {expected} callgraph refresh worker call(s)"
13350            );
13351            std::thread::sleep(Duration::from_millis(5));
13352        }
13353    }
13354
13355    fn wait_for_refresh_worker_idle() {
13356        let deadline = Instant::now() + Duration::from_secs(12);
13357        loop {
13358            let worker = CALLGRAPH_REFRESH_WORKER
13359                .get_or_init(|| Mutex::new(None))
13360                .lock()
13361                .expect("callgraph refresh worker mutex poisoned")
13362                .clone();
13363            let idle = worker.is_none_or(|worker| {
13364                let queue = worker
13365                    .shared
13366                    .queue
13367                    .lock()
13368                    .expect("callgraph refresh queue mutex poisoned");
13369                queue.active.is_none() && queue.order.is_empty()
13370            });
13371            if idle {
13372                return;
13373            }
13374            assert!(
13375                Instant::now() < deadline,
13376                "timed out waiting for callgraph refresh worker to become idle"
13377            );
13378            std::thread::sleep(Duration::from_millis(5));
13379        }
13380    }
13381
13382    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
13383        let temp = tempdir().unwrap();
13384        let root = temp.path().join("workspace");
13385        fs::create_dir_all(root.join("app/src")).unwrap();
13386        let artifact_key = crate::search_index::artifact_cache_key(&root);
13387        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
13388        let callgraph_dir = temp
13389            .path()
13390            .join("storage")
13391            .join("callgraph")
13392            .join(artifact_key);
13393        fs::write(
13394            root.join("Cargo.toml"),
13395            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
13396        )
13397        .unwrap();
13398        fs::write(
13399            root.join("app/Cargo.toml"),
13400            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13401        )
13402        .unwrap();
13403        let caller = root.join("app/src/lib.rs");
13404        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
13405        let (store, _) = CallGraphStore::cold_build_with_lease(
13406            callgraph_dir.clone(),
13407            root.clone(),
13408            std::slice::from_ref(&caller),
13409        )
13410        .unwrap();
13411        drop(store);
13412        (temp, root, callgraph_dir, caller)
13413    }
13414
13415    #[test]
13416    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
13417        let _guard = REFRESH_WORKER_TEST_LOCK
13418            .lock()
13419            .unwrap_or_else(std::sync::PoisonError::into_inner);
13420        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13421        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13422        reset_workspace_crate_prefix_build_count(&root);
13423        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13424
13425        for revision in ["first", "second"] {
13426            fs::write(
13427                &caller,
13428                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
13429            )
13430            .unwrap();
13431            enqueue_callgraph_store_refresh(
13432                callgraph_dir.clone(),
13433                root.clone(),
13434                vec![caller.clone()],
13435                pending_paths(),
13436            );
13437            wait_for_refresh_worker_idle();
13438        }
13439
13440        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13441        assert!(flush_callgraph_store_refreshes_with_budget(
13442            Duration::from_secs(5)
13443        ));
13444        clear_callgraph_refresh_worker_test_seam(&root);
13445    }
13446
13447    #[test]
13448    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
13449        let _guard = REFRESH_WORKER_TEST_LOCK
13450            .lock()
13451            .unwrap_or_else(std::sync::PoisonError::into_inner);
13452        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13453        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
13454        reset_workspace_crate_prefix_build_count(&root);
13455        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13456
13457        fs::write(
13458            &caller,
13459            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
13460        )
13461        .unwrap();
13462        enqueue_callgraph_store_refresh(
13463            callgraph_dir.clone(),
13464            root.clone(),
13465            vec![caller.clone()],
13466            pending_paths(),
13467        );
13468        wait_for_refresh_worker_idle();
13469        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
13470
13471        let added_manifest = root.join("added/Cargo.toml");
13472        let added_source = root.join("added/src/lib.rs");
13473        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
13474        fs::write(
13475            root.join("Cargo.toml"),
13476            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
13477        )
13478        .unwrap();
13479        fs::write(
13480            &added_manifest,
13481            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
13482        )
13483        .unwrap();
13484        fs::write(&added_source, "pub fn target() {}\n").unwrap();
13485        fs::write(
13486            &caller,
13487            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
13488        )
13489        .unwrap();
13490
13491        enqueue_callgraph_store_refresh(
13492            callgraph_dir.clone(),
13493            root.clone(),
13494            vec![
13495                root.join("Cargo.toml"),
13496                added_manifest,
13497                added_source,
13498                caller,
13499            ],
13500            pending_paths(),
13501        );
13502        assert!(flush_callgraph_store_refreshes_with_budget(
13503            Duration::from_secs(12)
13504        ));
13505
13506        // This is the negative control for a permanently-static cache: without
13507        // manifest invalidation the build count stays at one and the call remains
13508        // unresolved because `added_crate` was absent when the map was primed.
13509        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
13510        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13511            .unwrap()
13512            .expect("refreshed workspace store");
13513        let tree = store
13514            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
13515            .unwrap();
13516        assert_eq!(tree.children.len(), 1);
13517        assert_eq!(tree.children[0].file, "added/src/lib.rs");
13518        assert_eq!(tree.children[0].name, "target");
13519        assert!(tree.children[0].resolved);
13520        clear_callgraph_refresh_worker_test_seam(&root);
13521    }
13522
13523    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
13524        let temp = tempdir().unwrap();
13525        let main = temp.path().join("main");
13526        let worktree = temp.path().join("worktree");
13527        fs::create_dir_all(&main).unwrap();
13528        let mut git = std::process::Command::new("git");
13529        assert!(
13530            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
13531                .status()
13532                .unwrap()
13533                .success()
13534        );
13535        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
13536        for args in [
13537            vec![
13538                "-C",
13539                main.to_str().unwrap(),
13540                "config",
13541                "user.email",
13542                "test@example.com",
13543            ],
13544            vec![
13545                "-C",
13546                main.to_str().unwrap(),
13547                "config",
13548                "user.name",
13549                "AFT Test",
13550            ],
13551            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
13552            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
13553        ] {
13554            let mut command = std::process::Command::new("git");
13555            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
13556                .status()
13557                .unwrap()
13558                .success());
13559        }
13560        let mut add_worktree = std::process::Command::new("git");
13561        assert!(crate::test_env::apply_hermetic_git_env(
13562            add_worktree
13563                .arg("-C")
13564                .arg(&main)
13565                .args(["worktree", "add", "--detach"])
13566                .arg(&worktree),
13567        )
13568        .status()
13569        .unwrap()
13570        .success());
13571        let main = fs::canonicalize(main).unwrap();
13572        let worktree = fs::canonicalize(worktree).unwrap();
13573        let project_key = crate::search_index::artifact_cache_key(&main);
13574        assert_eq!(
13575            crate::search_index::artifact_cache_key(&worktree),
13576            project_key
13577        );
13578        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13579        (temp, main, worktree, project_key, callgraph_dir)
13580    }
13581
13582    #[test]
13583    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
13584        let _git_env = crate::test_env::hermetic_git_env_guard();
13585        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
13586        crate::root_cache::configure_artifact_access(&root, &project_key, true);
13587        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13588        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13589        let publications_for_observer = Arc::clone(&publications);
13590        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13591            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13592        })));
13593        let source = root.join("lib.rs");
13594
13595        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
13596            .expect_err("borrow-only writable open must remain unavailable");
13597        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
13598        assert!(
13599            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
13600                .unwrap()
13601                .is_none()
13602        );
13603        assert!(
13604            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
13605                .unwrap()
13606                .is_none()
13607        );
13608        assert!(matches!(
13609            CallGraphStore::cold_build_with_lease(
13610                callgraph_dir.clone(),
13611                root.clone(),
13612                std::slice::from_ref(&source),
13613            ),
13614            Err(CallGraphStoreError::Unavailable(_))
13615        ));
13616        assert!(matches!(
13617            CallGraphStore::ensure_built_with_lease(
13618                callgraph_dir.clone(),
13619                root.clone(),
13620                std::slice::from_ref(&source),
13621            ),
13622            Err(CallGraphStoreError::Unavailable(_))
13623        ));
13624        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
13625            callgraph_dir.clone(),
13626            root.clone(),
13627            &[source],
13628            1,
13629        )
13630        .expect_err("borrow-only forced rebuild must remain unsatisfied");
13631        set_cold_build_swap_observer(None);
13632
13633        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
13634        assert_eq!(
13635            crate::root_cache::writer_lease_acquisition_count_for_test(
13636                crate::root_cache::RootCacheDomain::Callgraph,
13637                &project_key,
13638                &root,
13639            ),
13640            0
13641        );
13642        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
13643        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
13644    }
13645
13646    #[test]
13647    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
13648        let _git_env = crate::test_env::hermetic_git_env_guard();
13649        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
13650        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
13651        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
13652        let source = owner.join("lib.rs");
13653        let (store, _) = CallGraphStore::cold_build_with_lease(
13654            callgraph_dir.clone(),
13655            owner.clone(),
13656            std::slice::from_ref(&source),
13657        )
13658        .unwrap();
13659        let sqlite_path = store.sqlite_path().to_path_buf();
13660        drop(store);
13661
13662        let conn = Connection::open(&sqlite_path).unwrap();
13663        conn.execute(
13664            "UPDATE backend_file_state SET workspace_root = ?1",
13665            [worktree.display().to_string()],
13666        )
13667        .unwrap();
13668        drop(conn);
13669
13670        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13671        let publications_for_observer = Arc::clone(&publications);
13672        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
13673            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
13674        })));
13675        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
13676
13677        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13678            .unwrap()
13679            .expect("owner should purge the storm-era worktree root");
13680        drop(repaired);
13681        for _ in 0..3 {
13682            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
13683                .unwrap()
13684                .expect("linked worktree should borrow the owner generation");
13685            drop(borrower);
13686            assert!(
13687                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
13688                    .unwrap()
13689                    .is_none()
13690            );
13691            let owner_store =
13692                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
13693                    .unwrap()
13694                    .expect("owner generation should remain ready");
13695            drop(owner_store);
13696        }
13697        set_cold_build_swap_observer(None);
13698
13699        assert_eq!(
13700            publications.load(AtomicOrdering::SeqCst),
13701            1,
13702            "the owner performs one expected post-storm purge and alternation stays read-only"
13703        );
13704        assert_eq!(
13705            crate::root_cache::writer_lease_acquisition_count_for_test(
13706                crate::root_cache::RootCacheDomain::Callgraph,
13707                &project_key,
13708                &worktree,
13709            ),
13710            0
13711        );
13712    }
13713
13714    #[test]
13715    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
13716        let temp = tempdir().unwrap();
13717        let root = temp.path().join("owner");
13718        let other_root = temp.path().join("other");
13719        fs::create_dir_all(&root).unwrap();
13720        fs::create_dir_all(&other_root).unwrap();
13721        let source = root.join("lib.rs");
13722        fs::write(&source, "pub fn marker() {}\n").unwrap();
13723        let project_key = crate::search_index::artifact_cache_key(&root);
13724        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
13725        crate::root_cache::configure_artifact_access(&root, &project_key, false);
13726        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
13727        rebuild_cooldown_records()
13728            .lock()
13729            .unwrap_or_else(std::sync::PoisonError::into_inner)
13730            .remove(&cooldown_key);
13731        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13732        let stale_epoch = epoch.current();
13733        epoch.next();
13734
13735        let failed = with_publish_epoch(epoch, stale_epoch, || {
13736            CallGraphStore::cold_build_with_lease(
13737                callgraph_dir.clone(),
13738                root.clone(),
13739                std::slice::from_ref(&source),
13740            )
13741        });
13742        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
13743        assert!(
13744            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13745                .is_none()
13746        );
13747
13748        let (store, _) = CallGraphStore::cold_build_with_lease(
13749            callgraph_dir.clone(),
13750            root.clone(),
13751            std::slice::from_ref(&source),
13752        )
13753        .unwrap();
13754        drop(store);
13755        assert!(
13756            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
13757                .is_none()
13758        );
13759
13760        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
13761        assert!(
13762            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
13763        );
13764    }
13765
13766    #[test]
13767    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
13768        let _guard = REFRESH_WORKER_TEST_LOCK
13769            .lock()
13770            .unwrap_or_else(std::sync::PoisonError::into_inner);
13771        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13772        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13773        let pending = pending_paths();
13774        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13775
13776        let lifecycle = SubcLifecycleAdmission::default();
13777        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13778        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13779        let ticket = CallgraphRefreshTicket::new(
13780            lifecycle,
13781            Arc::clone(&generation),
13782            7,
13783            publish_epoch.clone(),
13784            publish_epoch.current(),
13785        );
13786        // Supersede before the worker runs: the batch must defer, not commit.
13787        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13788        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13789            .unwrap()
13790            .expect("ready store snapshot");
13791        let refresh_state = CallgraphRefreshState::new(
13792            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
13793            Arc::new(AtomicBool::new(true)),
13794        );
13795
13796        enqueue_callgraph_store_refresh_fenced_with_state(
13797            callgraph_dir,
13798            root.clone(),
13799            vec![source.clone()],
13800            Arc::clone(&pending),
13801            refresh_state,
13802            ticket,
13803        );
13804        assert!(flush_callgraph_store_refreshes_with_budget(
13805            Duration::from_secs(5)
13806        ));
13807        assert_eq!(
13808            callgraph_refresh_worker_test_counts(&root).0,
13809            0,
13810            "superseded batch must not reach refresh_files or self-replay"
13811        );
13812        assert!(
13813            pending.lock().contains(&source),
13814            "superseded batch must defer its paths to the pending sink"
13815        );
13816        clear_callgraph_refresh_worker_test_seam(&root);
13817    }
13818
13819    #[test]
13820    fn superseded_open_failure_defers_without_self_replay() {
13821        let _guard = REFRESH_WORKER_TEST_LOCK
13822            .lock()
13823            .unwrap_or_else(std::sync::PoisonError::into_inner);
13824        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13825        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13826        let pending = pending_paths();
13827        let installed = Arc::new(
13828            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
13829                .unwrap()
13830                .expect("ready store snapshot"),
13831        );
13832        let refresh_state = CallgraphRefreshState::new(
13833            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
13834            Arc::new(AtomicBool::new(true)),
13835        );
13836        assert!(!installed.is_legacy_fallback());
13837        assert!(installed.is_current());
13838        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13839        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13840        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
13841        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
13842
13843        let lifecycle = SubcLifecycleAdmission::default();
13844        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
13845        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13846        let ticket = CallgraphRefreshTicket::new(
13847            lifecycle,
13848            Arc::clone(&generation),
13849            7,
13850            publish_epoch.clone(),
13851            publish_epoch.current(),
13852        );
13853        enqueue_callgraph_store_refresh_fenced_with_state(
13854            callgraph_dir,
13855            root.clone(),
13856            vec![source.clone()],
13857            Arc::clone(&pending),
13858            refresh_state,
13859            ticket,
13860        );
13861        held_rx
13862            .recv_timeout(Duration::from_secs(12))
13863            .expect("refresh worker must hold after injected open failure");
13864
13865        // Mark the refresh request obsolete after the injected open failure,
13866        // then unblock the worker before its deferred retry can run.
13867        generation.store(8, std::sync::atomic::Ordering::SeqCst);
13868        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
13869        release_tx
13870            .send(())
13871            .expect("release superseded refresh worker");
13872        wait_for_refresh_worker_idle();
13873
13874        assert_eq!(
13875            callgraph_refresh_worker_test_counts(&root).0,
13876            1,
13877            "superseded open-failure batch must not self-replay"
13878        );
13879        assert_eq!(
13880            callgraph_refresh_worker_test_worker_calls(&root),
13881            1,
13882            "superseded open-failure batch must not create another worker call"
13883        );
13884        assert!(
13885            pending.lock().contains(&source),
13886            "superseded open-failure paths must remain in the pending sink"
13887        );
13888        let tree = installed
13889            .call_tree(Path::new("main.rs"), "entry", 1)
13890            .unwrap();
13891        assert_eq!(
13892            tree.children[0].name, "old_leaf",
13893            "superseded open-failure batch must not converge the store"
13894        );
13895        clear_callgraph_refresh_worker_test_seam(&root);
13896    }
13897
13898    #[test]
13899    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
13900        let _guard = REFRESH_WORKER_TEST_LOCK
13901            .lock()
13902            .unwrap_or_else(std::sync::PoisonError::into_inner);
13903        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13904        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13905        let pending = pending_paths();
13906        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13907
13908        let lifecycle = SubcLifecycleAdmission::default();
13909        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
13910        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13911        let expected_epoch = publish_epoch.current();
13912        let ticket = CallgraphRefreshTicket::new(
13913            lifecycle,
13914            generation,
13915            3,
13916            publish_epoch.clone(),
13917            expected_epoch,
13918        );
13919        // A cold build published a replacement generation after enqueue.
13920        publish_epoch.next();
13921
13922        enqueue_callgraph_store_refresh_fenced(
13923            callgraph_dir,
13924            root.clone(),
13925            vec![source.clone()],
13926            Arc::clone(&pending),
13927            ticket,
13928        );
13929        assert!(flush_callgraph_store_refreshes_with_budget(
13930            Duration::from_secs(5)
13931        ));
13932        assert_eq!(
13933            callgraph_refresh_worker_test_counts(&root).0,
13934            0,
13935            "epoch-superseded batch must not reach refresh_files"
13936        );
13937        assert!(
13938            pending.lock().contains(&source),
13939            "epoch-superseded batch must defer its paths to the pending sink"
13940        );
13941        clear_callgraph_refresh_worker_test_seam(&root);
13942    }
13943
13944    #[test]
13945    fn fenced_refresh_with_current_ticket_commits_normally() {
13946        let _guard = REFRESH_WORKER_TEST_LOCK
13947            .lock()
13948            .unwrap_or_else(std::sync::PoisonError::into_inner);
13949        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
13950        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
13951        let pending = pending_paths();
13952        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
13953
13954        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
13955
13956        let lifecycle = SubcLifecycleAdmission::default();
13957        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
13958        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
13959        let ticket = CallgraphRefreshTicket::new(
13960            lifecycle,
13961            generation,
13962            5,
13963            publish_epoch.clone(),
13964            publish_epoch.current(),
13965        );
13966
13967        enqueue_callgraph_store_refresh_fenced(
13968            callgraph_dir.clone(),
13969            root.clone(),
13970            vec![source.clone()],
13971            Arc::clone(&pending),
13972            ticket,
13973        );
13974        assert!(flush_callgraph_store_refreshes_with_budget(
13975            Duration::from_secs(5)
13976        ));
13977        assert_eq!(
13978            callgraph_refresh_worker_test_counts(&root).0,
13979            1,
13980            "current ticket must run the refresh"
13981        );
13982        assert!(
13983            pending.lock().is_empty(),
13984            "committed batch must not defer paths"
13985        );
13986
13987        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
13988            .unwrap()
13989            .expect("published generation must remain readable");
13990        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
13991        assert_eq!(
13992            tree.children[0].name, "new_leaf",
13993            "fenced commit must actually persist the refreshed content"
13994        );
13995        clear_callgraph_refresh_worker_test_seam(&root);
13996    }
13997
13998    #[test]
13999    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14000        let _guard = REFRESH_WORKER_TEST_LOCK
14001            .lock()
14002            .unwrap_or_else(std::sync::PoisonError::into_inner);
14003        // Generous pre-drain: the refresh worker is process-wide, so a prior
14004        // test's still-running batch (slow Windows CI) must fully settle
14005        // before this test enqueues, or its wait deadline absorbs the
14006        // leftover work. Idle workers return immediately.
14007        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14008        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14009        let pending = pending_paths();
14010        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14011
14012        enqueue_callgraph_store_refresh(
14013            callgraph_dir.clone(),
14014            root.clone(),
14015            vec![source.clone()],
14016            Arc::clone(&pending),
14017        );
14018        wait_for_refresh_calls(&root, 1);
14019        for _ in 0..3 {
14020            enqueue_callgraph_store_refresh(
14021                callgraph_dir.clone(),
14022                root.clone(),
14023                vec![source.clone()],
14024                Arc::clone(&pending),
14025            );
14026        }
14027
14028        assert!(flush_callgraph_store_refreshes_with_budget(
14029            Duration::from_secs(2)
14030        ));
14031        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
14032        assert!(pending.lock().is_empty());
14033        clear_callgraph_refresh_worker_test_seam(&root);
14034    }
14035
14036    #[test]
14037    fn queued_refresh_opens_generation_published_after_enqueue() {
14038        let _guard = REFRESH_WORKER_TEST_LOCK
14039            .lock()
14040            .unwrap_or_else(std::sync::PoisonError::into_inner);
14041        // Generous pre-drain: the refresh worker is process-wide, so a prior
14042        // test's still-running batch (slow Windows CI) must fully settle
14043        // before this test enqueues, or its wait deadline absorbs the
14044        // leftover work. Idle workers return immediately.
14045        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14046        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14047        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
14048        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
14049        let (active_held_rx, active_release_tx) =
14050            install_callgraph_refresh_worker_test_gate(active_root.clone());
14051        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
14052        enqueue_callgraph_store_refresh(
14053            active_dir,
14054            active_root.clone(),
14055            vec![active_source],
14056            pending_paths(),
14057        );
14058        active_held_rx
14059            .recv_timeout(Duration::from_secs(12))
14060            .expect("active refresh worker holds the queue");
14061
14062        fs::write(
14063            &target_source,
14064            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14065        )
14066        .unwrap();
14067        enqueue_callgraph_store_refresh(
14068            target_dir.clone(),
14069            target_root.clone(),
14070            vec![target_source.clone()],
14071            pending_paths(),
14072        );
14073        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
14074            target_dir.clone(),
14075            target_root.clone(),
14076            std::slice::from_ref(&target_source),
14077        )
14078        .unwrap();
14079        fs::write(
14080            &target_source,
14081            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14082        )
14083        .unwrap();
14084        drop(new_generation);
14085
14086        active_release_tx
14087            .send(())
14088            .expect("release active refresh worker");
14089        wait_for_refresh_calls(&target_root, 1);
14090        assert!(flush_callgraph_store_refreshes_with_budget(
14091            Duration::from_secs(12)
14092        ));
14093        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
14094            .unwrap()
14095            .expect("current callgraph generation");
14096        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14097        assert_eq!(tree.children[0].name, "worker_leaf");
14098        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
14099        clear_callgraph_refresh_worker_test_seam(&active_root);
14100        clear_callgraph_refresh_worker_test_seam(&target_root);
14101    }
14102
14103    #[test]
14104    fn refresh_failure_marks_files_stale() {
14105        let _guard = REFRESH_WORKER_TEST_LOCK
14106            .lock()
14107            .unwrap_or_else(std::sync::PoisonError::into_inner);
14108        // Generous pre-drain: the refresh worker is process-wide, so a prior
14109        // test's still-running batch (slow Windows CI) must fully settle
14110        // before this test enqueues, or its wait deadline absorbs the
14111        // leftover work. Idle workers return immediately.
14112        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14113        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14114        let pending = pending_paths();
14115        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
14116
14117        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
14118        assert!(flush_callgraph_store_refreshes_with_budget(
14119            Duration::from_secs(2)
14120        ));
14121
14122        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
14123        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
14124            .unwrap()
14125            .expect("ready callgraph store");
14126        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
14127        clear_callgraph_refresh_worker_test_seam(&root);
14128    }
14129
14130    #[test]
14131    fn idle_refresh_truncates_wal() {
14132        let _guard = REFRESH_WORKER_TEST_LOCK
14133            .lock()
14134            .unwrap_or_else(std::sync::PoisonError::into_inner);
14135        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14136        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14137        let generation = read_pointer(
14138            &callgraph_dir,
14139            &crate::search_index::artifact_cache_key(&root),
14140        )
14141        .expect("fixture publishes a generation");
14142        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
14143        let pending = pending_paths();
14144        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14145
14146        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
14147        enqueue_callgraph_store_refresh(
14148            callgraph_dir.clone(),
14149            root.clone(),
14150            vec![source.clone()],
14151            Arc::clone(&pending),
14152        );
14153        wait_for_refresh_calls(&root, 1);
14154        wait_for_refresh_worker_idle();
14155        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
14156        while fs::metadata(&wal_path)
14157            .map(|metadata| metadata.len())
14158            .unwrap_or(0)
14159            != 0
14160        {
14161            assert!(
14162                Instant::now() < checkpoint_deadline,
14163                "idle checkpoint did not truncate WAL"
14164            );
14165            std::thread::sleep(Duration::from_millis(5));
14166        }
14167        assert_eq!(
14168            fs::metadata(&wal_path)
14169                .map(|metadata| metadata.len())
14170                .unwrap_or(0),
14171            0,
14172            "idle transition truncates the refresh WAL"
14173        );
14174
14175        clear_callgraph_refresh_worker_test_seam(&root);
14176    }
14177
14178    #[test]
14179    fn bounded_shutdown_defers_unprocessed_batches() {
14180        let _guard = REFRESH_WORKER_TEST_LOCK
14181            .lock()
14182            .unwrap_or_else(std::sync::PoisonError::into_inner);
14183        // Generous pre-drain: the refresh worker is process-wide, so a prior
14184        // test's still-running batch (slow Windows CI) must fully settle
14185        // before this test enqueues, or its wait deadline absorbs the
14186        // leftover work. Idle workers return immediately.
14187        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14188        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14189        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
14190        let active_pending = pending_paths();
14191        let queued_pending = pending_paths();
14192        set_callgraph_refresh_worker_test_seam(
14193            active_root.clone(),
14194            Duration::from_millis(300),
14195            false,
14196        );
14197
14198        enqueue_callgraph_store_refresh(
14199            active_dir,
14200            active_root.clone(),
14201            vec![active_source.clone()],
14202            Arc::clone(&active_pending),
14203        );
14204        wait_for_refresh_calls(&active_root, 1);
14205        enqueue_callgraph_store_refresh(
14206            queued_dir,
14207            queued_root.clone(),
14208            vec![queued_source.clone()],
14209            Arc::clone(&queued_pending),
14210        );
14211
14212        assert!(!flush_callgraph_store_refreshes_with_budget(
14213            Duration::from_millis(20)
14214        ));
14215        assert!(active_pending.lock().contains(&active_source));
14216        assert!(queued_pending.lock().contains(&queued_source));
14217        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
14218        clear_callgraph_refresh_worker_test_seam(&active_root);
14219    }
14220}
14221
14222#[cfg(test)]
14223mod cold_build_insert_tests {
14224    use super::*;
14225    use crate::imports::ImportBlock;
14226    use std::cell::Cell;
14227    use std::fs;
14228    use std::path::{Path, PathBuf};
14229    use tempfile::tempdir;
14230
14231    thread_local! {
14232        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
14233        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
14234        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
14235    }
14236
14237    fn count_caller_traversal_selects(sql: &str) {
14238        let sql = sql.trim_start();
14239        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
14240            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
14241        }
14242        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
14243            && sql.contains("e.target_file =")
14244        {
14245            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
14246        }
14247        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
14248            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
14249        }
14250    }
14251
14252    #[test]
14253    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
14254        let dir = tempdir().unwrap();
14255        let previous_root = dir.path().join("previous-root");
14256        let current_root = dir.path().join("current-root");
14257        fs::create_dir_all(&previous_root).unwrap();
14258        fs::create_dir_all(&current_root).unwrap();
14259        fs::remove_dir(&previous_root).unwrap();
14260        let mut conn = Connection::open_in_memory().unwrap();
14261        initialize_schema(&conn).unwrap();
14262        conn.execute(
14263            "INSERT INTO backend_file_state(
14264                backend, workspace_root, file_path, content_hash, status, updated_at
14265             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
14266            params![previous_root.display().to_string()],
14267        )
14268        .unwrap();
14269
14270        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
14271
14272        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
14273        assert_eq!(
14274            stored_workspace_roots(&conn).unwrap(),
14275            vec![previous_root.display().to_string()]
14276        );
14277    }
14278
14279    #[test]
14280    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
14281        assert_eq!(
14282            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
14283            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
14284        );
14285    }
14286
14287    #[test]
14288    fn legacy_migration_completion_log_has_operator_fields() {
14289        assert_eq!(
14290            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
14291            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
14292        );
14293    }
14294
14295    fn write_generation_with_age(
14296        dir: &Path,
14297        project_key: &str,
14298        ordinal: u64,
14299        age: Duration,
14300    ) -> String {
14301        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
14302        let path = dir.join(&generation);
14303        fs::write(&path, b"sqlite placeholder").unwrap();
14304        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14305        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14306        generation
14307    }
14308
14309    #[test]
14310    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
14311        let dir = tempfile::tempdir().unwrap();
14312        let project_key = "project";
14313        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14314        let previous =
14315            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14316        let pinned =
14317            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14318        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14319
14320        gc_old_generations(dir.path(), project_key, &current);
14321
14322        assert!(dir.path().join(&previous).is_file());
14323        assert!(dir.path().join(&pinned).is_file());
14324
14325        drop(marker);
14326        gc_old_generations(dir.path(), project_key, &current);
14327
14328        assert!(dir.path().join(&previous).is_file());
14329        assert!(!dir.path().join(&pinned).exists());
14330    }
14331
14332    #[test]
14333    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
14334        let dir = tempfile::tempdir().unwrap();
14335        let project_key = "project";
14336        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14337        let _previous =
14338            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
14339        let pinned =
14340            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
14341        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
14342        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
14343
14344        gc_old_generations(dir.path(), project_key, &current);
14345
14346        assert!(dir.path().join(&pinned).is_file());
14347    }
14348
14349    #[test]
14350    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
14351        let dir = tempfile::tempdir().unwrap();
14352        let project_key = "project";
14353        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
14354        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
14355        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
14356        let old = write_generation_with_age(
14357            dir.path(),
14358            project_key,
14359            200,
14360            expired + Duration::from_secs(60),
14361        );
14362        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
14363
14364        gc_old_generations(dir.path(), project_key, &current);
14365
14366        assert!(dir.path().join(&current).is_file());
14367        assert!(dir.path().join(&previous).is_file());
14368        assert!(!dir.path().join(&old).exists());
14369    }
14370
14371    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
14372        let path = dir.join(name);
14373        fs::write(&path, b"temp placeholder").unwrap();
14374        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
14375        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
14376        path
14377    }
14378
14379    #[test]
14380    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
14381        let dir = tempdir().unwrap();
14382        // One directory holds both an aged orphan (with its journal sidecar) and a
14383        // fresh temporary, so this proves the sweep SELECTS by age rather than
14384        // deleting everything in the directory.
14385        let aged = "project.g100.1.sqlite.tmp.1.200";
14386        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
14387        let fresh = "project.g300.1.sqlite.tmp.1.400";
14388        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
14389        write_build_temp_with_age(dir.path(), aged, aged_age);
14390        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
14391        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14392
14393        sweep_orphaned_build_temps(dir.path());
14394
14395        assert!(
14396            !dir.path().join(aged).exists(),
14397            "aged orphan must be removed"
14398        );
14399        assert!(
14400            !dir.path().join(aged_journal).exists(),
14401            "aged journal sidecar must be removed"
14402        );
14403        assert!(
14404            dir.path().join(fresh).is_file(),
14405            "fresh temporary must survive"
14406        );
14407    }
14408
14409    #[test]
14410    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
14411        let storage = tempdir().unwrap();
14412        let storage_root = storage.path();
14413        // The production shape: a legacy per-harness store whose root no longer
14414        // builds there — no `.current` pointer, no running build — so the per-root
14415        // cleanup never fires for it. A sibling root still building in the
14416        // root-keyed store triggers the store-wide sweep, which must reach into the
14417        // legacy directory and reclaim the orphan.
14418        let legacy_dir = storage_root.join("opencode").join("callgraph");
14419        fs::create_dir_all(&legacy_dir).unwrap();
14420        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
14421        write_build_temp_with_age(
14422            &legacy_dir,
14423            orphan,
14424            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14425        );
14426        assert!(
14427            !legacy_dir.join("deadbeef.current").exists(),
14428            "the dead root has no current pointer"
14429        );
14430
14431        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
14432        fs::create_dir_all(&root_keyed_dir).unwrap();
14433
14434        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
14435
14436        assert!(
14437            !legacy_dir.join(orphan).exists(),
14438            "legacy orphan must be reclaimed by the store-wide sweep"
14439        );
14440    }
14441
14442    #[test]
14443    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
14444        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
14445        // everything (min_age = 0) removes the fresh temporary that the real 24h
14446        // threshold spares in the test above. If a mutation to the age check leaves
14447        // the fresh file in place here, the predicate is no longer doing the
14448        // selection work the fresh-survives assertion relies on.
14449        let dir = tempdir().unwrap();
14450        let fresh = "project.g300.1.sqlite.tmp.1.400";
14451        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
14452
14453        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
14454
14455        assert!(
14456            !dir.path().join(fresh).exists(),
14457            "with the age predicate forced open, the fresh temporary is removed"
14458        );
14459    }
14460
14461    #[test]
14462    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
14463        let dir = tempdir().unwrap();
14464        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
14465        // to be swept, plus a live read marker, is generation GC's jurisdiction.
14466        // The orphan sweep must not intersect it.
14467        let generation = write_generation_with_age(
14468            dir.path(),
14469            "project",
14470            400,
14471            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
14472        );
14473        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14474
14475        sweep_orphaned_build_temps(dir.path());
14476
14477        assert!(
14478            dir.path().join(&generation).is_file(),
14479            "completed generation must survive the orphan sweep"
14480        );
14481        assert!(
14482            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
14483            "read marker must survive the orphan sweep"
14484        );
14485    }
14486
14487    #[test]
14488    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
14489        let dir = tempfile::tempdir().unwrap();
14490        let project_key = "project".to_string();
14491        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
14492        let sqlite_path = dir.path().join(&generation);
14493        fs::remove_file(&sqlite_path).unwrap();
14494        let conn = Connection::open(&sqlite_path).unwrap();
14495        let store = CallGraphStore::from_connection(
14496            dir.path().to_path_buf(),
14497            project_key,
14498            sqlite_path,
14499            dir.path().to_path_buf(),
14500            false,
14501            Some(generation.clone()),
14502            None,
14503            None,
14504            conn,
14505        );
14506
14507        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
14508        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
14509
14510        drop(marker);
14511        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
14512    }
14513
14514    #[test]
14515    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
14516        let dir = tempdir().expect("temp dir");
14517        let file = dir.path().join("main.ts");
14518        fs::write(&file, "export function main() {}\n").expect("write fixture");
14519        let store = CallGraphStore::open(
14520            dir.path().join(".store-readiness-cache"),
14521            dir.path().to_path_buf(),
14522        )
14523        .expect("open store");
14524        {
14525            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14526            conn.trace(Some(count_caller_traversal_selects));
14527        }
14528
14529        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14530        assert!(store.indexed_file_count().is_err());
14531        assert!(store.indexed_file_count().is_err());
14532        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14533
14534        store
14535            .cold_build(std::slice::from_ref(&file))
14536            .expect("cold build");
14537        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14538        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
14539        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
14540        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
14541
14542        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14543        conn.trace(None);
14544    }
14545
14546    #[test]
14547    fn direct_caller_frontier_chunks_sqlite_selects() {
14548        let dir = tempdir().expect("temp dir");
14549        let file = dir.path().join("main.ts");
14550        fs::write(
14551            &file,
14552            "export function caller() { target(); }\nexport function target() {}\n",
14553        )
14554        .expect("write fixture");
14555        let store = CallGraphStore::open(
14556            dir.path().join(".store-caller-frontier-query"),
14557            dir.path().to_path_buf(),
14558        )
14559        .expect("open store");
14560        store
14561            .cold_build(std::slice::from_ref(&file))
14562            .expect("cold build");
14563        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
14564        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
14565
14566        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14567        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14568        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14569        {
14570            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14571            conn.trace(Some(count_caller_traversal_selects));
14572        }
14573        let callers = store
14574            .direct_callers_for_symbols(&targets)
14575            .expect("batched callers");
14576        {
14577            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14578            conn.trace(None);
14579        }
14580
14581        assert_eq!(callers.len(), 1_000);
14582        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
14583        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
14584        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
14585        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
14586    }
14587
14588    #[test]
14589    fn callers_depth_boundary_batches_sqlite_counts() {
14590        const CALLER_COUNT: usize = 1_000;
14591
14592        let dir = tempdir().expect("temp dir");
14593        let file = dir.path().join("main.ts");
14594        let mut source = String::from("export function sharedHotHelper() {}\n");
14595        for index in 0..CALLER_COUNT {
14596            source.push_str(&format!(
14597                "export function caller{index}() {{ sharedHotHelper(); }}\n"
14598            ));
14599        }
14600        fs::write(&file, source).expect("write fixture");
14601
14602        let store = CallGraphStore::open(
14603            dir.path().join(".store-callers-query-fanout"),
14604            dir.path().to_path_buf(),
14605        )
14606        .expect("open store");
14607        store
14608            .cold_build(std::slice::from_ref(&file))
14609            .expect("cold build");
14610
14611        CALLER_QUERY_SELECTS.with(|count| count.set(0));
14612        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
14613        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
14614        {
14615            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14616            conn.trace(Some(count_caller_traversal_selects));
14617        }
14618
14619        let started = Instant::now();
14620        let result = crate::commands::callgraph_store_adapter::callers_result(
14621            &store,
14622            Path::new("main.ts"),
14623            "sharedHotHelper",
14624            1,
14625            true,
14626        )
14627        .expect("callers result");
14628        let elapsed = started.elapsed();
14629
14630        {
14631            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
14632            conn.trace(None);
14633        }
14634        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
14635        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
14636        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
14637        eprintln!(
14638            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
14639            result.total_callers,
14640            caller_queries,
14641            boundary_queries,
14642            total_selects,
14643            elapsed.as_secs_f64() * 1_000.0
14644        );
14645
14646        assert_eq!(result.total_callers, CALLER_COUNT);
14647        assert_eq!(caller_queries, 1);
14648        assert_eq!(boundary_queries, 3);
14649        assert_eq!(total_selects, 9);
14650    }
14651
14652    #[test]
14653    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
14654        let dir = tempdir().expect("temp dir");
14655        let file = dir.path().join("main.ts");
14656        fs::write(
14657            &file,
14658            r#"export function topA() {
14659  root();
14660}
14661
14662export function topB() {
14663  root();
14664}
14665
14666export function root() {
14667  leaf();
14668  missing();
14669}
14670
14671export function leaf() {}
14672"#,
14673        )
14674        .expect("write fixture");
14675
14676        let store = CallGraphStore::open(
14677            dir.path().join(".store-depth-boundary-counts"),
14678            dir.path().to_path_buf(),
14679        )
14680        .expect("open store");
14681        store
14682            .cold_build(std::slice::from_ref(&file))
14683            .expect("cold build");
14684
14685        let root = store
14686            .node_for(Path::new("main.ts"), "root")
14687            .expect("root node");
14688        let leaf = store
14689            .node_for(Path::new("main.ts"), "leaf")
14690            .expect("leaf node");
14691
14692        let (full_forward_len, full_direct_len) = {
14693            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
14694            conn.execute(
14695                "INSERT INTO edges (
14696                    edge_id, ref_id, source_node, target_node, target_file,
14697                    target_symbol, kind, line, provenance
14698                 ) VALUES (
14699                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
14700                    ?2, ?3, 'call', 98, ?4
14701                 )",
14702                rusqlite::params![
14703                    &root.node_id,
14704                    &leaf.file,
14705                    &leaf.symbol,
14706                    PROVENANCE_TREESITTER
14707                ],
14708            )
14709            .expect("insert dangling forward edge");
14710            conn.execute(
14711                "INSERT INTO edges (
14712                    edge_id, ref_id, source_node, target_node, target_file,
14713                    target_symbol, kind, line, provenance
14714                 ) VALUES (
14715                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
14716                    ?1, ?2, ?3, 'call', 99, ?4
14717                 )",
14718                rusqlite::params![
14719                    &root.node_id,
14720                    &root.file,
14721                    &root.symbol,
14722                    PROVENANCE_TREESITTER
14723                ],
14724            )
14725            .expect("insert dangling direct-caller edge");
14726
14727            let full_forward_len = forward_calls_for_node(&conn, &root)
14728                .expect("full forward calls")
14729                .len();
14730            let counted_forward_len =
14731                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
14732            assert_eq!(
14733                counted_forward_len, full_forward_len,
14734                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
14735            );
14736
14737            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
14738                .expect("full direct callers");
14739            let full_direct_len = full_direct.len();
14740            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
14741                .expect("counted direct callers");
14742            assert_eq!(
14743                counted_direct_len, full_direct_len,
14744                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
14745            );
14746
14747            let distinct_direct_len = full_direct
14748                .iter()
14749                .map(|site| {
14750                    (
14751                        site.caller.file.clone(),
14752                        site.line,
14753                        site.target_file.clone(),
14754                        site.target_symbol.clone(),
14755                    )
14756                })
14757                .collect::<BTreeSet<_>>()
14758                .len();
14759            let batch_counts = direct_caller_counts_for_tuples(
14760                &conn,
14761                &[
14762                    (root.file.clone(), root.symbol.clone()),
14763                    (root.file.clone(), root.symbol.clone()),
14764                    (leaf.file.clone(), leaf.symbol.clone()),
14765                ],
14766            )
14767            .expect("batched direct-caller counts");
14768            assert_eq!(batch_counts.len(), 2);
14769            assert_eq!(
14770                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
14771                Some(&distinct_direct_len)
14772            );
14773
14774            (full_forward_len, full_direct_len)
14775        };
14776
14777        assert_eq!(
14778            full_forward_len, 2,
14779            "fixture root should have one resolved and one unresolved outgoing call"
14780        );
14781        assert_eq!(
14782            full_direct_len, 2,
14783            "fixture root should have two real direct callers"
14784        );
14785
14786        let tree = store
14787            .call_tree(Path::new("main.ts"), "root", 0)
14788            .expect("call tree");
14789        assert!(tree.depth_limited);
14790        assert_eq!(tree.children.len(), 0);
14791        assert_eq!(
14792            tree.truncated, full_forward_len,
14793            "call_tree depth boundary must report the full forward-call list length"
14794        );
14795
14796        let callers = store
14797            .callers_of(Path::new("main.ts"), "leaf", 0)
14798            .expect("callers");
14799        assert!(callers.depth_limited);
14800        assert_eq!(callers.callers.len(), 1);
14801        assert_eq!(callers.callers[0].caller.symbol, "root");
14802        assert_eq!(
14803            callers.truncated, full_direct_len,
14804            "callers depth boundary must report the full direct-caller list length"
14805        );
14806    }
14807
14808    #[test]
14809    fn source_freshness_matches_cache_collect_for_same_bytes() {
14810        let dir = tempdir().expect("temp dir");
14811        let path = dir.path().join("fixture.ts");
14812        let source = "export function main() { return helper(); }\n";
14813        fs::write(&path, source).expect("write fixture");
14814
14815        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
14816        let actual =
14817            collect_source_freshness(&path, source).expect("collect freshness from source");
14818
14819        assert_eq!(actual, expected);
14820    }
14821
14822    #[test]
14823    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
14824        let root = tempfile::tempdir().unwrap();
14825        let callgraph_dir = tempfile::tempdir().unwrap();
14826        let source_dir = root.path().join("src");
14827        std::fs::create_dir_all(&source_dir).unwrap();
14828        let source = source_dir.join("lib.rs");
14829        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
14830        let files = vec![source.clone()];
14831        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14832        let old_epoch = epoch.next();
14833        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
14834        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
14835        let old_epoch_flag = epoch.clone();
14836        let old_dir = callgraph_dir.path().to_path_buf();
14837        let old_root = root.path().to_path_buf();
14838        let old_files = files.clone();
14839        let old = std::thread::spawn(move || {
14840            set_cold_build_before_publish_observer(Some(Arc::new(move || {
14841                reached_tx.send(()).unwrap();
14842                release_rx.recv().unwrap();
14843            })));
14844            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
14845                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
14846            });
14847            set_cold_build_before_publish_observer(None);
14848            result
14849        });
14850        // Positive wait: the older build runs a real cold build (git probe +
14851        // SQLite schema init) before the barrier, which can exceed 5s on a
14852        // contended Windows CI runner. Only negative waits stay short.
14853        reached_rx
14854            .recv_timeout(Duration::from_secs(30))
14855            .expect("older build did not reach its publication barrier");
14856
14857        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
14858        let new_epoch = epoch.next();
14859        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
14860            CallGraphStore::cold_build_with_lease(
14861                callgraph_dir.path().to_path_buf(),
14862                root.path().to_path_buf(),
14863                &files,
14864            )
14865        })
14866        .expect("newer build should publish");
14867        drop(new_store);
14868
14869        release_tx.send(()).unwrap();
14870        assert!(matches!(
14871            old.join().unwrap(),
14872            Err(CallGraphStoreError::Superseded)
14873        ));
14874
14875        let current = CallGraphStore::open_readonly(
14876            callgraph_dir.path().to_path_buf(),
14877            root.path().to_path_buf(),
14878        )
14879        .unwrap()
14880        .expect("current callgraph generation");
14881        assert_eq!(
14882            current
14883                .nodes_matching("new_generation_marker")
14884                .unwrap()
14885                .len(),
14886            1
14887        );
14888        assert!(current
14889            .nodes_matching("old_generation_marker")
14890            .unwrap()
14891            .is_empty());
14892    }
14893
14894    #[test]
14895    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
14896        let dir = tempdir().expect("temp dir");
14897        let project_root = dir.path();
14898        let extract = fixture_extract(project_root);
14899        let resolved = fixture_resolved(&extract);
14900
14901        let reference = build_reference_connection(project_root, &extract, &resolved);
14902        let optimized = build_optimized_connection(project_root, &extract, &resolved);
14903
14904        for table in [
14905            "files",
14906            "nodes",
14907            "file_dependencies",
14908            "dispatch_hints",
14909            "refs",
14910            "edges",
14911        ] {
14912            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
14913            // the reference and optimized builds run sequentially and can straddle a
14914            // one-second tick under load, so it is legitimately allowed to differ.
14915            // This mirrors the existing exclusions of `backend_file_state.updated_at`
14916            // and the chunked-vs-unchunked sibling test. The check is for structural
14917            // row equivalence of the optimized bulk insert, not wall-clock equality.
14918            let excluded: &[&str] = if table == "files" {
14919                &["indexed_at"]
14920            } else {
14921                &[]
14922            };
14923            assert_eq!(
14924                table_rows_without(&reference, table, excluded),
14925                table_rows_without(&optimized, table, excluded),
14926                "table `{table}` rows must match apart from wall-clock columns"
14927            );
14928        }
14929        assert_eq!(
14930            backend_state_rows(&reference),
14931            backend_state_rows(&optimized),
14932            "backend freshness rows must match apart from updated_at"
14933        );
14934        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
14935    }
14936
14937    #[test]
14938    fn cold_build_chunked_matches_unchunked_logical_rows() {
14939        let dir = tempdir().expect("temp dir");
14940        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
14941        write_chunked_equivalence_fixture(&project_root);
14942        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
14943        assert!(
14944            files.len() > 6,
14945            "fixture should be large enough to split into multiple chunks"
14946        );
14947
14948        let unchunked = CallGraphStore::open(
14949            project_root.join(".store-unchunked"),
14950            project_root.to_path_buf(),
14951        )
14952        .expect("open unchunked store");
14953        let unchunked_stats = unchunked
14954            .cold_build_chunked(&files, 0)
14955            .expect("unchunked cold build");
14956
14957        let chunked = CallGraphStore::open(
14958            project_root.join(".store-chunked"),
14959            project_root.to_path_buf(),
14960        )
14961        .expect("open chunked store");
14962        let chunked_stats = chunked
14963            .cold_build_chunked(&files, 3)
14964            .expect("chunked cold build");
14965
14966        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
14967        assert_eq!(
14968            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
14969            chunked.edge_snapshot().expect("chunked edge snapshot"),
14970            "public edge snapshots must match"
14971        );
14972
14973        let dispatch_edges = {
14974            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
14975            conn.query_row(
14976                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
14977                [],
14978                |row| row.get::<_, i64>(0),
14979            )
14980            .expect("count dispatch edges")
14981        };
14982        assert!(
14983            dispatch_edges > 0,
14984            "fixture must exercise method-dispatch edge insertion"
14985        );
14986
14987        for table in [
14988            "edges",
14989            "refs",
14990            "nodes",
14991            "file_dependencies",
14992            "dispatch_hints",
14993        ] {
14994            assert_eq!(
14995                graph_table_rows(&unchunked, table),
14996                graph_table_rows(&chunked, table),
14997                "chunked cold build must match unchunked rows for {table}"
14998            );
14999        }
15000        assert_eq!(
15001            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
15002            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
15003            "files rows must match apart from indexed_at"
15004        );
15005        assert_eq!(
15006            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
15007            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
15008            "backend freshness rows must match apart from updated_at"
15009        );
15010
15011        let published_dir = project_root.join(".store-published");
15012        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
15013            published_dir.clone(),
15014            project_root.to_path_buf(),
15015            &files,
15016            0,
15017        )
15018        .expect("published unchunked cold build");
15019        assert!(
15020            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
15021                .expect("needs_cold_build after publish"),
15022            "published store should be ready"
15023        );
15024        drop(_published);
15025        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
15026            published_dir,
15027            project_root.to_path_buf(),
15028            &files,
15029            3,
15030        )
15031        .expect("ensure with a different chunk size");
15032        assert!(
15033            rebuild_stats.is_none(),
15034            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
15035        );
15036    }
15037
15038    // Perf A/B bench (not a gate): measures cold_build wall time at a given
15039    // chunk size against a real repo. Driven by env so the same binary can A/B
15040    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
15041    // memory work. Run:
15042    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
15043    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
15044    #[test]
15045    #[ignore]
15046    fn bench_cold_build_chunk() {
15047        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
15048        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
15049            .expect("AFT_PERF_CHUNK")
15050            .parse()
15051            .expect("AFT_PERF_CHUNK must be a non-negative integer");
15052        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
15053        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
15054        let dir = tempdir().expect("temp dir");
15055        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
15056            .expect("open store");
15057        let started = Instant::now();
15058        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
15059        let ms = started.elapsed().as_millis();
15060        println!(
15061            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
15062            stats.files, stats.nodes, stats.refs, stats.edges
15063        );
15064    }
15065
15066    #[test]
15067    fn persisted_workspace_reexport_selects_its_package_dependency() {
15068        let root = tempdir().expect("temp dir");
15069        let dependencies = BTreeSet::from([
15070            "packages/aft-bridge/src/index.ts".to_string(),
15071            "packages/opencode-plugin/src/types.ts".to_string(),
15072        ]);
15073        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
15074
15075        assert_eq!(
15076            stored_dependencies_for_module(
15077                root.path(),
15078                "packages/opencode-plugin/src/shared/bash-hints.ts",
15079                "@cortexkit/aft-bridge",
15080                &dependencies,
15081                &indexed_files,
15082            ),
15083            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
15084        );
15085    }
15086
15087    #[test]
15088    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
15089        let dir = tempdir().expect("temp dir");
15090        let project_root = dir.path();
15091        let files =
15092            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
15093        let index_path = project_root.join("src/index.ts");
15094
15095        let store = CallGraphStore::open(
15096            project_root.join(".store-incremental-barrel"),
15097            project_root.to_path_buf(),
15098        )
15099        .expect("open incremental store");
15100        store.cold_build(&files).expect("initial cold build");
15101
15102        {
15103            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15104            let tx = conn.transaction().expect("dependency transaction");
15105            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
15106                .expect("dependent refs for barrel");
15107            let selected_ref_ids = dependent_refs
15108                .iter()
15109                .map(|dependent_ref| dependent_ref.ref_id.clone())
15110                .collect::<BTreeSet<_>>();
15111            let mut threaded_ref_ids = BTreeSet::new();
15112            let mut threaded_by_caller = BTreeMap::new();
15113            record_dependent_refs(
15114                &mut threaded_ref_ids,
15115                &mut threaded_by_caller,
15116                dependent_refs,
15117            );
15118            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
15119                .expect("old per-ref caller lookup");
15120
15121            assert_eq!(threaded_ref_ids, selected_ref_ids);
15122            assert_eq!(threaded_by_caller, old_by_caller);
15123            for consumer in [
15124                "src/consumer_a.ts",
15125                "src/consumer_b.ts",
15126                "src/consumer_c.ts",
15127            ] {
15128                assert!(
15129                    threaded_by_caller.contains_key(consumer),
15130                    "barrel edit should select dependent refs from {consumer}"
15131                );
15132            }
15133        }
15134
15135        fs::write(
15136            &index_path,
15137            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
15138        )
15139        .expect("edit barrel");
15140        let stats = store
15141            .refresh_files(std::slice::from_ref(&index_path))
15142            .expect("incremental refresh");
15143        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
15144        assert!(
15145            stats.dependency_selected_refs > 0,
15146            "barrel surface edit should select dependent refs"
15147        );
15148
15149        let cold_store = CallGraphStore::open(
15150            project_root.join(".store-cold-barrel"),
15151            project_root.to_path_buf(),
15152        )
15153        .expect("open cold rebuild store");
15154        cold_store
15155            .cold_build(&files)
15156            .expect("comparison cold build");
15157
15158        for table in [
15159            "nodes",
15160            "refs",
15161            "file_dependencies",
15162            "edges",
15163            "dispatch_hints",
15164        ] {
15165            assert_eq!(
15166                graph_table_rows(&store, table),
15167                graph_table_rows(&cold_store, table),
15168                "incremental refresh {table} rows must match cold rebuild"
15169            );
15170        }
15171
15172        let consumer_path = project_root.join("src/consumer_a.ts");
15173        fs::write(
15174            &consumer_path,
15175            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
15176        )
15177        .expect("edit barrel consumer");
15178        store
15179            .refresh_files(std::slice::from_ref(&consumer_path))
15180            .expect("refresh consumer through unchanged barrel");
15181        cold_store
15182            .cold_build(&files)
15183            .expect("comparison cold rebuild after consumer refresh");
15184        for table in [
15185            "nodes",
15186            "refs",
15187            "file_dependencies",
15188            "edges",
15189            "dispatch_hints",
15190        ] {
15191            assert_eq!(
15192                graph_table_rows(&store, table),
15193                graph_table_rows(&cold_store, table),
15194                "refresh through a persisted barrel must preserve cold-build {table} rows"
15195            );
15196        }
15197    }
15198
15199    fn build_reference_connection(
15200        project_root: &Path,
15201        extract: &FileExtract,
15202        resolved: &ResolvedRef,
15203    ) -> Connection {
15204        let mut conn = Connection::open_in_memory().expect("open reference db");
15205        configure_build_connection(&conn).expect("configure reference db");
15206        initialize_schema(&conn).expect("initialize reference schema");
15207        {
15208            let tx = conn.transaction().expect("reference transaction");
15209            clear_tables(&tx).expect("reference clear");
15210            insert_meta(&tx).expect("reference meta");
15211            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
15212            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
15213            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15214                .expect("reference dispatch edges");
15215            assert_eq!(supplemental, 0);
15216            tx.commit().expect("reference commit");
15217        }
15218        conn
15219    }
15220
15221    fn build_optimized_connection(
15222        project_root: &Path,
15223        extract: &FileExtract,
15224        resolved: &ResolvedRef,
15225    ) -> Connection {
15226        let mut conn = Connection::open_in_memory().expect("open optimized db");
15227        configure_build_connection(&conn).expect("configure optimized db");
15228        initialize_schema(&conn).expect("initialize optimized schema");
15229        {
15230            let tx = conn.transaction().expect("optimized transaction");
15231            clear_tables(&tx).expect("optimized clear");
15232            insert_meta(&tx).expect("optimized meta");
15233            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
15234            {
15235                let workspace_root = project_root.display().to_string();
15236                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
15237                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
15238                    .expect("optimized file extract");
15239                insert_resolved_ref_prepared(&mut inserts, resolved)
15240                    .expect("optimized resolved ref");
15241            }
15242            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
15243            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
15244                .expect("optimized dispatch edges");
15245            assert_eq!(supplemental, 0);
15246            tx.commit().expect("optimized commit");
15247        }
15248        conn
15249    }
15250
15251    fn fixture_extract(_project_root: &Path) -> FileExtract {
15252        let rel_path = "src/main.ts".to_string();
15253        let target_path = "src/helper.ts".to_string();
15254        let node = NodeRecord {
15255            id: "node-main".to_string(),
15256            file_path: rel_path.clone(),
15257            name: "main".to_string(),
15258            scoped_name: "main".to_string(),
15259            kind: "function".to_string(),
15260            range: Range {
15261                start_line: 0,
15262                start_col: 0,
15263                end_line: 0,
15264                end_col: 32,
15265            },
15266            range_ordinal: 0,
15267            signature: Some("export function main()".to_string()),
15268            exported: true,
15269            is_default_export: false,
15270            is_type_like: false,
15271            is_callgraph_entry_point: true,
15272        };
15273        let mut dependencies = BTreeSet::new();
15274        dependencies.insert(target_path.clone());
15275        let raw_ref = RawRef {
15276            ref_id: "ref-main-helper".to_string(),
15277            caller_node: Some(node.id.clone()),
15278            caller_symbol: Some(node.scoped_name.clone()),
15279            caller_file: rel_path.clone(),
15280            kind: "call".to_string(),
15281            short_name: Some("helper".to_string()),
15282            full_ref: Some("helper".to_string()),
15283            module_path: None,
15284            import_kind: None,
15285            local_name: Some("helper".to_string()),
15286            requested_name: Some("helper".to_string()),
15287            namespace_alias: None,
15288            wildcard: false,
15289            line: 1,
15290            byte_start: 24,
15291            byte_end: 32,
15292            dependencies,
15293        };
15294        FileExtract {
15295            rel_path,
15296            freshness: FileFreshness {
15297                mtime: UNIX_EPOCH + Duration::from_secs(123),
15298                size: 40,
15299                content_hash: cache_freshness::hash_bytes(b"fixture source"),
15300            },
15301            lang: LangId::TypeScript,
15302            data: FileCallData {
15303                calls_by_symbol: HashMap::new(),
15304                value_refs_by_symbol: HashMap::new(),
15305                exported_symbols: Vec::new(),
15306                symbol_metadata: HashMap::new(),
15307                default_export_symbol: None,
15308                import_block: ImportBlock::empty(),
15309                lang: LangId::TypeScript,
15310            },
15311            nodes: vec![node.clone()],
15312            raw_refs: vec![raw_ref],
15313            dispatch_hints: vec![DispatchHint {
15314                id: "dispatch-main-helper".to_string(),
15315                method_name: "helper".to_string(),
15316                caller_node: node.id,
15317                file: "src/main.ts".to_string(),
15318                line: 1,
15319                byte_start: 24,
15320                byte_end: 32,
15321            }],
15322            surface_fingerprint: "surface".to_string(),
15323        }
15324    }
15325
15326    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
15327        let raw = extract.raw_refs[0].clone();
15328        let mut dependencies = raw.dependencies.clone();
15329        dependencies.insert("src/helper.ts".to_string());
15330        ResolvedRef {
15331            edge: Some(EdgeRecord {
15332                edge_id: "edge-main-helper".to_string(),
15333                source_node: raw.caller_node.clone().expect("caller node"),
15334                target_node: Some("node-helper".to_string()),
15335                target_file: "src/helper.ts".to_string(),
15336                target_symbol: "helper".to_string(),
15337                kind: "call".to_string(),
15338                line: raw.line,
15339            }),
15340            raw,
15341            status: "resolved".to_string(),
15342            target_node: Some("node-helper".to_string()),
15343            target_file: Some("src/helper.ts".to_string()),
15344            target_symbol: Some("helper".to_string()),
15345            dependencies,
15346        }
15347    }
15348
15349    fn write_chunked_equivalence_fixture(project_root: &Path) {
15350        let ts_dir = project_root.join("ts");
15351        fs::create_dir_all(&ts_dir).expect("create ts dir");
15352        fs::write(
15353            ts_dir.join("leaf.ts"),
15354            "export function leaf(value: number) {\n  return value + 1;\n}\n",
15355        )
15356        .expect("write ts leaf");
15357        fs::write(
15358            ts_dir.join("mid.ts"),
15359            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
15360        )
15361        .expect("write ts mid");
15362        fs::write(
15363            ts_dir.join("entry.ts"),
15364            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
15365        )
15366        .expect("write ts entry");
15367        fs::write(
15368            ts_dir.join("worker.ts"),
15369            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
15370        )
15371        .expect("write ts worker");
15372        for idx in 0..4 {
15373            fs::write(
15374                ts_dir.join(format!("extra_{idx}.ts")),
15375                format!(
15376                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
15377                ),
15378            )
15379            .expect("write ts extra");
15380        }
15381
15382        let rust_dir = project_root.join("src");
15383        let commands_dir = rust_dir.join("commands");
15384        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
15385        fs::write(
15386            rust_dir.join("context.rs"),
15387            r#"pub struct AppContext;
15388
15389impl AppContext {
15390    pub fn callgraph_store_for_ops(&self) -> usize {
15391        1
15392    }
15393}
15394"#,
15395        )
15396        .expect("write rust context");
15397        fs::write(
15398            rust_dir.join("lib.rs"),
15399            "pub mod context;\npub mod commands;\n",
15400        )
15401        .expect("write rust lib");
15402        fs::write(
15403            commands_dir.join("mod.rs"),
15404            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
15405        )
15406        .expect("write rust commands mod");
15407        for name in ["callers", "impact", "trace_to"] {
15408            fs::write(
15409                commands_dir.join(format!("{name}.rs")),
15410                format!(
15411                    r#"use crate::context::AppContext;
15412
15413pub fn handle_{name}(ctx: &AppContext) -> usize {{
15414    ctx.callgraph_store_for_ops()
15415}}
15416"#
15417                ),
15418            )
15419            .expect("write rust command");
15420        }
15421    }
15422
15423    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
15424        let src_dir = project_root.join("src");
15425        fs::create_dir_all(&src_dir).expect("create src dir");
15426
15427        let target_path = src_dir.join("target.ts");
15428        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
15429            .expect("write target");
15430
15431        let index_path = src_dir.join("index.ts");
15432        fs::write(&index_path, barrel_source).expect("write barrel");
15433
15434        let mut files = vec![target_path, index_path];
15435        for (file_name, function_name) in [
15436            ("consumer_a.ts", "consumerA"),
15437            ("consumer_b.ts", "consumerB"),
15438            ("consumer_c.ts", "consumerC"),
15439        ] {
15440            let path = src_dir.join(file_name);
15441            fs::write(
15442                &path,
15443                format!(
15444                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
15445                ),
15446            )
15447            .expect("write consumer");
15448            files.push(path);
15449        }
15450        files
15451    }
15452
15453    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
15454        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15455        table_rows(&conn, table)
15456    }
15457
15458    fn graph_table_rows_without(
15459        store: &CallGraphStore,
15460        table: &str,
15461        excluded_columns: &[&str],
15462    ) -> Vec<String> {
15463        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15464        table_rows_without(&conn, table, excluded_columns)
15465    }
15466
15467    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
15468        table_rows_without(conn, table, &[])
15469    }
15470
15471    fn table_rows_without(
15472        conn: &Connection,
15473        table: &str,
15474        excluded_columns: &[&str],
15475    ) -> Vec<String> {
15476        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
15477        let columns: Vec<String> = conn
15478            .prepare(&format!("PRAGMA table_info({table})"))
15479            .expect("prepare table_info")
15480            .query_map([], |row| row.get::<_, String>(1))
15481            .expect("query table_info")
15482            .collect::<std::result::Result<Vec<String>, _>>()
15483            .expect("collect columns")
15484            .into_iter()
15485            .filter(|column| !excluded_columns.contains(column.as_str()))
15486            .collect();
15487        let sql = format!(
15488            "SELECT {} FROM {table} ORDER BY {}",
15489            columns.join(", "),
15490            columns.join(", ")
15491        );
15492        conn.prepare(&sql)
15493            .expect("prepare table rows")
15494            .query_map([], |row| row_to_strings(row, columns.len()))
15495            .expect("query table rows")
15496            .collect::<std::result::Result<_, _>>()
15497            .expect("collect table rows")
15498    }
15499
15500    fn assert_cold_build_stats_match_except_elapsed(
15501        expected: &ColdBuildStats,
15502        actual: &ColdBuildStats,
15503    ) {
15504        assert_eq!(actual.files, expected.files, "file counts must match");
15505        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
15506        assert_eq!(actual.refs, expected.refs, "ref counts must match");
15507        assert_eq!(actual.edges, expected.edges, "edge counts must match");
15508        assert_eq!(
15509            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
15510            expected
15511                .failed_files
15512                .iter()
15513                .cloned()
15514                .collect::<BTreeSet<_>>(),
15515            "failed file sets must match"
15516        );
15517    }
15518
15519    fn backend_state_rows(conn: &Connection) -> Vec<String> {
15520        conn.prepare(
15521            "SELECT backend, workspace_root, file_path, content_hash, status
15522             FROM backend_file_state
15523             ORDER BY backend, workspace_root, file_path, content_hash, status",
15524        )
15525        .expect("prepare backend rows")
15526        .query_map([], |row| row_to_strings(row, 5))
15527        .expect("query backend rows")
15528        .collect::<std::result::Result<_, _>>()
15529        .expect("collect backend rows")
15530    }
15531
15532    fn secondary_indexes(conn: &Connection) -> Vec<String> {
15533        let mut indexes = Vec::new();
15534        for table in [
15535            "files",
15536            "nodes",
15537            "refs",
15538            "file_dependencies",
15539            "edges",
15540            "dispatch_hints",
15541            "type_ref_names",
15542            "backend_file_state",
15543            "meta",
15544        ] {
15545            let sql = format!("PRAGMA index_list({table})");
15546            let mut stmt = conn.prepare(&sql).expect("prepare index list");
15547            let rows = stmt
15548                .query_map([], |row| row.get::<_, String>(1))
15549                .expect("query index list");
15550            for name in rows {
15551                let name = name.expect("index name");
15552                if name.starts_with("idx_") {
15553                    indexes.push(format!("{table}:{name}"));
15554                }
15555            }
15556        }
15557        indexes.sort();
15558        indexes
15559    }
15560
15561    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
15562        let mut values = Vec::with_capacity(len);
15563        for index in 0..len {
15564            let value = row.get_ref(index)?;
15565            values.push(match value {
15566                rusqlite::types::ValueRef::Null => "NULL".to_string(),
15567                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
15568                rusqlite::types::ValueRef::Real(value) => value.to_string(),
15569                rusqlite::types::ValueRef::Text(value) => {
15570                    String::from_utf8_lossy(value).into_owned()
15571                }
15572                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
15573            });
15574        }
15575        Ok(values.join("\u{1f}"))
15576    }
15577}
15578
15579#[cfg(test)]
15580mod rust_resolution_tests {
15581    use super::*;
15582    use crate::inspect::job::CallgraphSnapshot;
15583    use std::fs;
15584    use tempfile::tempdir;
15585
15586    #[test]
15587    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
15588        let dir = tempdir().expect("tempdir");
15589        let root = dir.path();
15590        write_rust_manifest(root, "scoped-alias-fixture");
15591        write_file(
15592            root,
15593            "src/lib.rs",
15594            r#"pub mod finalization_contract;
15595
15596pub fn run_alias() {
15597    use crate::finalization_contract as fc;
15598    fc::check_mason_contract();
15599}
15600"#,
15601        );
15602        write_file(
15603            root,
15604            "src/finalization_contract.rs",
15605            r#"pub fn check_mason_contract() {}
15606fn planted_dead() {}
15607"#,
15608        );
15609
15610        let (store, snapshot) = cold_build_twice(root);
15611        assert_direct_caller(
15612            &store,
15613            "src/finalization_contract.rs",
15614            "check_mason_contract",
15615            "src/lib.rs",
15616            "run_alias",
15617        );
15618        assert_projected_call(
15619            root,
15620            &snapshot,
15621            "src/finalization_contract.rs",
15622            "check_mason_contract",
15623        );
15624        assert_no_projected_call(
15625            root,
15626            &snapshot,
15627            "src/finalization_contract.rs",
15628            "planted_dead",
15629        );
15630        assert!(
15631            store
15632                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
15633                .expect("planted dead callers")
15634                .is_empty(),
15635            "planted-dead guard should stay without callers"
15636        );
15637    }
15638
15639    #[test]
15640    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
15641        let dir = tempdir().expect("tempdir");
15642        let root = dir.path();
15643        write_rust_manifest(root, "inline-module-fixture");
15644        write_file(
15645            root,
15646            "src/lib.rs",
15647            r#"mod work_graph { fn operations() {} }
15648mod manifest { fn operations() {} }
15649mod audit { fn operations() {} }
15650mod dispatch { fn operations() {} }
15651mod finalization { fn operations() {} }
15652
15653pub fn run_inline_operations() {
15654    work_graph::operations();
15655    manifest::operations();
15656    audit::operations();
15657    dispatch::operations();
15658    finalization::operations();
15659}
15660
15661fn planted_dead() {}
15662"#,
15663        );
15664
15665        let (store, snapshot) = cold_build_twice(root);
15666        for module in [
15667            "work_graph",
15668            "manifest",
15669            "audit",
15670            "dispatch",
15671            "finalization",
15672        ] {
15673            assert_direct_caller(
15674                &store,
15675                "src/lib.rs",
15676                &format!("{module}::operations"),
15677                "src/lib.rs",
15678                "run_inline_operations",
15679            );
15680        }
15681        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
15682        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15683    }
15684
15685    #[test]
15686    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
15687        let dir = tempdir().expect("tempdir");
15688        let root = dir.path();
15689        fs::write(
15690            root.join("Cargo.toml"),
15691            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
15692        )
15693        .expect("write workspace manifest");
15694        write_file(
15695            root,
15696            "crates/but-action/Cargo.toml",
15697            r#"[package]
15698name = "but-action"
15699version = "0.1.0"
15700edition = "2021"
15701"#,
15702        );
15703        write_file(
15704            root,
15705            "crates/but-action/src/lib.rs",
15706            "mod action;\npub use action::{list_actions};\n",
15707        );
15708        write_file(
15709            root,
15710            "crates/but-action/src/action.rs",
15711            "pub fn list_actions() {}\nfn planted_dead() {}\n",
15712        );
15713        write_file(
15714            root,
15715            "crates/app/Cargo.toml",
15716            r#"[package]
15717name = "app"
15718version = "0.1.0"
15719edition = "2021"
15720"#,
15721        );
15722        write_file(
15723            root,
15724            "crates/app/src/lib.rs",
15725            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
15726        );
15727
15728        let (store, snapshot) = cold_build_twice(root);
15729        assert_direct_caller(
15730            &store,
15731            "crates/but-action/src/action.rs",
15732            "list_actions",
15733            "crates/app/src/lib.rs",
15734            "run_actions",
15735        );
15736        assert!(
15737            store
15738                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
15739                .expect("lib reexport callers")
15740                .is_empty(),
15741            "call should target the reexported source function, not lib.rs"
15742        );
15743        assert_projected_call(
15744            root,
15745            &snapshot,
15746            "crates/but-action/src/action.rs",
15747            "list_actions",
15748        );
15749        assert_no_projected_call(
15750            root,
15751            &snapshot,
15752            "crates/but-action/src/action.rs",
15753            "planted_dead",
15754        );
15755    }
15756
15757    #[test]
15758    fn rust_generic_self_turbofish_method_dispatch_resolves() {
15759        let dir = tempdir().expect("tempdir");
15760        let root = dir.path();
15761        write_rust_manifest(root, "generic-self-fixture");
15762        write_file(
15763            root,
15764            "src/lib.rs",
15765            r#"pub struct Matcher;
15766
15767impl Matcher {
15768    pub fn run(&self) -> bool {
15769        self.fuzzy_match_optimal::<usize>("needle")
15770    }
15771
15772    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
15773        let _ = std::marker::PhantomData::<T>;
15774        true
15775    }
15776
15777    fn planted_dead(&self) {}
15778}
15779
15780pub fn entry() -> bool {
15781    let matcher = Matcher;
15782    matcher.run()
15783}
15784"#,
15785        );
15786
15787        let (store, snapshot) = cold_build_twice(root);
15788        assert_direct_caller(
15789            &store,
15790            "src/lib.rs",
15791            "Matcher::fuzzy_match_optimal",
15792            "src/lib.rs",
15793            "Matcher::run",
15794        );
15795        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
15796        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
15797    }
15798
15799    #[test]
15800    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
15801        let dir = tempdir().expect("tempdir");
15802        let root = dir.path();
15803        write_rust_manifest(root, "manifest-operations-fixture");
15804        write_file(
15805            root,
15806            "src/main.rs",
15807            r#"mod dispatch;
15808use dispatch::{manifest_operations};
15809
15810fn main() {
15811    manifest_operations();
15812}
15813"#,
15814        );
15815        write_file(
15816            root,
15817            "src/dispatch.rs",
15818            r#"mod work_graph { fn operations() {} }
15819mod manifest { fn operations() {} }
15820mod audit { fn operations() {} }
15821mod descriptor { fn operations() {} }
15822mod writer { fn operations() {} }
15823
15824pub fn manifest_operations() {
15825    manifest::operations();
15826}
15827
15828pub fn work_graph_operations() {
15829    work_graph::operations();
15830}
15831
15832pub fn audit_operations() {
15833    audit::operations();
15834}
15835
15836pub fn descriptor_operations() {
15837    descriptor::operations();
15838}
15839
15840pub fn writer_operations() {
15841    writer::operations();
15842}
15843
15844fn planted_dead() {}
15845"#,
15846        );
15847
15848        let (store, snapshot) = cold_build_twice(root);
15849        assert_direct_caller(
15850            &store,
15851            "src/dispatch.rs",
15852            "manifest_operations",
15853            "src/main.rs",
15854            "main",
15855        );
15856        assert_direct_caller(
15857            &store,
15858            "src/dispatch.rs",
15859            "manifest::operations",
15860            "src/dispatch.rs",
15861            "manifest_operations",
15862        );
15863        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
15864        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
15865        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
15866    }
15867
15868    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
15869        let files = rust_files(root);
15870        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
15871            .expect("open first store");
15872        first.cold_build(&files).expect("first cold build");
15873        let first_snapshot =
15874            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
15875
15876        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
15877            .expect("open second store");
15878        second.cold_build(&files).expect("second cold build");
15879        let second_snapshot =
15880            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
15881
15882        assert_eq!(
15883            projection_rows(&first_snapshot),
15884            projection_rows(&second_snapshot),
15885            "cold-build projection should be deterministic"
15886        );
15887        (first, first_snapshot)
15888    }
15889
15890    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
15891        let mut rows = Vec::new();
15892        for export in &snapshot.exported_symbols {
15893            rows.push(format!(
15894                "export\t{}\t{}\t{}\t{}",
15895                export.file.display(),
15896                export.symbol,
15897                export.kind,
15898                export.line
15899            ));
15900        }
15901        for call in &snapshot.outbound_calls {
15902            rows.push(format!(
15903                "call\t{}\t{}\t{}\t{}\t{}",
15904                call.caller_file.display(),
15905                call.caller_symbol,
15906                call.target,
15907                call.line,
15908                call.provenance
15909            ));
15910        }
15911        for file in &snapshot.entry_points {
15912            rows.push(format!("entry_file\t{}", file.display()));
15913        }
15914        for (file, symbols) in &snapshot.entry_point_symbols {
15915            for symbol in symbols {
15916                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
15917            }
15918        }
15919        rows.sort();
15920        rows
15921    }
15922
15923    fn assert_direct_caller(
15924        store: &CallGraphStore,
15925        target_rel: &str,
15926        target_symbol: &str,
15927        caller_rel: &str,
15928        caller_symbol: &str,
15929    ) {
15930        let callers = store
15931            .direct_callers_of(Path::new(target_rel), target_symbol)
15932            .unwrap_or_else(|error| {
15933                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
15934            });
15935        assert!(
15936            callers.iter().any(|site| {
15937                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
15938            }),
15939            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
15940        );
15941    }
15942
15943    fn assert_projected_call(
15944        root: &Path,
15945        snapshot: &CallgraphSnapshot,
15946        target_rel: &str,
15947        symbol: &str,
15948    ) {
15949        let target = projected_target(root, target_rel, symbol);
15950        assert!(
15951            snapshot.outbound_calls.iter().any(|call| {
15952                call.target == target
15953                    || call.target.starts_with(&format!(
15954                        "{target}{}",
15955                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15956                    ))
15957            }),
15958            "expected projected call to {target}; calls: {:#?}",
15959            snapshot.outbound_calls
15960        );
15961    }
15962
15963    fn assert_no_projected_call(
15964        root: &Path,
15965        snapshot: &CallgraphSnapshot,
15966        target_rel: &str,
15967        symbol: &str,
15968    ) {
15969        let target = projected_target(root, target_rel, symbol);
15970        assert!(
15971            snapshot.outbound_calls.iter().all(|call| {
15972                call.target != target
15973                    && !call.target.starts_with(&format!(
15974                        "{target}{}",
15975                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
15976                    ))
15977            }),
15978            "did not expect projected call to {target}; calls: {:#?}",
15979            snapshot.outbound_calls
15980        );
15981    }
15982
15983    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
15984        // Projection targets carry the normalized (verbatim-stripped)
15985        // canonical form; bare fs::canonicalize diverges on Windows.
15986        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
15987        format!("{}::{symbol}", path.display())
15988    }
15989
15990    fn write_rust_manifest(root: &Path, name: &str) {
15991        write_file(
15992            root,
15993            "Cargo.toml",
15994            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
15995        );
15996    }
15997
15998    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
15999        let path = root.join(rel_path);
16000        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
16001        fs::write(&path, source).expect("write fixture file");
16002        path
16003    }
16004
16005    fn rust_files(root: &Path) -> Vec<PathBuf> {
16006        let mut files = Vec::new();
16007        collect_rust_files(root, &mut files);
16008        files.sort();
16009        files
16010    }
16011
16012    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
16013        for entry in fs::read_dir(dir).expect("read fixture dir") {
16014            let entry = entry.expect("read fixture entry");
16015            let path = entry.path();
16016            if path.is_dir() {
16017                let name = path
16018                    .file_name()
16019                    .and_then(|name| name.to_str())
16020                    .unwrap_or("");
16021                if !name.starts_with(".store") {
16022                    collect_rust_files(&path, files);
16023                }
16024            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
16025                files.push(path);
16026            }
16027        }
16028    }
16029}
16030
16031#[cfg(test)]
16032mod build_pool_tests {
16033    use super::build_pool_size;
16034
16035    #[test]
16036    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
16037        let size = build_pool_size();
16038        // Never zero, never the full core count, never above the 8 cap — this is
16039        // the starvation guard for the cold-build's all-cores tree-sitter pass.
16040        assert!(size >= 1, "pool size must be at least 1");
16041        assert!(size <= 8, "pool size must be capped at 8, got {size}");
16042
16043        let cores = std::thread::available_parallelism()
16044            .map(|p| p.get())
16045            .unwrap_or(1);
16046        let expected = cores.div_ceil(2).clamp(1, 8);
16047        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
16048    }
16049}
16050
16051#[cfg(test)]
16052mod reexport_resolution_tests {
16053    use super::*;
16054
16055    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
16056        ProjectIndex {
16057            project_root: PathBuf::from("/fixture"),
16058            files: files.into_iter().collect(),
16059            caller_data: HashMap::new(),
16060            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
16061        }
16062    }
16063
16064    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
16065        DbFileIndex {
16066            lang: None,
16067            exports: HashSet::new(),
16068            default_export: None,
16069            export_aliases: HashMap::new(),
16070            node_by_scoped: HashMap::new(),
16071            node_by_bare: HashMap::new(),
16072            node_kind_by_id: HashMap::new(),
16073            module_targets: HashMap::new(),
16074            reexports: reexport_targets
16075                .iter()
16076                .map(|target| ReexportIndex {
16077                    target_file: Some((*target).to_string()),
16078                    named: HashMap::new(),
16079                    wildcard: true,
16080                })
16081                .collect(),
16082        }
16083    }
16084
16085    /// A dense wildcard re-export cycle (barrel files re-exporting each
16086    /// other) must resolve in O(files), not O(branching^depth). Without the
16087    /// resolver's memoization, resolving a MISSING symbol through this
16088    /// 12-file complete digraph explores ~11^16 paths and this test never
16089    /// finishes: the depth cap bounds path length, not path count, and one
16090    /// such resolution can pin a worker thread at 100% CPU indefinitely.
16091    #[test]
16092    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
16093        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
16094        let files = names
16095            .iter()
16096            .map(|name| {
16097                let targets: Vec<&str> = names
16098                    .iter()
16099                    .filter(|other| *other != name)
16100                    .map(String::as_str)
16101                    .collect();
16102                (name.clone(), barrel_file(&targets))
16103            })
16104            .collect();
16105        let index = barrel_index(files);
16106
16107        assert_eq!(
16108            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
16109            None
16110        );
16111    }
16112
16113    /// Depth-dominance counterexample: the walk first reaches `shared` down a
16114    /// 16-hop chain (no budget left for its leaf), then reaches it again
16115    /// directly at depth 1. Plain visited-set pruning would skip the second
16116    /// visit and lose a resolution the capped resolver finds; the
16117    /// depth-dominance memo revisits because the second arrival is shallower.
16118    #[test]
16119    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
16120        let mut leaf = barrel_file(&[]);
16121        leaf.exports.insert("deep_symbol".to_string());
16122        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
16123        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
16124        // entry's SECOND reexport goes straight to shared.
16125        files.push((
16126            "src/entry.ts".to_string(),
16127            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
16128        ));
16129        for i in 0..15 {
16130            let next = if i == 14 {
16131                "src/shared.ts".to_string()
16132            } else {
16133                format!("src/chain{}.ts", i + 1)
16134            };
16135            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
16136        }
16137        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
16138        files.push(("src/leaf.ts".to_string(), leaf));
16139        let index = barrel_index(files);
16140
16141        assert_eq!(
16142            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
16143            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
16144            "a shallower re-visit must not be pruned by a deeper capped visit"
16145        );
16146    }
16147
16148    #[test]
16149    fn symbol_reachable_through_reexport_cycle_still_resolves() {
16150        let mut leaf = barrel_file(&[]);
16151        leaf.exports.insert("real_symbol".to_string());
16152        let index = barrel_index(vec![
16153            (
16154                "src/a.ts".to_string(),
16155                barrel_file(&["src/b.ts", "src/a.ts"]),
16156            ),
16157            (
16158                "src/b.ts".to_string(),
16159                barrel_file(&["src/a.ts", "src/leaf.ts"]),
16160            ),
16161            ("src/leaf.ts".to_string(), leaf),
16162        ]);
16163
16164        assert_eq!(
16165            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
16166            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
16167        );
16168    }
16169}
16170
16171#[cfg(test)]
16172mod method_dispatch_inference_tests {
16173    use super::*;
16174    use std::fs;
16175    use tempfile::tempdir;
16176
16177    #[test]
16178    fn java_field_receiver_type_selects_declared_class_method() {
16179        let source = r#"class EntryPoint {
16180    private UserService userService;
16181
16182    void handle() {
16183        userService.find();
16184    }
16185}
16186
16187class UserService {
16188    void find() {}
16189}
16190
16191class AuditService {
16192    void find() {}
16193}
16194"#;
16195        let dir = tempdir().expect("temp dir");
16196        let root = dir.path();
16197        write_fixture(root, "src/EntryPoint.java", source);
16198        let reference = reference(
16199            "java",
16200            "src/EntryPoint.java",
16201            "EntryPoint::handle",
16202            "userService",
16203            "find",
16204            line_of(source, "userService.find()"),
16205        );
16206        let mut cache = DispatchSourceCache::new();
16207
16208        let receiver_type =
16209            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
16210        assert_eq!(receiver_type, "UserService");
16211
16212        let candidates = vec![
16213            method_candidate("audit", "AuditService::find"),
16214            method_candidate("user", "UserService::find"),
16215        ];
16216        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
16217            .expect("type candidate");
16218        assert_eq!(selected.scoped_name, "UserService::find");
16219
16220        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
16221        assert!(
16222            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
16223        );
16224    }
16225
16226    #[test]
16227    fn kotlin_property_and_local_value_types_are_inferred() {
16228        let source = r#"class Handler {
16229    private val auditService: AuditService = AuditService()
16230
16231    fun handle() {
16232        auditService.find()
16233        val userService: UserService = UserService()
16234        userService.find()
16235        val billingService = BillingService()
16236        billingService.find()
16237    }
16238}
16239
16240class UserService { fun find() {} }
16241class AuditService { fun find() {} }
16242class BillingService { fun find() {} }
16243"#;
16244        let dir = tempdir().expect("temp dir");
16245        let root = dir.path();
16246        write_fixture(root, "src/Handler.kt", source);
16247        let mut cache = DispatchSourceCache::new();
16248
16249        let audit_ref = reference(
16250            "kotlin",
16251            "src/Handler.kt",
16252            "Handler::handle",
16253            "auditService",
16254            "find",
16255            line_of(source, "auditService.find()"),
16256        );
16257        assert_eq!(
16258            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
16259            Some("AuditService")
16260        );
16261
16262        let user_ref = reference(
16263            "kotlin",
16264            "src/Handler.kt",
16265            "Handler::handle",
16266            "userService",
16267            "find",
16268            line_of(source, "userService.find()"),
16269        );
16270        assert_eq!(
16271            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
16272            Some("UserService")
16273        );
16274
16275        let billing_ref = reference(
16276            "kotlin",
16277            "src/Handler.kt",
16278            "Handler::handle",
16279            "billingService",
16280            "find",
16281            line_of(source, "billingService.find()"),
16282        );
16283        assert_eq!(
16284            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
16285            Some("BillingService")
16286        );
16287    }
16288
16289    #[test]
16290    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
16291        let source = r#"struct Foo { void run(); };
16292struct PointerFoo { void run(); };
16293struct FactoryFoo { void run(); };
16294FactoryFoo makeFactoryFoo();
16295
16296void handle() {
16297    Foo foo;
16298    foo.run();
16299    PointerFoo* pointerFoo = nullptr;
16300    pointerFoo->run();
16301    auto factoryFoo = makeFactoryFoo();
16302    factoryFoo.run();
16303}
16304"#;
16305        let dir = tempdir().expect("temp dir");
16306        let root = dir.path();
16307        write_fixture(root, "src/fixture.cpp", source);
16308        let mut cache = DispatchSourceCache::new();
16309
16310        let foo_ref = reference(
16311            "cpp",
16312            "src/fixture.cpp",
16313            "handle",
16314            "foo",
16315            "run",
16316            line_of(source, "foo.run()"),
16317        );
16318        assert_eq!(
16319            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
16320            Some("Foo")
16321        );
16322
16323        let pointer_ref = reference(
16324            "cpp",
16325            "src/fixture.cpp",
16326            "handle",
16327            "pointerFoo",
16328            "run",
16329            line_of(source, "pointerFoo->run()"),
16330        );
16331        assert_eq!(
16332            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
16333            Some("PointerFoo")
16334        );
16335
16336        let factory_ref = reference(
16337            "cpp",
16338            "src/fixture.cpp",
16339            "handle",
16340            "factoryFoo",
16341            "run",
16342            line_of(source, "factoryFoo.run()"),
16343        );
16344        assert_eq!(
16345            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
16346            Some("FactoryFoo")
16347        );
16348    }
16349
16350    #[test]
16351    fn rust_direct_self_field_name_trims_separator_whitespace() {
16352        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
16353            assert_eq!(
16354                rust_direct_self_field_name(receiver_expression),
16355                Some("engine")
16356            );
16357        }
16358    }
16359
16360    #[test]
16361    fn rust_direct_self_field_receiver_type_is_conservative() {
16362        let source = r#"struct Engine;
16363
16364struct Car {
16365    engine: Engine,
16366}
16367
16368impl Car {
16369    fn run(&self) {
16370        self.engine.start();
16371    }
16372}
16373
16374struct NestedCar {
16375    engine: Engine,
16376}
16377
16378impl NestedCar {
16379    fn run(&self) {
16380        self.inner.engine.start();
16381    }
16382}
16383
16384struct WrappedCar {
16385    engine: Option<Engine>,
16386}
16387
16388impl WrappedCar {
16389    fn run(&self) {
16390        self.engine.start(); // wrapped
16391    }
16392}
16393
16394struct GenericCar<T> {
16395    engine: T,
16396}
16397
16398impl<T> GenericCar<T> {
16399    fn run(&self) {
16400        self.engine.start(); // generic
16401    }
16402}
16403
16404type EngineAlias = Engine;
16405
16406struct AliasCar {
16407    engine: EngineAlias,
16408}
16409
16410impl AliasCar {
16411    fn run(&self) {
16412        self.engine.start(); // alias
16413    }
16414}
16415"#;
16416        let dir = tempdir().expect("temp dir");
16417        let root = dir.path();
16418        write_fixture(root, "src/lib.rs", source);
16419        let mut cache = DispatchSourceCache::new();
16420
16421        let mut direct = reference(
16422            "rust",
16423            "src/lib.rs",
16424            "Car::run",
16425            "engine",
16426            "start",
16427            line_of(source, "self.engine.start()"),
16428        );
16429        direct.receiver_expression = "self.engine".to_string();
16430        assert_eq!(
16431            infer_receiver_type(root, &direct, &mut cache).as_deref(),
16432            Some("Engine")
16433        );
16434
16435        let mut mismatched_impl_target = direct.clone();
16436        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
16437        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
16438
16439        let mut nested = reference(
16440            "rust",
16441            "src/lib.rs",
16442            "NestedCar::run",
16443            "engine",
16444            "start",
16445            line_of(source, "self.inner.engine.start()"),
16446        );
16447        nested.receiver_expression = "self.inner.engine".to_string();
16448        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
16449
16450        let mut wrapped = reference(
16451            "rust",
16452            "src/lib.rs",
16453            "WrappedCar::run",
16454            "engine",
16455            "start",
16456            line_of(source, "self.engine.start(); // wrapped"),
16457        );
16458        wrapped.receiver_expression = "self.engine".to_string();
16459        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
16460
16461        let mut generic = reference(
16462            "rust",
16463            "src/lib.rs",
16464            "GenericCar::run",
16465            "engine",
16466            "start",
16467            line_of(source, "self.engine.start(); // generic"),
16468        );
16469        generic.receiver_expression = "self.engine".to_string();
16470        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
16471
16472        let mut alias = reference(
16473            "rust",
16474            "src/lib.rs",
16475            "AliasCar::run",
16476            "engine",
16477            "start",
16478            line_of(source, "self.engine.start(); // alias"),
16479        );
16480        alias.receiver_expression = "self.engine".to_string();
16481        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
16482    }
16483
16484    #[test]
16485    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
16486        let source = r#"struct Engine;
16487
16488struct Car {
16489    engine: &'static Engine,
16490}
16491
16492impl Car {
16493    fn run(&self) {
16494        self.engine.start();
16495    }
16496}
16497"#;
16498        let dir = tempdir().expect("temp dir");
16499        let root = dir.path();
16500        write_fixture(root, "src/lib.rs", source);
16501        let mut cache = DispatchSourceCache::new();
16502        let mut reference = reference(
16503            "rust",
16504            "src/lib.rs",
16505            "Car::run",
16506            "engine",
16507            "start",
16508            line_of(source, "self.engine.start()"),
16509        );
16510        reference.receiver_expression = "self.engine".to_string();
16511
16512        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16513    }
16514
16515    #[test]
16516    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
16517        let source = r#"trait Drive {
16518    fn run(&self);
16519}
16520
16521struct Engine;
16522
16523struct Car {
16524    engine: Engine,
16525}
16526
16527impl Drive for Car {
16528    fn run(&self) {
16529        self.engine.start();
16530    }
16531}
16532"#;
16533        let dir = tempdir().expect("temp dir");
16534        let root = dir.path();
16535        write_fixture(root, "src/lib.rs", source);
16536        let mut cache = DispatchSourceCache::new();
16537        let mut reference = reference(
16538            "rust",
16539            "src/lib.rs",
16540            "Car::run",
16541            "engine",
16542            "start",
16543            line_of(source, "self.engine.start()"),
16544        );
16545        reference.receiver_expression = "self.engine".to_string();
16546
16547        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16548    }
16549
16550    #[test]
16551    fn rust_self_field_does_not_bind_struct_from_another_module() {
16552        let source = r#"struct Engine;
16553
16554mod unrelated {
16555    struct Car {
16556        engine: Engine,
16557    }
16558}
16559
16560impl Car {
16561    fn run(&self) {
16562        self.engine.start();
16563    }
16564}
16565"#;
16566        let dir = tempdir().expect("temp dir");
16567        let root = dir.path();
16568        write_fixture(root, "src/lib.rs", source);
16569        let mut cache = DispatchSourceCache::new();
16570        let mut reference = reference(
16571            "rust",
16572            "src/lib.rs",
16573            "Car::run",
16574            "engine",
16575            "start",
16576            line_of(source, "self.engine.start()"),
16577        );
16578        reference.receiver_expression = "self.engine".to_string();
16579
16580        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16581    }
16582
16583    #[test]
16584    fn unknown_java_receiver_still_uses_name_match_fallback() {
16585        let source = r#"class EntryPoint {
16586    void handle() {
16587        service.runSpecial();
16588    }
16589}
16590
16591class OnlyService {
16592    void runSpecial() {}
16593}
16594"#;
16595        let dir = tempdir().expect("temp dir");
16596        let root = dir.path();
16597        write_fixture(root, "src/EntryPoint.java", source);
16598        let reference = reference(
16599            "java",
16600            "src/EntryPoint.java",
16601            "EntryPoint::handle",
16602            "service",
16603            "runSpecial",
16604            line_of(source, "service.runSpecial()"),
16605        );
16606        let mut cache = DispatchSourceCache::new();
16607
16608        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
16609        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
16610        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
16611        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
16612    }
16613
16614    fn reference(
16615        lang: &str,
16616        caller_file: &str,
16617        caller_symbol: &str,
16618        receiver: &str,
16619        method_name: &str,
16620        line: u32,
16621    ) -> NameMatchRef {
16622        NameMatchRef {
16623            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
16624            caller_node: format!("{caller_symbol}:node"),
16625            caller_file: caller_file.to_string(),
16626            caller_symbol: caller_symbol.to_string(),
16627            caller_signature: None,
16628            receiver_expression: receiver.to_string(),
16629            receiver: receiver.to_string(),
16630            method_name: method_name.to_string(),
16631            colon_dispatch: false,
16632            line,
16633            lang: lang.to_string(),
16634        }
16635    }
16636
16637    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
16638        NameMatchCandidate {
16639            node_id: node_id.to_string(),
16640            file_path: "src/targets.fixture".to_string(),
16641            scoped_name: scoped_name.to_string(),
16642            kind: "method".to_string(),
16643            start_line: 1,
16644        }
16645    }
16646
16647    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
16648        let path = root.join(rel_path);
16649        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
16650        fs::write(path, source).expect("write fixture");
16651    }
16652
16653    fn line_of(source: &str, needle: &str) -> u32 {
16654        source
16655            .lines()
16656            .position(|line| line.contains(needle))
16657            .map(|index| index as u32 + 1)
16658            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
16659    }
16660}
16661
16662#[cfg(test)]
16663mod bounded_build_breaker_tests {
16664    use super::*;
16665    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
16666    use tempfile::tempdir;
16667
16668    #[test]
16669    fn staged_inventory_drives_ordered_bounded_file_batches() {
16670        let temp = tempdir().unwrap();
16671        let root = temp.path().join("root");
16672        std::fs::create_dir_all(&root).unwrap();
16673        let first = root.join("a.ts");
16674        let second = root.join("b.ts");
16675        let third = root.join("c.ts");
16676        for path in [&first, &second, &third] {
16677            std::fs::write(path, "export function item() {}\n").unwrap();
16678        }
16679        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
16680            .unwrap()
16681            .expect("test root may write its private staging database");
16682        let store = CallGraphStore::open_at_path(
16683            root.clone(),
16684            "inventory-key".to_string(),
16685            temp.path().join("inventory.sqlite"),
16686            None,
16687            true,
16688            Some(writer_lease),
16689            None,
16690        )
16691        .unwrap()
16692        .store;
16693        let fingerprint = store
16694            .stage_cold_build_file_inventory(&[
16695                third.clone(),
16696                first.clone(),
16697                second.clone(),
16698                first.clone(),
16699            ])
16700            .unwrap();
16701
16702        let conn = store.conn.lock().unwrap();
16703        assert_eq!(
16704            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
16705            3,
16706            "the primary key deduplicates caller-supplied paths on disk"
16707        );
16708        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
16709            .unwrap()
16710            .expect("first batch");
16711        assert_eq!(first_batch.paths, vec![first.clone(), second]);
16712        let second_batch =
16713            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
16714                .unwrap()
16715                .expect("second batch");
16716        assert_eq!(second_batch.paths, vec![third]);
16717        assert_eq!(
16718            fingerprint,
16719            callgraph_corpus_fingerprint(&root).unwrap(),
16720            "staged and direct streaming fingerprints agree without walk-order dependence"
16721        );
16722    }
16723
16724    #[test]
16725    fn resumed_stage_preserves_committed_batch_and_counter() {
16726        let temp = tempdir().unwrap();
16727        let root = temp.path().join("root");
16728        std::fs::create_dir_all(&root).unwrap();
16729        let first = root.join("first.ts");
16730        let second = root.join("second.ts");
16731        std::fs::write(&first, "export function first() {}\n").unwrap();
16732        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
16733        let staging = temp.path().join("stage.sqlite");
16734        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
16735            .unwrap()
16736            .expect("test root may write its private staging database");
16737        let store = CallGraphStore::open_at_path(
16738            root.clone(),
16739            "test-key".to_string(),
16740            staging,
16741            None,
16742            true,
16743            Some(writer_lease),
16744            None,
16745        )
16746        .unwrap()
16747        .store;
16748        let corpus_fingerprint = store
16749            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
16750            .unwrap();
16751        let first_extract = build_file_extract(&root, &first).unwrap();
16752        let first_bytes = first_extract.freshness.size;
16753        {
16754            let mut conn = store.conn.lock().unwrap();
16755            let tx = conn.transaction().unwrap();
16756            clear_tables(&tx).unwrap();
16757            insert_meta(&tx).unwrap();
16758            drop_cold_build_secondary_indexes(&tx).unwrap();
16759            set_meta_ready(&tx, false).unwrap();
16760            set_staged_build_phase(&tx, "extracting").unwrap();
16761            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
16762            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
16763            {
16764                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
16765                insert_file_extract_prepared(
16766                    &mut inserts,
16767                    &root.display().to_string(),
16768                    &first_extract,
16769                )
16770                .unwrap();
16771                for raw in &first_extract.raw_refs {
16772                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
16773                }
16774            }
16775            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
16776            tx.commit().unwrap();
16777        }
16778
16779        store
16780            .cold_build_chunked(&[first.clone(), second.clone()], 1)
16781            .unwrap();
16782        let conn = store.conn.lock().unwrap();
16783        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
16784        assert_eq!(
16785            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16786            first_bytes + std::fs::metadata(second).unwrap().len(),
16787            "the already committed batch and its credit survive adoption; only the new batch increments credit"
16788        );
16789        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
16790    }
16791
16792    const SPECIMEN_CHILD_TEST: &str =
16793        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
16794    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
16795    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
16796    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
16797    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
16798
16799    fn wait_for_child_barrier(path: &Path) {
16800        let deadline = Instant::now() + Duration::from_secs(10);
16801        while !path.exists() {
16802            assert!(
16803                Instant::now() < deadline,
16804                "callgraph child did not reach barrier {}",
16805                path.display()
16806            );
16807            std::thread::sleep(Duration::from_millis(5));
16808        }
16809    }
16810
16811    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
16812        let signal = store.join("specimen-child.reached");
16813        let _ = std::fs::remove_file(&signal);
16814        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
16815        command
16816            .arg("--exact")
16817            .arg(SPECIMEN_CHILD_TEST)
16818            .arg("--nocapture")
16819            .arg("--test-threads=1")
16820            .env(SPECIMEN_CHILD_ROOT, root)
16821            .env(SPECIMEN_CHILD_STORE, store)
16822            .env(SPECIMEN_CHILD_SIGNAL, &signal)
16823            .stdout(std::process::Stdio::null())
16824            .stderr(std::process::Stdio::null());
16825        if let Some(phase) = phase {
16826            command.env(SPECIMEN_CHILD_PHASE, phase);
16827        }
16828        command.spawn().unwrap()
16829    }
16830
16831    fn staging_path(root: &Path, store: &Path) -> PathBuf {
16832        let project_key = crate::search_index::artifact_cache_key(root);
16833        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
16834    }
16835
16836    fn durable_staging_state(path: &Path) -> (u64, u64) {
16837        if !path.exists() {
16838            return (0, 0);
16839        }
16840        let conn = Connection::open(path).unwrap();
16841        (
16842            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
16843            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
16844        )
16845    }
16846
16847    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
16848        wait_for_child_barrier(signal);
16849        child.kill().unwrap();
16850        let _ = child.wait().unwrap();
16851    }
16852
16853    #[test]
16854    fn respawn_loop_build_child() {
16855        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
16856            return;
16857        };
16858        let root = PathBuf::from(root);
16859        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
16860        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
16861            let phase = phase.to_string_lossy().into_owned();
16862            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
16863            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
16864                if observed == phase {
16865                    std::fs::write(&signal, observed.as_bytes()).unwrap();
16866                    std::thread::sleep(Duration::from_secs(30));
16867                }
16868            })));
16869        }
16870        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
16871        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
16872    }
16873
16874    #[test]
16875    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
16876        let temp = tempdir().unwrap();
16877        let root = temp.path().join("resumable-root");
16878        let store = temp.path().join("resumable-store");
16879        std::fs::create_dir_all(&root).unwrap();
16880        std::fs::create_dir_all(&store).unwrap();
16881        for index in 0..3 {
16882            std::fs::write(
16883                root.join(format!("file-{index}.ts")),
16884                format!("export function specimen{index}() {{ return {index}; }}\n"),
16885            )
16886            .unwrap();
16887        }
16888        let stage = staging_path(&root, &store);
16889        let signal = store.join("specimen-child.reached");
16890
16891        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16892        kill_barrier_child(&mut first, &signal);
16893        let (first_rows, first_bytes) = durable_staging_state(&stage);
16894        assert_eq!(first_rows, 1);
16895        assert!(first_bytes > 0);
16896
16897        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
16898        kill_barrier_child(&mut second, &signal);
16899        let (second_rows, second_bytes) = durable_staging_state(&stage);
16900        assert_eq!(second_rows, 2);
16901        assert!(
16902            second_bytes > first_bytes,
16903            "a replacement process must adopt committed bytes instead of restarting from zero"
16904        );
16905
16906        let status = spawn_build_child(&root, &store, None).wait().unwrap();
16907        assert!(status.success(), "uninterrupted replacement build failed");
16908        assert!(!stage.exists(), "published staging file must be renamed");
16909        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
16910            .unwrap()
16911            .expect("replacement attempts must converge to a published graph");
16912        assert_eq!(ready.indexed_file_count().unwrap(), 3);
16913
16914        let fast_root = temp.path().join("zero-credit-root");
16915        let fast_store = temp.path().join("zero-credit-store");
16916        std::fs::create_dir_all(&fast_root).unwrap();
16917        std::fs::create_dir_all(&fast_store).unwrap();
16918        std::fs::write(
16919            fast_root.join("main.ts"),
16920            "export function neverCommitted() {}\n",
16921        )
16922        .unwrap();
16923        let fast_stage = staging_path(&fast_root, &fast_store);
16924        let fast_signal = fast_store.join("specimen-child.reached");
16925        let breaker_path = fast_store.join("build-breaker.sqlite");
16926        let now = unix_millis_now();
16927
16928        for death in 0..3 {
16929            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
16930            wait_for_child_barrier(&fast_signal);
16931            let attempt_id = Connection::open(&breaker_path)
16932                .unwrap()
16933                .query_row(
16934                    "SELECT attempt_id FROM breaker_attempts
16935                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
16936                    [],
16937                    |row| row.get::<_, String>(0),
16938                )
16939                .unwrap();
16940            let (_, committed_bytes) = durable_staging_state(&fast_stage);
16941            assert_eq!(
16942                committed_bytes, 0,
16943                "the fast-kill schedule must not cross an extraction commit"
16944            );
16945            child.kill().unwrap();
16946            let _ = child.wait().unwrap();
16947
16948            let key = BreakerKey::new(
16949                fast_root.display().to_string(),
16950                BuildDomain::CallgraphCold,
16951                callgraph_corpus_fingerprint(&fast_root).unwrap(),
16952            );
16953            BuildDeathBreaker::open(&breaker_path)
16954                .unwrap()
16955                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
16956                .unwrap();
16957        }
16958
16959        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
16960        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
16961            .unwrap()
16962            .expect("three zero-credit process deaths must suspend the root");
16963        assert_eq!(suspension.reason, "zero_credit_death_limit");
16964        assert_eq!(suspension.death_count, 3);
16965        let response = crate::commands::callgraph_store_adapter::suspended_response(
16966            "specimen",
16967            "callers",
16968            &suspension,
16969        );
16970        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
16971        let message = response.data["message"].as_str().unwrap();
16972        assert!(
16973            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
16974        );
16975        assert!(message.ends_with(
16976            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
16977        ));
16978        let refused =
16979            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
16980                .expect_err("a suspended root must not report a perpetually building worker");
16981        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
16982    }
16983
16984    #[test]
16985    fn published_callgraph_build_respects_durable_domain_suspension() {
16986        let temp = tempdir().unwrap();
16987        let root = temp.path().join("root");
16988        let store_dir = temp.path().join("store");
16989        std::fs::create_dir_all(&root).unwrap();
16990        let source = root.join("main.ts");
16991        std::fs::write(&source, "export function marker() {}\n").unwrap();
16992        let files = vec![source];
16993        let key = BreakerKey::new(
16994            root.display().to_string(),
16995            BuildDomain::CallgraphCold,
16996            callgraph_corpus_fingerprint(&root).unwrap(),
16997        );
16998        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
16999        for _ in 0..3 {
17000            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
17001                panic!("unexpected early suspension");
17002            };
17003            breaker
17004                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
17005                .unwrap();
17006        }
17007
17008        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
17009            .expect_err("durably tripped callgraph domain must refuse a new cold build");
17010        assert!(matches!(
17011            error,
17012            CallGraphStoreError::Suspended(ref suspension)
17013                if suspension.domain == BuildDomain::CallgraphCold
17014                    && suspension.death_count == 3
17015        ));
17016    }
17017}