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/// A root removed from `cache-keys.json` cannot be reached by a future checkout.
61/// Wait the same seven-day grace period as cache-key eviction before deleting its
62/// callgraph directory so an interrupted configuration never loses recent data.
63const CALLGRAPH_ROOT_ORPHAN_MIN_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
64/// One publish must not spend unbounded time walking a large artifact store. The
65/// cursor resumes after this many root-keyed directories on the next publication.
66const CALLGRAPH_ROOT_SWEEP_LIMIT: usize = 200;
67const CALLGRAPH_ROOT_SWEEP_BUDGET: Duration = Duration::from_secs(5);
68static CALLGRAPH_ROOT_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
69
70// Cold-build working-set limits are implementation constants rather than user
71// knobs so a large non-git root cannot accidentally opt back into an OOM path.
72const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
73const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
74// A 20k-reference resolver window kept peak RSS working-set shaped in the
75// committed 20k/40k corpus harness; 100k rows did not.
76const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
77const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
78const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
79const STAGED_BUILD_PHASE: &str = "staged_build_phase";
80const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
81
82fn write_amplification_baseline_enabled() -> bool {
83    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
84}
85
86type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
87pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
88#[cfg(test)]
89type ColdBuildSliceObserver = dyn Fn(&'static str, usize, usize) + Send + Sync + 'static;
90#[cfg(test)]
91type ColdBuildExtractObserver = dyn Fn(&[PathBuf]) + Send + Sync + 'static;
92
93static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
94    OnceLock::new();
95
96/// Install a process-local phase hook for the reproducible cold-build harness.
97/// Production callers leave it unset, so phase reporting adds no allocation on
98/// the build path.
99pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
100    *COLD_BUILD_PHASE_OBSERVER
101        .get_or_init(|| Mutex::new(None))
102        .lock()
103        .expect("cold build phase observer mutex poisoned") = observer;
104}
105
106fn note_cold_build_phase(phase: &'static str) {
107    if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
108        .get_or_init(|| Mutex::new(None))
109        .lock()
110        .expect("cold build phase observer mutex poisoned")
111        .as_ref()
112        .cloned()
113    {
114        observer(phase);
115    }
116}
117
118#[cfg(test)]
119fn note_cold_build_commit_barrier(phase: &'static str) {
120    note_cold_build_phase(phase);
121}
122
123#[cfg(not(test))]
124fn note_cold_build_commit_barrier(_phase: &'static str) {}
125
126#[derive(Clone, Debug, Eq, Hash, PartialEq)]
127struct RebuildCooldownKey {
128    callgraph_dir: PathBuf,
129    project_key: String,
130}
131
132#[derive(Clone, Debug)]
133struct RebuildCooldownRecord {
134    project_root: PathBuf,
135    published_at: Instant,
136    cross_root_cooldown_armed: bool,
137}
138
139// Prevent repeated rebuilds when requests rapidly switch between project
140// roots. Allow the first successful rebuild for a different root; after that
141// transition, report the artifact as unavailable instead of publishing another
142// complete generation. Record only successful publications in this map.
143static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
144    OnceLock::new();
145
146#[derive(Clone, Debug, Eq, Hash, PartialEq)]
147struct RootRepairWarningKey {
148    project_key: String,
149}
150
151#[derive(Clone, Debug)]
152struct RootRepairWarningRecord {
153    window_start: Instant,
154    last_emitted: Instant,
155    entry_count: u64,
156    suppressed: u64,
157}
158
159static ROOT_REPAIR_WARNINGS: OnceLock<
160    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
161> = OnceLock::new();
162
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
164pub(crate) struct CallgraphWriteMetricsSnapshot {
165    pub commits_60s: u64,
166    pub pages_or_bytes_written_60s: u64,
167}
168
169#[derive(Debug, Default)]
170struct CallgraphWriteMetrics {
171    window_start_ms: AtomicU64,
172    commits_60s: AtomicU64,
173    pages_or_bytes_written_60s: AtomicU64,
174}
175
176static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
177    OnceLock::new();
178
179fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
180    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
181    let mut metrics = metrics
182        .lock()
183        .expect("callgraph write metrics mutex poisoned");
184    Arc::clone(
185        metrics
186            .entry(project_key.to_string())
187            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
188    )
189}
190
191fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
192    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
193    if current_start == 0 {
194        let _ = metrics.window_start_ms.compare_exchange(
195            0,
196            now_ms,
197            AtomicOrdering::AcqRel,
198            AtomicOrdering::Acquire,
199        );
200        return;
201    }
202    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
203        return;
204    }
205    if metrics
206        .window_start_ms
207        .compare_exchange(
208            current_start,
209            now_ms,
210            AtomicOrdering::AcqRel,
211            AtomicOrdering::Acquire,
212        )
213        .is_ok()
214    {
215        metrics.commits_60s.store(0, AtomicOrdering::Release);
216        metrics
217            .pages_or_bytes_written_60s
218            .store(0, AtomicOrdering::Release);
219    }
220}
221
222impl CallgraphWriteMetrics {
223    fn record_commit(&self, pages_or_bytes_written: u64) {
224        let now_ms = unix_millis_now();
225        roll_callgraph_write_metric_window(self, now_ms);
226        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
227        self.pages_or_bytes_written_60s
228            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
229    }
230
231    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
232        roll_callgraph_write_metric_window(self, unix_millis_now());
233        CallgraphWriteMetricsSnapshot {
234            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
235            pages_or_bytes_written_60s: self
236                .pages_or_bytes_written_60s
237                .load(AtomicOrdering::Acquire),
238        }
239    }
240}
241
242pub(crate) fn callgraph_write_metrics_for_project(
243    project_key: &str,
244) -> CallgraphWriteMetricsSnapshot {
245    callgraph_write_metrics_for_key(project_key).snapshot()
246}
247
248pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
249    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
250        return CallgraphWriteMetricsSnapshot::default();
251    };
252    let metrics = metrics
253        .lock()
254        .expect("callgraph write metrics mutex poisoned");
255    metrics.values().map(|metrics| metrics.snapshot()).fold(
256        CallgraphWriteMetricsSnapshot::default(),
257        |total, current| CallgraphWriteMetricsSnapshot {
258            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
259            pages_or_bytes_written_60s: total
260                .pages_or_bytes_written_60s
261                .saturating_add(current.pages_or_bytes_written_60s),
262        },
263    )
264}
265
266const ROOT_REPAIR_WARNING_TEXT: &str =
267    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
268
269fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
270    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
271    let mut warnings = warnings.lock().ok()?;
272    let entry = warnings.entry(key);
273    let record = match entry {
274        Entry::Vacant(entry) => {
275            entry.insert(RootRepairWarningRecord {
276                window_start: now,
277                last_emitted: now,
278                entry_count: 1,
279                suppressed: 0,
280            });
281            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
282        }
283        Entry::Occupied(entry) => entry.into_mut(),
284    };
285
286    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
287        let suppressed = record.suppressed;
288        record.window_start = now;
289        record.last_emitted = now;
290        record.entry_count = 1;
291        record.suppressed = 0;
292        return Some(if suppressed == 0 {
293            ROOT_REPAIR_WARNING_TEXT.to_string()
294        } else {
295            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
296        });
297    }
298
299    record.entry_count = record.entry_count.saturating_add(1);
300    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
301        record.suppressed = record.suppressed.saturating_add(1);
302        None
303    } else {
304        record.last_emitted = now;
305        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
306    }
307}
308
309pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
310    next_root_repair_warning(
311        RootRepairWarningKey {
312            project_key: project_key.to_string(),
313        },
314        Instant::now(),
315    )
316}
317
318/// Return the number of repair entries in the active 60-second window.
319///
320/// The window start is returned for callers that need to show freshness without
321/// adding another status verdict or turning this into a user-facing setting.
322pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
323    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
324    let warnings = warnings.lock().ok()?;
325    let record = warnings.get(&RootRepairWarningKey {
326        project_key: project_key.to_string(),
327    })?;
328    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
329        .then_some((record.entry_count, record.window_start))
330}
331
332pub(crate) fn repair_entry_rate_total() -> u64 {
333    let Ok(warnings) = ROOT_REPAIR_WARNINGS
334        .get_or_init(|| Mutex::new(HashMap::new()))
335        .lock()
336    else {
337        return 0;
338    };
339    let now = Instant::now();
340    warnings
341        .values()
342        .filter(|record| {
343            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
344        })
345        .map(|record| record.entry_count)
346        .sum()
347}
348
349#[cfg(test)]
350pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
351    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
352    let mut warnings = warnings.lock().unwrap();
353    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
354        project_key: project_key.to_string(),
355    }) {
356        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
357    }
358}
359
360#[cfg(test)]
361mod root_repair_warning_tests {
362    use super::*;
363
364    #[test]
365    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
366        let key = RootRepairWarningKey {
367            project_key: "test-project".to_string(),
368        };
369        let first_at = Instant::now();
370        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
371        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
372        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
373        assert_eq!(
374            repair_entry_rate("test-project").map(|rate| rate.0),
375            Some(2)
376        );
377
378        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
379        assert!(repeated.ends_with("(repeated 1x in 60s)"));
380        expire_repair_entry_window_for_test("test-project");
381        assert!(repair_entry_rate("test-project").is_none());
382    }
383}
384
385#[cfg(test)]
386mod write_amplification_tests {
387    use super::*;
388    use std::fs;
389    use tempfile::tempdir;
390
391    #[test]
392    fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
393        let temp = tempdir().unwrap();
394        let sqlite_path = temp.path().join("contended.sqlite");
395        let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
396        blocker
397            .execute_batch(
398                "PRAGMA journal_mode=DELETE;
399                 CREATE TABLE lock_probe (value INTEGER NOT NULL);
400                 INSERT INTO lock_probe VALUES (1);
401                 BEGIN EXCLUSIVE;
402                 UPDATE lock_probe SET value = 2;",
403            )
404            .expect("hold exclusive write transaction");
405
406        let (started_tx, started_rx) = std::sync::mpsc::channel();
407        let configure = std::thread::spawn(move || {
408            let conn = Connection::open(sqlite_path).expect("open contending connection");
409            started_tx.send(()).expect("signal configure start");
410            configure_connection(&conn)
411        });
412        started_rx.recv().expect("configure thread started");
413        std::thread::sleep(Duration::from_millis(100));
414        blocker.execute_batch("COMMIT").expect("release write lock");
415
416        configure
417            .join()
418            .expect("configure thread joined")
419            .expect("WAL setup waits for the writer instead of failing locked");
420    }
421
422    #[test]
423    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
424        let temp = tempdir().unwrap();
425        let root = temp.path().join("root");
426        fs::create_dir_all(&root).unwrap();
427        let source = root.join("main.ts");
428        fs::write(&source, "export function main() {}\n").unwrap();
429        let store_dir = temp.path().join("store");
430        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
431
432        let conn = store.conn.lock().unwrap();
433        let synchronous: i64 = conn
434            .pragma_query_value(None, "synchronous", |row| row.get(0))
435            .unwrap();
436        let autocheckpoint: i64 = conn
437            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
438            .unwrap();
439        let cache_size: i64 = conn
440            .pragma_query_value(None, "cache_size", |row| row.get(0))
441            .unwrap();
442        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
443        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
444        assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
445        drop(conn);
446        store.cold_build(std::slice::from_ref(&source)).unwrap();
447        drop(store);
448
449        let readonly = CallGraphStore::open_readonly(store_dir, root)
450            .unwrap()
451            .expect("writer-created empty schema should be readable");
452        let conn = readonly.inner.conn.lock().unwrap();
453        let synchronous: i64 = conn
454            .pragma_query_value(None, "synchronous", |row| row.get(0))
455            .unwrap();
456        assert_eq!(synchronous, 1);
457    }
458
459    #[test]
460    fn own_refresh_skips_identical_extract_but_not_position_shift() {
461        let temp = tempdir().unwrap();
462        let root = temp.path().join("root");
463        fs::create_dir_all(&root).unwrap();
464        let source = root.join("main.ts");
465        fs::write(&source, "export function main() { return 1; }\n").unwrap();
466        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
467        store.cold_build(std::slice::from_ref(&source)).unwrap();
468        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
469        assert!(write_metrics.commits_60s > 0);
470        assert!(write_metrics.pages_or_bytes_written_60s > 0);
471
472        let before = store.conn.lock().unwrap().total_changes();
473        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
474        let (stats, _) = store
475            .refresh_files_profiled(std::slice::from_ref(&source))
476            .unwrap();
477        let after = store.conn.lock().unwrap().total_changes();
478        assert_eq!(stats.unchanged_extract_files, 1);
479        assert_eq!(stats.refreshed_own_files, 0);
480        assert_eq!(
481            after - before,
482            3,
483            "files, backend freshness, and the durable projection revision update"
484        );
485
486        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
487        let (shifted_stats, _) = store
488            .refresh_files_profiled(std::slice::from_ref(&source))
489            .unwrap();
490        assert_eq!(shifted_stats.unchanged_extract_files, 0);
491        assert_eq!(shifted_stats.refreshed_own_files, 1);
492    }
493
494    #[cfg(unix)]
495    #[test]
496    fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
497        let temp = tempdir().unwrap();
498        let root = temp.path().join("project");
499        let source = root.join("src/lib.ts");
500        fs::create_dir_all(source.parent().unwrap()).unwrap();
501        fs::write(&source, "export function live() {}\n").unwrap();
502        let alias = temp.path().join("project-alias");
503        std::os::unix::fs::symlink(&root, &alias).unwrap();
504        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
505        store.cold_build(std::slice::from_ref(&source)).unwrap();
506        store
507            .mark_files_stale(std::slice::from_ref(&source))
508            .unwrap();
509
510        fs::remove_file(&source).unwrap();
511        let stats = store
512            .refresh_files(&[alias.join("src/lib.ts")])
513            .expect("deleted alias path must resolve through its existing parent");
514
515        assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
516        assert!(store.stale_files().unwrap().is_empty());
517    }
518
519    #[cfg(unix)]
520    #[test]
521    fn symlink_alias_refresh_preserves_real_mutation_detection() {
522        let temp = tempdir().unwrap();
523        let root = temp.path().join("project");
524        let source = root.join("src/lib.ts");
525        fs::create_dir_all(source.parent().unwrap()).unwrap();
526        fs::write(&source, "export function before() {}\n").unwrap();
527        let alias = temp.path().join("project-alias");
528        std::os::unix::fs::symlink(&root, &alias).unwrap();
529        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
530        store.cold_build(std::slice::from_ref(&source)).unwrap();
531
532        fs::write(&source, "export function after() {}\n").unwrap();
533        let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
534
535        assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
536        assert_eq!(stats.refreshed_own_files, 1);
537        assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
538    }
539
540    #[test]
541    fn unresolvable_refresh_path_records_a_path_identity_gap() {
542        let temp = tempdir().unwrap();
543        let root = temp.path().join("project");
544        let source = root.join("src/lib.ts");
545        fs::create_dir_all(source.parent().unwrap()).unwrap();
546        fs::write(&source, "export function live() {}\n").unwrap();
547        let foreign = temp.path().join("foreign.ts");
548        fs::write(&foreign, "export function foreign() {}\n").unwrap();
549        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
550        store.cold_build(std::slice::from_ref(&source)).unwrap();
551
552        let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
553        assert!(matches!(
554            error,
555            CallGraphStoreError::PathIdentityMismatch { .. }
556        ));
557        let conn = store.conn.lock().unwrap();
558        assert_eq!(
559            path_identity_mismatch_reason(&conn).unwrap(),
560            Some(format!(
561                "callgraph_path_identity_mismatch path={} project_root={}",
562                foreign.display(),
563                root.display()
564            ))
565        );
566    }
567
568    #[test]
569    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
570        let now = Instant::now();
571        assert!(idle_checkpoint_due(None, now));
572        assert!(!idle_checkpoint_due(
573            Some(now),
574            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
575        ));
576        assert!(idle_checkpoint_due(
577            Some(now),
578            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
579        ));
580    }
581
582    #[test]
583    fn write_metrics_decay_after_the_sixty_second_window() {
584        let key = format!("metrics-test-{}", now_nanos());
585        let metrics = callgraph_write_metrics_for_key(&key);
586        metrics.record_commit(17);
587        assert_eq!(metrics.snapshot().commits_60s, 1);
588        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
589        metrics.window_start_ms.store(
590            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
591            AtomicOrdering::Release,
592        );
593        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
594    }
595}
596
597#[cfg(test)]
598type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
599// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
600// thread running the cold build, and the only caller (a test) installs and
601// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
602// across parallel tests — one test's installed observer fired during ANOTHER
603// test's `cold_build_with_lease`, asserting against the wrong build's edges
604// (flaked on Windows CI under parallel scheduling). Production never sets it.
605thread_local! {
606    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
607        const { std::cell::RefCell::new(None) };
608    #[cfg(test)]
609    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
610        const { std::cell::RefCell::new(None) };
611    #[cfg(test)]
612    static COLD_BUILD_SLICE_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSliceObserver>>> =
613        const { std::cell::RefCell::new(None) };
614    #[cfg(test)]
615    static COLD_BUILD_EXTRACT_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildExtractObserver>>> =
616        const { std::cell::RefCell::new(None) };
617    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
618        const { std::cell::RefCell::new(None) };
619    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
620    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
621        const { std::cell::Cell::new(false) };
622    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
623        const { std::cell::RefCell::new(None) };
624    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
625        const { std::cell::RefCell::new(None) };
626}
627
628mod dead_code_projection;
629pub use dead_code_projection::project_dead_code_snapshot;
630pub(crate) use dead_code_projection::project_dead_code_snapshot_with_revision;
631#[cfg(test)]
632pub(crate) use dead_code_projection::set_projection_before_open_observer;
633
634#[doc(hidden)]
635pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
636    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
637}
638
639#[cfg(test)]
640fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
641    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
642}
643
644#[cfg(test)]
645fn notify_cold_build_before_publish_observer() {
646    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
647    if let Some(observer) = observer {
648        observer();
649    }
650}
651
652#[cfg(not(test))]
653fn notify_cold_build_before_publish_observer() {}
654
655#[cfg(test)]
656fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
657    COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
658}
659
660#[cfg(test)]
661fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
662    let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
663    if let Some(observer) = observer {
664        observer(stage, completed, total);
665    }
666}
667
668#[cfg(not(test))]
669fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
670
671#[cfg(test)]
672fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
673    COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
674}
675
676#[cfg(test)]
677fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
678    let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
679    if let Some(observer) = observer {
680        observer(paths);
681    }
682}
683
684#[cfg(not(test))]
685fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
686
687#[doc(hidden)]
688pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
689    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
690}
691
692#[doc(hidden)]
693pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
694    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
695}
696
697#[doc(hidden)]
698pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
699    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
700}
701
702struct PublishAdmissionGuard {
703    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
704}
705
706impl Drop for PublishAdmissionGuard {
707    fn drop(&mut self) {
708        PUBLISH_ADMISSION.with(|slot| {
709            *slot.borrow_mut() = self.previous.take();
710        });
711    }
712}
713
714pub(crate) fn with_publish_epoch<R>(
715    epoch: crate::root_cache::ArtifactPublishEpoch,
716    expected: u64,
717    run: impl FnOnce() -> R,
718) -> R {
719    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
720    let _guard = PublishAdmissionGuard { previous };
721    run()
722}
723
724fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
725    notify_cold_build_slice_observer(stage, completed, total);
726    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
727    if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
728        return Ok(());
729    }
730    crate::slog_info!(
731        "callgraph cold build superseded, stopping after {}/{} ({})",
732        completed,
733        total,
734        stage
735    );
736    Err(CallGraphStoreError::Superseded)
737}
738
739fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
740    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
741    match admission {
742        Some((epoch, expected)) => epoch
743            .run_if_current(expected, publish)
744            .unwrap_or(Err(CallGraphStoreError::Superseded)),
745        None => publish(),
746    }
747}
748
749struct RefreshCommitAdmissionGuard {
750    previous: Option<(
751        SubcLifecycleAdmission,
752        Arc<std::sync::atomic::AtomicU64>,
753        u64,
754    )>,
755}
756
757impl Drop for RefreshCommitAdmissionGuard {
758    fn drop(&mut self) {
759        REFRESH_COMMIT_ADMISSION.with(|slot| {
760            *slot.borrow_mut() = self.previous.take();
761        });
762    }
763}
764
765fn with_refresh_commit_admission<R>(
766    lifecycle: SubcLifecycleAdmission,
767    generation_flag: Arc<std::sync::atomic::AtomicU64>,
768    expected_generation: u64,
769    run: impl FnOnce() -> R,
770) -> R {
771    let previous = REFRESH_COMMIT_ADMISSION
772        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
773    let _guard = RefreshCommitAdmissionGuard { previous };
774    run()
775}
776
777fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
778    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
779    let commit = || {
780        publish_if_current(|| {
781            tx.commit()?;
782            Ok(())
783        })
784    };
785    match admission {
786        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
787            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
788            .unwrap_or(Err(CallGraphStoreError::Superseded)),
789        None => commit(),
790    }
791}
792
793fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
794    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
795    if let Some(observer) = observer {
796        observer(temp_path, target_path);
797    }
798}
799
800#[derive(Debug)]
801pub enum CallGraphStoreError {
802    Io(std::io::Error),
803    Sqlite(rusqlite::Error),
804    Json(serde_json::Error),
805    Aft(AftError),
806    Lock(crate::fs_lock::AcquireError),
807    MissingCallerData {
808        file: String,
809    },
810    Unavailable(String),
811    PathIdentityMismatch {
812        path: PathBuf,
813        project_root: PathBuf,
814    },
815    Suspended(crate::build_breaker::BuildSuspension),
816    Superseded,
817    StaleFiles(Vec<String>),
818}
819
820impl CallGraphStoreError {
821    pub(crate) fn is_transient_lock_contention(&self) -> bool {
822        matches!(
823            self,
824            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
825                if matches!(
826                    error.code,
827                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
828                )
829        )
830    }
831}
832
833impl fmt::Display for CallGraphStoreError {
834    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
835        match self {
836            Self::Io(error) => write!(formatter, "I/O error: {error}"),
837            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
838            Self::Json(error) => write!(formatter, "json error: {error}"),
839            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
840            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
841            Self::MissingCallerData { file } => {
842                write!(formatter, "missing extracted caller data for {file}")
843            }
844            Self::Unavailable(message) => {
845                write!(formatter, "callgraph store unavailable: {message}")
846            }
847            Self::PathIdentityMismatch { path, project_root } => write!(
848                formatter,
849                "callgraph path identity mismatch: {} is not under project root {}",
850                path.display(),
851                project_root.display()
852            ),
853            Self::Suspended(suspension) => write!(
854                formatter,
855                "callgraph build suspended for {} after {} deaths ({})",
856                suspension.domain.as_str(),
857                suspension.death_count,
858                suspension.reason
859            ),
860            Self::Superseded => {
861                write!(formatter, "callgraph store build superseded before publish")
862            }
863            Self::StaleFiles(files) => {
864                write!(
865                    formatter,
866                    "callgraph store has stale files: {}",
867                    files.join(", ")
868                )
869            }
870        }
871    }
872}
873
874impl std::error::Error for CallGraphStoreError {}
875
876impl From<std::io::Error> for CallGraphStoreError {
877    fn from(error: std::io::Error) -> Self {
878        Self::Io(error)
879    }
880}
881
882impl From<rusqlite::Error> for CallGraphStoreError {
883    fn from(error: rusqlite::Error) -> Self {
884        Self::Sqlite(error)
885    }
886}
887
888impl From<serde_json::Error> for CallGraphStoreError {
889    fn from(error: serde_json::Error) -> Self {
890        Self::Json(error)
891    }
892}
893
894impl From<AftError> for CallGraphStoreError {
895    fn from(error: AftError) -> Self {
896        Self::Aft(error)
897    }
898}
899
900impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
901    fn from(error: crate::fs_lock::AcquireError) -> Self {
902        Self::Lock(error)
903    }
904}
905
906pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
907
908/// Config flag name gating whether the store is opened (default on). Production
909/// commands open it through `open_if_enabled` so the substrate can be disabled
910/// without code changes.
911pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
912
913#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
914pub struct CallGraphStoreOptions {
915    pub enabled: bool,
916}
917
918pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
919
920/// Shared context state that lets the refresh worker observe a store installed
921/// after its batch was opened. The worker clones the installed store Arc before
922/// checking it, so no context lock guard crosses the check or enqueue call.
923#[derive(Clone)]
924pub(crate) struct CallgraphRefreshState {
925    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
926    heavy_root_work_allowed: Arc<AtomicBool>,
927}
928
929impl CallgraphRefreshState {
930    pub(crate) fn new(
931        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
932        heavy_root_work_allowed: Arc<AtomicBool>,
933    ) -> Self {
934        Self {
935            store,
936            heavy_root_work_allowed,
937        }
938    }
939
940    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
941        self.store
942            .read()
943            .unwrap_or_else(std::sync::PoisonError::into_inner)
944            .as_ref()
945            .map(Arc::clone)
946    }
947}
948
949type WorkspaceCratePrefixes = HashMap<String, String>;
950
951#[derive(Clone, Debug, Default)]
952struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
953
954const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
955
956pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
957    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
958}
959
960#[derive(Clone, Debug, Hash, PartialEq, Eq)]
961struct RefreshRoot {
962    callgraph_dir: PathBuf,
963    project_root: PathBuf,
964}
965
966#[derive(Clone)]
967pub(crate) struct CallgraphRefreshTicket {
968    lifecycle: SubcLifecycleAdmission,
969    generation_flag: Arc<std::sync::atomic::AtomicU64>,
970    expected_generation: u64,
971    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
972    expected_publish_epoch: u64,
973}
974
975impl CallgraphRefreshTicket {
976    pub(crate) fn new(
977        lifecycle: SubcLifecycleAdmission,
978        generation_flag: Arc<std::sync::atomic::AtomicU64>,
979        expected_generation: u64,
980        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
981        expected_publish_epoch: u64,
982    ) -> Self {
983        Self {
984            lifecycle,
985            generation_flag,
986            expected_generation,
987            publish_epoch,
988            expected_publish_epoch,
989        }
990    }
991
992    fn is_current(&self) -> bool {
993        self.lifecycle
994            .is_current(self.generation_flag.as_ref(), self.expected_generation)
995            && self.publish_epoch.current() == self.expected_publish_epoch
996    }
997}
998
999#[derive(Clone)]
1000struct RefreshBatch {
1001    root: RefreshRoot,
1002    paths: BTreeSet<PathBuf>,
1003    pending_sinks: Vec<PendingCallGraphStorePaths>,
1004    refresh_states: Vec<CallgraphRefreshState>,
1005    ticket: Option<CallgraphRefreshTicket>,
1006}
1007
1008impl RefreshBatch {
1009    fn defer(&self) {
1010        for sink in &self.pending_sinks {
1011            sink.lock().extend(self.paths.iter().cloned());
1012        }
1013    }
1014
1015    fn defer_after_open_failure(&self) {
1016        self.defer();
1017        if self
1018            .ticket
1019            .as_ref()
1020            .is_some_and(|ticket| !ticket.is_current())
1021            || !self
1022                .refresh_states
1023                .iter()
1024                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1025        {
1026            return;
1027        }
1028
1029        let ready_store_installed = self.refresh_states.iter().any(|state| {
1030            let store = state.installed_store_snapshot();
1031            store.is_some_and(|store| {
1032                store.project_root() == self.root.project_root
1033                    && !store.is_legacy_fallback()
1034                    && store.is_current()
1035            })
1036        });
1037        if !ready_store_installed {
1038            return;
1039        }
1040
1041        // This re-check and the ready-store install's pending-sink take form a
1042        // check-then-act handoff: after this defer, exactly one site observes
1043        // the parked paths with a ready current store, so no polling is needed.
1044        for sink in &self.pending_sinks {
1045            let paths = {
1046                let mut pending = sink.lock();
1047                self.paths
1048                    .iter()
1049                    .filter(|path| pending.remove(*path))
1050                    .cloned()
1051                    .collect::<Vec<_>>()
1052            };
1053            if paths.is_empty() {
1054                continue;
1055            }
1056            let _ = enqueue_callgraph_store_refresh_inner(
1057                self.root.callgraph_dir.clone(),
1058                self.root.project_root.clone(),
1059                paths,
1060                Arc::clone(sink),
1061                self.refresh_states.clone(),
1062                self.ticket.clone(),
1063            );
1064        }
1065    }
1066
1067    fn merge(
1068        &mut self,
1069        paths: impl IntoIterator<Item = PathBuf>,
1070        sink: PendingCallGraphStorePaths,
1071        refresh_states: Vec<CallgraphRefreshState>,
1072        ticket: Option<CallgraphRefreshTicket>,
1073    ) {
1074        self.paths.extend(paths);
1075        if ticket.is_some() {
1076            self.ticket = ticket;
1077        }
1078        if !self
1079            .pending_sinks
1080            .iter()
1081            .any(|existing| Arc::ptr_eq(existing, &sink))
1082        {
1083            self.pending_sinks.push(sink);
1084        }
1085        for refresh_state in refresh_states {
1086            if !self.refresh_states.iter().any(|existing| {
1087                Arc::ptr_eq(&existing.store, &refresh_state.store)
1088                    && Arc::ptr_eq(
1089                        &existing.heavy_root_work_allowed,
1090                        &refresh_state.heavy_root_work_allowed,
1091                    )
1092            }) {
1093                self.refresh_states.push(refresh_state);
1094            }
1095        }
1096    }
1097}
1098
1099#[derive(Default)]
1100struct RefreshQueue {
1101    order: VecDeque<RefreshRoot>,
1102    queued: HashMap<RefreshRoot, RefreshBatch>,
1103    active: Option<RefreshBatch>,
1104    shutdown_requested: bool,
1105}
1106
1107struct RefreshWorkerShared {
1108    queue: Mutex<RefreshQueue>,
1109    wake: Condvar,
1110}
1111
1112struct RefreshWorker {
1113    shared: Arc<RefreshWorkerShared>,
1114    thread: Mutex<Option<JoinHandle<()>>>,
1115}
1116
1117struct RefreshWorkerWatchdog {
1118    first_path: PathBuf,
1119    batch_len: usize,
1120    started: Instant,
1121}
1122
1123impl RefreshWorkerWatchdog {
1124    fn start(paths: &[PathBuf]) -> Self {
1125        Self {
1126            first_path: paths
1127                .first()
1128                .expect("non-empty callgraph refresh batch has a first path")
1129                .clone(),
1130            batch_len: paths.len(),
1131            started: Instant::now(),
1132        }
1133    }
1134}
1135
1136impl Drop for RefreshWorkerWatchdog {
1137    fn drop(&mut self) {
1138        let elapsed = self.started.elapsed();
1139        if elapsed < REFRESH_WORKER_WARN_AFTER {
1140            return;
1141        }
1142        let path = if self.batch_len == 1 {
1143            self.first_path.display().to_string()
1144        } else {
1145            format!(
1146                "{} (+{} paths)",
1147                self.first_path.display(),
1148                self.batch_len - 1
1149            )
1150        };
1151        log::warn!(
1152            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1153            path,
1154            elapsed.as_millis()
1155        );
1156        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1157            log::warn!(
1158                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1159                path,
1160                elapsed.as_millis()
1161            );
1162        }
1163    }
1164}
1165
1166impl RefreshWorker {
1167    fn spawn() -> Arc<Self> {
1168        let shared = Arc::new(RefreshWorkerShared {
1169            queue: Mutex::new(RefreshQueue::default()),
1170            wake: Condvar::new(),
1171        });
1172        let thread_shared = Arc::clone(&shared);
1173        let thread = std::thread::Builder::new()
1174            .name("aft-callgraph-refresh".to_string())
1175            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1176            .expect("failed to spawn callgraph refresh worker");
1177        Arc::new(Self {
1178            shared,
1179            thread: Mutex::new(Some(thread)),
1180        })
1181    }
1182
1183    fn enqueue(
1184        &self,
1185        root: RefreshRoot,
1186        paths: Vec<PathBuf>,
1187        pending_sink: PendingCallGraphStorePaths,
1188        refresh_states: Vec<CallgraphRefreshState>,
1189        ticket: Option<CallgraphRefreshTicket>,
1190    ) -> bool {
1191        let mut queue = self
1192            .shared
1193            .queue
1194            .lock()
1195            .expect("callgraph refresh queue mutex poisoned");
1196        if queue.shutdown_requested {
1197            pending_sink.lock().extend(paths);
1198            return false;
1199        }
1200        if let Some(batch) = queue.queued.get_mut(&root) {
1201            batch.merge(paths, pending_sink, refresh_states, ticket);
1202        } else {
1203            queue.order.push_back(root.clone());
1204            queue.queued.insert(
1205                root.clone(),
1206                RefreshBatch {
1207                    root,
1208                    paths: paths.into_iter().collect(),
1209                    pending_sinks: vec![pending_sink],
1210                    refresh_states,
1211                    ticket,
1212                },
1213            );
1214        }
1215        self.shared.wake.notify_one();
1216        true
1217    }
1218
1219    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1220        let deadline = Instant::now() + budget;
1221        let mut queue = self
1222            .shared
1223            .queue
1224            .lock()
1225            .expect("callgraph refresh queue mutex poisoned");
1226        queue.shutdown_requested = true;
1227        self.shared.wake.notify_one();
1228        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1229            let remaining = deadline.saturating_duration_since(Instant::now());
1230            let (next, _) = self
1231                .shared
1232                .wake
1233                .wait_timeout(queue, remaining)
1234                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1235            queue = next;
1236        }
1237        let drained = queue.active.is_none() && queue.order.is_empty();
1238        if !drained {
1239            if let Some(active) = queue.active.as_ref() {
1240                active.defer();
1241            }
1242            for batch in queue.queued.values() {
1243                batch.defer();
1244            }
1245            queue.order.clear();
1246            queue.queued.clear();
1247        }
1248        drop(queue);
1249
1250        if drained {
1251            if let Some(thread) = self
1252                .thread
1253                .lock()
1254                .expect("callgraph refresh worker thread mutex poisoned")
1255                .take()
1256            {
1257                let _ = thread.join();
1258            }
1259        }
1260        drained
1261    }
1262}
1263
1264static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1265
1266pub fn enqueue_callgraph_store_refresh(
1267    callgraph_dir: PathBuf,
1268    project_root: PathBuf,
1269    paths: Vec<PathBuf>,
1270    pending_sink: PendingCallGraphStorePaths,
1271) -> bool {
1272    enqueue_callgraph_store_refresh_inner(
1273        callgraph_dir,
1274        project_root,
1275        paths,
1276        pending_sink,
1277        Vec::new(),
1278        None,
1279    )
1280}
1281
1282#[cfg(test)]
1283pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1284    callgraph_dir: PathBuf,
1285    project_root: PathBuf,
1286    paths: Vec<PathBuf>,
1287    pending_sink: PendingCallGraphStorePaths,
1288    ticket: CallgraphRefreshTicket,
1289) -> bool {
1290    enqueue_callgraph_store_refresh_inner(
1291        callgraph_dir,
1292        project_root,
1293        paths,
1294        pending_sink,
1295        Vec::new(),
1296        Some(ticket),
1297    )
1298}
1299
1300pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1301    callgraph_dir: PathBuf,
1302    project_root: PathBuf,
1303    paths: Vec<PathBuf>,
1304    pending_sink: PendingCallGraphStorePaths,
1305    refresh_state: CallgraphRefreshState,
1306    ticket: CallgraphRefreshTicket,
1307) -> bool {
1308    enqueue_callgraph_store_refresh_inner(
1309        callgraph_dir,
1310        project_root,
1311        paths,
1312        pending_sink,
1313        vec![refresh_state],
1314        Some(ticket),
1315    )
1316}
1317
1318fn enqueue_callgraph_store_refresh_inner(
1319    callgraph_dir: PathBuf,
1320    project_root: PathBuf,
1321    paths: Vec<PathBuf>,
1322    pending_sink: PendingCallGraphStorePaths,
1323    refresh_states: Vec<CallgraphRefreshState>,
1324    ticket: Option<CallgraphRefreshTicket>,
1325) -> bool {
1326    if paths.is_empty() {
1327        return true;
1328    }
1329    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1330    let worker = {
1331        let mut worker = slot
1332            .lock()
1333            .expect("callgraph refresh worker mutex poisoned");
1334        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1335    };
1336    worker.enqueue(
1337        RefreshRoot {
1338            callgraph_dir,
1339            project_root,
1340        },
1341        paths,
1342        pending_sink,
1343        refresh_states,
1344        ticket,
1345    )
1346}
1347
1348pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1349    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1350}
1351
1352#[doc(hidden)]
1353pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1354    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1355    let worker = slot
1356        .lock()
1357        .expect("callgraph refresh worker mutex poisoned")
1358        .clone();
1359    let Some(worker) = worker else {
1360        return true;
1361    };
1362    let drained = worker.shutdown_with_budget(budget);
1363    if drained {
1364        let mut current = slot
1365            .lock()
1366            .expect("callgraph refresh worker mutex poisoned");
1367        if current
1368            .as_ref()
1369            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1370        {
1371            *current = None;
1372        }
1373    }
1374    drained
1375}
1376
1377fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1378    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1379}
1380
1381fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1382    // The worker owns these caches so maps are shared only by refreshes for the
1383    // same canonical root and disappear when the worker shuts down.
1384    let mut workspace_crate_prefixes = HashMap::new();
1385    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1386    loop {
1387        let batch = {
1388            let mut queue = shared
1389                .queue
1390                .lock()
1391                .expect("callgraph refresh queue mutex poisoned");
1392            loop {
1393                if let Some(root) = queue.order.pop_front() {
1394                    let batch = queue
1395                        .queued
1396                        .remove(&root)
1397                        .expect("queued callgraph refresh root has a batch");
1398                    queue.active = Some(batch.clone());
1399                    break batch;
1400                }
1401                if queue.shutdown_requested {
1402                    return;
1403                }
1404                queue = shared
1405                    .wake
1406                    .wait(queue)
1407                    .expect("callgraph refresh queue mutex poisoned while waiting");
1408            }
1409        };
1410
1411        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1412
1413        let mut queue = shared
1414            .queue
1415            .lock()
1416            .expect("callgraph refresh queue mutex poisoned");
1417        queue.active = None;
1418        let became_idle = queue.order.is_empty();
1419        shared.wake.notify_all();
1420        drop(queue);
1421
1422        if became_idle {
1423            let checkpoint_due = idle_checkpoint_due(
1424                last_idle_checkpoints.get(&batch.root).copied(),
1425                Instant::now(),
1426            );
1427            if checkpoint_due {
1428                if let Some(store) = store {
1429                    if store.checkpoint_wal_truncate() {
1430                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1431                    }
1432                }
1433            }
1434        }
1435    }
1436}
1437
1438fn process_callgraph_refresh_batch(
1439    batch: &RefreshBatch,
1440    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1441) -> Option<CallGraphStore> {
1442    // A manifest event is an invalidation signal, not a source file to parse.
1443    // Drop the root's map even for a superseded batch: the filesystem changed,
1444    // and a later configure must never inherit crate membership from before it.
1445    if batch
1446        .paths
1447        .iter()
1448        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1449    {
1450        workspace_crate_prefixes.remove(&batch.root);
1451    }
1452
1453    let paths = batch
1454        .paths
1455        .iter()
1456        .filter(|path| crate::parser::detect_language(path).is_some())
1457        .cloned()
1458        .collect::<Vec<_>>();
1459    if paths.is_empty() {
1460        return None;
1461    }
1462    note_refresh_worker_batch_for_test(&batch.root.project_root);
1463    if batch
1464        .ticket
1465        .as_ref()
1466        .is_some_and(|ticket| !ticket.is_current())
1467    {
1468        // Superseded before starting: park the paths so the next configure's
1469        // pending replay (or unbind cleanup) decides their fate.
1470        batch.defer();
1471        return None;
1472    }
1473    let workspace_crate_prefix_cache =
1474        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1475    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1476    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1477    note_refresh_worker_call_for_test(&batch.root.project_root);
1478    let opened = if test_seam.fail_open {
1479        Ok(None)
1480    } else {
1481        CallGraphStore::open_ready(
1482            batch.root.callgraph_dir.clone(),
1483            batch.root.project_root.clone(),
1484        )
1485    };
1486    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1487        // The gate is deliberately after open_ready so tests can hold a failed
1488        // open between its result and the defer that parks the batch.
1489        let _ = gate.held_tx.send(());
1490        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1491    }
1492    let store = match opened {
1493        Ok(Some(store)) => store,
1494        Ok(None) => {
1495            batch.defer_after_open_failure();
1496            return None;
1497        }
1498        Err(error) => {
1499            batch.defer_after_open_failure();
1500            crate::slog_warn!(
1501                "callgraph store writer open failed during refresh; deferred paths: {}",
1502                error
1503            );
1504            return None;
1505        }
1506    };
1507    if !test_seam.delay.is_zero() {
1508        std::thread::sleep(test_seam.delay);
1509    }
1510    if batch
1511        .ticket
1512        .as_ref()
1513        .is_some_and(|ticket| !ticket.is_current())
1514    {
1515        // This is a superseded-ticket defer, not an open-failure defer: leave
1516        // the paths for the replacement configure instead of self-replaying.
1517        batch.defer();
1518        return Some(store);
1519    }
1520    let refresh_result = if test_seam.fail_refresh {
1521        Err(CallGraphStoreError::Unavailable(
1522            "injected refresh worker failure".to_string(),
1523        ))
1524    } else if let Some(ticket) = &batch.ticket {
1525        with_publish_epoch(
1526            ticket.publish_epoch.clone(),
1527            ticket.expected_publish_epoch,
1528            || {
1529                with_refresh_commit_admission(
1530                    ticket.lifecycle.clone(),
1531                    Arc::clone(&ticket.generation_flag),
1532                    ticket.expected_generation,
1533                    || {
1534                        store
1535                            .refresh_files_with_workspace_crate_prefix_cache(
1536                                &paths,
1537                                workspace_crate_prefix_cache.clone(),
1538                            )
1539                            .map(|_| ())
1540                    },
1541                )
1542            },
1543        )
1544    } else {
1545        store
1546            .refresh_files_with_workspace_crate_prefix_cache(
1547                &paths,
1548                workspace_crate_prefix_cache.clone(),
1549            )
1550            .map(|_| ())
1551    };
1552    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1553        // The commit lost the fence race: a newer configure or publication
1554        // owns the store now. Defer instead of stale-marking — the paths were
1555        // never committed, and the replacement generation re-indexes them.
1556        batch.defer();
1557        return Some(store);
1558    }
1559    if let Err(error) = refresh_result {
1560        crate::slog_warn!("callgraph store refresh failed: {}", error);
1561        match store.mark_files_stale(&paths) {
1562            Ok(marked) => {
1563                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1564                crate::slog_warn!(
1565                    "marked {} callgraph store file(s) stale after refresh failure",
1566                    marked.len()
1567                );
1568            }
1569            Err(mark_error) => crate::slog_warn!(
1570                "failed to mark callgraph store files stale after refresh failure: {}",
1571                mark_error
1572            ),
1573        }
1574    } else {
1575        crate::logging::note_callgraph_invalidations(paths.len());
1576    }
1577    Some(store)
1578}
1579
1580fn workspace_crate_prefix_cache_for_root(
1581    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1582    root: &RefreshRoot,
1583) -> WorkspaceCratePrefixCache {
1584    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1585        // Eviction only costs a future rebuild; it cannot make resolution stale.
1586        if let Some(evicted) = caches.keys().next().cloned() {
1587            caches.remove(&evicted);
1588        }
1589    }
1590    caches.entry(root.clone()).or_default().clone()
1591}
1592
1593#[derive(Clone, Copy, Default)]
1594struct RefreshWorkerTestSeam {
1595    delay: Duration,
1596    fail_refresh: bool,
1597    fail_open: bool,
1598    refresh_calls: usize,
1599    worker_calls: usize,
1600    stale_marks: usize,
1601}
1602
1603static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1604    OnceLock::new();
1605
1606struct RefreshWorkerTestGate {
1607    held_tx: crossbeam_channel::Sender<()>,
1608    release_rx: crossbeam_channel::Receiver<()>,
1609}
1610
1611static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1612    OnceLock::new();
1613
1614#[doc(hidden)]
1615pub fn install_callgraph_refresh_worker_test_gate(
1616    project_root: PathBuf,
1617) -> (
1618    crossbeam_channel::Receiver<()>,
1619    crossbeam_channel::Sender<()>,
1620) {
1621    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1622    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1623    REFRESH_WORKER_TEST_GATES
1624        .get_or_init(|| Mutex::new(HashMap::new()))
1625        .lock()
1626        .expect("callgraph refresh test gate mutex poisoned")
1627        .insert(
1628            project_root,
1629            RefreshWorkerTestGate {
1630                held_tx,
1631                release_rx,
1632            },
1633        );
1634    (held_rx, release_tx)
1635}
1636
1637fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1638    REFRESH_WORKER_TEST_GATES
1639        .get_or_init(|| Mutex::new(HashMap::new()))
1640        .lock()
1641        .expect("callgraph refresh test gate mutex poisoned")
1642        .remove(project_root)
1643}
1644
1645fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1646    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1647        return RefreshWorkerTestSeam::default();
1648    };
1649    seams
1650        .lock()
1651        .expect("callgraph refresh test seam mutex poisoned")
1652        .get(project_root)
1653        .copied()
1654        .unwrap_or_default()
1655}
1656
1657fn note_refresh_worker_batch_for_test(project_root: &Path) {
1658    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1659        if let Some(seam) = seams
1660            .lock()
1661            .expect("callgraph refresh test seam mutex poisoned")
1662            .get_mut(project_root)
1663        {
1664            seam.worker_calls += 1;
1665        }
1666    }
1667}
1668
1669fn note_refresh_worker_call_for_test(project_root: &Path) {
1670    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1671        if let Some(seam) = seams
1672            .lock()
1673            .expect("callgraph refresh test seam mutex poisoned")
1674            .get_mut(project_root)
1675        {
1676            seam.refresh_calls += 1;
1677        }
1678    }
1679}
1680
1681fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1682    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1683        if let Some(seam) = seams
1684            .lock()
1685            .expect("callgraph refresh test seam mutex poisoned")
1686            .get_mut(project_root)
1687        {
1688            seam.stale_marks += 1;
1689        }
1690    }
1691}
1692
1693#[doc(hidden)]
1694pub fn set_callgraph_refresh_worker_test_seam(
1695    project_root: PathBuf,
1696    delay: Duration,
1697    fail_refresh: bool,
1698) {
1699    REFRESH_WORKER_TEST_SEAMS
1700        .get_or_init(|| Mutex::new(HashMap::new()))
1701        .lock()
1702        .expect("callgraph refresh test seam mutex poisoned")
1703        .insert(
1704            project_root,
1705            RefreshWorkerTestSeam {
1706                delay,
1707                fail_refresh,
1708                ..RefreshWorkerTestSeam::default()
1709            },
1710        );
1711}
1712
1713#[doc(hidden)]
1714pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1715    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1716        if let Some(seam) = seams
1717            .lock()
1718            .expect("callgraph refresh test seam mutex poisoned")
1719            .get_mut(&project_root)
1720        {
1721            seam.fail_open = enabled;
1722        }
1723    }
1724}
1725
1726#[doc(hidden)]
1727pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1728    let seam = refresh_worker_test_seam(project_root);
1729    (seam.refresh_calls, seam.stale_marks)
1730}
1731
1732#[doc(hidden)]
1733pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1734    refresh_worker_test_seam(project_root).worker_calls
1735}
1736
1737#[doc(hidden)]
1738pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1739    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1740        seams
1741            .lock()
1742            .expect("callgraph refresh test seam mutex poisoned")
1743            .remove(project_root);
1744    }
1745}
1746
1747#[derive(Debug)]
1748pub struct CallGraphStore {
1749    project_root: PathBuf,
1750    project_key: String,
1751    /// The concrete on-disk DB file this store opened. With the generation
1752    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1753    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1754    sqlite_path: PathBuf,
1755    /// Root-keyed directory whose pointer controls this store. For a legacy
1756    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1757    /// newly published root-keyed generation invalidates the fallback reader.
1758    publication_dir: PathBuf,
1759    /// True only when the root-keyed read path opened data from a legacy
1760    /// harness partition. Writer-capable callers use this to schedule migration
1761    /// without making read-only/worktree callers acquire a writer lease.
1762    legacy_fallback: bool,
1763    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1764    /// or `None` when it opened the legacy single-file DB. Used to detect when
1765    /// another process has published a newer generation so this process can
1766    /// drop its connection and reopen (see `current_generation`).
1767    generation: Option<String>,
1768    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1769    read_marker: Option<crate::root_cache::ReadMarker>,
1770    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1771    // Failed validations are not cached, so a later successful build remains visible.
1772    database_ready: AtomicBool,
1773    write_metrics: Arc<CallgraphWriteMetrics>,
1774    conn: Mutex<Connection>,
1775}
1776
1777#[derive(Debug)]
1778pub struct ReadonlyCallGraphStore {
1779    inner: CallGraphStore,
1780}
1781
1782pub trait CallGraphRead {
1783    fn project_root(&self) -> &Path;
1784    fn project_key(&self) -> &str;
1785    fn sqlite_path(&self) -> &Path;
1786    fn is_current(&self) -> bool;
1787    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1788    fn indexed_file_count(&self) -> Result<usize>;
1789    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1790    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1791    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1792    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1793    fn direct_callers_for_symbols(
1794        &self,
1795        targets: &[(String, String)],
1796    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1797        targets
1798            .iter()
1799            .cloned()
1800            .map(|target| {
1801                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1802                Ok((target, callers))
1803            })
1804            .collect()
1805    }
1806    fn direct_caller_counts_of(
1807        &self,
1808        targets: &[(String, String)],
1809    ) -> Result<HashMap<(String, String), usize>>;
1810    fn outgoing_calls_for_symbols(
1811        &self,
1812        sources: &[(String, String)],
1813    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1814    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1815        -> Result<StoreCallersResult>;
1816    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1817    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1818    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1819    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1820    fn call_tree(
1821        &self,
1822        file_rel: &Path,
1823        symbol: &str,
1824        depth: usize,
1825    ) -> Result<callgraph::CallTreeNode>;
1826    fn trace_to(
1827        &self,
1828        file_rel: &Path,
1829        symbol: &str,
1830        max_depth: usize,
1831    ) -> Result<callgraph::TraceToResult>;
1832    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1833    fn trace_to_symbol(
1834        &self,
1835        file_rel: &Path,
1836        symbol: &str,
1837        to_symbol: &str,
1838        to_file: Option<&Path>,
1839        max_depth: usize,
1840    ) -> Result<callgraph::TraceToSymbolResult>;
1841}
1842
1843#[derive(Debug, Clone, PartialEq, Eq)]
1844enum OpenRootRepair {
1845    None,
1846    ReRooted,
1847    NeedsRebuild {
1848        previous_roots: Vec<String>,
1849        current_root: String,
1850        reason: String,
1851    },
1852}
1853
1854struct OpenedStore {
1855    store: CallGraphStore,
1856    root_repair: OpenRootRepair,
1857}
1858
1859#[derive(Clone, Debug)]
1860struct LegacyCallgraphPartition {
1861    harness: String,
1862    dir: PathBuf,
1863    key: String,
1864    bytes: u64,
1865    freshness: Option<SystemTime>,
1866}
1867
1868#[derive(Clone, Debug)]
1869struct LegacyCallgraphTarget {
1870    partition: LegacyCallgraphPartition,
1871    sqlite_path: PathBuf,
1872    generation: Option<String>,
1873    source_bytes: u64,
1874    source_blake3: String,
1875}
1876
1877#[derive(Clone, Debug)]
1878struct SourceFingerprint {
1879    bytes: u64,
1880    blake3: String,
1881}
1882
1883#[derive(Clone, Debug)]
1884struct PublishedLegacyMigration {
1885    generation: String,
1886    migrated_bytes: u64,
1887}
1888
1889#[derive(Debug, Clone)]
1890pub struct ColdBuildStats {
1891    pub files: usize,
1892    pub nodes: usize,
1893    pub refs: usize,
1894    pub edges: usize,
1895    pub failed_files: Vec<String>,
1896    pub elapsed_ms: u128,
1897}
1898
1899#[derive(Debug, Clone)]
1900pub struct IncrementalStats {
1901    pub changed_files: Vec<String>,
1902    pub surface_changed: Vec<String>,
1903    pub deleted_files: Vec<String>,
1904    pub dependency_selected_refs: usize,
1905    pub refreshed_own_files: usize,
1906    pub unchanged_extract_files: usize,
1907}
1908
1909/// Phase timings for the copy-based incremental refresh benchmark.
1910#[doc(hidden)]
1911#[derive(Debug, Clone, Default, PartialEq, Eq)]
1912pub struct RefreshFilesProfile {
1913    pub parse: Duration,
1914    pub dependency_selection: Duration,
1915    pub row_deletes: Duration,
1916    pub row_inserts: Duration,
1917    pub dependent_parse: Duration,
1918    pub index_load: Duration,
1919    pub ref_resolution: Duration,
1920    pub method_dispatch: Duration,
1921    pub commit: Duration,
1922    pub total: Duration,
1923}
1924
1925impl RefreshFilesProfile {
1926    pub fn report(&self) -> String {
1927        format!(
1928            "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",
1929            self.parse.as_millis(),
1930            self.dependency_selection.as_millis(),
1931            self.row_deletes.as_millis(),
1932            self.row_inserts.as_millis(),
1933            self.dependent_parse.as_millis(),
1934            self.index_load.as_millis(),
1935            self.ref_resolution.as_millis(),
1936            self.method_dispatch.as_millis(),
1937            self.commit.as_millis(),
1938            self.total.as_millis(),
1939        )
1940    }
1941}
1942
1943#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1944pub struct StoredEdge {
1945    pub source_file: String,
1946    pub source_symbol: String,
1947    pub target_file: String,
1948    pub target_symbol: String,
1949    pub kind: String,
1950    pub line: u32,
1951}
1952
1953#[derive(Debug, Clone, PartialEq, Eq)]
1954pub struct StoreNode {
1955    node_id: String,
1956    pub file: String,
1957    pub symbol: String,
1958    pub name: String,
1959    pub kind: String,
1960    pub line: u32,
1961    pub end_line: u32,
1962    pub signature: Option<String>,
1963    pub exported: bool,
1964    pub is_entry_point: bool,
1965    pub lang: LangId,
1966}
1967
1968#[cfg(test)]
1969impl StoreNode {
1970    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1971        Self {
1972            node_id: format!("{file}:{symbol}"),
1973            file: file.to_string(),
1974            symbol: symbol.to_string(),
1975            name: symbol.to_string(),
1976            kind: "function".to_string(),
1977            line: 1,
1978            end_line: 1,
1979            signature: None,
1980            exported: is_entry_point,
1981            is_entry_point,
1982            lang: LangId::TypeScript,
1983        }
1984    }
1985}
1986
1987#[derive(Debug, Clone, PartialEq, Eq)]
1988pub struct StoreCallSite {
1989    pub caller: StoreNode,
1990    pub target_file: String,
1991    pub target_symbol: String,
1992    pub target: Option<StoreNode>,
1993    pub line: u32,
1994    pub byte_start: usize,
1995    pub byte_end: usize,
1996    pub resolved: bool,
1997    pub provenance: String,
1998}
1999
2000impl StoreCallSite {
2001    pub fn approximate(&self) -> bool {
2002        self.provenance == PROVENANCE_NAME_MATCH
2003    }
2004
2005    pub fn resolved_by(&self) -> &str {
2006        &self.provenance
2007    }
2008
2009    pub fn supplemental_resolution(&self) -> Option<&str> {
2010        match self.provenance.as_str() {
2011            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2012            _ => None,
2013        }
2014    }
2015}
2016
2017#[derive(Debug, Clone, PartialEq, Eq)]
2018pub struct StoreUnresolvedCall {
2019    pub caller: StoreNode,
2020    pub symbol: String,
2021    pub full_ref: Option<String>,
2022    pub line: u32,
2023    pub byte_start: usize,
2024    pub byte_end: usize,
2025}
2026
2027#[derive(Debug, Clone, PartialEq, Eq)]
2028pub struct StoreCallersResult {
2029    pub target: StoreNode,
2030    pub callers: Vec<StoreCallSite>,
2031    pub scanned_files: usize,
2032    pub depth_limited: bool,
2033    pub truncated: usize,
2034}
2035
2036#[derive(Debug, Clone, PartialEq, Eq)]
2037pub struct StoreImpactCaller {
2038    pub site: StoreCallSite,
2039    pub signature: Option<String>,
2040    pub is_entry_point: bool,
2041    pub call_expression: Option<String>,
2042    pub parameters: Vec<String>,
2043}
2044
2045#[derive(Debug, Clone, PartialEq, Eq)]
2046pub struct StoreImpactResult {
2047    pub target: StoreNode,
2048    pub parameters: Vec<String>,
2049    pub callers: Vec<StoreImpactCaller>,
2050    pub depth_limited: bool,
2051    pub truncated: usize,
2052}
2053
2054#[derive(Debug, Clone)]
2055struct ExtractFailure {
2056    rel_path: String,
2057    freshness: Option<FileFreshness>,
2058}
2059
2060#[derive(Debug, Clone)]
2061struct BuildExtractsResult {
2062    extracts: Vec<FileExtract>,
2063    failures: Vec<ExtractFailure>,
2064}
2065
2066#[derive(Debug, Clone)]
2067enum StoreForwardCall {
2068    Resolved(StoreCallSite),
2069    Unresolved(StoreUnresolvedCall),
2070}
2071
2072impl StoreForwardCall {
2073    fn byte_start(&self) -> usize {
2074        match self {
2075            Self::Resolved(site) => site.byte_start,
2076            Self::Unresolved(call) => call.byte_start,
2077        }
2078    }
2079
2080    fn line(&self) -> u32 {
2081        match self {
2082            Self::Resolved(site) => site.line,
2083            Self::Unresolved(call) => call.line,
2084        }
2085    }
2086}
2087
2088#[derive(Debug, Clone)]
2089struct FileExtract {
2090    rel_path: String,
2091    freshness: FileFreshness,
2092    lang: LangId,
2093    data: FileCallData,
2094    nodes: Vec<NodeRecord>,
2095    raw_refs: Vec<RawRef>,
2096    dispatch_hints: Vec<DispatchHint>,
2097    surface_fingerprint: String,
2098}
2099
2100#[derive(Debug, Clone)]
2101struct NodeRecord {
2102    id: String,
2103    file_path: String,
2104    name: String,
2105    scoped_name: String,
2106    kind: String,
2107    range: Range,
2108    range_ordinal: u32,
2109    signature: Option<String>,
2110    exported: bool,
2111    is_default_export: bool,
2112    is_type_like: bool,
2113    is_callgraph_entry_point: bool,
2114}
2115
2116#[derive(Debug, Clone)]
2117struct RawRef {
2118    ref_id: String,
2119    caller_node: Option<String>,
2120    caller_symbol: Option<String>,
2121    caller_file: String,
2122    kind: String,
2123    short_name: Option<String>,
2124    full_ref: Option<String>,
2125    module_path: Option<String>,
2126    import_kind: Option<String>,
2127    local_name: Option<String>,
2128    requested_name: Option<String>,
2129    namespace_alias: Option<String>,
2130    wildcard: bool,
2131    line: u32,
2132    byte_start: usize,
2133    byte_end: usize,
2134    dependencies: BTreeSet<String>,
2135}
2136
2137/// A raw reference read from the durable staging table with its SQLite ordering
2138/// key. The ordering key is advanced only in the same transaction that writes
2139/// the resolved result, so a crash resumes at a committed window boundary.
2140#[derive(Debug)]
2141struct StagedRef {
2142    rowid: u64,
2143    raw: RawRef,
2144}
2145
2146#[derive(Debug, Clone)]
2147struct ResolvedRef {
2148    raw: RawRef,
2149    status: String,
2150    target_node: Option<String>,
2151    target_file: Option<String>,
2152    target_symbol: Option<String>,
2153    dependencies: BTreeSet<String>,
2154    edge: Option<EdgeRecord>,
2155}
2156
2157#[derive(Debug, Clone)]
2158struct EdgeRecord {
2159    edge_id: String,
2160    source_node: String,
2161    target_node: Option<String>,
2162    target_file: String,
2163    target_symbol: String,
2164    kind: String,
2165    line: u32,
2166}
2167
2168#[derive(Debug, Clone)]
2169struct DispatchHint {
2170    id: String,
2171    method_name: String,
2172    caller_node: String,
2173    file: String,
2174    line: u32,
2175    byte_start: usize,
2176    byte_end: usize,
2177}
2178
2179#[derive(Debug, Clone)]
2180struct NameMatchRef {
2181    ref_id: String,
2182    caller_node: String,
2183    caller_file: String,
2184    caller_symbol: String,
2185    caller_signature: Option<String>,
2186    receiver_expression: String,
2187    receiver: String,
2188    method_name: String,
2189    colon_dispatch: bool,
2190    line: u32,
2191    lang: String,
2192}
2193
2194#[derive(Debug, Clone)]
2195struct NameMatchCandidate {
2196    node_id: String,
2197    file_path: String,
2198    scoped_name: String,
2199    kind: String,
2200    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2201    start_line: u32,
2202}
2203
2204#[derive(Debug, Clone)]
2205struct FileRow {
2206    surface_fingerprint: String,
2207    freshness: FileFreshness,
2208}
2209
2210#[derive(Debug, Clone)]
2211struct DbFileIndex {
2212    lang: Option<LangId>,
2213    exports: HashSet<String>,
2214    default_export: Option<String>,
2215    export_aliases: HashMap<String, String>,
2216    node_by_scoped: HashMap<String, String>,
2217    node_by_bare: HashMap<String, String>,
2218    node_kind_by_id: HashMap<String, String>,
2219    module_targets: HashMap<String, Option<String>>,
2220    declared_module_targets: HashMap<String, Option<String>>,
2221    reexports: Vec<ReexportIndex>,
2222}
2223
2224#[derive(Debug, Clone)]
2225struct ReexportIndex {
2226    target_file: Option<String>,
2227    named: HashMap<String, String>,
2228    wildcard: bool,
2229}
2230
2231#[derive(Debug, Clone)]
2232struct ProjectIndex<'a> {
2233    project_root: PathBuf,
2234    files: HashMap<String, DbFileIndex>,
2235    caller_data: HashMap<String, &'a FileCallData>,
2236    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2237    /// watcher events replace the cache before another batch can resolve refs.
2238    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2239    /// its own workspace mapping.
2240    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2241}
2242
2243/// Resolution reads symbols and exports through one interface. Incremental
2244/// refreshes use the in-memory index, while cold builds query only the rows
2245/// needed by the active caller from SQLite.
2246trait ResolverIndex {
2247    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2248    fn lang_for(&self, file: &str) -> Option<LangId>;
2249    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2250    fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2251    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2252    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2253    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2254    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2255    fn has_export(&self, file: &str, symbol: &str) -> bool;
2256    fn default_export(&self, file: &str) -> Option<String>;
2257    fn contains_file(&self, file: &str) -> bool;
2258    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2259    fn inline_scoped_target(
2260        &self,
2261        caller_file: &str,
2262        module_segments: &[String],
2263        short_name: &str,
2264    ) -> Option<(String, String)>;
2265}
2266
2267impl ResolverIndex for ProjectIndex<'_> {
2268    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2269        self.caller_data.get(file).copied()
2270    }
2271
2272    fn lang_for(&self, file: &str) -> Option<LangId> {
2273        self.lang_for(file)
2274    }
2275
2276    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2277        self.module_target(caller_file, module_path)
2278    }
2279
2280    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2281        let mut parents = self
2282            .files
2283            .iter()
2284            .flat_map(|(file, index)| {
2285                index
2286                    .declared_module_targets
2287                    .iter()
2288                    .filter_map(move |(module, target)| {
2289                        (target.as_deref() == Some(target_file))
2290                            .then(|| (file.clone(), module.clone()))
2291                    })
2292            })
2293            .collect::<Vec<_>>();
2294        parents.sort();
2295        parents.into_iter().next()
2296    }
2297
2298    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2299        self.reexports_for(file).to_vec()
2300    }
2301
2302    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2303        self.node_for_symbol(file, symbol)
2304    }
2305
2306    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2307        self.node_is_callable(file, node_id)
2308    }
2309
2310    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2311        self.files
2312            .get(file)
2313            .and_then(|item| item.export_aliases.get(symbol))
2314            .cloned()
2315    }
2316
2317    fn has_export(&self, file: &str, symbol: &str) -> bool {
2318        self.files
2319            .get(file)
2320            .is_some_and(|item| item.exports.contains(symbol))
2321    }
2322
2323    fn default_export(&self, file: &str) -> Option<String> {
2324        self.files
2325            .get(file)
2326            .and_then(|item| item.default_export.clone())
2327    }
2328
2329    fn contains_file(&self, file: &str) -> bool {
2330        self.files.contains_key(file)
2331    }
2332
2333    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2334        self.workspace_crate_prefixes
2335            .0
2336            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2337            .get(crate_name)
2338            .cloned()
2339    }
2340
2341    fn inline_scoped_target(
2342        &self,
2343        caller_file: &str,
2344        module_segments: &[String],
2345        short_name: &str,
2346    ) -> Option<(String, String)> {
2347        let src_prefix = rust_src_prefix(caller_file);
2348        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2349        file_paths.sort();
2350        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2351            let caller = file_paths.remove(position);
2352            file_paths.insert(0, caller);
2353        }
2354        for file_path in file_paths {
2355            if self.lang_for(&file_path) != Some(LangId::Rust)
2356                || rust_src_prefix(&file_path) != src_prefix
2357            {
2358                continue;
2359            }
2360            let file_module_segments = rust_module_segments_for_rel(&file_path);
2361            if !module_segments.starts_with(&file_module_segments) {
2362                continue;
2363            }
2364            let scoped_segments = &module_segments[file_module_segments.len()..];
2365            if scoped_segments.is_empty() {
2366                continue;
2367            }
2368            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2369            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2370                return Some((file_path, scoped_symbol));
2371            }
2372        }
2373        None
2374    }
2375}
2376
2377/// A cold-build resolver view that loads one file's index at a time. Keeping the
2378/// complete staged corpus in SQLite makes the heap proportional to the active
2379/// reference window rather than to the number of project files.
2380struct DiskProjectIndex<'a> {
2381    project_root: &'a Path,
2382    conn: &'a Connection,
2383    caller_file: &'a str,
2384    caller_data: &'a FileCallData,
2385    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2386    module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2387}
2388
2389impl DiskProjectIndex<'_> {
2390    fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2391        let lang: String = self
2392            .conn
2393            .query_row(
2394                "SELECT lang FROM files WHERE path = ?1",
2395                params![rel_path],
2396                |row| row.get(0),
2397            )
2398            .optional()
2399            .ok()??;
2400        let mut index = DbFileIndex {
2401            lang: lang_from_label(&lang),
2402            exports: HashSet::new(),
2403            default_export: None,
2404            export_aliases: HashMap::new(),
2405            node_by_scoped: HashMap::new(),
2406            node_by_bare: HashMap::new(),
2407            node_kind_by_id: HashMap::new(),
2408            module_targets: HashMap::new(),
2409            declared_module_targets: HashMap::new(),
2410            reexports: Vec::new(),
2411        };
2412        let mut nodes = self
2413            .conn
2414            .prepare(
2415                "SELECT id, name, scoped_name, kind, exported, is_default_export
2416                 FROM nodes WHERE file_path = ?1",
2417            )
2418            .ok()?;
2419        let rows = nodes
2420            .query_map(params![rel_path], |row| {
2421                Ok((
2422                    row.get::<_, String>(0)?,
2423                    row.get::<_, String>(1)?,
2424                    row.get::<_, String>(2)?,
2425                    row.get::<_, String>(3)?,
2426                    row.get::<_, i64>(4)? != 0,
2427                    row.get::<_, i64>(5)? != 0,
2428                ))
2429            })
2430            .ok()?
2431            .collect::<std::result::Result<Vec<_>, _>>()
2432            .ok()?;
2433        drop(nodes);
2434        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2435            if exported {
2436                index.exports.insert(name.clone());
2437                index.exports.insert(scoped_name.clone());
2438            }
2439            if is_default_export {
2440                index.default_export = Some(scoped_name.clone());
2441            }
2442            index.node_by_scoped.insert(scoped_name, id.clone());
2443            index.node_by_bare.entry(name).or_insert(id.clone());
2444            index.node_kind_by_id.insert(id, kind);
2445        }
2446
2447        let mut refs = self
2448            .conn
2449            .prepare(
2450                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2451                  FROM refs
2452                  WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2453            )
2454            .ok()?;
2455        let rows = refs
2456            .query_map(params![rel_path], |row| {
2457                Ok((
2458                    row.get::<_, String>(0)?,
2459                    row.get::<_, String>(1)?,
2460                    row.get::<_, Option<String>>(2)?,
2461                    row.get::<_, Option<String>>(3)?,
2462                    row.get::<_, i64>(4)? != 0,
2463                    row.get::<_, Option<String>>(5)?,
2464                    row.get::<_, Option<String>>(6)?,
2465                ))
2466            })
2467            .ok()?
2468            .collect::<std::result::Result<Vec<_>, _>>()
2469            .ok()?;
2470        drop(refs);
2471        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2472            if kind == "export_alias" {
2473                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2474                    index.export_aliases.insert(exported, source);
2475                }
2476                continue;
2477            }
2478            let Some(module_path) = module_path else {
2479                continue;
2480            };
2481            let target_file = if kind == "module" {
2482                rust_declared_module_target(&self.project_root, rel_path, &module_path)
2483            } else {
2484                self.disk_module_target(rel_path, &module_path)
2485            }
2486            .or_else(|| {
2487                self.conn
2488                    .query_row(
2489                        "SELECT d.dep_file
2490                         FROM file_dependencies d
2491                         JOIN files f ON f.path = d.dep_file
2492                         WHERE d.file_path = ?1
2493                         ORDER BY d.dep_file
2494                         LIMIT 1",
2495                        params![rel_path],
2496                        |row| row.get::<_, String>(0),
2497                    )
2498                    .optional()
2499                    .ok()
2500                    .flatten()
2501            });
2502            index
2503                .module_targets
2504                .entry(module_path.clone())
2505                .or_insert_with(|| target_file.clone());
2506            if kind == "module" {
2507                index
2508                    .declared_module_targets
2509                    .entry(module_path.clone())
2510                    .or_insert_with(|| target_file.clone());
2511            }
2512            if kind == "reexport" {
2513                let raw = RawRef {
2514                    ref_id,
2515                    caller_node: None,
2516                    caller_symbol: None,
2517                    caller_file: rel_path.to_string(),
2518                    kind,
2519                    short_name: None,
2520                    full_ref,
2521                    module_path: Some(module_path),
2522                    import_kind: Some("reexport".to_string()),
2523                    local_name: None,
2524                    requested_name: None,
2525                    namespace_alias: None,
2526                    wildcard,
2527                    line: 0,
2528                    byte_start: 0,
2529                    byte_end: 0,
2530                    dependencies: BTreeSet::new(),
2531                };
2532                index
2533                    .reexports
2534                    .push(reexport_index_from_raw(&raw, target_file));
2535            }
2536        }
2537        Some(index)
2538    }
2539
2540    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2541        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2542        let candidate = callgraph::resolve_module_path_with_memo(
2543            &caller_dir,
2544            module_path,
2545            self.module_resolution_memo,
2546        )?;
2547        let rel_path = relative_path(self.project_root, &candidate);
2548        self.contains_file(&rel_path).then_some(rel_path)
2549    }
2550}
2551
2552impl ResolverIndex for DiskProjectIndex<'_> {
2553    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2554        (file == self.caller_file).then_some(self.caller_data)
2555    }
2556
2557    fn lang_for(&self, file: &str) -> Option<LangId> {
2558        self.file_index(file).and_then(|index| index.lang)
2559    }
2560
2561    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2562        self.file_index(caller_file)
2563            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2564    }
2565
2566    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2567        let mut stmt = self
2568            .conn
2569            .prepare(
2570                "SELECT caller_file, module_path FROM refs
2571                 WHERE kind = 'module' AND module_path IS NOT NULL
2572                 ORDER BY caller_file, module_path",
2573            )
2574            .ok()?;
2575        let rows = stmt
2576            .query_map([], |row| {
2577                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2578            })
2579            .ok()?;
2580        for row in rows.flatten() {
2581            if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2582                return Some(row);
2583            }
2584        }
2585        None
2586    }
2587
2588    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2589        self.file_index(file)
2590            .map(|index| index.reexports)
2591            .unwrap_or_default()
2592    }
2593
2594    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2595        self.file_index(file).and_then(|index| {
2596            index
2597                .node_by_scoped
2598                .get(symbol)
2599                .cloned()
2600                .or_else(|| index.node_by_bare.get(symbol).cloned())
2601        })
2602    }
2603
2604    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2605        self.file_index(file)
2606            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2607            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2608    }
2609
2610    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2611        self.file_index(file)
2612            .and_then(|index| index.export_aliases.get(symbol).cloned())
2613    }
2614
2615    fn has_export(&self, file: &str, symbol: &str) -> bool {
2616        self.file_index(file)
2617            .is_some_and(|index| index.exports.contains(symbol))
2618    }
2619
2620    fn default_export(&self, file: &str) -> Option<String> {
2621        self.file_index(file).and_then(|index| index.default_export)
2622    }
2623
2624    fn contains_file(&self, file: &str) -> bool {
2625        self.conn
2626            .query_row(
2627                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2628                params![file],
2629                |_| Ok(()),
2630            )
2631            .is_ok()
2632    }
2633
2634    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2635        self.workspace_crate_prefixes
2636            .0
2637            .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2638            .get(crate_name)
2639            .cloned()
2640    }
2641
2642    fn inline_scoped_target(
2643        &self,
2644        caller_file: &str,
2645        module_segments: &[String],
2646        short_name: &str,
2647    ) -> Option<(String, String)> {
2648        let src_prefix = rust_src_prefix(caller_file);
2649        let check = |file_path: String| {
2650            let file_module_segments = rust_module_segments_for_rel(&file_path);
2651            if rust_src_prefix(&file_path) != src_prefix
2652                || !module_segments.starts_with(&file_module_segments)
2653            {
2654                return None;
2655            }
2656            let scoped_segments = &module_segments[file_module_segments.len()..];
2657            if scoped_segments.is_empty() {
2658                return None;
2659            }
2660            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2661            self.node_for_symbol(&file_path, &scoped_symbol)
2662                .map(|_| (file_path, scoped_symbol))
2663        };
2664        if let Some(target) = check(caller_file.to_string()) {
2665            return Some(target);
2666        }
2667        let mut statement = self
2668            .conn
2669            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2670            .ok()?;
2671        let rows = statement
2672            .query_map(params![caller_file], |row| row.get::<_, String>(0))
2673            .ok()?;
2674        for path in rows.flatten() {
2675            if let Some(target) = check(path) {
2676                return Some(target);
2677            }
2678        }
2679        None
2680    }
2681}
2682
2683impl CallGraphStore {
2684    pub fn open_if_enabled(
2685        options: CallGraphStoreOptions,
2686        callgraph_dir: PathBuf,
2687        project_root: PathBuf,
2688    ) -> Result<Option<Self>> {
2689        if !options.enabled {
2690            return Ok(None);
2691        }
2692        Self::open(callgraph_dir, project_root).map(Some)
2693    }
2694
2695    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2696        let project_key = crate::search_index::artifact_cache_key(&project_root);
2697        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2698        else {
2699            return Err(CallGraphStoreError::Unavailable(
2700                "writer capability denied; use the read-only callgraph opener".to_string(),
2701            ));
2702        };
2703        std::fs::create_dir_all(&callgraph_dir)?;
2704        // Resolve the current generation via the pointer (falling back to the
2705        // legacy single-file DB). If nothing is published yet, open the legacy
2706        // path so a brand-new store still gets a writable DB + schema.
2707        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2708            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2709        let OpenedStore { store, root_repair } = Self::open_at_path(
2710            project_root.clone(),
2711            project_key,
2712            sqlite_path,
2713            generation,
2714            true,
2715            Some(Arc::clone(&writer_lease)),
2716            None,
2717        )?;
2718        match root_repair {
2719            OpenRootRepair::NeedsRebuild { .. } => {
2720                log_root_repair_rebuild(&root_repair);
2721                drop(store);
2722                drop(writer_lease);
2723                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2724                let (store, _stats) =
2725                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2726                Ok(store)
2727            }
2728            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2729        }
2730    }
2731
2732    pub fn open_readonly(
2733        callgraph_dir: PathBuf,
2734        project_root: PathBuf,
2735    ) -> Result<Option<ReadonlyCallGraphStore>> {
2736        let project_key = crate::search_index::artifact_cache_key(&project_root);
2737        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2738        {
2739            let conn = open_readonly_connection(&sqlite_path)?;
2740            if !database_ready(&conn).unwrap_or(false) {
2741                return Ok(None);
2742            }
2743            let marker_label = generation.as_deref().unwrap_or("legacy");
2744            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2745            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2746                Self::from_connection(
2747                    project_root,
2748                    project_key,
2749                    sqlite_path,
2750                    callgraph_dir,
2751                    false,
2752                    generation,
2753                    None,
2754                    Some(read_marker),
2755                    conn,
2756                ),
2757            )));
2758        }
2759
2760        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2761            return Ok(None);
2762        };
2763        crate::slog_warn!(
2764            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2765            target.partition.harness,
2766            target.sqlite_path.display()
2767        );
2768        let conn = open_readonly_connection(&target.sqlite_path)?;
2769        if !database_ready(&conn).unwrap_or(false) {
2770            return Ok(None);
2771        }
2772        let marker_label =
2773            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2774        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2775        Ok(Some(ReadonlyCallGraphStore::from_inner(
2776            Self::from_connection(
2777                project_root,
2778                project_key,
2779                target.sqlite_path,
2780                callgraph_dir,
2781                true,
2782                target.generation,
2783                None,
2784                Some(read_marker),
2785                conn,
2786            ),
2787        )))
2788    }
2789
2790    /// Open the currently-published ready store with write access so moved-root
2791    /// metadata can be repaired before projection readers consume it. Unlike
2792    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2793    /// ready generation exists, it returns `Ok(None)` instead of creating an
2794    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2795    pub fn open_ready_repairing(
2796        callgraph_dir: PathBuf,
2797        project_root: PathBuf,
2798    ) -> Result<Option<Self>> {
2799        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2800    }
2801
2802    /// Open a ready store for bounded maintenance work without repairing root
2803    /// metadata or starting a cold rebuild. A store that needs either action is
2804    /// reported as unavailable so a background build can own that work.
2805    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2806        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2807    }
2808
2809    pub fn open_ready_no_rebuild(
2810        callgraph_dir: PathBuf,
2811        project_root: PathBuf,
2812    ) -> Result<Option<Self>> {
2813        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2814    }
2815
2816    fn open_ready_with_rebuild_policy(
2817        callgraph_dir: PathBuf,
2818        project_root: PathBuf,
2819        allow_cold_build: bool,
2820        allow_root_repair: bool,
2821    ) -> Result<Option<Self>> {
2822        let project_key = crate::search_index::artifact_cache_key(&project_root);
2823        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2824        else {
2825            return Ok(None);
2826        };
2827        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2828        else {
2829            return Ok(None);
2830        };
2831        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2832            project_root.clone(),
2833            project_key.clone(),
2834            sqlite_path,
2835            generation,
2836            true,
2837            Some(Arc::clone(&writer_lease)),
2838            None,
2839            allow_root_repair,
2840        )?;
2841        match root_repair {
2842            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2843                log_root_repair_rebuild(&root_repair);
2844                drop(store);
2845                drop(writer_lease);
2846                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2847                let (store, _stats) =
2848                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2849                Ok(Some(store))
2850            }
2851            OpenRootRepair::NeedsRebuild { .. } => {
2852                if let Some(message) = note_repair_entry(&project_key) {
2853                    crate::slog_warn!("{message}");
2854                }
2855                Ok(None)
2856            }
2857            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2858        }
2859    }
2860
2861    pub fn cold_build_with_lease(
2862        callgraph_dir: PathBuf,
2863        project_root: PathBuf,
2864        files: &[PathBuf],
2865    ) -> Result<(Self, ColdBuildStats)> {
2866        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2867    }
2868
2869    pub fn cold_build_with_lease_chunked(
2870        callgraph_dir: PathBuf,
2871        project_root: PathBuf,
2872        files: &[PathBuf],
2873        chunk_size: usize,
2874    ) -> Result<(Self, ColdBuildStats)> {
2875        Self::cold_build_with_lease_chunked_inner(
2876            callgraph_dir,
2877            project_root,
2878            files,
2879            chunk_size,
2880            false,
2881        )
2882    }
2883
2884    pub(crate) fn force_cold_build_with_lease_chunked(
2885        callgraph_dir: PathBuf,
2886        project_root: PathBuf,
2887        files: &[PathBuf],
2888        chunk_size: usize,
2889    ) -> Result<(Self, ColdBuildStats)> {
2890        Self::cold_build_with_lease_chunked_inner(
2891            callgraph_dir,
2892            project_root,
2893            files,
2894            chunk_size,
2895            true,
2896        )
2897    }
2898
2899    fn cold_build_with_lease_chunked_inner(
2900        callgraph_dir: PathBuf,
2901        project_root: PathBuf,
2902        files: &[PathBuf],
2903        chunk_size: usize,
2904        require_new_publication: bool,
2905    ) -> Result<(Self, ColdBuildStats)> {
2906        let project_key = crate::search_index::artifact_cache_key(&project_root);
2907        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2908        else {
2909            let operation = if require_new_publication {
2910                "forced rebuild"
2911            } else {
2912                "cold build"
2913            };
2914            return Err(CallGraphStoreError::Unavailable(format!(
2915                "{operation} could not acquire writer capability"
2916            )));
2917        };
2918        std::fs::create_dir_all(&callgraph_dir)?;
2919        let (stats, generation) = Self::cold_build_publish_locked(
2920            &callgraph_dir,
2921            &project_root,
2922            &project_key,
2923            files,
2924            chunk_size,
2925            Arc::clone(&writer_lease),
2926        )?;
2927        let store = Self::open_generation(
2928            &callgraph_dir,
2929            project_root,
2930            project_key,
2931            generation,
2932            writer_lease,
2933        )?;
2934        Ok((store, stats))
2935    }
2936
2937    pub fn ensure_built_with_lease(
2938        callgraph_dir: PathBuf,
2939        project_root: PathBuf,
2940        files: &[PathBuf],
2941    ) -> Result<(Self, Option<ColdBuildStats>)> {
2942        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2943    }
2944
2945    pub fn ensure_built_with_lease_chunked(
2946        callgraph_dir: PathBuf,
2947        project_root: PathBuf,
2948        files: &[PathBuf],
2949        chunk_size: usize,
2950    ) -> Result<(Self, Option<ColdBuildStats>)> {
2951        let project_key = crate::search_index::artifact_cache_key(&project_root);
2952        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2953        else {
2954            return Err(CallGraphStoreError::Unavailable(
2955                "callgraph ensure could not acquire writer capability".to_string(),
2956            ));
2957        };
2958        std::fs::create_dir_all(&callgraph_dir)?;
2959        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2960        // Another process may have published a ready generation while we waited
2961        // for the lock — open it instead of rebuilding. If that generation is
2962        // from this same project at an older filesystem root, repair the root
2963        // metadata in-place while still holding the build lease. If data rows
2964        // contain absolute paths, publish a fresh generation under this lease
2965        // rather than recursively reacquiring the same lock.
2966        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2967        {
2968            let OpenedStore { store, root_repair } = Self::open_at_path(
2969                project_root.clone(),
2970                project_key.clone(),
2971                sqlite_path,
2972                generation,
2973                true,
2974                Some(Arc::clone(&writer_lease)),
2975                None,
2976            )?;
2977            match root_repair {
2978                OpenRootRepair::NeedsRebuild { .. } => {
2979                    log_root_repair_rebuild(&root_repair);
2980                    drop(store);
2981                    let (stats, generation) = Self::cold_build_publish_locked(
2982                        &callgraph_dir,
2983                        &project_root,
2984                        &project_key,
2985                        files,
2986                        chunk_size,
2987                        Arc::clone(&writer_lease),
2988                    )?;
2989                    let store = Self::open_generation(
2990                        &callgraph_dir,
2991                        project_root,
2992                        project_key,
2993                        generation,
2994                        writer_lease,
2995                    )?;
2996                    return Ok((store, Some(stats)));
2997                }
2998                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2999                    return Ok((store, None));
3000                }
3001            }
3002        }
3003        if let Some(store) = try_legacy_migration_or_fallback(
3004            &callgraph_dir,
3005            &project_root,
3006            &project_key,
3007            Arc::clone(&writer_lease),
3008        )? {
3009            return Ok((store, None));
3010        }
3011        let (stats, generation) = Self::cold_build_publish_locked(
3012            &callgraph_dir,
3013            &project_root,
3014            &project_key,
3015            files,
3016            chunk_size,
3017            Arc::clone(&writer_lease),
3018        )?;
3019        let store = Self::open_generation(
3020            &callgraph_dir,
3021            project_root,
3022            project_key,
3023            generation,
3024            writer_lease,
3025        )?;
3026        Ok((store, Some(stats)))
3027    }
3028
3029    /// Migrate a legacy harness-partition store without falling through to a
3030    /// cold build. This is used after a query has already opened a read-only
3031    /// fallback: the caller runs it on the same limited background lane as cold
3032    /// builds while queries continue using that fallback. Public so crash/retry
3033    /// tests can drive the migration synchronously on a thread where the
3034    /// thread-local failure seams apply.
3035    pub fn migrate_legacy_with_lease(
3036        callgraph_dir: PathBuf,
3037        project_root: PathBuf,
3038    ) -> Result<Option<Self>> {
3039        let project_key = crate::search_index::artifact_cache_key(&project_root);
3040        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3041        else {
3042            return Ok(None);
3043        };
3044        std::fs::create_dir_all(&callgraph_dir)?;
3045        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3046
3047        // Another writer may have completed the migration while this worker was
3048        // waiting for the lease. Adopt its root-keyed generation rather than
3049        // copying the legacy source a second time.
3050        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3051        {
3052            let OpenedStore { store, root_repair } = Self::open_at_path(
3053                project_root,
3054                project_key,
3055                sqlite_path,
3056                generation,
3057                true,
3058                Some(writer_lease),
3059                None,
3060            )?;
3061            return match root_repair {
3062                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3063                OpenRootRepair::NeedsRebuild { reason, .. } => {
3064                    Err(CallGraphStoreError::Unavailable(format!(
3065                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3066                    )))
3067                }
3068            };
3069        }
3070
3071        let store = try_legacy_migration_or_fallback(
3072            &callgraph_dir,
3073            &project_root,
3074            &project_key,
3075            writer_lease,
3076        )?;
3077        // A disk-floor or backup-budget failure returns a readable legacy store.
3078        // Keep the already-resident fallback instead of sending this duplicate
3079        // reader through the background-install channel.
3080        Ok(store.filter(|store| !store.is_legacy_fallback()))
3081    }
3082
3083    /// Build a fresh DB and publish it as a new generation, then atomically flip
3084    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
3085    /// succeeds even when other processes hold an older generation open (the
3086    /// multi-TUI Windows case). The builder owns the temp + generation files
3087    /// exclusively (unique pid+nanos names), so it can rename/replace them
3088    /// freely; only the tiny pointer is shared, and only Rust std touches it.
3089    ///
3090    /// Returns the published generation file name so callers open exactly the
3091    /// generation they built (avoiding a race where a concurrent build's flip
3092    /// would otherwise reopen a different generation).
3093    fn cold_build_publish_locked(
3094        callgraph_dir: &Path,
3095        project_root: &Path,
3096        project_key: &str,
3097        files: &[PathBuf],
3098        chunk_size: usize,
3099        writer_lease: Arc<crate::root_cache::WriterLease>,
3100    ) -> Result<(ColdBuildStats, String)> {
3101        if let Some((previous_root, remaining)) =
3102            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3103        {
3104            return Err(CallGraphStoreError::Unavailable(format!(
3105                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3106                previous_root.display(),
3107                remaining.as_millis()
3108            )));
3109        }
3110        let breaker = crate::build_breaker::BuildDeathBreaker::open(
3111            callgraph_dir.join("build-breaker.sqlite"),
3112        )
3113        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3114
3115        let generation = generation_file_name(project_key);
3116        let gen_path = callgraph_dir.join(&generation);
3117        // A writer lease makes this root/domain's staging generation exclusive.
3118        // Keep its identity stable so a replacement process adopts committed
3119        // batches instead of minting a second temp and starting from zero.
3120        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3121        let adopting_staging = temp_path.exists();
3122        if !adopting_staging {
3123            remove_sqlite_file_set(&temp_path);
3124        }
3125
3126        let (stats, breaker_key) = {
3127            if adopting_staging {
3128                crate::slog_info!(
3129                    "resuming callgraph cold build from staged generation {}",
3130                    temp_path.display()
3131                );
3132            }
3133            let temp_store = Self::open_at_path(
3134                project_root.to_path_buf(),
3135                project_key.to_string(),
3136                temp_path.clone(),
3137                None,
3138                false,
3139                Some(Arc::clone(&writer_lease)),
3140                None,
3141            )?
3142            .store;
3143            // Admission must precede every expensive build phase and every
3144            // staging write: a suspended root is refused before the process
3145            // spends anything, and a death during enumeration is attributable
3146            // to an admitted attempt. The breaker key needs the corpus
3147            // fingerprint, so that one input is resolved by a standalone
3148            // streaming walk first (sanctioned pre-admission work) - the
3149            // inventory pass below recomputes it while staging; the staged
3150            // value governs resume cursors, while the admission key stays
3151            // pinned to the admitted fingerprint so a file racing the walk
3152            // cannot detach the attempt from its breaker record.
3153            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3154            let breaker_key = crate::build_breaker::BreakerKey::new(
3155                project_root.display().to_string(),
3156                crate::build_breaker::BuildDomain::CallgraphCold,
3157                admission_fingerprint,
3158            );
3159            match breaker
3160                .admit(&breaker_key, 0)
3161                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3162            {
3163                crate::build_breaker::BreakerAdmission::Admitted(_) => {}
3164                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3165                    return Err(CallGraphStoreError::Suspended(suspension));
3166                }
3167            }
3168            ensure_cold_build_current("inventory", 0, 1)?;
3169            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3170            ensure_cold_build_current("inventory", 1, 1)?;
3171            let stats = temp_store
3172                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3173            let _ = temp_store.checkpoint_wal_truncate();
3174            temp_store.prepare_for_atomic_swap()?;
3175            (stats, breaker_key)
3176        };
3177
3178        notify_cold_build_before_publish_observer();
3179        let publication = publish_if_current(|| {
3180            verify_writer_lease(&writer_lease)?;
3181            // Move the finished build to its final generation path. This target is
3182            // brand-new and owned by us, so the rename never hits an open file.
3183            remove_sqlite_file_set(&gen_path);
3184            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3185            crate::fs_lock::sync_parent(&gen_path);
3186            remove_sqlite_sidecars(&gen_path);
3187
3188            notify_cold_build_swap_observer(&temp_path, &gen_path);
3189
3190            // Atomically publish the new generation, then best-effort GC old ones.
3191            verify_writer_lease(&writer_lease)?;
3192            publish_pointer(callgraph_dir, project_key, &generation)?;
3193            gc_old_generations(callgraph_dir, project_key, &generation);
3194            // Store-wide orphan sweep on the same cadence: reclaims aged build
3195            // temps for roots that no longer build here, which the per-root GC
3196            // above never reaches.
3197            sweep_orphaned_build_temps_store_wide(callgraph_dir);
3198            sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3199            crate::search_index::sweep_transient_search_cache_dirs();
3200            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3201                let inspect_root =
3202                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3203                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3204                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3205            }
3206            Ok(())
3207        });
3208        // A superseded generation remains a valid resumable staging artifact.
3209        // Its successor compares the durable corpus fingerprint before either
3210        // adopting this work or resetting it for a changed corpus.
3211        publication?;
3212        // Pointer publication is the only automatic breaker reset. The staging
3213        // batches above never reset history because a process can die after them.
3214        breaker
3215            .record_ready_publication(&breaker_key)
3216            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3217        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3218        Ok((stats, generation))
3219    }
3220
3221    /// Open a specific just-published generation (read-write, WAL) so a builder
3222    /// returns a store pinned to exactly what it built.
3223    fn open_generation(
3224        callgraph_dir: &Path,
3225        project_root: PathBuf,
3226        project_key: String,
3227        generation: String,
3228        writer_lease: Arc<crate::root_cache::WriterLease>,
3229    ) -> Result<Self> {
3230        let gen_path = callgraph_dir.join(&generation);
3231        Ok(Self::open_at_path(
3232            project_root,
3233            project_key,
3234            gen_path,
3235            Some(generation),
3236            true,
3237            Some(writer_lease),
3238            None,
3239        )?
3240        .store)
3241    }
3242
3243    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3244        let project_key = crate::search_index::artifact_cache_key(project_root);
3245        // A cold build is needed unless a ready generation (or ready legacy DB)
3246        // is currently published.
3247        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3248    }
3249
3250    /// Check the durable callgraph-domain breaker before a query starts a cold
3251    /// worker. This only runs while no ready generation exists; it never builds
3252    /// inline and lets a tripped root return a terminal answer instead of an
3253    /// endless `Building` response.
3254    pub fn cold_build_suspension(
3255        callgraph_dir: &Path,
3256        project_root: &Path,
3257    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3258        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3259        if !breaker_path.exists() {
3260            return Ok(None);
3261        }
3262        let key = crate::build_breaker::BreakerKey::new(
3263            project_root.display().to_string(),
3264            crate::build_breaker::BuildDomain::CallgraphCold,
3265            callgraph_corpus_fingerprint(project_root)?,
3266        );
3267        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3268            .and_then(|breaker| breaker.suspension(&key))
3269            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3270    }
3271
3272    fn open_at_path(
3273        project_root: PathBuf,
3274        project_key: String,
3275        sqlite_path: PathBuf,
3276        generation: Option<String>,
3277        use_wal: bool,
3278        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3279        read_marker: Option<crate::root_cache::ReadMarker>,
3280    ) -> Result<OpenedStore> {
3281        Self::open_at_path_with_root_repair(
3282            project_root,
3283            project_key,
3284            sqlite_path,
3285            generation,
3286            use_wal,
3287            writer_lease,
3288            read_marker,
3289            true,
3290        )
3291    }
3292
3293    fn open_at_path_with_root_repair(
3294        project_root: PathBuf,
3295        project_key: String,
3296        sqlite_path: PathBuf,
3297        generation: Option<String>,
3298        use_wal: bool,
3299        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3300        read_marker: Option<crate::root_cache::ReadMarker>,
3301        allow_root_repair: bool,
3302    ) -> Result<OpenedStore> {
3303        if let Some(lease) = writer_lease.as_ref() {
3304            verify_writer_lease(lease)?;
3305        }
3306        if let Some(parent) = sqlite_path.parent() {
3307            std::fs::create_dir_all(parent)?;
3308        }
3309        let mut conn = Connection::open(&sqlite_path)?;
3310        if use_wal {
3311            configure_connection(&conn)?;
3312        } else {
3313            configure_build_connection(&conn)?;
3314        }
3315        if let Some(lease) = writer_lease.as_ref() {
3316            verify_writer_lease(lease)?;
3317        }
3318        initialize_schema(&conn)?;
3319        if let Some(lease) = writer_lease.as_ref() {
3320            verify_writer_lease(lease)?;
3321        }
3322        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3323        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3324            (Some(marker), _, _) => Some(marker),
3325            (None, Some(label), Some(cache_dir)) => {
3326                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3327            }
3328            (None, _, _) => None,
3329        };
3330        let publication_dir = sqlite_path
3331            .parent()
3332            .map(Path::to_path_buf)
3333            .unwrap_or_default();
3334        let store = Self::from_connection(
3335            project_root,
3336            project_key,
3337            sqlite_path,
3338            publication_dir,
3339            false,
3340            generation,
3341            writer_lease,
3342            read_marker,
3343            conn,
3344        );
3345        Ok(OpenedStore { store, root_repair })
3346    }
3347
3348    fn prepare_for_atomic_swap(&self) -> Result<()> {
3349        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3350        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3351        Ok(())
3352    }
3353
3354    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3355        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3356            self.sqlite_path
3357                .parent()
3358                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3359        });
3360        if protected_reader {
3361            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3362        } else {
3363            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3364        }
3365    }
3366
3367    fn from_connection(
3368        project_root: PathBuf,
3369        project_key: String,
3370        sqlite_path: PathBuf,
3371        publication_dir: PathBuf,
3372        legacy_fallback: bool,
3373        generation: Option<String>,
3374        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3375        read_marker: Option<crate::root_cache::ReadMarker>,
3376        conn: Connection,
3377    ) -> Self {
3378        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3379        Self {
3380            project_root,
3381            project_key,
3382            sqlite_path,
3383            publication_dir,
3384            legacy_fallback,
3385            generation,
3386            writer_lease,
3387            read_marker,
3388            database_ready: AtomicBool::new(false),
3389            write_metrics,
3390            conn: Mutex::new(conn),
3391        }
3392    }
3393
3394    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3395        if self.database_ready.load(AtomicOrdering::Acquire) {
3396            return Ok(());
3397        }
3398        ensure_database_ready(conn)?;
3399        self.database_ready.store(true, AtomicOrdering::Release);
3400        Ok(())
3401    }
3402
3403    pub fn project_root(&self) -> &Path {
3404        &self.project_root
3405    }
3406
3407    pub fn project_key(&self) -> &str {
3408        &self.project_key
3409    }
3410
3411    pub fn sqlite_path(&self) -> &Path {
3412        &self.sqlite_path
3413    }
3414
3415    /// The generation file named by the publication pointer when this store opened.
3416    pub(crate) fn projection_generation(&self) -> Option<&str> {
3417        self.generation.as_deref()
3418    }
3419
3420    /// Read the durable revision that changes in the same transaction as graph writes.
3421    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3422        self.refresh_read_marker()?;
3423        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3424        self.ensure_ready(&conn)?;
3425        projection_write_revision(&conn)
3426    }
3427
3428    /// Whether this store is reading from a legacy harness partition because
3429    /// the root-keyed store has not published a generation yet.
3430    pub fn is_legacy_fallback(&self) -> bool {
3431        self.legacy_fallback
3432    }
3433
3434    pub(crate) fn is_legacy_migration(&self) -> bool {
3435        self.generation.as_deref().is_some_and(|generation| {
3436            migration_generation_requires_manifest(generation)
3437                && migration_manifest_valid(&self.publication_dir, generation)
3438        })
3439    }
3440
3441    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3442        self.writer_lease.as_ref().map(|lease| lease.epoch())
3443    }
3444
3445    fn verify_writer_lease(&self) -> Result<()> {
3446        let Some(lease) = self.writer_lease.as_ref() else {
3447            return Err(CallGraphStoreError::Unavailable(
3448                "callgraph store opened read-only; write API is unavailable".to_string(),
3449            ));
3450        };
3451        verify_writer_lease(lease)
3452    }
3453
3454    fn refresh_read_marker(&self) -> Result<()> {
3455        if let Some(marker) = self.read_marker.as_ref() {
3456            marker.touch_if_due()?;
3457        }
3458        Ok(())
3459    }
3460
3461    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3462        self.write_metrics
3463            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3464    }
3465
3466    fn checkpoint_wal_truncate(&self) -> bool {
3467        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3468        checkpoint_wal_truncate(&conn)
3469    }
3470
3471    /// True if this store still reflects the currently-published generation.
3472    /// Cheap (one small pointer-file read). When false, another process (or a
3473    /// local cold rebuild) has published a newer generation and the holder
3474    /// should drop this store and reopen via the pointer to converge. A missing
3475    /// pointer keeps the current store (legacy DB still valid, or transient).
3476    pub fn is_current(&self) -> bool {
3477        let _ = self.refresh_read_marker();
3478        match (
3479            read_pointer(&self.publication_dir, &self.project_key),
3480            &self.generation,
3481        ) {
3482            // Even when both generations happen to have the same filename, the
3483            // root-keyed pointer names a different directory from the fallback.
3484            (Some(_), _) if self.legacy_fallback => false,
3485            (Some(published), Some(opened)) => &published == opened,
3486            // A generation now supersedes the legacy single-file DB we opened.
3487            (Some(_), None) => false,
3488            // No pointer: keep serving (legacy DB, or an anomalous pointer
3489            // removal where our open generation file is still valid).
3490            (None, _) => true,
3491        }
3492    }
3493
3494    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3495        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3496    }
3497
3498    /// Build in two durable passes. Discovery first commits a disk-backed file
3499    /// inventory, extraction consumes bounded batches from that inventory, and
3500    /// resolution pages through staged raw references after all symbols exist.
3501    pub fn cold_build_chunked(
3502        &self,
3503        files: &[PathBuf],
3504        chunk_size: usize,
3505    ) -> Result<ColdBuildStats> {
3506        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3507        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3508    }
3509
3510    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3511        note_cold_build_phase("enumeration");
3512        if files.is_empty() {
3513            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3514                &self.project_root,
3515            ))
3516        } else {
3517            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3518        }
3519    }
3520
3521    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3522    where
3523        I: IntoIterator<Item = PathBuf>,
3524    {
3525        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3526        self.verify_writer_lease()?;
3527        let total_changes_before = conn.total_changes();
3528        let tx = conn.transaction()?;
3529        tx.execute("DELETE FROM staging_file_inventory", [])?;
3530        tx.commit()?;
3531        self.record_commit(total_changes_before, &conn);
3532
3533        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3534        for path in paths {
3535            let path = normalize_file_path(&self.project_root, &path)?;
3536            let rel_path = relative_path(&self.project_root, &path);
3537            let size = std::fs::metadata(&path)
3538                .map(|metadata| metadata.len())
3539                .unwrap_or(0);
3540            batch.push((rel_path, size));
3541            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3542                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3543                batch.clear();
3544            }
3545        }
3546        if !batch.is_empty() {
3547            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3548        }
3549
3550        staged_corpus_fingerprint(&conn, &self.project_root)
3551    }
3552
3553    fn insert_staged_file_inventory_batch(
3554        &self,
3555        conn: &mut Connection,
3556        batch: &[(String, u64)],
3557    ) -> Result<()> {
3558        self.verify_writer_lease()?;
3559        let total_changes_before = conn.total_changes();
3560        let tx = conn.transaction()?;
3561        {
3562            let mut insert = tx.prepare(
3563                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3564            )?;
3565            for (path, size) in batch {
3566                insert.execute(params![path, *size as i64])?;
3567            }
3568        }
3569        tx.commit()?;
3570        self.record_commit(total_changes_before, conn);
3571        Ok(())
3572    }
3573
3574    fn cold_build_chunked_from_staged_inventory(
3575        &self,
3576        chunk_size: usize,
3577        corpus_fingerprint: &str,
3578    ) -> Result<ColdBuildStats> {
3579        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3580        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3581            chunk_size,
3582            corpus_fingerprint,
3583            COLD_BUILD_RESOLVE_WINDOW,
3584            &module_resolution_memo,
3585        )
3586    }
3587
3588    #[cfg(test)]
3589    fn cold_build_chunked_with_resolution_memo_for_test(
3590        &self,
3591        files: &[PathBuf],
3592        chunk_size: usize,
3593        resolve_window: usize,
3594        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3595    ) -> Result<ColdBuildStats> {
3596        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3597        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3598            chunk_size,
3599            &corpus_fingerprint,
3600            resolve_window.max(1),
3601            module_resolution_memo,
3602        )
3603    }
3604
3605    fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
3606        &self,
3607        chunk_size: usize,
3608        corpus_fingerprint: &str,
3609        resolve_window: usize,
3610        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3611    ) -> Result<ColdBuildStats> {
3612        let started = Instant::now();
3613        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3614        let workspace_root = self.project_root.display().to_string();
3615        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3616
3617        self.verify_writer_lease()?;
3618        ensure_cold_build_current("staging-admission", 0, 1)?;
3619        let mut phase = staged_build_phase(&conn)?;
3620        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3621        let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
3622        if phase.as_deref() == Some("ready") && fingerprint_matches {
3623            ensure_cold_build_current("completed-staging", 1, 1)?;
3624            crate::slog_info!(
3625                "callgraph cold-build decision: reason=matching completed staging; action=publish"
3626            );
3627            conn.execute("DELETE FROM staging_file_inventory", [])?;
3628            return cold_build_stats_from_connection(&conn, started);
3629        }
3630        if phase.is_none() || !fingerprint_matches {
3631            if staged_fingerprint.is_some() && !fingerprint_matches {
3632                crate::slog_info!(
3633                    "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
3634                );
3635            }
3636            let total_changes_before = conn.total_changes();
3637            let tx = conn.transaction()?;
3638            clear_tables(&tx)?;
3639            tx.execute("DELETE FROM staging_ref_context", [])?;
3640            insert_meta(&tx)?;
3641            drop_cold_build_secondary_indexes(&tx)?;
3642            set_meta_ready(&tx, false)?;
3643            set_staged_build_phase(&tx, "extracting")?;
3644            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3645            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3646            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3647            tx.commit()?;
3648            self.record_commit(total_changes_before, &conn);
3649            phase = Some("extracting".to_string());
3650        }
3651
3652        // A crashed extraction pass has already committed complete batches. Compare the
3653        // staged content identity with the current file before parsing so unchanged
3654        // committed files are not restarted from zero after adoption.
3655        note_cold_build_phase("extraction");
3656        if phase.as_deref() == Some("extracting") {
3657            prune_staged_files_not_in_inventory(&mut conn)?;
3658
3659            let total_files =
3660                query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
3661            let mut completed_files = 0usize;
3662            ensure_cold_build_current("extraction", completed_files, total_files)?;
3663            let mut after_path = String::new();
3664            loop {
3665                let Some(batch) = load_staged_file_batch(
3666                    &conn,
3667                    &self.project_root,
3668                    &after_path,
3669                    batch_files,
3670                    COLD_BUILD_EXTRACT_BATCH_BYTES,
3671                )?
3672                else {
3673                    break;
3674                };
3675                after_path = batch.last_path;
3676                let batch_files = batch.paths.len();
3677
3678                let mut needs_extract = Vec::with_capacity(batch_files);
3679                for path in batch.paths {
3680                    if !staged_content_matches(&conn, &self.project_root, &path)? {
3681                        needs_extract.push(path);
3682                    }
3683                }
3684                if needs_extract.is_empty() {
3685                    completed_files = completed_files.saturating_add(batch_files);
3686                    ensure_cold_build_current("extraction", completed_files, total_files)?;
3687                    continue;
3688                }
3689
3690                notify_cold_build_extract_observer(&needs_extract);
3691                let build = build_extracts_parallel(&self.project_root, &needs_extract);
3692                self.verify_writer_lease()?;
3693                let total_changes_before = conn.total_changes();
3694                let tx = conn.transaction()?;
3695                let mut extracted_bytes = 0u64;
3696                {
3697                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3698                    for extract in &build.extracts {
3699                        delete_staged_file_rows(&tx, &extract.rel_path)?;
3700                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3701                        for raw in &extract.raw_refs {
3702                            insert_staged_ref_prepared(&mut inserts, raw)?;
3703                        }
3704                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3705                    }
3706                    for failure in &build.failures {
3707                        insert_backend_state_prepared(
3708                            &mut inserts.backend_state,
3709                            &workspace_root,
3710                            &failure.rel_path,
3711                            failure
3712                                .freshness
3713                                .as_ref()
3714                                .map(|freshness| &freshness.content_hash),
3715                            "stale",
3716                        )?;
3717                    }
3718                }
3719                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3720                note_cold_build_commit_barrier("extraction_batch_before_commit");
3721                tx.commit()?;
3722                note_cold_build_commit_barrier("extraction_batch_committed");
3723                self.record_commit(total_changes_before, &conn);
3724                completed_files = completed_files.saturating_add(batch_files);
3725                ensure_cold_build_current("extraction", completed_files, total_files)?;
3726            }
3727
3728            ensure_cold_build_current("extraction", completed_files, total_files)?;
3729            let total_changes_before = conn.total_changes();
3730            let tx = conn.transaction()?;
3731            set_staged_build_phase(&tx, "indexing")?;
3732            tx.commit()?;
3733            self.record_commit(total_changes_before, &conn);
3734            phase = Some("indexing".to_string());
3735            ensure_cold_build_current("extraction", total_files, total_files)?;
3736        }
3737
3738        // Secondary indexes are intentionally created only after every extract is
3739        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
3740        // corpus-wide symbol/export table.
3741        note_cold_build_phase("symbol_export_index");
3742        if phase.as_deref() == Some("indexing") {
3743            ensure_cold_build_current("symbol-export-index", 0, 1)?;
3744            self.verify_writer_lease()?;
3745            let total_changes_before = conn.total_changes();
3746            let tx = conn.transaction()?;
3747            create_cold_build_secondary_indexes(&tx)?;
3748            set_staged_build_phase(&tx, "resolving")?;
3749            tx.commit()?;
3750            self.record_commit(total_changes_before, &conn);
3751            ensure_cold_build_current("symbol-export-index", 1, 1)?;
3752        }
3753
3754        note_cold_build_phase("resolution");
3755        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3756        let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3757        let mut resolved_refs =
3758            query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
3759        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3760        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3761        loop {
3762            let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
3763            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3764                break;
3765            };
3766
3767            self.verify_writer_lease()?;
3768            let total_changes_before = conn.total_changes();
3769            let tx = conn.transaction()?;
3770            {
3771                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3772                let mut offset = 0;
3773                while offset < staged.len() {
3774                    let caller_file = staged[offset].raw.caller_file.clone();
3775                    let end = staged[offset..]
3776                        .iter()
3777                        .position(|entry| entry.raw.caller_file != caller_file)
3778                        .map(|relative| offset + relative)
3779                        .unwrap_or(staged.len());
3780                    let caller_extract = build_file_extract(
3781                        &self.project_root,
3782                        &self.project_root.join(&caller_file),
3783                    );
3784                    if let Ok(caller_extract) = caller_extract {
3785                        let index = DiskProjectIndex {
3786                            project_root: &self.project_root,
3787                            conn: &tx,
3788                            caller_file: &caller_file,
3789                            caller_data: &caller_extract.data,
3790                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3791                            module_resolution_memo,
3792                        };
3793                        for staged_ref in &staged[offset..end] {
3794                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3795                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3796                        }
3797                    } else {
3798                        for staged_ref in &staged[offset..end] {
3799                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3800                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3801                        }
3802                    }
3803                    offset = end;
3804                }
3805            }
3806            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3807            tx.commit()?;
3808            self.record_commit(total_changes_before, &conn);
3809            resolve_cursor = last_rowid;
3810            resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
3811            ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3812        }
3813
3814        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3815        note_cold_build_phase("publication");
3816        self.verify_writer_lease()?;
3817        let total_changes_before = conn.total_changes();
3818        let tx = conn.transaction()?;
3819        let _supplemental_edge_count =
3820            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3821        set_meta_ready(&tx, true)?;
3822        set_staged_build_phase(&tx, "ready")?;
3823        tx.execute("DELETE FROM staging_file_inventory", [])?;
3824        tx.execute("DELETE FROM staging_ref_context", [])?;
3825        bump_projection_write_revision(&tx)?;
3826        tx.commit()?;
3827        self.record_commit(total_changes_before, &conn);
3828
3829        cold_build_stats_from_connection(&conn, started)
3830    }
3831
3832    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3833        self.refresh_files_with_workspace_crate_prefix_cache(
3834            changed_files,
3835            WorkspaceCratePrefixCache::default(),
3836        )
3837    }
3838
3839    fn refresh_files_with_workspace_crate_prefix_cache(
3840        &self,
3841        changed_files: &[PathBuf],
3842        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3843    ) -> Result<IncrementalStats> {
3844        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3845            changed_files,
3846            workspace_crate_prefixes,
3847        )?;
3848        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3849            eprintln!("refresh_files phases: {}", profile.report());
3850        }
3851        Ok(stats)
3852    }
3853
3854    /// Run an incremental refresh and return phase timings for an offline store copy.
3855    #[doc(hidden)]
3856    pub fn refresh_files_profiled(
3857        &self,
3858        changed_files: &[PathBuf],
3859    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3860        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3861            changed_files,
3862            WorkspaceCratePrefixCache::default(),
3863        )
3864    }
3865
3866    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3867        &self,
3868        changed_files: &[PathBuf],
3869        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3870    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3871        let total_started = Instant::now();
3872        let mut profile = RefreshFilesProfile::default();
3873        self.verify_writer_lease()?;
3874        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3875        ensure_database_ready(&conn)?;
3876        let total_changes_before = conn.total_changes();
3877        let mut changed = Vec::new();
3878        let mut surface_changed = BTreeSet::new();
3879        let mut deleted = BTreeSet::new();
3880        let mut own_refresh = BTreeSet::new();
3881        let mut candidate_own_refresh = BTreeSet::new();
3882        let mut confirmed_fresh = BTreeSet::new();
3883        let mut unchanged_extracts = 0usize;
3884        let mut selected_ref_ids = BTreeSet::new();
3885        let mut selected_refs_by_caller = BTreeMap::new();
3886        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3887        let mut fresh_metadata = BTreeMap::new();
3888
3889        for input in changed_files {
3890            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3891            {
3892                Ok(path) => path,
3893                Err(error) => {
3894                    record_path_identity_mismatch(&conn, &error)?;
3895                    return Err(error);
3896                }
3897            };
3898            changed.push(rel_path.clone());
3899            let old_row = load_file_row(&conn, &rel_path)?;
3900            if !abs_path.exists() {
3901                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3902                    surface_changed.insert(rel_path.clone());
3903                    let started = Instant::now();
3904                    let dependent_refs =
3905                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3906                    profile.dependency_selection += started.elapsed();
3907                    record_dependent_refs(
3908                        &mut selected_ref_ids,
3909                        &mut selected_refs_by_caller,
3910                        dependent_refs,
3911                    );
3912                }
3913                continue;
3914            }
3915
3916            if let Some(row) = &old_row {
3917                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3918                    FreshnessVerdict::HotFresh => {
3919                        // Content still matches the stored graph. A prior failed
3920                        // refresh may have left backend_file_state='stale' without
3921                        // changing bytes; skip the extract but still clear that
3922                        // leftover so dead-code projection can use this store.
3923                        confirmed_fresh.insert(rel_path.clone());
3924                        continue;
3925                    }
3926                    FreshnessVerdict::ContentFresh {
3927                        new_mtime,
3928                        new_size,
3929                    } => {
3930                        fresh_metadata.insert(
3931                            rel_path.clone(),
3932                            FileFreshness {
3933                                content_hash: row.freshness.content_hash,
3934                                mtime: new_mtime,
3935                                size: new_size,
3936                            },
3937                        );
3938                        continue;
3939                    }
3940                    FreshnessVerdict::Deleted => {
3941                        if deleted.insert(rel_path.clone()) {
3942                            surface_changed.insert(rel_path.clone());
3943                            let started = Instant::now();
3944                            let dependent_refs =
3945                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3946                            profile.dependency_selection += started.elapsed();
3947                            record_dependent_refs(
3948                                &mut selected_ref_ids,
3949                                &mut selected_refs_by_caller,
3950                                dependent_refs,
3951                            );
3952                        }
3953                        continue;
3954                    }
3955                    FreshnessVerdict::Stale => {}
3956                }
3957            }
3958
3959            let started = Instant::now();
3960            let extract = build_file_extract(&self.project_root, &abs_path)?;
3961            profile.parse += started.elapsed();
3962            let surface_is_changed = old_row
3963                .as_ref()
3964                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3965                .unwrap_or(true);
3966            if surface_is_changed {
3967                surface_changed.insert(rel_path.clone());
3968                let started = Instant::now();
3969                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3970                profile.dependency_selection += started.elapsed();
3971                record_dependent_refs(
3972                    &mut selected_ref_ids,
3973                    &mut selected_refs_by_caller,
3974                    dependent_refs,
3975                );
3976            }
3977            candidate_own_refresh.insert(rel_path.clone());
3978            changed_extracts.insert(rel_path, extract);
3979        }
3980
3981        let dependency_selected_refs = selected_ref_ids.len();
3982        let mut touched_callers: BTreeSet<String> =
3983            selected_refs_by_caller.keys().cloned().collect();
3984        touched_callers.extend(candidate_own_refresh.iter().cloned());
3985
3986        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3987        for rel_path in &touched_callers {
3988            if deleted.contains(rel_path) {
3989                continue;
3990            }
3991            if let Some(extract) = changed_extracts.get(rel_path) {
3992                caller_extracts.insert(rel_path.clone(), extract.clone());
3993                continue;
3994            }
3995            let abs_path = self.project_root.join(rel_path);
3996            if abs_path.exists() {
3997                let started = Instant::now();
3998                let extract = build_file_extract(&self.project_root, &abs_path)?;
3999                profile.dependent_parse += started.elapsed();
4000                caller_extracts.insert(rel_path.clone(), extract);
4001            }
4002        }
4003
4004        let tx = conn.transaction()?;
4005        for (rel_path, freshness) in fresh_metadata {
4006            update_file_fresh_metadata(
4007                &tx,
4008                &self.project_root,
4009                &rel_path,
4010                &freshness.content_hash,
4011                freshness.mtime,
4012                freshness.size,
4013            )?;
4014        }
4015        for rel_path in &confirmed_fresh {
4016            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4017        }
4018        for rel_path in &deleted {
4019            let started = Instant::now();
4020            delete_file_rows(&tx, rel_path)?;
4021            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4022            profile.row_deletes += started.elapsed();
4023        }
4024
4025        let started = Instant::now();
4026        let index = ProjectIndex::from_db_and_callers(
4027            &tx,
4028            &self.project_root,
4029            &caller_extracts,
4030            workspace_crate_prefixes,
4031        )?;
4032        profile.index_load += started.elapsed();
4033
4034        let workspace_root = self.project_root.display().to_string();
4035        {
4036            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4037            for rel_path in &candidate_own_refresh {
4038                let Some(extract) = changed_extracts.get(rel_path) else {
4039                    continue;
4040                };
4041                if !write_amplification_baseline_enabled()
4042                    && stored_extract_matches(&tx, rel_path, extract, &index)?
4043                {
4044                    unchanged_extracts += 1;
4045                    update_file_fresh_metadata(
4046                        &tx,
4047                        &self.project_root,
4048                        rel_path,
4049                        &extract.freshness.content_hash,
4050                        extract.freshness.mtime,
4051                        extract.freshness.size,
4052                    )?;
4053                    continue;
4054                }
4055
4056                own_refresh.insert(rel_path.clone());
4057                let started = Instant::now();
4058                delete_file_rows(&tx, rel_path)?;
4059                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4060                profile.row_deletes += started.elapsed();
4061                let started = Instant::now();
4062                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4063                profile.row_inserts += started.elapsed();
4064            }
4065
4066            let dependency_callers = touched_callers
4067                .iter()
4068                .filter(|rel_path| {
4069                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4070                })
4071                .cloned()
4072                .collect::<Vec<_>>();
4073            for rel_path in dependency_callers {
4074                let Some(extract) = caller_extracts.get(&rel_path) else {
4075                    continue;
4076                };
4077                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4078                    continue;
4079                }
4080
4081                own_refresh.insert(rel_path.clone());
4082                let started = Instant::now();
4083                delete_file_rows(&tx, &rel_path)?;
4084                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4085                profile.row_deletes += started.elapsed();
4086                let started = Instant::now();
4087                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4088                profile.row_inserts += started.elapsed();
4089            }
4090            let started = Instant::now();
4091            for rel_path in &touched_callers {
4092                if deleted.contains(rel_path) {
4093                    continue;
4094                }
4095                let Some(extract) = caller_extracts.get(rel_path) else {
4096                    continue;
4097                };
4098                if own_refresh.contains(rel_path) {
4099                    delete_refs_for_caller(&tx, rel_path)?;
4100                    for raw_ref in &extract.raw_refs {
4101                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4102                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4103                    }
4104                    continue;
4105                }
4106
4107                let selected_for_caller = selected_refs_by_caller
4108                    .get(rel_path)
4109                    .cloned()
4110                    .unwrap_or_default();
4111                delete_ref_ids(&tx, &selected_for_caller)?;
4112                for raw_ref in &extract.raw_refs {
4113                    if selected_for_caller.contains(&raw_ref.ref_id) {
4114                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4115                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4116                    }
4117                }
4118            }
4119            profile.ref_resolution += started.elapsed();
4120        }
4121
4122        let started = Instant::now();
4123        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4124        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4125        profile.method_dispatch += started.elapsed();
4126
4127        bump_projection_write_revision(&tx)?;
4128        let started = Instant::now();
4129        commit_incremental_if_current(tx)?;
4130        self.record_commit(total_changes_before, &conn);
4131        profile.commit += started.elapsed();
4132        profile.total = total_started.elapsed();
4133        Ok((
4134            IncrementalStats {
4135                changed_files: changed,
4136                surface_changed: surface_changed.into_iter().collect(),
4137                deleted_files: deleted.into_iter().collect(),
4138                dependency_selected_refs,
4139                refreshed_own_files: own_refresh.len(),
4140                unchanged_extract_files: unchanged_extracts,
4141            },
4142            profile,
4143        ))
4144    }
4145
4146    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4147        self.cold_build(current_files)
4148    }
4149
4150    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4151        self.verify_writer_lease()?;
4152        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4153        let total_changes_before = conn.total_changes();
4154        let tx = conn.transaction()?;
4155        let mut marked = Vec::new();
4156        for path in files {
4157            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4158                Ok(path) => path,
4159                Err(error) => {
4160                    drop(tx);
4161                    record_path_identity_mismatch(&conn, &error)?;
4162                    return Err(error);
4163                }
4164            };
4165            let freshness = cache_freshness::collect(&abs_path).ok();
4166            mark_backend_state(
4167                &tx,
4168                &self.project_root,
4169                &rel_path,
4170                freshness.as_ref().map(|freshness| &freshness.content_hash),
4171                "stale",
4172            )?;
4173            marked.push(rel_path);
4174        }
4175        bump_projection_write_revision(&tx)?;
4176        tx.commit()?;
4177        self.record_commit(total_changes_before, &conn);
4178        marked.sort();
4179        marked.dedup();
4180        Ok(marked)
4181    }
4182
4183    pub fn stale_files(&self) -> Result<Vec<String>> {
4184        self.refresh_read_marker()?;
4185        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4186        let mut stmt = conn.prepare(
4187            "SELECT DISTINCT file_path FROM backend_file_state
4188             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4189             ORDER BY file_path",
4190        )?;
4191        let rows = stmt.query_map(
4192            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4193            |row| row.get::<_, String>(0),
4194        )?;
4195        rows.collect::<std::result::Result<Vec<_>, _>>()
4196            .map_err(Into::into)
4197    }
4198
4199    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4200        self.refresh_read_marker()?;
4201        let rel_path = relative_path(
4202            &self.project_root,
4203            &normalize_file_path(&self.project_root, file)?,
4204        );
4205        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4206        conn.query_row(
4207            "SELECT status FROM backend_file_state
4208             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4209             ORDER BY updated_at DESC LIMIT 1",
4210            params![
4211                BACKEND_TREESITTER,
4212                self.project_root.display().to_string(),
4213                rel_path
4214            ],
4215            |row| row.get(0),
4216        )
4217        .optional()
4218        .map_err(Into::into)
4219    }
4220
4221    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4222        self.refresh_read_marker()?;
4223        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4224        self.ensure_ready(&conn)?;
4225        edge_snapshot_with_conn(&conn)
4226    }
4227
4228    pub fn indexed_file_count(&self) -> Result<usize> {
4229        self.refresh_read_marker()?;
4230        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4231        self.ensure_ready(&conn)?;
4232        indexed_file_count(&conn)
4233    }
4234
4235    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4236        self.refresh_read_marker()?;
4237        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4238        let rel_path = relative_path(&self.project_root, &abs_path);
4239        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4240        self.ensure_ready(&conn)?;
4241        resolve_node_for_rel(&conn, &rel_path, symbol)
4242    }
4243
4244    /// Return all positional nodes matching a legacy symbol query in a file.
4245    ///
4246    /// Consumers that need legacy compatibility can collapse these by
4247    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
4248    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4249        self.refresh_read_marker()?;
4250        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4251        let rel_path = relative_path(&self.project_root, &abs_path);
4252        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4253        self.ensure_ready(&conn)?;
4254        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4255    }
4256
4257    /// Return all positional nodes matching a symbol query anywhere in the store.
4258    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4259        self.refresh_read_marker()?;
4260        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4261        self.ensure_ready(&conn)?;
4262        nodes_matching_symbol(&conn, symbol)
4263    }
4264
4265    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
4266    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4267        self.refresh_read_marker()?;
4268        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4269        let rel_path = relative_path(&self.project_root, &abs_path);
4270        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4271        self.ensure_ready(&conn)?;
4272        direct_callers_for_tuple(&conn, &rel_path, symbol)
4273    }
4274
4275    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4276    pub fn direct_callers_for_symbols(
4277        &self,
4278        targets: &[(String, String)],
4279    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4280        if targets.is_empty() {
4281            return Ok(HashMap::new());
4282        }
4283        self.refresh_read_marker()?;
4284        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4285        self.ensure_ready(&conn)?;
4286        direct_callers_for_tuples(&conn, targets)
4287    }
4288
4289    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4290    pub fn direct_caller_counts_of(
4291        &self,
4292        targets: &[(String, String)],
4293    ) -> Result<HashMap<(String, String), usize>> {
4294        if targets.is_empty() {
4295            return Ok(HashMap::new());
4296        }
4297        self.refresh_read_marker()?;
4298        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4299        self.ensure_ready(&conn)?;
4300        direct_caller_counts_for_tuples(&conn, targets)
4301    }
4302
4303    pub fn callers_of(
4304        &self,
4305        file_rel: &Path,
4306        symbol: &str,
4307        depth: usize,
4308    ) -> Result<StoreCallersResult> {
4309        let target = self.node_for(file_rel, symbol)?;
4310        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4311        self.ensure_ready(&conn)?;
4312        let effective_depth = depth.max(1);
4313        let mut visited = HashSet::new();
4314        let mut callers = Vec::new();
4315        let mut depth_limited = false;
4316        let mut truncated = 0usize;
4317        collect_callers_recursive(
4318            &conn,
4319            &target.file,
4320            &target.symbol,
4321            effective_depth,
4322            0,
4323            &mut visited,
4324            &mut callers,
4325            &mut depth_limited,
4326            &mut truncated,
4327        )?;
4328        Ok(StoreCallersResult {
4329            target,
4330            callers,
4331            scanned_files: indexed_file_count(&conn)?,
4332            depth_limited,
4333            truncated,
4334        })
4335    }
4336
4337    pub fn impact_of(
4338        &self,
4339        file_rel: &Path,
4340        symbol: &str,
4341        depth: usize,
4342    ) -> Result<StoreImpactResult> {
4343        let callers = self.callers_of(file_rel, symbol, depth)?;
4344        let target_parameters = callers
4345            .target
4346            .signature
4347            .as_deref()
4348            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4349            .unwrap_or_default();
4350        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4351        for site in &callers.callers {
4352            source_lines_by_file
4353                .entry(site.caller.file.clone())
4354                .or_insert_with(|| {
4355                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4356                });
4357        }
4358        let enriched = callers
4359            .callers
4360            .iter()
4361            .map(|site| StoreImpactCaller {
4362                site: site.clone(),
4363                signature: site.caller.signature.clone(),
4364                is_entry_point: site.caller.is_entry_point,
4365                call_expression: source_lines_by_file
4366                    .get(&site.caller.file)
4367                    .and_then(|lines| lines.as_ref())
4368                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4369                    .cloned(),
4370                parameters: site
4371                    .caller
4372                    .signature
4373                    .as_deref()
4374                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4375                    .unwrap_or_default(),
4376            })
4377            .collect();
4378        Ok(StoreImpactResult {
4379            target: callers.target,
4380            parameters: target_parameters,
4381            callers: enriched,
4382            depth_limited: callers.depth_limited,
4383            truncated: callers.truncated,
4384        })
4385    }
4386
4387    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4388        self.refresh_read_marker()?;
4389        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4390        self.ensure_ready(&conn)?;
4391        outgoing_calls_for_node(&conn, node)
4392    }
4393
4394    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4395    pub fn outgoing_calls_for_symbols(
4396        &self,
4397        sources: &[(String, String)],
4398    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4399        if sources.is_empty() {
4400            return Ok(HashMap::new());
4401        }
4402        self.refresh_read_marker()?;
4403        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4404        self.ensure_ready(&conn)?;
4405        outgoing_calls_for_symbol_tuples(&conn, sources)
4406    }
4407
4408    /// Return resolved direct self-call refs suppressed from the general edge table.
4409    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4410        self.refresh_read_marker()?;
4411        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4412        self.ensure_ready(&conn)?;
4413        resolved_self_calls_for_node(&conn, node)
4414    }
4415
4416    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4417        self.refresh_read_marker()?;
4418        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4419        self.ensure_ready(&conn)?;
4420        unresolved_calls_for_node(&conn, node)
4421    }
4422
4423    pub fn call_tree(
4424        &self,
4425        file_rel: &Path,
4426        symbol: &str,
4427        max_depth: usize,
4428    ) -> Result<callgraph::CallTreeNode> {
4429        let node = self.node_for(file_rel, symbol)?;
4430        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4431        self.ensure_ready(&conn)?;
4432        let mut visited = HashSet::new();
4433        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4434    }
4435
4436    pub fn trace_to(
4437        &self,
4438        file_rel: &Path,
4439        symbol: &str,
4440        max_depth: usize,
4441    ) -> Result<callgraph::TraceToResult> {
4442        let target = self.node_for(file_rel, symbol)?;
4443        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4444        self.ensure_ready(&conn)?;
4445        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4446
4447        #[derive(Clone)]
4448        struct PathElem {
4449            node: StoreNode,
4450        }
4451
4452        let initial = vec![PathElem {
4453            node: target.clone(),
4454        }];
4455        let mut complete_paths = Vec::new();
4456        if target.is_entry_point {
4457            complete_paths.push(initial.clone());
4458        }
4459
4460        let mut queue = vec![(initial, 0usize)];
4461        let mut max_depth_reached = false;
4462        let mut truncated_paths = 0usize;
4463
4464        while let Some((path, depth)) = queue.pop() {
4465            if depth >= effective_max {
4466                max_depth_reached = true;
4467                continue;
4468            }
4469            let Some(current) = path.last() else {
4470                continue;
4471            };
4472            let callers =
4473                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4474            if callers.is_empty() {
4475                if path.len() > 1 {
4476                    truncated_paths += 1;
4477                }
4478                continue;
4479            }
4480
4481            let mut has_new_path = false;
4482            for site in callers {
4483                if path.iter().any(|elem| {
4484                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4485                }) {
4486                    continue;
4487                }
4488                has_new_path = true;
4489                let mut new_path = path.clone();
4490                new_path.push(PathElem {
4491                    node: site.caller.clone(),
4492                });
4493                if site.caller.is_entry_point {
4494                    complete_paths.push(new_path.clone());
4495                }
4496                queue.push((new_path, depth + 1));
4497            }
4498            if !has_new_path && path.len() > 1 {
4499                truncated_paths += 1;
4500            }
4501        }
4502
4503        let mut paths: Vec<callgraph::TracePath> = complete_paths
4504            .into_iter()
4505            .map(|mut elems| {
4506                elems.reverse();
4507                let hops = elems
4508                    .iter()
4509                    .enumerate()
4510                    .map(|(index, elem)| callgraph::TraceHop {
4511                        symbol: elem.node.symbol.clone(),
4512                        file: elem.node.file.clone(),
4513                        line: elem.node.line,
4514                        signature: elem.node.signature.clone(),
4515                        is_entry_point: index == 0 && elem.node.is_entry_point,
4516                    })
4517                    .collect();
4518                callgraph::TracePath { hops }
4519            })
4520            .collect();
4521        paths.sort_by(|left, right| {
4522            let left_entry = left
4523                .hops
4524                .first()
4525                .map(|hop| hop.symbol.as_str())
4526                .unwrap_or("");
4527            let right_entry = right
4528                .hops
4529                .first()
4530                .map(|hop| hop.symbol.as_str())
4531                .unwrap_or("");
4532            left_entry
4533                .cmp(right_entry)
4534                .then(left.hops.len().cmp(&right.hops.len()))
4535        });
4536        let entry_points_found = paths
4537            .iter()
4538            .filter_map(|path| path.hops.first())
4539            .filter(|hop| hop.is_entry_point)
4540            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4541            .collect::<HashSet<_>>()
4542            .len();
4543
4544        Ok(callgraph::TraceToResult {
4545            target_symbol: target.symbol,
4546            target_file: target.file,
4547            total_paths: paths.len(),
4548            paths,
4549            entry_points_found,
4550            max_depth_reached,
4551            truncated_paths,
4552        })
4553    }
4554
4555    pub fn trace_to_symbol_candidates(
4556        &self,
4557        to_symbol: &str,
4558    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4559        self.refresh_read_marker()?;
4560        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4561        self.ensure_ready(&conn)?;
4562        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4563        for node in nodes_matching_symbol(&conn, to_symbol)? {
4564            candidates_by_file
4565                .entry(node.file)
4566                .and_modify(|line| *line = (*line).min(node.line))
4567                .or_insert(node.line);
4568        }
4569        let mut candidates: Vec<_> = candidates_by_file
4570            .into_iter()
4571            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4572            .collect();
4573        candidates
4574            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4575        Ok(candidates)
4576    }
4577
4578    pub fn trace_to_symbol(
4579        &self,
4580        file_rel: &Path,
4581        symbol: &str,
4582        to_symbol: &str,
4583        to_file: Option<&Path>,
4584        max_depth: usize,
4585    ) -> Result<callgraph::TraceToSymbolResult> {
4586        let origin = self.node_for(file_rel, symbol)?;
4587        let target_file = to_file
4588            .map(|path| normalize_file_path(&self.project_root, path))
4589            .transpose()?
4590            .map(|path| relative_path(&self.project_root, &path));
4591        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4592        self.ensure_ready(&conn)?;
4593        let effective_max = if max_depth == 0 {
4594            10
4595        } else {
4596            max_depth.min(16)
4597        };
4598
4599        let start_hop = trace_to_symbol_hop(&origin);
4600        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4601            return Ok(callgraph::TraceToSymbolResult {
4602                path: Some(vec![start_hop]),
4603                complete: true,
4604                reason: None,
4605            });
4606        }
4607
4608        let mut queue = VecDeque::new();
4609        queue.push_back((origin.clone(), vec![start_hop], 0usize));
4610        let mut visited = HashSet::new();
4611        visited.insert((origin.file.clone(), origin.symbol.clone()));
4612        let mut max_depth_exhausted = false;
4613
4614        while let Some((current, path, depth)) = queue.pop_front() {
4615            let callees = outgoing_calls_for_node(&conn, &current)?
4616                .into_iter()
4617                .filter_map(|site| site.target)
4618                .collect::<Vec<_>>();
4619
4620            if depth >= effective_max {
4621                if callees
4622                    .iter()
4623                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4624                {
4625                    max_depth_exhausted = true;
4626                }
4627                continue;
4628            }
4629
4630            for callee in callees {
4631                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4632                    continue;
4633                }
4634                let mut next_path = path.clone();
4635                next_path.push(trace_to_symbol_hop(&callee));
4636                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4637                    return Ok(callgraph::TraceToSymbolResult {
4638                        path: Some(next_path),
4639                        complete: true,
4640                        reason: None,
4641                    });
4642                }
4643                queue.push_back((callee, next_path, depth + 1));
4644            }
4645        }
4646
4647        if max_depth_exhausted {
4648            Ok(callgraph::TraceToSymbolResult {
4649                path: None,
4650                complete: false,
4651                reason: Some("max_depth_exhausted".to_string()),
4652            })
4653        } else {
4654            Ok(callgraph::TraceToSymbolResult {
4655                path: None,
4656                complete: true,
4657                reason: Some("no_path_found".to_string()),
4658            })
4659        }
4660    }
4661}
4662
4663impl ReadonlyCallGraphStore {
4664    fn from_inner(inner: CallGraphStore) -> Self {
4665        Self { inner }
4666    }
4667
4668    pub fn project_root(&self) -> &Path {
4669        self.inner.project_root()
4670    }
4671
4672    pub fn project_key(&self) -> &str {
4673        self.inner.project_key()
4674    }
4675
4676    pub fn sqlite_path(&self) -> &Path {
4677        self.inner.sqlite_path()
4678    }
4679
4680    pub fn stale_files(&self) -> Result<Vec<String>> {
4681        self.inner.stale_files()
4682    }
4683
4684    pub(crate) fn projection_generation(&self) -> Option<&str> {
4685        self.inner.projection_generation()
4686    }
4687
4688    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4689        self.inner.projection_write_revision()
4690    }
4691
4692    /// Report the open generation handle. SQLite-owned allocations are measured
4693    /// once by the process-wide SQLite allocator counters.
4694    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4695        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4696    }
4697
4698    /// Whether this reader is temporarily serving a legacy harness partition.
4699    pub fn is_legacy_fallback(&self) -> bool {
4700        self.inner.is_legacy_fallback()
4701    }
4702
4703    pub fn is_current(&self) -> bool {
4704        self.inner.is_current()
4705    }
4706
4707    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4708        self.inner.edge_snapshot()
4709    }
4710
4711    pub fn indexed_file_count(&self) -> Result<usize> {
4712        self.inner.indexed_file_count()
4713    }
4714
4715    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4716        self.inner.node_for(file_rel, symbol)
4717    }
4718
4719    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4720        self.inner.nodes_for(file_rel, symbol)
4721    }
4722
4723    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4724        self.inner.nodes_matching(symbol)
4725    }
4726
4727    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4728        self.inner.direct_callers_of(file_rel, symbol)
4729    }
4730
4731    pub fn direct_callers_for_symbols(
4732        &self,
4733        targets: &[(String, String)],
4734    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4735        self.inner.direct_callers_for_symbols(targets)
4736    }
4737
4738    pub fn direct_caller_counts_of(
4739        &self,
4740        targets: &[(String, String)],
4741    ) -> Result<HashMap<(String, String), usize>> {
4742        self.inner.direct_caller_counts_of(targets)
4743    }
4744
4745    pub fn callers_of(
4746        &self,
4747        file_rel: &Path,
4748        symbol: &str,
4749        depth: usize,
4750    ) -> Result<StoreCallersResult> {
4751        self.inner.callers_of(file_rel, symbol, depth)
4752    }
4753
4754    pub fn impact_of(
4755        &self,
4756        file_rel: &Path,
4757        symbol: &str,
4758        depth: usize,
4759    ) -> Result<StoreImpactResult> {
4760        self.inner.impact_of(file_rel, symbol, depth)
4761    }
4762
4763    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4764        self.inner.outgoing_calls_of(node)
4765    }
4766
4767    pub fn outgoing_calls_for_symbols(
4768        &self,
4769        sources: &[(String, String)],
4770    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4771        self.inner.outgoing_calls_for_symbols(sources)
4772    }
4773
4774    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4775        self.inner.resolved_self_calls_of(node)
4776    }
4777
4778    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4779        self.inner.unresolved_calls_of(node)
4780    }
4781
4782    pub fn call_tree(
4783        &self,
4784        file_rel: &Path,
4785        symbol: &str,
4786        depth: usize,
4787    ) -> Result<callgraph::CallTreeNode> {
4788        self.inner.call_tree(file_rel, symbol, depth)
4789    }
4790
4791    pub fn trace_to(
4792        &self,
4793        file_rel: &Path,
4794        symbol: &str,
4795        max_depth: usize,
4796    ) -> Result<callgraph::TraceToResult> {
4797        self.inner.trace_to(file_rel, symbol, max_depth)
4798    }
4799
4800    pub fn trace_to_symbol_candidates(
4801        &self,
4802        to_symbol: &str,
4803    ) -> Result<Vec<TraceToSymbolCandidate>> {
4804        self.inner.trace_to_symbol_candidates(to_symbol)
4805    }
4806
4807    pub fn trace_to_symbol(
4808        &self,
4809        file_rel: &Path,
4810        symbol: &str,
4811        to_symbol: &str,
4812        to_file: Option<&Path>,
4813        max_depth: usize,
4814    ) -> Result<callgraph::TraceToSymbolResult> {
4815        self.inner
4816            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4817    }
4818}
4819
4820impl CallGraphRead for CallGraphStore {
4821    fn project_root(&self) -> &Path {
4822        CallGraphStore::project_root(self)
4823    }
4824    fn project_key(&self) -> &str {
4825        CallGraphStore::project_key(self)
4826    }
4827    fn sqlite_path(&self) -> &Path {
4828        CallGraphStore::sqlite_path(self)
4829    }
4830    fn is_current(&self) -> bool {
4831        CallGraphStore::is_current(self)
4832    }
4833    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4834        CallGraphStore::edge_snapshot(self)
4835    }
4836    fn indexed_file_count(&self) -> Result<usize> {
4837        CallGraphStore::indexed_file_count(self)
4838    }
4839    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4840        CallGraphStore::node_for(self, file_rel, symbol)
4841    }
4842    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4843        CallGraphStore::nodes_for(self, file_rel, symbol)
4844    }
4845    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4846        CallGraphStore::nodes_matching(self, symbol)
4847    }
4848    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4849        CallGraphStore::direct_callers_of(self, file_rel, symbol)
4850    }
4851    fn direct_callers_for_symbols(
4852        &self,
4853        targets: &[(String, String)],
4854    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4855        CallGraphStore::direct_callers_for_symbols(self, targets)
4856    }
4857    fn direct_caller_counts_of(
4858        &self,
4859        targets: &[(String, String)],
4860    ) -> Result<HashMap<(String, String), usize>> {
4861        CallGraphStore::direct_caller_counts_of(self, targets)
4862    }
4863    fn callers_of(
4864        &self,
4865        file_rel: &Path,
4866        symbol: &str,
4867        depth: usize,
4868    ) -> Result<StoreCallersResult> {
4869        CallGraphStore::callers_of(self, file_rel, symbol, depth)
4870    }
4871    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4872        CallGraphStore::impact_of(self, file_rel, symbol, depth)
4873    }
4874    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4875        CallGraphStore::outgoing_calls_of(self, node)
4876    }
4877    fn outgoing_calls_for_symbols(
4878        &self,
4879        sources: &[(String, String)],
4880    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4881        CallGraphStore::outgoing_calls_for_symbols(self, sources)
4882    }
4883    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4884        CallGraphStore::resolved_self_calls_of(self, node)
4885    }
4886    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4887        CallGraphStore::unresolved_calls_of(self, node)
4888    }
4889    fn call_tree(
4890        &self,
4891        file_rel: &Path,
4892        symbol: &str,
4893        depth: usize,
4894    ) -> Result<callgraph::CallTreeNode> {
4895        CallGraphStore::call_tree(self, file_rel, symbol, depth)
4896    }
4897    fn trace_to(
4898        &self,
4899        file_rel: &Path,
4900        symbol: &str,
4901        max_depth: usize,
4902    ) -> Result<callgraph::TraceToResult> {
4903        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4904    }
4905    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4906        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4907    }
4908    fn trace_to_symbol(
4909        &self,
4910        file_rel: &Path,
4911        symbol: &str,
4912        to_symbol: &str,
4913        to_file: Option<&Path>,
4914        max_depth: usize,
4915    ) -> Result<callgraph::TraceToSymbolResult> {
4916        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4917    }
4918}
4919
4920impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
4921    fn project_root(&self) -> &Path {
4922        (**self).project_root()
4923    }
4924    fn project_key(&self) -> &str {
4925        (**self).project_key()
4926    }
4927    fn sqlite_path(&self) -> &Path {
4928        (**self).sqlite_path()
4929    }
4930    fn is_current(&self) -> bool {
4931        (**self).is_current()
4932    }
4933    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4934        (**self).edge_snapshot()
4935    }
4936    fn indexed_file_count(&self) -> Result<usize> {
4937        (**self).indexed_file_count()
4938    }
4939    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4940        (**self).node_for(file_rel, symbol)
4941    }
4942    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4943        (**self).nodes_for(file_rel, symbol)
4944    }
4945    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4946        (**self).nodes_matching(symbol)
4947    }
4948    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4949        (**self).direct_callers_of(file_rel, symbol)
4950    }
4951    fn direct_callers_for_symbols(
4952        &self,
4953        targets: &[(String, String)],
4954    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4955        (**self).direct_callers_for_symbols(targets)
4956    }
4957    fn direct_caller_counts_of(
4958        &self,
4959        targets: &[(String, String)],
4960    ) -> Result<HashMap<(String, String), usize>> {
4961        (**self).direct_caller_counts_of(targets)
4962    }
4963    fn callers_of(
4964        &self,
4965        file_rel: &Path,
4966        symbol: &str,
4967        depth: usize,
4968    ) -> Result<StoreCallersResult> {
4969        (**self).callers_of(file_rel, symbol, depth)
4970    }
4971    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4972        (**self).impact_of(file_rel, symbol, depth)
4973    }
4974    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4975        (**self).outgoing_calls_of(node)
4976    }
4977    fn outgoing_calls_for_symbols(
4978        &self,
4979        sources: &[(String, String)],
4980    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4981        (**self).outgoing_calls_for_symbols(sources)
4982    }
4983    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4984        (**self).resolved_self_calls_of(node)
4985    }
4986    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4987        (**self).unresolved_calls_of(node)
4988    }
4989    fn call_tree(
4990        &self,
4991        file_rel: &Path,
4992        symbol: &str,
4993        depth: usize,
4994    ) -> Result<callgraph::CallTreeNode> {
4995        (**self).call_tree(file_rel, symbol, depth)
4996    }
4997    fn trace_to(
4998        &self,
4999        file_rel: &Path,
5000        symbol: &str,
5001        max_depth: usize,
5002    ) -> Result<callgraph::TraceToResult> {
5003        (**self).trace_to(file_rel, symbol, max_depth)
5004    }
5005    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5006        (**self).trace_to_symbol_candidates(to_symbol)
5007    }
5008    fn trace_to_symbol(
5009        &self,
5010        file_rel: &Path,
5011        symbol: &str,
5012        to_symbol: &str,
5013        to_file: Option<&Path>,
5014        max_depth: usize,
5015    ) -> Result<callgraph::TraceToSymbolResult> {
5016        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5017    }
5018}
5019
5020impl CallGraphRead for ReadonlyCallGraphStore {
5021    fn project_root(&self) -> &Path {
5022        self.project_root()
5023    }
5024    fn project_key(&self) -> &str {
5025        self.project_key()
5026    }
5027    fn sqlite_path(&self) -> &Path {
5028        self.sqlite_path()
5029    }
5030    fn is_current(&self) -> bool {
5031        self.is_current()
5032    }
5033    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5034        self.edge_snapshot()
5035    }
5036    fn indexed_file_count(&self) -> Result<usize> {
5037        self.indexed_file_count()
5038    }
5039    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5040        self.node_for(file_rel, symbol)
5041    }
5042    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5043        self.nodes_for(file_rel, symbol)
5044    }
5045    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5046        self.nodes_matching(symbol)
5047    }
5048    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5049        self.direct_callers_of(file_rel, symbol)
5050    }
5051    fn direct_callers_for_symbols(
5052        &self,
5053        targets: &[(String, String)],
5054    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5055        self.direct_callers_for_symbols(targets)
5056    }
5057    fn direct_caller_counts_of(
5058        &self,
5059        targets: &[(String, String)],
5060    ) -> Result<HashMap<(String, String), usize>> {
5061        self.direct_caller_counts_of(targets)
5062    }
5063    fn callers_of(
5064        &self,
5065        file_rel: &Path,
5066        symbol: &str,
5067        depth: usize,
5068    ) -> Result<StoreCallersResult> {
5069        self.callers_of(file_rel, symbol, depth)
5070    }
5071    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5072        self.impact_of(file_rel, symbol, depth)
5073    }
5074    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5075        self.outgoing_calls_of(node)
5076    }
5077    fn outgoing_calls_for_symbols(
5078        &self,
5079        sources: &[(String, String)],
5080    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5081        self.outgoing_calls_for_symbols(sources)
5082    }
5083    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5084        self.resolved_self_calls_of(node)
5085    }
5086    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5087        self.unresolved_calls_of(node)
5088    }
5089    fn call_tree(
5090        &self,
5091        file_rel: &Path,
5092        symbol: &str,
5093        depth: usize,
5094    ) -> Result<callgraph::CallTreeNode> {
5095        self.call_tree(file_rel, symbol, depth)
5096    }
5097    fn trace_to(
5098        &self,
5099        file_rel: &Path,
5100        symbol: &str,
5101        max_depth: usize,
5102    ) -> Result<callgraph::TraceToResult> {
5103        self.trace_to(file_rel, symbol, max_depth)
5104    }
5105    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5106        self.trace_to_symbol_candidates(to_symbol)
5107    }
5108    fn trace_to_symbol(
5109        &self,
5110        file_rel: &Path,
5111        symbol: &str,
5112        to_symbol: &str,
5113        to_file: Option<&Path>,
5114        max_depth: usize,
5115    ) -> Result<callgraph::TraceToSymbolResult> {
5116        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5117    }
5118}
5119
5120fn indexed_file_count(conn: &Connection) -> Result<usize> {
5121    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5122    Ok(count.max(0) as usize)
5123}
5124
5125fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5126    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5127    match candidates.as_slice() {
5128        [candidate] => Ok(candidate.clone()),
5129        [] => Err(AftError::SymbolNotFound {
5130            name: symbol.to_string(),
5131            file: rel_path.to_string(),
5132        }
5133        .into()),
5134        _ => Err(AftError::AmbiguousSymbol {
5135            name: symbol.to_string(),
5136            candidates: candidates
5137                .iter()
5138                .map(|candidate| candidate.symbol.clone())
5139                .collect(),
5140        }
5141        .into()),
5142    }
5143}
5144
5145fn nodes_for_file_matching_symbol(
5146    conn: &Connection,
5147    rel_path: &str,
5148    symbol: &str,
5149) -> Result<Vec<StoreNode>> {
5150    let qualified_query = symbol.contains("::");
5151    let sql = if qualified_query {
5152        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5153                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5154         FROM nodes n JOIN files f ON f.path = n.file_path
5155         WHERE n.file_path = ?1 AND n.scoped_name = ?2
5156         ORDER BY n.scoped_name, n.start_line, n.start_col"
5157    } else {
5158        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5159                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5160         FROM nodes n JOIN files f ON f.path = n.file_path
5161         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5162         ORDER BY n.scoped_name, n.start_line, n.start_col"
5163    };
5164    let mut stmt = conn.prepare(sql)?;
5165    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5166    rows.collect::<std::result::Result<Vec<_>, _>>()
5167        .map_err(Into::into)
5168}
5169
5170fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5171    let qualified_query = symbol.contains("::");
5172    let sql = if qualified_query {
5173        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5174                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5175         FROM nodes n JOIN files f ON f.path = n.file_path
5176         WHERE n.scoped_name = ?1
5177         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5178    } else {
5179        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5180                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5181         FROM nodes n JOIN files f ON f.path = n.file_path
5182         WHERE n.scoped_name = ?1 OR n.name = ?1
5183         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5184    };
5185    let mut stmt = conn.prepare(sql)?;
5186    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5187    rows.collect::<std::result::Result<Vec<_>, _>>()
5188        .map_err(Into::into)
5189}
5190
5191fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5192    store_node_from_row_at(row, 0)
5193}
5194
5195fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5196    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5197    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5198    let lang_label_value: String = row.get(offset + 10)?;
5199    Ok(StoreNode {
5200        node_id: row.get(offset)?,
5201        file: row.get(offset + 1)?,
5202        symbol: row.get(offset + 2)?,
5203        name: row.get(offset + 3)?,
5204        kind: row.get(offset + 4)?,
5205        line: start_line.saturating_add(1),
5206        end_line: end_line.saturating_add(1),
5207        signature: row.get(offset + 7)?,
5208        exported: row.get::<_, i64>(offset + 8)? != 0,
5209        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5210        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5211    })
5212}
5213
5214fn optional_store_node_from_row_at(
5215    row: &rusqlite::Row<'_>,
5216    offset: usize,
5217) -> rusqlite::Result<Option<StoreNode>> {
5218    if row.get::<_, Option<String>>(offset)?.is_some() {
5219        store_node_from_row_at(row, offset).map(Some)
5220    } else {
5221        Ok(None)
5222    }
5223}
5224
5225#[allow(clippy::too_many_arguments)]
5226fn collect_callers_recursive(
5227    conn: &Connection,
5228    file: &str,
5229    symbol: &str,
5230    max_depth: usize,
5231    current_depth: usize,
5232    visited: &mut HashSet<(String, String)>,
5233    result: &mut Vec<StoreCallSite>,
5234    depth_limited: &mut bool,
5235    truncated: &mut usize,
5236) -> Result<()> {
5237    if current_depth >= max_depth {
5238        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5239        if omitted > 0 {
5240            *depth_limited = true;
5241            *truncated += omitted;
5242        }
5243        return Ok(());
5244    }
5245
5246    if !visited.insert((file.to_string(), symbol.to_string())) {
5247        return Ok(());
5248    }
5249
5250    let sites = direct_callers_for_tuple(conn, file, symbol)?;
5251    for site in sites {
5252        result.push(site.clone());
5253        if current_depth + 1 < max_depth {
5254            collect_callers_recursive(
5255                conn,
5256                &site.caller.file,
5257                &site.caller.symbol,
5258                max_depth,
5259                current_depth + 1,
5260                visited,
5261                result,
5262                depth_limited,
5263                truncated,
5264            )?;
5265        } else {
5266            let omitted =
5267                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5268            if omitted > 0 {
5269                *depth_limited = true;
5270                *truncated += omitted;
5271            }
5272        }
5273    }
5274    Ok(())
5275}
5276
5277// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5278const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5279
5280fn direct_caller_counts_for_tuples(
5281    conn: &Connection,
5282    targets: &[(String, String)],
5283) -> Result<HashMap<(String, String), usize>> {
5284    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5285    let mut counts = unique_targets
5286        .iter()
5287        .cloned()
5288        .map(|target| (target, 0usize))
5289        .collect::<HashMap<_, _>>();
5290
5291    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5292    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5293        let requested_values = (0..chunk.len())
5294            .map(|_| "(?, ?)")
5295            .collect::<Vec<_>>()
5296            .join(", ");
5297        let sql = format!(
5298            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5299             deduped AS (
5300                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5301                 FROM requested requested
5302                 JOIN edges e
5303                   ON e.target_file = requested.target_file
5304                  AND e.target_symbol = requested.target_symbol
5305                  AND e.kind = 'call'
5306                 JOIN refs r ON r.ref_id = e.ref_id
5307                 JOIN nodes src ON src.id = e.source_node
5308                 JOIN files src_file ON src_file.path = src.file_path
5309                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5310             )
5311             SELECT target_file, target_symbol, COUNT(*)
5312             FROM deduped
5313             GROUP BY target_file, target_symbol"
5314        );
5315        let bindings = chunk
5316            .iter()
5317            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5318        let mut stmt = conn.prepare(&sql)?;
5319        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5320            Ok((
5321                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5322                row.get::<_, i64>(2)?,
5323            ))
5324        })?;
5325        for row in rows {
5326            let (target, count) = row?;
5327            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5328        }
5329    }
5330
5331    Ok(counts)
5332}
5333
5334fn direct_caller_count_for_tuple(
5335    conn: &Connection,
5336    target_file: &str,
5337    target_symbol: &str,
5338) -> Result<usize> {
5339    let count: i64 = conn.query_row(
5340        "SELECT COUNT(*)
5341         FROM edges e
5342         JOIN refs r ON r.ref_id = e.ref_id
5343         JOIN nodes src ON src.id = e.source_node
5344         JOIN files src_file ON src_file.path = src.file_path
5345         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5346        params![target_file, target_symbol],
5347        |row| row.get(0),
5348    )?;
5349    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5350}
5351
5352fn direct_callers_for_tuple(
5353    conn: &Connection,
5354    target_file: &str,
5355    target_symbol: &str,
5356) -> Result<Vec<StoreCallSite>> {
5357    let mut stmt = conn.prepare(
5358        "SELECT e.target_file, e.target_symbol, e.line,
5359                r.byte_start, r.byte_end, r.status, e.provenance,
5360                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5361                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5362                src_file.lang,
5363                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5364                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5365                tgt_file.lang
5366         FROM edges e
5367         JOIN refs r ON r.ref_id = e.ref_id
5368         JOIN nodes src ON src.id = e.source_node
5369         JOIN files src_file ON src_file.path = src.file_path
5370         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5371             ON tgt.id = e.target_node
5372         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5373         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5374    )?;
5375    let rows = stmt.query_map(
5376        params![target_file, target_symbol],
5377        direct_call_site_from_row,
5378    )?;
5379    rows.collect::<std::result::Result<Vec<_>, _>>()
5380        .map_err(Into::into)
5381}
5382
5383fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5384    let caller = store_node_from_row_at(row, 7)?;
5385    let target = optional_store_node_from_row_at(row, 18)?;
5386    Ok(StoreCallSite {
5387        caller,
5388        target_file: row.get(0)?,
5389        target_symbol: row.get(1)?,
5390        target,
5391        line: row.get::<_, i64>(2)?.max(0) as u32,
5392        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5393        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5394        resolved: row.get::<_, String>(5)? == "resolved",
5395        provenance: row.get(6)?,
5396    })
5397}
5398
5399fn direct_callers_for_tuples(
5400    conn: &Connection,
5401    targets: &[(String, String)],
5402) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5403    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5404    let mut callers_by_target = unique_targets
5405        .iter()
5406        .cloned()
5407        .map(|target| (target, Vec::new()))
5408        .collect::<HashMap<_, _>>();
5409    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5410
5411    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5412        let requested_values = (0..chunk.len())
5413            .map(|_| "(?, ?)")
5414            .collect::<Vec<_>>()
5415            .join(", ");
5416        let sql = format!(
5417            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5418             SELECT e.target_file, e.target_symbol, e.line,
5419                    r.byte_start, r.byte_end, r.status, e.provenance,
5420                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5421                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5422                    src_file.lang,
5423                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5424                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5425                    tgt_file.lang
5426             FROM requested requested
5427             JOIN edges e
5428               ON e.target_file = requested.target_file
5429              AND e.target_symbol = requested.target_symbol
5430              AND e.kind = 'call'
5431             JOIN refs r ON r.ref_id = e.ref_id
5432             JOIN nodes src ON src.id = e.source_node
5433             JOIN files src_file ON src_file.path = src.file_path
5434             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5435                 ON tgt.id = e.target_node
5436             ORDER BY e.target_file, e.target_symbol, e.source_node,
5437                      r.byte_start, r.line, r.ref_id"
5438        );
5439        let bindings = chunk
5440            .iter()
5441            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5442        let mut stmt = conn.prepare(&sql)?;
5443        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5444            let call = direct_call_site_from_row(row)?;
5445            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5446            Ok((target_key, call))
5447        })?;
5448        for row in rows {
5449            let (target, call) = row?;
5450            callers_by_target
5451                .get_mut(&target)
5452                .expect("batched caller row belongs to a requested target")
5453                .push(call);
5454        }
5455    }
5456
5457    Ok(callers_by_target)
5458}
5459
5460// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5461const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5462// Outgoing-edge batches bind one source node per parameter.
5463const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5464
5465fn outgoing_calls_for_symbol_tuples(
5466    conn: &Connection,
5467    sources: &[(String, String)],
5468) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5469    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5470    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5471    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5472    let source_nodes = unique_sources
5473        .iter()
5474        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5475        .cloned()
5476        .collect::<Vec<_>>();
5477    let source_nodes_by_id = source_nodes
5478        .iter()
5479        .cloned()
5480        .map(|node| (node.node_id.clone(), node))
5481        .collect::<HashMap<_, _>>();
5482    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5483
5484    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5485        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5486        let sql = format!(
5487            "SELECT e.source_node,
5488                    e.target_file, e.target_symbol, e.line,
5489                    r.byte_start, r.byte_end, r.status, e.provenance,
5490                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5491                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5492                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5493                    tgt_file.lang
5494             FROM edges e
5495             JOIN refs r ON r.ref_id = e.ref_id
5496             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5497             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5498             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5499             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5500        );
5501        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5502        let mut stmt = conn.prepare(&sql)?;
5503        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5504            let source_node_id = row.get::<_, String>(0)?;
5505            let caller = source_nodes_by_id
5506                .get(&source_node_id)
5507                .expect("batched outgoing row belongs to a requested source node")
5508                .clone();
5509            let target = optional_store_node_from_row_at(row, 8)?;
5510            Ok((
5511                source_node_id,
5512                StoreCallSite {
5513                    caller,
5514                    target_file: row.get(1)?,
5515                    target_symbol: row.get(2)?,
5516                    target,
5517                    line: row.get::<_, i64>(3)?.max(0) as u32,
5518                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5519                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5520                    resolved: row.get::<_, String>(6)? == "resolved",
5521                    provenance: row.get(7)?,
5522                },
5523            ))
5524        })?;
5525        for row in rows {
5526            let (source_node_id, call) = row?;
5527            calls_by_node.entry(source_node_id).or_default().push(call);
5528        }
5529    }
5530
5531    let mut calls_by_source = HashMap::new();
5532    for source in &unique_sources {
5533        let mut calls = Vec::new();
5534        if let Some(nodes) = source_nodes_by_symbol.get(source) {
5535            for node in nodes {
5536                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5537                    calls.extend(node_calls);
5538                }
5539            }
5540        }
5541        calls_by_source.insert(source.clone(), calls);
5542    }
5543
5544    // Resolve each logical target once for the whole frontier. Keeping this separate
5545    // preserves positional-symbol representatives without a correlated lookup per edge.
5546    let target_tuples = calls_by_source
5547        .values()
5548        .flatten()
5549        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5550        .collect::<Vec<_>>();
5551    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5552    for calls in calls_by_source.values_mut() {
5553        for call in calls {
5554            if let Some(target) = target_nodes
5555                .get(&(call.target_file.clone(), call.target_symbol.clone()))
5556                .and_then(|nodes| nodes.first())
5557            {
5558                call.target = Some(target.clone());
5559            }
5560        }
5561    }
5562
5563    Ok(calls_by_source)
5564}
5565
5566fn nodes_for_symbol_tuples(
5567    conn: &Connection,
5568    symbols: &[(String, String)],
5569) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5570    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5571    let mut nodes_by_symbol = unique_symbols
5572        .iter()
5573        .cloned()
5574        .map(|symbol| (symbol, Vec::new()))
5575        .collect::<HashMap<_, _>>();
5576    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5577
5578    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5579        let requested_values = (0..chunk.len())
5580            .map(|_| "(?, ?)")
5581            .collect::<Vec<_>>()
5582            .join(", ");
5583        let sql = format!(
5584            "WITH requested(file, symbol) AS (VALUES {requested_values})
5585             SELECT requested.file, requested.symbol,
5586                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
5587                    node.start_line, node.end_line, node.signature, node.exported,
5588                    node.is_callgraph_entry_point, node_file.lang
5589             FROM requested
5590             JOIN nodes node INDEXED BY idx_nodes_file
5591               ON node.file_path = requested.file
5592              AND node.scoped_name = requested.symbol
5593             JOIN files node_file ON node_file.path = node.file_path
5594             ORDER BY requested.file, requested.symbol,
5595                      node.scoped_name, node.start_line, node.end_line,
5596                      node.start_col, node.range_ordinal"
5597        );
5598        let bindings = chunk
5599            .iter()
5600            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5601        let mut stmt = conn.prepare(&sql)?;
5602        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5603            Ok((
5604                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5605                store_node_from_row_at(row, 2)?,
5606            ))
5607        })?;
5608        for row in rows {
5609            let (symbol, node) = row?;
5610            nodes_by_symbol.entry(symbol).or_default().push(node);
5611        }
5612    }
5613
5614    Ok(nodes_by_symbol)
5615}
5616
5617fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5618    let mut stmt = conn.prepare(
5619        "SELECT e.target_file, e.target_symbol, e.line,
5620                r.byte_start, r.byte_end, r.status, e.provenance,
5621                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5622                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5623                tgt_file.lang
5624         FROM edges e
5625         JOIN refs r ON r.ref_id = e.ref_id
5626         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5627             ON tgt.id = e.target_node
5628         WHERE e.kind = 'call' AND e.source_node = ?1
5629         ORDER BY r.byte_start, r.line, r.ref_id",
5630    )?;
5631    let rows = stmt.query_map(params![node.node_id], |row| {
5632        let target = optional_store_node_from_row_at(row, 7)?;
5633        Ok(StoreCallSite {
5634            caller: node.clone(),
5635            target_file: row.get(0)?,
5636            target_symbol: row.get(1)?,
5637            target,
5638            line: row.get::<_, i64>(2)?.max(0) as u32,
5639            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5640            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5641            resolved: row.get::<_, String>(5)? == "resolved",
5642            provenance: row.get(6)?,
5643        })
5644    })?;
5645    rows.collect::<std::result::Result<Vec<_>, _>>()
5646        .map_err(Into::into)
5647}
5648
5649fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5650    let mut stmt = conn.prepare(
5651        "SELECT r.target_file, r.target_symbol, r.line,
5652                r.byte_start, r.byte_end, r.status, r.provenance,
5653                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5654                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5655                tgt_file.lang
5656         FROM refs r
5657         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5658             ON tgt.id = r.target_node
5659         WHERE r.caller_node = ?1
5660           AND r.kind = 'call'
5661           AND r.status <> 'unresolved'
5662           AND r.target_file = ?2
5663           AND r.target_symbol = ?3
5664           AND r.provenance = ?4
5665           AND NOT EXISTS (
5666               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5667           )
5668         ORDER BY r.byte_start, r.line, r.ref_id",
5669    )?;
5670    let rows = stmt.query_map(
5671        params![
5672            &node.node_id,
5673            &node.file,
5674            &node.symbol,
5675            PROVENANCE_TREESITTER
5676        ],
5677        |row| {
5678            let target = optional_store_node_from_row_at(row, 7)?;
5679            Ok(StoreCallSite {
5680                caller: node.clone(),
5681                target_file: row.get(0)?,
5682                target_symbol: row.get(1)?,
5683                target,
5684                line: row.get::<_, i64>(2)?.max(0) as u32,
5685                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5686                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5687                resolved: row.get::<_, String>(5)? == "resolved",
5688                provenance: row.get(6)?,
5689            })
5690        },
5691    )?;
5692    rows.collect::<std::result::Result<Vec<_>, _>>()
5693        .map_err(Into::into)
5694}
5695
5696fn unresolved_calls_for_node(
5697    conn: &Connection,
5698    node: &StoreNode,
5699) -> Result<Vec<StoreUnresolvedCall>> {
5700    let mut stmt = conn.prepare(
5701        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5702         FROM refs
5703         WHERE caller_node = ?1
5704           AND kind = 'call'
5705           AND status = 'unresolved'
5706           AND NOT EXISTS (
5707               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5708           )
5709         ORDER BY byte_start, line, ref_id",
5710    )?;
5711    let rows = stmt.query_map(params![node.node_id], |row| {
5712        Ok(StoreUnresolvedCall {
5713            caller: node.clone(),
5714            symbol: row.get(0)?,
5715            full_ref: row.get(1)?,
5716            line: row.get::<_, i64>(2)?.max(0) as u32,
5717            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5718            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5719        })
5720    })?;
5721    rows.collect::<std::result::Result<Vec<_>, _>>()
5722        .map_err(Into::into)
5723}
5724
5725fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5726    let mut calls = Vec::new();
5727    calls.extend(
5728        outgoing_calls_for_node(conn, node)?
5729            .into_iter()
5730            .map(StoreForwardCall::Resolved),
5731    );
5732    calls.extend(
5733        unresolved_calls_for_node(conn, node)?
5734            .into_iter()
5735            .map(StoreForwardCall::Unresolved),
5736    );
5737    calls.sort_by(|left, right| {
5738        left.byte_start()
5739            .cmp(&right.byte_start())
5740            .then(left.line().cmp(&right.line()))
5741    });
5742    Ok(calls)
5743}
5744
5745fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5746    let resolved_count: i64 = conn.query_row(
5747        "SELECT COUNT(*)
5748         FROM edges e
5749         JOIN refs r ON r.ref_id = e.ref_id
5750         WHERE e.kind = 'call' AND e.source_node = ?1",
5751        params![&node.node_id],
5752        |row| row.get(0),
5753    )?;
5754    let unresolved_count: i64 = conn.query_row(
5755        "SELECT COUNT(*)
5756         FROM refs
5757         WHERE caller_node = ?1
5758           AND kind = 'call'
5759           AND status = 'unresolved'
5760           AND NOT EXISTS (
5761               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5762           )",
5763        params![&node.node_id],
5764        |row| row.get(0),
5765    )?;
5766    let total = resolved_count.saturating_add(unresolved_count);
5767    Ok(usize::try_from(total).unwrap_or(usize::MAX))
5768}
5769
5770fn call_tree_inner(
5771    conn: &Connection,
5772    node: &StoreNode,
5773    max_depth: usize,
5774    current_depth: usize,
5775    visited: &mut HashSet<(String, String)>,
5776) -> Result<callgraph::CallTreeNode> {
5777    let visit_key = (node.file.clone(), node.symbol.clone());
5778    if visited.contains(&visit_key) {
5779        return Ok(callgraph::CallTreeNode {
5780            name: node.symbol.clone(),
5781            file: node.file.clone(),
5782            line: node.line,
5783            signature: node.signature.clone(),
5784            resolved: true,
5785            children: Vec::new(),
5786            depth_limited: false,
5787            truncated: 0,
5788        });
5789    }
5790    visited.insert(visit_key.clone());
5791
5792    let mut children = Vec::new();
5793    let mut depth_limited = false;
5794    let mut truncated = 0usize;
5795
5796    if current_depth < max_depth {
5797        let calls = forward_calls_for_node(conn, node)?;
5798        for call in calls {
5799            match call {
5800                StoreForwardCall::Resolved(site) => {
5801                    if let Some(target) = site.target {
5802                        let child =
5803                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5804                        depth_limited |= child.depth_limited;
5805                        truncated += child.truncated;
5806                        children.push(child);
5807                    } else {
5808                        children.push(callgraph::CallTreeNode {
5809                            name: site.target_symbol,
5810                            file: site.target_file,
5811                            line: site.line,
5812                            signature: None,
5813                            resolved: false,
5814                            children: Vec::new(),
5815                            depth_limited: false,
5816                            truncated: 0,
5817                        });
5818                    }
5819                }
5820                StoreForwardCall::Unresolved(call) => {
5821                    children.push(callgraph::CallTreeNode {
5822                        name: call.symbol,
5823                        file: call.caller.file,
5824                        line: call.line,
5825                        signature: None,
5826                        resolved: false,
5827                        children: Vec::new(),
5828                        depth_limited: false,
5829                        truncated: 0,
5830                    });
5831                }
5832            }
5833        }
5834    } else {
5835        truncated = forward_call_count_for_node(conn, node)?;
5836        depth_limited = truncated > 0;
5837    }
5838
5839    visited.remove(&visit_key);
5840    Ok(callgraph::CallTreeNode {
5841        name: node.symbol.clone(),
5842        file: node.file.clone(),
5843        line: node.line,
5844        signature: node.signature.clone(),
5845        resolved: true,
5846        children,
5847        depth_limited,
5848        truncated,
5849    })
5850}
5851
5852fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5853    callgraph::TraceToSymbolHop {
5854        symbol: node.symbol.clone(),
5855        file: node.file.clone(),
5856        line: node.line,
5857    }
5858}
5859
5860fn trace_to_symbol_matches_target(
5861    node: &StoreNode,
5862    to_symbol: &str,
5863    to_file: Option<&str>,
5864) -> bool {
5865    if !symbol_query_matches(&node.symbol, to_symbol) {
5866        return false;
5867    }
5868    match to_file {
5869        Some(file) => node.file == file,
5870        None => true,
5871    }
5872}
5873
5874fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5875    symbol == query || unqualified_name(symbol) == query
5876}
5877
5878fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5879    let source = std::fs::read_to_string(path).ok()?;
5880    Some(source.lines().map(|line| line.trim().to_string()).collect())
5881}
5882
5883#[doc(hidden)]
5884pub fn live_callgraph_edge_snapshot(
5885    project_root: &Path,
5886    files: &[PathBuf],
5887) -> Result<BTreeSet<StoredEdge>> {
5888    let files = normalize_file_list(project_root, files)?;
5889    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5890    let mut file_data = Vec::new();
5891    for file in &files {
5892        let canon = canonicalize_path(file);
5893        let data = graph.build_file(&canon)?.clone();
5894        file_data.push((canon, data));
5895    }
5896
5897    let mut edges = BTreeSet::new();
5898    for (caller_file, data) in &file_data {
5899        for (caller_symbol, call_sites) in &data.calls_by_symbol {
5900            for call_site in call_sites {
5901                let resolution = graph.resolve_cross_file_edge(
5902                    &call_site.full_callee,
5903                    &call_site.callee_name,
5904                    caller_file,
5905                    &data.import_block,
5906                );
5907                let (target_file, target_symbol) = match resolution {
5908                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5909                    EdgeResolution::Unresolved { callee_name } => {
5910                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5911                            continue;
5912                        }
5913                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5914                            data,
5915                            caller_file,
5916                            &callee_name,
5917                        ) else {
5918                            continue;
5919                        };
5920                        (caller_file.clone(), target_symbol)
5921                    }
5922                };
5923                if target_file == *caller_file && target_symbol == *caller_symbol {
5924                    continue;
5925                }
5926                edges.insert(StoredEdge {
5927                    source_file: relative_path(project_root, caller_file),
5928                    source_symbol: caller_symbol.clone(),
5929                    target_file: relative_path(project_root, &target_file),
5930                    target_symbol,
5931                    kind: "call".to_string(),
5932                    line: call_site.line,
5933                });
5934            }
5935        }
5936    }
5937    Ok(edges)
5938}
5939
5940fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
5941{
5942    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
5943}
5944
5945fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
5946    RebuildCooldownKey {
5947        callgraph_dir: std::fs::canonicalize(callgraph_dir)
5948            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
5949        project_key: project_key.to_string(),
5950    }
5951}
5952
5953fn rebuild_cooldown_denial(
5954    callgraph_dir: &Path,
5955    project_key: &str,
5956    project_root: &Path,
5957    now: Instant,
5958) -> Option<(PathBuf, Duration)> {
5959    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5960    let records = rebuild_cooldown_records()
5961        .lock()
5962        .unwrap_or_else(std::sync::PoisonError::into_inner);
5963    let record = records.get(&key)?;
5964    if record.project_root == project_root || !record.cross_root_cooldown_armed {
5965        return None;
5966    }
5967    let elapsed = now.saturating_duration_since(record.published_at);
5968    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
5969}
5970
5971fn record_successful_rebuild(
5972    callgraph_dir: &Path,
5973    project_key: &str,
5974    project_root: &Path,
5975    published_at: Instant,
5976) {
5977    let key = rebuild_cooldown_key(callgraph_dir, project_key);
5978    let mut records = rebuild_cooldown_records()
5979        .lock()
5980        .unwrap_or_else(std::sync::PoisonError::into_inner);
5981    if records.len() >= 4_096 && !records.contains_key(&key) {
5982        if let Some(evict) = records.keys().next().cloned() {
5983            records.remove(&evict);
5984        }
5985    }
5986    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
5987        previous.cross_root_cooldown_armed || previous.project_root != project_root
5988    });
5989    records.insert(
5990        key,
5991        RebuildCooldownRecord {
5992            project_root: project_root.to_path_buf(),
5993            published_at,
5994            cross_root_cooldown_armed,
5995        },
5996    );
5997}
5998
5999fn acquire_writer_lease(
6000    callgraph_dir: &Path,
6001    project_key: &str,
6002    project_root: &Path,
6003) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6004    crate::root_cache::WriterLease::acquire_shared(
6005        crate::root_cache::RootCacheDomain::Callgraph,
6006        callgraph_dir,
6007        project_key,
6008        project_root,
6009    )
6010    .map_err(CallGraphStoreError::from)
6011}
6012
6013fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6014    if lease.verify()? {
6015        Ok(())
6016    } else {
6017        Err(CallGraphStoreError::Unavailable(format!(
6018            "callgraph writer lease for key {} lost epoch {}; aborting write",
6019            lease.key(),
6020            lease.epoch()
6021        )))
6022    }
6023}
6024
6025fn legacy_migration_completion_line(
6026    project_key: &str,
6027    method: &str,
6028    legacy_bytes: u64,
6029    migrated_bytes: u64,
6030) -> String {
6031    format!(
6032        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6033    )
6034}
6035
6036fn log_legacy_migration_completion(
6037    project_key: &str,
6038    method: &str,
6039    legacy_bytes: u64,
6040    migrated_bytes: u64,
6041) {
6042    crate::slog_info!(
6043        "{}",
6044        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6045    );
6046}
6047
6048fn try_legacy_migration_or_fallback(
6049    callgraph_dir: &Path,
6050    project_root: &Path,
6051    project_key: &str,
6052    writer_lease: Arc<crate::root_cache::WriterLease>,
6053) -> Result<Option<CallGraphStore>> {
6054    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6055    if partitions.is_empty() {
6056        return Ok(None);
6057    }
6058
6059    for partition in &partitions {
6060        if let Some(source) = newest_superseded_legacy_generation(partition)? {
6061            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6062                return open_legacy_fallback_store(
6063                    callgraph_dir,
6064                    project_root,
6065                    project_key,
6066                    &partitions,
6067                );
6068            }
6069            match publish_generation_copy_migration(
6070                callgraph_dir,
6071                project_key,
6072                &source,
6073                Arc::clone(&writer_lease),
6074            ) {
6075                Ok(published) => {
6076                    log_legacy_migration_completion(
6077                        project_key,
6078                        "generation_copy",
6079                        source.source_bytes,
6080                        published.migrated_bytes,
6081                    );
6082                    return CallGraphStore::open_generation(
6083                        callgraph_dir,
6084                        project_root.to_path_buf(),
6085                        project_key.to_string(),
6086                        published.generation,
6087                        writer_lease,
6088                    )
6089                    .map(Some);
6090                }
6091                Err(error) => {
6092                    crate::slog_warn!(
6093                        "root-keyed callgraph generation-copy migration failed from {}: {}",
6094                        source.sqlite_path.display(),
6095                        error
6096                    );
6097                    return open_legacy_fallback_store(
6098                        callgraph_dir,
6099                        project_root,
6100                        project_key,
6101                        &partitions,
6102                    );
6103                }
6104            }
6105        }
6106
6107        if let Some(source) = current_legacy_generation(partition)? {
6108            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6109                return open_legacy_fallback_store(
6110                    callgraph_dir,
6111                    project_root,
6112                    project_key,
6113                    &partitions,
6114                );
6115            }
6116            match publish_backup_migration(
6117                callgraph_dir,
6118                project_key,
6119                &source,
6120                Arc::clone(&writer_lease),
6121            ) {
6122                Ok(published) => {
6123                    log_legacy_migration_completion(
6124                        project_key,
6125                        "sqlite_backup",
6126                        source.source_bytes,
6127                        published.migrated_bytes,
6128                    );
6129                    return CallGraphStore::open_generation(
6130                        callgraph_dir,
6131                        project_root.to_path_buf(),
6132                        project_key.to_string(),
6133                        published.generation,
6134                        writer_lease,
6135                    )
6136                    .map(Some);
6137                }
6138                Err(error) => {
6139                    crate::slog_warn!(
6140                        "root-keyed callgraph backup migration failed from {}: {}",
6141                        source.sqlite_path.display(),
6142                        error
6143                    );
6144                    return open_legacy_fallback_store(
6145                        callgraph_dir,
6146                        project_root,
6147                        project_key,
6148                        &partitions,
6149                    );
6150                }
6151            }
6152        }
6153    }
6154
6155    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6156}
6157
6158fn open_legacy_fallback_store(
6159    callgraph_dir: &Path,
6160    project_root: &Path,
6161    project_key: &str,
6162    partitions: &[LegacyCallgraphPartition],
6163) -> Result<Option<CallGraphStore>> {
6164    let Some(target) = first_ready_legacy_target(partitions)? else {
6165        return Ok(None);
6166    };
6167    crate::slog_warn!(
6168        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6169        target.partition.harness,
6170        target.sqlite_path.display()
6171    );
6172    let conn = open_readonly_connection(&target.sqlite_path)?;
6173    if !database_ready(&conn).unwrap_or(false) {
6174        return Ok(None);
6175    }
6176    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6177    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6178    Ok(Some(CallGraphStore::from_connection(
6179        project_root.to_path_buf(),
6180        project_key.to_string(),
6181        target.sqlite_path,
6182        callgraph_dir.to_path_buf(),
6183        true,
6184        target.generation,
6185        None,
6186        Some(read_marker),
6187        conn,
6188    )))
6189}
6190
6191fn migration_disk_floor_allows(
6192    source: &LegacyCallgraphTarget,
6193    callgraph_dir: &Path,
6194) -> Result<bool> {
6195    let available = migration_available_disk(callgraph_dir)?;
6196    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6197        source.source_bytes,
6198        available,
6199    );
6200    if decision.should_skip_copy() {
6201        crate::slog_warn!(
6202            "{}",
6203            decision.warning_message(&source.sqlite_path, callgraph_dir)
6204        );
6205        return Ok(false);
6206    }
6207    Ok(true)
6208}
6209
6210fn migration_available_disk(path: &Path) -> Result<u64> {
6211    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6212        return Ok(bytes);
6213    }
6214    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6215}
6216
6217fn legacy_callgraph_partitions(
6218    callgraph_dir: &Path,
6219    project_key: &str,
6220) -> Result<Vec<LegacyCallgraphPartition>> {
6221    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6222        return Ok(Vec::new());
6223    };
6224    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6225    let mut partitions = inventory
6226        .into_iter()
6227        .filter(|entry| {
6228            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6229                && entry.key == project_key
6230        })
6231        .map(|entry| {
6232            let dir = if entry.path.is_dir() {
6233                entry.path.clone()
6234            } else {
6235                entry
6236                    .path
6237                    .parent()
6238                    .map(Path::to_path_buf)
6239                    .unwrap_or_else(|| entry.path.clone())
6240            };
6241            LegacyCallgraphPartition {
6242                harness: entry.harness,
6243                dir,
6244                key: entry.key,
6245                bytes: entry.bytes,
6246                freshness: entry.callgraph_pointer_mtime,
6247            }
6248        })
6249        .collect::<Vec<_>>();
6250    partitions.sort_by(|left, right| {
6251        right
6252            .freshness
6253            .cmp(&left.freshness)
6254            .then_with(|| right.bytes.cmp(&left.bytes))
6255            .then_with(|| left.harness.cmp(&right.harness))
6256    });
6257    Ok(partitions)
6258}
6259
6260fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6261    let domain_dir = callgraph_dir.parent()?;
6262    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6263        return None;
6264    }
6265    domain_dir.parent().map(Path::to_path_buf)
6266}
6267
6268pub(crate) fn all_legacy_partitions_migrated_for_keys(
6269    callgraph_dir: &Path,
6270    configured_keys: &BTreeSet<String>,
6271) -> Result<bool> {
6272    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6273        return Ok(false);
6274    };
6275    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6276        .into_iter()
6277        .filter(|entry| {
6278            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6279                && configured_keys.contains(&entry.key)
6280        })
6281        .map(|entry| entry.key)
6282        .collect::<BTreeSet<_>>();
6283    if legacy_keys.is_empty() {
6284        return Ok(false);
6285    }
6286
6287    for key in legacy_keys {
6288        let migrated_dir = storage_root.join("callgraph").join(&key);
6289        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6290            return Ok(false);
6291        };
6292        if !migration_generation_requires_manifest(&generation)
6293            || !migration_manifest_valid(&migrated_dir, &generation)
6294        {
6295            return Ok(false);
6296        }
6297    }
6298    Ok(true)
6299}
6300
6301fn newest_superseded_legacy_generation(
6302    partition: &LegacyCallgraphPartition,
6303) -> Result<Option<LegacyCallgraphTarget>> {
6304    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6305        return Ok(None);
6306    };
6307    let prefix = format!("{}.g", partition.key);
6308    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6309        return Ok(None);
6310    };
6311    let mut candidates = Vec::new();
6312    for entry in entries.flatten() {
6313        let name = entry.file_name().to_string_lossy().to_string();
6314        if name == current
6315            || name.contains(".tmp.")
6316            || !name.starts_with(&prefix)
6317            || !name.ends_with(".sqlite")
6318        {
6319            continue;
6320        }
6321        let path = entry.path();
6322        if !db_path_ready(&path) {
6323            continue;
6324        }
6325        let modified = entry
6326            .metadata()
6327            .and_then(|metadata| metadata.modified())
6328            .unwrap_or(SystemTime::UNIX_EPOCH);
6329        candidates.push((modified, path, name));
6330    }
6331    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6332    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6333        return Ok(None);
6334    };
6335    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6336    Ok(Some(LegacyCallgraphTarget {
6337        partition: partition.clone(),
6338        sqlite_path,
6339        generation: Some(generation),
6340        source_bytes,
6341        source_blake3: String::new(),
6342    }))
6343}
6344
6345fn current_legacy_generation(
6346    partition: &LegacyCallgraphPartition,
6347) -> Result<Option<LegacyCallgraphTarget>> {
6348    let Some(target) = ready_legacy_target(partition)? else {
6349        return Ok(None);
6350    };
6351    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6352    if has_superseded {
6353        return Ok(None);
6354    }
6355    Ok(Some(target))
6356}
6357
6358fn freshest_legacy_fallback_target(
6359    callgraph_dir: &Path,
6360    project_key: &str,
6361) -> Result<Option<LegacyCallgraphTarget>> {
6362    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6363    first_ready_legacy_target(&partitions)
6364}
6365
6366fn first_ready_legacy_target(
6367    partitions: &[LegacyCallgraphPartition],
6368) -> Result<Option<LegacyCallgraphTarget>> {
6369    for partition in partitions {
6370        if let Some(target) = ready_legacy_target(partition)? {
6371            return Ok(Some(target));
6372        }
6373    }
6374    Ok(None)
6375}
6376
6377fn ready_legacy_target(
6378    partition: &LegacyCallgraphPartition,
6379) -> Result<Option<LegacyCallgraphTarget>> {
6380    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6381        let sqlite_path = partition.dir.join(&generation);
6382        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6383            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6384            return Ok(Some(LegacyCallgraphTarget {
6385                partition: partition.clone(),
6386                sqlite_path,
6387                generation: Some(generation),
6388                source_bytes,
6389                source_blake3: String::new(),
6390            }));
6391        }
6392    }
6393
6394    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6395    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6396        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6397        return Ok(Some(LegacyCallgraphTarget {
6398            partition: partition.clone(),
6399            sqlite_path,
6400            generation: None,
6401            source_bytes,
6402            source_blake3: String::new(),
6403        }));
6404    }
6405    Ok(None)
6406}
6407
6408fn publish_generation_copy_migration(
6409    callgraph_dir: &Path,
6410    project_key: &str,
6411    source: &LegacyCallgraphTarget,
6412    writer_lease: Arc<crate::root_cache::WriterLease>,
6413) -> Result<PublishedLegacyMigration> {
6414    let generation = migration_generation_file_name(project_key, "copy");
6415    let temp_path = migration_temp_path(callgraph_dir, &generation);
6416    remove_sqlite_file_set(&temp_path);
6417    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6418    fail_after_temp_copy_for_test()?;
6419
6420    let mut source = source.clone();
6421    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6422    source.source_blake3 = fingerprint.blake3;
6423    let generation = publish_migrated_generation(
6424        callgraph_dir,
6425        project_key,
6426        &generation,
6427        &temp_path,
6428        &source,
6429        fingerprint.bytes,
6430        writer_lease,
6431        "generation_copy",
6432    )?;
6433    Ok(PublishedLegacyMigration {
6434        generation,
6435        migrated_bytes: fingerprint.bytes,
6436    })
6437}
6438
6439fn publish_backup_migration(
6440    callgraph_dir: &Path,
6441    project_key: &str,
6442    source: &LegacyCallgraphTarget,
6443    writer_lease: Arc<crate::root_cache::WriterLease>,
6444) -> Result<PublishedLegacyMigration> {
6445    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6446        return Err(CallGraphStoreError::Unavailable(
6447            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6448        ));
6449    }
6450
6451    let generation = migration_generation_file_name(project_key, "backup");
6452    let temp_path = migration_temp_path(callgraph_dir, &generation);
6453    remove_sqlite_file_set(&temp_path);
6454
6455    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6456    let mut destination = Connection::open(&temp_path)?;
6457    destination.busy_timeout(Duration::from_secs(5))?;
6458    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6459    let started = Instant::now();
6460    let mut retries = 0;
6461    loop {
6462        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6463            rusqlite::backup::StepResult::Done => break,
6464            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6465            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6466                retries += 1;
6467                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6468                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6469                {
6470                    return Err(CallGraphStoreError::Unavailable(format!(
6471                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6472                    )));
6473                }
6474                std::thread::sleep(Duration::from_millis(20));
6475            }
6476            _ => {
6477                return Err(CallGraphStoreError::Unavailable(
6478                    "legacy callgraph backup returned an unknown step result".to_string(),
6479                ));
6480            }
6481        }
6482    }
6483    drop(backup);
6484
6485    let integrity: String =
6486        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6487    if integrity != "ok" {
6488        return Err(CallGraphStoreError::Unavailable(format!(
6489            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6490        )));
6491    }
6492    if !database_ready(&destination)? {
6493        return Err(CallGraphStoreError::Unavailable(
6494            "legacy callgraph backup produced a database without ready metadata".to_string(),
6495        ));
6496    }
6497    destination.execute_batch("PRAGMA optimize;")?;
6498    drop(destination);
6499    sync_file(&temp_path)?;
6500    fail_after_temp_copy_for_test()?;
6501
6502    let mut source = source.clone();
6503    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6504    source.source_blake3 = fingerprint.blake3;
6505    let generation = publish_migrated_generation(
6506        callgraph_dir,
6507        project_key,
6508        &generation,
6509        &temp_path,
6510        &source,
6511        fingerprint.bytes,
6512        writer_lease,
6513        "sqlite_backup",
6514    )?;
6515    Ok(PublishedLegacyMigration {
6516        generation,
6517        migrated_bytes: fingerprint.bytes,
6518    })
6519}
6520
6521fn publish_migrated_generation(
6522    callgraph_dir: &Path,
6523    project_key: &str,
6524    generation: &str,
6525    temp_path: &Path,
6526    source: &LegacyCallgraphTarget,
6527    migrated_bytes: u64,
6528    writer_lease: Arc<crate::root_cache::WriterLease>,
6529    method: &str,
6530) -> Result<String> {
6531    let gen_path = callgraph_dir.join(generation);
6532    checkpoint_sqlite_before_publication(temp_path);
6533    let publication = publish_if_current(|| {
6534        verify_writer_lease(&writer_lease)?;
6535        remove_sqlite_file_set(&gen_path);
6536        rename_sqlite_file_set(temp_path, &gen_path)?;
6537        crate::fs_lock::sync_parent(&gen_path);
6538
6539        verify_writer_lease(&writer_lease)?;
6540        publish_pointer(callgraph_dir, project_key, generation)?;
6541        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6542        Ok(generation.to_string())
6543    });
6544    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6545        remove_sqlite_file_set(temp_path);
6546    }
6547    publication
6548}
6549
6550fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6551    if let Some(parent) = destination.parent() {
6552        std::fs::create_dir_all(parent)?;
6553    }
6554    for suffix in SQLITE_FILE_SET_SUFFIXES {
6555        let source_path = sqlite_file_set_path(source, suffix);
6556        if !source_path.is_file() {
6557            continue;
6558        }
6559        let destination_path = sqlite_file_set_path(destination, suffix);
6560        std::fs::copy(&source_path, &destination_path)?;
6561        sync_file(&destination_path)?;
6562    }
6563    Ok(())
6564}
6565
6566fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6567    for suffix in SQLITE_FILE_SET_SUFFIXES {
6568        let source_path = sqlite_file_set_path(source, suffix);
6569        if !source_path.exists() {
6570            continue;
6571        }
6572        let destination_path = sqlite_file_set_path(destination, suffix);
6573        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6574            let _ = std::fs::remove_file(&source_path);
6575            return Err(error.into());
6576        }
6577    }
6578    Ok(())
6579}
6580
6581fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6582    let mut bytes = 0_u64;
6583    for suffix in SQLITE_FILE_SET_SUFFIXES {
6584        let member = sqlite_file_set_path(path, suffix);
6585        if !member.is_file() {
6586            continue;
6587        }
6588        bytes = bytes.saturating_add(member.metadata()?.len());
6589    }
6590    Ok(bytes)
6591}
6592
6593fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6594    let mut hasher = blake3::Hasher::new();
6595    let mut bytes = 0_u64;
6596    let mut buffer = [0_u8; 64 * 1024];
6597    for suffix in SQLITE_FILE_SET_SUFFIXES {
6598        let member = sqlite_file_set_path(path, suffix);
6599        if !member.is_file() {
6600            continue;
6601        }
6602        hasher.update(suffix.as_bytes());
6603        let mut file = std::fs::File::open(&member)?;
6604        loop {
6605            let read = file.read(&mut buffer)?;
6606            if read == 0 {
6607                break;
6608            }
6609            bytes = bytes.saturating_add(read as u64);
6610            hasher.update(&buffer[..read]);
6611        }
6612    }
6613    Ok(SourceFingerprint {
6614        bytes,
6615        blake3: hash_to_hex(hasher.finalize()),
6616    })
6617}
6618
6619fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6620    if suffix.is_empty() {
6621        path.to_path_buf()
6622    } else {
6623        PathBuf::from(format!("{}{suffix}", path.display()))
6624    }
6625}
6626
6627fn sync_file(path: &Path) -> Result<()> {
6628    let file = std::fs::OpenOptions::new()
6629        .read(true)
6630        .write(true)
6631        .open(path)?;
6632    file.sync_all()?;
6633    Ok(())
6634}
6635
6636fn fail_after_temp_copy_for_test() -> Result<()> {
6637    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6638        return Err(CallGraphStoreError::Unavailable(
6639            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6640        ));
6641    }
6642    Ok(())
6643}
6644
6645fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6646    format!(
6647        "{project_key}.g{}.{}{}{}.sqlite",
6648        now_nanos(),
6649        std::process::id(),
6650        MIGRATION_GENERATION_TAG,
6651        method
6652    )
6653}
6654
6655fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6656    callgraph_dir.join(format!(
6657        "{generation}.tmp.{}.{}",
6658        std::process::id(),
6659        now_nanos()
6660    ))
6661}
6662
6663fn write_migration_manifest(
6664    callgraph_dir: &Path,
6665    generation: &str,
6666    source: &LegacyCallgraphTarget,
6667    migrated_bytes: u64,
6668    method: &str,
6669) -> Result<()> {
6670    let manifest_path = migration_manifest_path(callgraph_dir, generation);
6671    let temp_path = manifest_path.with_extension(format!(
6672        "migration.json.tmp.{}.{}",
6673        std::process::id(),
6674        now_nanos()
6675    ));
6676    let manifest = serde_json::json!({
6677        "version": MIGRATION_MANIFEST_VERSION,
6678        "method": method,
6679        "target_generation": generation,
6680        "source_harness": source.partition.harness,
6681        "source_path": source.sqlite_path.display().to_string(),
6682        "source_generation": source.generation,
6683        "source_bytes": source.source_bytes,
6684        "source_blake3": source.source_blake3,
6685        "migrated_bytes": migrated_bytes,
6686    });
6687    {
6688        use std::io::Write as _;
6689        let mut file = std::fs::File::create(&temp_path)?;
6690        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6691        file.write_all(b"\n")?;
6692        file.sync_all()?;
6693    }
6694    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6695        let _ = std::fs::remove_file(&temp_path);
6696        return Err(error.into());
6697    }
6698    crate::fs_lock::sync_parent(&manifest_path);
6699    Ok(())
6700}
6701
6702fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6703    callgraph_dir.join(format!("{generation}.migration.json"))
6704}
6705
6706fn migration_generation_requires_manifest(generation: &str) -> bool {
6707    generation.contains(MIGRATION_GENERATION_TAG)
6708}
6709
6710fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6711    if !migration_generation_requires_manifest(generation) {
6712        return true;
6713    }
6714    let path = migration_manifest_path(callgraph_dir, generation);
6715    let Ok(bytes) = std::fs::read(path) else {
6716        return false;
6717    };
6718    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6719        return false;
6720    };
6721    value.get("version").and_then(serde_json::Value::as_u64)
6722        == Some(MIGRATION_MANIFEST_VERSION as u64)
6723        && value
6724            .get("target_generation")
6725            .and_then(serde_json::Value::as_str)
6726            == Some(generation)
6727        && value
6728            .get("source_bytes")
6729            .and_then(serde_json::Value::as_u64)
6730            .is_some_and(|bytes| bytes > 0)
6731        && value
6732            .get("source_blake3")
6733            .and_then(serde_json::Value::as_str)
6734            .is_some_and(|hash| hash.len() == 64)
6735}
6736
6737fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6738    let pointer_generation = read_pointer(callgraph_dir, project_key);
6739    if let Some(generation) = pointer_generation.as_deref() {
6740        if migration_generation_requires_manifest(generation)
6741            && !migration_manifest_valid(callgraph_dir, generation)
6742        {
6743            let path = callgraph_dir.join(generation);
6744            remove_sqlite_file_set(&path);
6745            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6746            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6747        }
6748    }
6749
6750    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6751        return;
6752    };
6753    for entry in entries.flatten() {
6754        let name = entry.file_name().to_string_lossy().to_string();
6755        let path = entry.path();
6756        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6757            let _ = std::fs::remove_file(path);
6758            continue;
6759        }
6760        if name.starts_with(&format!("{project_key}.g"))
6761            && name.ends_with(".sqlite")
6762            && name.contains(MIGRATION_GENERATION_TAG)
6763            && pointer_generation.as_deref() != Some(&name)
6764            && !migration_manifest_valid(callgraph_dir, &name)
6765        {
6766            remove_sqlite_file_set(&path);
6767            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6768        }
6769    }
6770    crate::fs_lock::sync_parent(callgraph_dir);
6771}
6772
6773fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6774    let mut hasher = blake3::Hasher::new();
6775    hasher.update(path.to_string_lossy().as_bytes());
6776    if let Some(generation) = generation {
6777        hasher.update(generation.as_bytes());
6778    }
6779    let digest = hash_to_hex(hasher.finalize());
6780    format!("legacy-{}", &digest[..16])
6781}
6782
6783fn open_readonly_connection(path: &Path) -> Result<Connection> {
6784    let uri = sqlite_readonly_uri(path);
6785    let conn = Connection::open_with_flags(
6786        &uri,
6787        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6788    )?;
6789    conn.pragma_update(
6790        None,
6791        "synchronous",
6792        if write_amplification_baseline_enabled() {
6793            "FULL"
6794        } else {
6795            "NORMAL"
6796        },
6797    )?;
6798    conn.busy_timeout(reader_busy_timeout())?;
6799    conn.execute_batch("PRAGMA query_only=ON;")?;
6800    Ok(conn)
6801}
6802
6803fn reader_busy_timeout() -> Duration {
6804    let jitter = (now_nanos() % 500) as u64;
6805    Duration::from_millis(250 + jitter)
6806}
6807
6808fn sqlite_readonly_uri(path: &Path) -> String {
6809    let raw = path.to_string_lossy().replace('\\', "/");
6810    let encoded = percent_encode_sqlite_uri_path(&raw);
6811    if raw.starts_with('/') {
6812        format!("file://{encoded}?mode=ro")
6813    } else if raw.as_bytes().get(1) == Some(&b':') {
6814        format!("file:///{encoded}?mode=ro")
6815    } else {
6816        format!("file:{encoded}?mode=ro")
6817    }
6818}
6819
6820fn percent_encode_sqlite_uri_path(path: &str) -> String {
6821    let mut encoded = String::with_capacity(path.len());
6822    for byte in path.bytes() {
6823        match byte {
6824            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6825                encoded.push(byte as char)
6826            }
6827            _ => encoded.push_str(&format!("%{byte:02X}")),
6828        }
6829    }
6830    encoded
6831}
6832
6833fn configure_connection(conn: &Connection) -> Result<()> {
6834    // Changing journal mode takes a database lock. Install the busy handler
6835    // first so concurrent cold-build and refresh connections wait rather than
6836    // failing immediately, especially under Windows byte-range locking.
6837    conn.busy_timeout(Duration::from_secs(5))?;
6838    conn.pragma_update(None, "journal_mode", "WAL")?;
6839    let baseline = write_amplification_baseline_enabled();
6840    conn.pragma_update(
6841        None,
6842        "synchronous",
6843        if baseline { "FULL" } else { "NORMAL" },
6844    )?;
6845    conn.pragma_update(
6846        None,
6847        "wal_autocheckpoint",
6848        if baseline {
6849            1_000
6850        } else {
6851            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6852        },
6853    )?;
6854    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6855    Ok(())
6856}
6857
6858fn configure_build_connection(conn: &Connection) -> Result<()> {
6859    // The staging database commits independently recoverable batches. WAL keeps
6860    // those commits durable without forcing a rollback journal rewrite per batch.
6861    // Set the busy handler before WAL because selecting the journal mode itself
6862    // can contend with a connection finishing an earlier staged transaction.
6863    conn.busy_timeout(Duration::from_secs(5))?;
6864    conn.pragma_update(None, "journal_mode", "WAL")?;
6865    conn.pragma_update(
6866        None,
6867        "synchronous",
6868        if write_amplification_baseline_enabled() {
6869            "FULL"
6870        } else {
6871            "NORMAL"
6872        },
6873    )?;
6874    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6875    Ok(())
6876}
6877
6878/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
6879/// private temporary copy before publishing it; a busy reader is harmless because
6880/// the next publication or cleanup pass can retry without affecting the source.
6881fn checkpoint_sqlite_before_publication(path: &Path) {
6882    let Ok(conn) = Connection::open(path) else {
6883        return;
6884    };
6885    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6886    let _ = conn.busy_timeout(Duration::from_secs(5));
6887    let _ = checkpoint_wal_truncate(&conn);
6888}
6889
6890fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6891    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6892        row.get::<_, i64>(0)
6893    }) {
6894        Ok(0) => true,
6895        Ok(_) => false,
6896        Err(rusqlite::Error::SqliteFailure(error, _))
6897            if matches!(
6898                error.code,
6899                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6900            ) =>
6901        {
6902            false
6903        }
6904        Err(error) => {
6905            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6906            false
6907        }
6908    }
6909}
6910
6911fn initialize_schema(conn: &Connection) -> Result<()> {
6912    conn.execute_batch(
6913        "CREATE TABLE IF NOT EXISTS files (
6914            path                TEXT PRIMARY KEY,
6915            content_hash        TEXT NOT NULL,
6916            mtime_ns            INTEGER NOT NULL,
6917            size                INTEGER NOT NULL,
6918            lang                TEXT NOT NULL,
6919            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
6920            is_public_api       INTEGER NOT NULL DEFAULT 0,
6921            surface_fingerprint TEXT NOT NULL,
6922            indexed_at          INTEGER NOT NULL
6923        );
6924
6925        CREATE TABLE IF NOT EXISTS nodes (
6926            id                         TEXT PRIMARY KEY,
6927            file_path                  TEXT NOT NULL,
6928            name                       TEXT NOT NULL,
6929            scoped_name                TEXT NOT NULL,
6930            kind                       TEXT NOT NULL,
6931            start_line                 INTEGER NOT NULL,
6932            start_col                  INTEGER NOT NULL,
6933            end_line                   INTEGER NOT NULL,
6934            end_col                    INTEGER NOT NULL,
6935            range_ordinal              INTEGER NOT NULL,
6936            signature                  TEXT,
6937            exported                   INTEGER NOT NULL,
6938            is_default_export          INTEGER NOT NULL,
6939            is_type_like               INTEGER NOT NULL,
6940            is_callgraph_entry_point   INTEGER NOT NULL,
6941            provenance                 TEXT NOT NULL,
6942            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
6943        );
6944        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6945        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6946        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6947
6948        CREATE TABLE IF NOT EXISTS refs (
6949            ref_id          TEXT PRIMARY KEY,
6950            caller_node     TEXT,
6951            caller_file     TEXT NOT NULL,
6952            kind            TEXT NOT NULL,
6953            short_name      TEXT,
6954            full_ref        TEXT,
6955            module_path     TEXT,
6956            import_kind     TEXT,
6957            local_name      TEXT,
6958            requested_name  TEXT,
6959            namespace_alias TEXT,
6960            wildcard        INTEGER NOT NULL DEFAULT 0,
6961            line            INTEGER NOT NULL,
6962            byte_start      INTEGER NOT NULL,
6963            byte_end        INTEGER NOT NULL,
6964            status          TEXT NOT NULL,
6965            target_node     TEXT,
6966            target_file     TEXT,
6967            target_symbol   TEXT,
6968            provenance      TEXT NOT NULL
6969        );
6970        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6971        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6972        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6973        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6974        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6975
6976        CREATE TABLE IF NOT EXISTS file_dependencies (
6977            file_path   TEXT NOT NULL,
6978            dep_file    TEXT NOT NULL,
6979            PRIMARY KEY(file_path, dep_file)
6980        );
6981        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6982
6983        CREATE TABLE IF NOT EXISTS edges (
6984            edge_id       TEXT PRIMARY KEY,
6985            ref_id        TEXT NOT NULL,
6986            source_node   TEXT NOT NULL,
6987            target_node   TEXT,
6988            target_file   TEXT NOT NULL,
6989            target_symbol TEXT NOT NULL,
6990            kind          TEXT NOT NULL,
6991            line          INTEGER NOT NULL,
6992            provenance    TEXT NOT NULL
6993        );
6994        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6995        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6996        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6997        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6998
6999        CREATE TABLE IF NOT EXISTS dispatch_hints (
7000            id           TEXT PRIMARY KEY,
7001            method_name  TEXT NOT NULL,
7002            caller_node  TEXT NOT NULL,
7003            file         TEXT NOT NULL,
7004            line         INTEGER NOT NULL,
7005            byte_start   INTEGER NOT NULL,
7006            byte_end     INTEGER NOT NULL,
7007            provenance   TEXT NOT NULL
7008        );
7009        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7010        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7011
7012        CREATE TABLE IF NOT EXISTS type_ref_names (
7013            name TEXT PRIMARY KEY
7014        );
7015
7016        CREATE TABLE IF NOT EXISTS backend_file_state (
7017            backend        TEXT NOT NULL,
7018            workspace_root TEXT NOT NULL,
7019            file_path      TEXT NOT NULL,
7020            content_hash   TEXT NOT NULL,
7021            status         TEXT NOT NULL,
7022            updated_at     INTEGER NOT NULL,
7023            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7024        );
7025        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7026
7027        CREATE TABLE IF NOT EXISTS meta (
7028            k TEXT PRIMARY KEY,
7029            v TEXT NOT NULL
7030        );
7031
7032        -- The file walk is staged on disk so extraction can page through a
7033        -- deterministic inventory without retaining every source path in heap.
7034        CREATE TABLE IF NOT EXISTS staging_file_inventory (
7035            path TEXT PRIMARY KEY,
7036            size INTEGER NOT NULL
7037        ) WITHOUT ROWID;
7038
7039        -- Context needed only while a generation is staged. Raw refs live in
7040        -- `refs` with status `staged`; this table preserves the caller symbol
7041        -- needed to avoid inventing self edges during the later resolve pass.
7042        CREATE TABLE IF NOT EXISTS staging_ref_context (
7043            ref_id        TEXT PRIMARY KEY,
7044            caller_symbol TEXT
7045        );",
7046    )?;
7047    insert_meta(conn)?;
7048    Ok(())
7049}
7050
7051fn insert_meta(conn: &Connection) -> Result<()> {
7052    conn.execute(
7053        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7054        params![SCHEMA_VERSION.to_string()],
7055    )?;
7056    conn.execute(
7057        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7058        params![schema_fingerprint()],
7059    )?;
7060    conn.execute(
7061        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7062        [],
7063    )?;
7064    Ok(())
7065}
7066
7067/// Return the durable revision paired atomically with graph mutations. Stores
7068/// created by older binaries lack the revision row, so callers cannot detect
7069/// in-place graph changes and must not cache their snapshots.
7070const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7071
7072fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7073    let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7074        return Ok(());
7075    };
7076    conn.execute(
7077        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7078        params![
7079            PATH_IDENTITY_MISMATCH_META_KEY,
7080            format!(
7081                "callgraph_path_identity_mismatch path={} project_root={}",
7082                path.display(),
7083                project_root.display()
7084            )
7085        ],
7086    )?;
7087    Ok(())
7088}
7089
7090pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7091    conn.query_row(
7092        "SELECT v FROM meta WHERE k = ?1",
7093        [PATH_IDENTITY_MISMATCH_META_KEY],
7094        |row| row.get(0),
7095    )
7096    .optional()
7097    .map_err(Into::into)
7098}
7099
7100fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7101    let revision: Option<String> = conn
7102        .query_row(
7103            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7104            [],
7105            |row| row.get(0),
7106        )
7107        .optional()?;
7108    revision
7109        .map(|revision| {
7110            revision.parse::<u64>().map_err(|error| {
7111                CallGraphStoreError::Unavailable(format!(
7112                    "callgraph projection write revision is invalid: {error}"
7113                ))
7114            })
7115        })
7116        .transpose()
7117}
7118
7119/// Advance the projection revision inside the graph mutation transaction so a
7120/// cached snapshot never survives an in-place refresh.
7121fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7122    tx.execute(
7123        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7124         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7125        [],
7126    )?;
7127    Ok(())
7128}
7129
7130fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7131    conn.execute(
7132        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7133        params![if ready { "1" } else { "0" }],
7134    )?;
7135    Ok(())
7136}
7137
7138fn database_ready(conn: &Connection) -> Result<bool> {
7139    let schema_version: Option<String> = conn
7140        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7141            row.get(0)
7142        })
7143        .optional()?;
7144    let fingerprint: Option<String> = conn
7145        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7146            row.get(0)
7147        })
7148        .optional()?;
7149    let ready: Option<String> = conn
7150        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7151        .optional()?;
7152
7153    let expected_schema = SCHEMA_VERSION.to_string();
7154    let expected_fingerprint = schema_fingerprint();
7155    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7156        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7157        && ready.as_deref() == Some("1"))
7158}
7159
7160fn ensure_database_ready(conn: &Connection) -> Result<()> {
7161    if database_ready(conn)? {
7162        Ok(())
7163    } else {
7164        Err(CallGraphStoreError::Unavailable(
7165            "database is missing, stale, or mid-build".to_string(),
7166        ))
7167    }
7168}
7169
7170fn schema_fingerprint() -> String {
7171    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
7172    // edge sources, broader call extraction) even if the table SHAPE is
7173    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
7174    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
7175    let input =
7176        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7177    hash_to_hex(blake3::hash(input.as_bytes()))
7178}
7179
7180fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7181    tx.execute_batch(
7182        "DELETE FROM staging_ref_context;
7183         DELETE FROM edges;
7184         DELETE FROM file_dependencies;
7185         DELETE FROM refs;
7186         DELETE FROM dispatch_hints;
7187         DELETE FROM type_ref_names;
7188         DELETE FROM backend_file_state;
7189         DELETE FROM nodes;
7190         DELETE FROM files;",
7191    )?;
7192    Ok(())
7193}
7194
7195fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7196    conn.query_row(
7197        "SELECT v FROM meta WHERE k = ?1",
7198        params![STAGED_BUILD_PHASE],
7199        |row| row.get(0),
7200    )
7201    .optional()
7202    .map_err(Into::into)
7203}
7204
7205fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7206    let value = staged_string(conn, key)?;
7207    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7208}
7209
7210fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7211    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7212        row.get::<_, String>(0)
7213    })
7214    .optional()
7215    .map_err(Into::into)
7216}
7217
7218fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7219    tx.execute(
7220        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7221        params![STAGED_BUILD_PHASE, phase],
7222    )?;
7223    Ok(())
7224}
7225
7226fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7227    set_staged_string(tx, key, &value.to_string())
7228}
7229
7230fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7231    tx.execute(
7232        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7233        params![key, value],
7234    )?;
7235    Ok(())
7236}
7237
7238/// The extract rows and this counter update share a SQLite transaction. This is
7239/// intentionally not inferred from file/page growth: rollback removes both the
7240/// rows and the claimed credit, while page reuse cannot fabricate credit.
7241fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7242    tx.execute(
7243        "INSERT INTO meta(k, v) VALUES(?1, ?2)
7244         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7245        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7246    )?;
7247    Ok(())
7248}
7249
7250fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7251    let Ok(source) = std::fs::read_to_string(path) else {
7252        return Ok(false);
7253    };
7254    let Ok(freshness) = collect_source_freshness(path, &source) else {
7255        return Ok(false);
7256    };
7257    let rel_path = relative_path(project_root, path);
7258    let staged_hash = conn
7259        .query_row(
7260            "SELECT content_hash FROM files WHERE path = ?1",
7261            params![rel_path],
7262            |row| row.get::<_, String>(0),
7263        )
7264        .optional()?;
7265    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7266}
7267
7268fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7269    tx.execute(
7270        "DELETE FROM staging_ref_context
7271         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7272        params![rel_path],
7273    )?;
7274    delete_file_rows(tx, rel_path)
7275}
7276
7277fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7278    loop {
7279        let removed = {
7280            let mut statement = conn.prepare(
7281                "SELECT path
7282                 FROM files
7283                 WHERE NOT EXISTS (
7284                     SELECT 1 FROM staging_file_inventory inventory
7285                     WHERE inventory.path = files.path
7286                 )
7287                 ORDER BY path
7288                 LIMIT ?1",
7289            )?;
7290            let paths = statement
7291                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7292                    row.get::<_, String>(0)
7293                })?
7294                .collect::<std::result::Result<Vec<_>, _>>()?;
7295            paths
7296        };
7297        if removed.is_empty() {
7298            return Ok(());
7299        }
7300        let tx = conn.transaction()?;
7301        for path in removed {
7302            delete_staged_file_rows(&tx, &path)?;
7303        }
7304        tx.commit()?;
7305    }
7306}
7307
7308struct StagedFileBatch {
7309    paths: Vec<PathBuf>,
7310    last_path: String,
7311}
7312
7313fn load_staged_file_batch(
7314    conn: &Connection,
7315    project_root: &Path,
7316    after_path: &str,
7317    max_files: usize,
7318    max_bytes: u64,
7319) -> Result<Option<StagedFileBatch>> {
7320    let mut statement = conn.prepare(
7321        "SELECT path, size
7322         FROM staging_file_inventory
7323         WHERE path > ?1
7324         ORDER BY path
7325         LIMIT ?2",
7326    )?;
7327    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7328    let mut paths = Vec::with_capacity(max_files.max(1));
7329    let mut last_path = String::new();
7330    let mut batch_bytes = 0u64;
7331    while let Some(row) = rows.next()? {
7332        let rel_path = row.get::<_, String>(0)?;
7333        let size = row.get::<_, i64>(1)?.max(0) as u64;
7334        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7335            break;
7336        }
7337        batch_bytes = batch_bytes.saturating_add(size);
7338        last_path.clone_from(&rel_path);
7339        paths.push(project_root.join(rel_path));
7340    }
7341    if paths.is_empty() {
7342        Ok(None)
7343    } else {
7344        Ok(Some(StagedFileBatch { paths, last_path }))
7345    }
7346}
7347
7348fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7349    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7350    let mut rows = statement.query([])?;
7351    let mut fingerprint = CorpusFingerprint::default();
7352    while let Some(row) = rows.next()? {
7353        let rel_path = row.get::<_, String>(0)?;
7354        fingerprint.add_path(project_root, &project_root.join(rel_path));
7355    }
7356    Ok(fingerprint.finish(project_root))
7357}
7358
7359fn load_staged_ref_window(
7360    conn: &Connection,
7361    after_rowid: u64,
7362    limit: usize,
7363) -> Result<Vec<StagedRef>> {
7364    let mut statement = conn.prepare(
7365        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7366                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7367                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7368                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7369         FROM refs
7370         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7371         WHERE refs.status = 'staged' AND refs.rowid > ?1
7372         ORDER BY refs.rowid
7373         LIMIT ?2",
7374    )?;
7375    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7376        Ok(StagedRef {
7377            rowid: row.get::<_, i64>(0)? as u64,
7378            raw: RawRef {
7379                ref_id: row.get(1)?,
7380                caller_node: row.get(2)?,
7381                caller_file: row.get(3)?,
7382                kind: row.get(4)?,
7383                short_name: row.get(5)?,
7384                full_ref: row.get(6)?,
7385                module_path: row.get(7)?,
7386                import_kind: row.get(8)?,
7387                local_name: row.get(9)?,
7388                requested_name: row.get(10)?,
7389                namespace_alias: row.get(11)?,
7390                wildcard: row.get::<_, i64>(12)? != 0,
7391                line: row.get::<_, i64>(13)? as u32,
7392                byte_start: row.get::<_, i64>(14)? as usize,
7393                byte_end: row.get::<_, i64>(15)? as usize,
7394                caller_symbol: row.get(16)?,
7395                dependencies: BTreeSet::new(),
7396            },
7397        })
7398    })?;
7399    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7400    drop(statement);
7401
7402    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7403    let mut dependency_statement = conn
7404        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7405    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7406        if !dependencies.contains_key(&raw.caller_file) {
7407            let rows =
7408                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7409            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7410            dependencies.insert(raw.caller_file.clone(), values);
7411        }
7412        raw.dependencies = dependencies
7413            .get(&raw.caller_file)
7414            .cloned()
7415            .unwrap_or_default();
7416    }
7417    Ok(refs)
7418}
7419
7420fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7421    ResolvedRef {
7422        dependencies: raw.dependencies.clone(),
7423        raw,
7424        status: "unresolved".to_string(),
7425        target_node: None,
7426        target_file: None,
7427        target_symbol: None,
7428        edge: None,
7429    }
7430}
7431
7432fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7433    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7434        .map(|count| count.max(0) as u64)
7435        .map_err(Into::into)
7436}
7437
7438fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7439    let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7440    let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7441    let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7442    let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7443    let failed_files = staged_failed_files(conn)?;
7444    let elapsed_ms = started.elapsed().as_millis();
7445    crate::slog_info!(
7446        "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7447        files,
7448        nodes,
7449        refs,
7450        edges,
7451        staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7452        elapsed_ms
7453    );
7454    Ok(ColdBuildStats {
7455        files,
7456        nodes,
7457        refs,
7458        edges,
7459        failed_files,
7460        elapsed_ms,
7461    })
7462}
7463
7464fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7465    let mut statement = conn.prepare(
7466        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7467    )?;
7468    let rows = statement.query_map([], |row| row.get(0))?;
7469    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7470}
7471
7472fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7473    tx.execute_batch(
7474        "DROP INDEX IF EXISTS idx_nodes_file;
7475         DROP INDEX IF EXISTS idx_nodes_name;
7476         DROP INDEX IF EXISTS idx_nodes_scoped;
7477         DROP INDEX IF EXISTS idx_refs_short_name;
7478         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7479         DROP INDEX IF EXISTS idx_refs_caller_file;
7480         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7481         DROP INDEX IF EXISTS idx_refs_target_file;
7482         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7483         DROP INDEX IF EXISTS idx_edges_source_kind;
7484         DROP INDEX IF EXISTS idx_edges_target_kind;
7485         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7486         DROP INDEX IF EXISTS idx_edges_ref_id;
7487         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7488         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7489         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7490    )?;
7491    Ok(())
7492}
7493
7494fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7495    tx.execute_batch(
7496        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7497         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7498         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7499         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7500         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7501         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7502         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7503         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7504         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7505         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7506         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7507         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7508         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7509         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7510         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7511         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7512    )?;
7513    Ok(())
7514}
7515
7516const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7517    ("files", "path"),
7518    ("nodes", "file_path"),
7519    ("refs", "caller_file"),
7520    ("refs", "target_file"),
7521    ("file_dependencies", "file_path"),
7522    ("file_dependencies", "dep_file"),
7523    ("edges", "target_file"),
7524    ("dispatch_hints", "file"),
7525    ("backend_file_state", "file_path"),
7526];
7527
7528/// Reconcile `backend_file_state.workspace_root` when the opener's project root
7529/// differs from what is stored. The store key is the git-root commit hash, so
7530/// multiple live checkouts/clones share one on-disk generation.
7531///
7532/// Cheap in-place re-root is only safe when every previously stored root path is
7533/// gone from disk (true move/rename). If any stale root still exists, another
7534/// clone is still alive and rewriting metadata would ping-pong relative rows
7535/// between trees (possibly on different branches). We then return
7536/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
7537/// opener. That can make each clone rebuild on open when they alternate — bounded
7538/// by open frequency — but each rebuild is correct for its opener, unlike silent
7539/// cross-clone corruption.
7540fn reconcile_workspace_roots(
7541    conn: &mut Connection,
7542    project_root: &Path,
7543    allow_repair: bool,
7544) -> Result<OpenRootRepair> {
7545    let roots = stored_workspace_roots(conn)?;
7546    let current_root = project_root.display().to_string();
7547    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7548        return Ok(OpenRootRepair::None);
7549    }
7550
7551    if let Some(sample) = sample_absolute_data_path(conn)? {
7552        return Ok(OpenRootRepair::NeedsRebuild {
7553            previous_roots: roots,
7554            current_root,
7555            reason: format!("absolute store data path row {sample}"),
7556        });
7557    }
7558
7559    for stored_root in roots.iter() {
7560        if stored_root == &current_root {
7561            continue;
7562        }
7563        if Path::new(stored_root).exists() {
7564            let reason = format!(
7565                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7566            );
7567            return Ok(OpenRootRepair::NeedsRebuild {
7568                previous_roots: roots,
7569                current_root,
7570                reason,
7571            });
7572        }
7573    }
7574
7575    if !allow_repair {
7576        return Ok(OpenRootRepair::NeedsRebuild {
7577            previous_roots: roots,
7578            current_root,
7579            reason: "workspace root metadata requires deferred repair".to_string(),
7580        });
7581    }
7582
7583    publish_if_current(|| {
7584        let tx = conn.transaction()?;
7585        tx.execute(
7586            "UPDATE OR IGNORE backend_file_state
7587             SET workspace_root = ?1
7588             WHERE workspace_root <> ?1",
7589            params![&current_root],
7590        )?;
7591        tx.execute(
7592            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7593            params![&current_root],
7594        )?;
7595        tx.commit()?;
7596        Ok(())
7597    })?;
7598
7599    crate::slog_info!(
7600        "callgraph store re-rooted from {} to {}",
7601        roots.join(", "),
7602        current_root
7603    );
7604    Ok(OpenRootRepair::ReRooted)
7605}
7606
7607fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7608    let mut stmt = conn.prepare(
7609        "SELECT DISTINCT workspace_root
7610         FROM backend_file_state
7611         ORDER BY workspace_root",
7612    )?;
7613    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7614    rows.collect::<std::result::Result<Vec<_>, _>>()
7615        .map_err(Into::into)
7616}
7617
7618fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7619    for (table, column) in STORE_DATA_PATH_COLUMNS {
7620        let sql = format!(
7621            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7622        );
7623        let mut stmt = conn.prepare(&sql)?;
7624        let mut rows = stmt.query([])?;
7625        while let Some(row) = rows.next()? {
7626            let value: String = row.get(0)?;
7627            if stored_path_is_absolute(&value) {
7628                return Ok(Some(format!("{table}.{column}={value}")));
7629            }
7630        }
7631    }
7632    Ok(None)
7633}
7634
7635fn stored_path_is_absolute(value: &str) -> bool {
7636    if value.is_empty() {
7637        return false;
7638    }
7639    if Path::new(value).is_absolute() || value.starts_with('/') {
7640        return true;
7641    }
7642    let bytes = value.as_bytes();
7643    if bytes.len() >= 3
7644        && bytes[1] == b':'
7645        && (bytes[2] == b'/' || bytes[2] == b'\\')
7646        && bytes[0].is_ascii_alphabetic()
7647    {
7648        return true;
7649    }
7650    value.starts_with("\\\\") || value.starts_with("//")
7651}
7652
7653fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7654    if let OpenRootRepair::NeedsRebuild {
7655        previous_roots,
7656        current_root,
7657        reason,
7658    } = repair
7659    {
7660        crate::slog_info!(
7661            "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
7662            previous_roots.join(", "),
7663            current_root,
7664            reason
7665        );
7666    }
7667}
7668
7669/// Nanosecond clock used to make temp/generation file names unique.
7670fn now_nanos() -> u128 {
7671    SystemTime::now()
7672        .duration_since(UNIX_EPOCH)
7673        .unwrap_or(Duration::ZERO)
7674        .as_nanos()
7675}
7676
7677/// The pointer file `<dir>/<key>.current`. Its single line names the current
7678/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
7679/// can always be atomically replaced via rename even on Windows — Rust opens
7680/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
7681fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7682    callgraph_dir.join(format!("{project_key}.current"))
7683}
7684
7685/// The legacy single-file DB path used before the generation scheme. Still read
7686/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
7687/// build publishes a generation.
7688fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7689    callgraph_dir.join(format!("{project_key}.sqlite"))
7690}
7691
7692/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
7693/// cold build writes a brand-new generation file, so publishing NEVER replaces
7694/// a file another process holds open (the root Windows fix).
7695fn generation_file_name(project_key: &str) -> String {
7696    format!(
7697        "{project_key}.g{}.{}.sqlite",
7698        now_nanos(),
7699        std::process::id()
7700    )
7701}
7702
7703/// Read the pointer; returns the generation file name if present and non-empty.
7704fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7705    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7706    let name = text.trim();
7707    if name.is_empty() {
7708        None
7709    } else {
7710        Some(name.to_string())
7711    }
7712}
7713
7714/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
7715/// `ready` flag). Uses a throwaway read-only connection.
7716fn db_path_ready(path: &Path) -> bool {
7717    (|| -> Result<bool> {
7718        let conn = open_readonly_connection(path)?;
7719        database_ready(&conn)
7720    })()
7721    .unwrap_or(false)
7722}
7723
7724/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
7725/// where `generation` is `Some(name)` for a pointer-published generation or
7726/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
7727/// published (caller treats that as "needs cold build").
7728///
7729/// Handles the GC race (the pointer names a generation that was just deleted) by
7730/// re-reading the pointer and retrying a few times.
7731fn resolve_ready_target(
7732    callgraph_dir: &Path,
7733    project_key: &str,
7734) -> Option<(PathBuf, Option<String>)> {
7735    for _ in 0..5 {
7736        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7737            let gen_path = callgraph_dir.join(&generation);
7738            if gen_path.is_file() {
7739                return (migration_manifest_valid(callgraph_dir, &generation)
7740                    && db_path_ready(&gen_path))
7741                .then_some((gen_path, Some(generation)));
7742            }
7743            // Pointer names a missing generation (a GC/publish race): re-read the
7744            // pointer and retry rather than failing the reader.
7745            std::thread::sleep(Duration::from_millis(5));
7746            continue;
7747        }
7748        // No pointer: fall back to the legacy single-file DB if it is ready.
7749        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7750        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7751    }
7752    None
7753}
7754
7755/// Atomically publish `generation` as the current store by flipping the pointer
7756/// file. Writes a temp file, fsyncs, then renames over the pointer — never
7757/// replacing an open DB file, so it succeeds cross-platform.
7758fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7759    let pointer = pointer_path(callgraph_dir, project_key);
7760    let tmp = callgraph_dir.join(format!(
7761        "{project_key}.current.tmp.{}.{}",
7762        std::process::id(),
7763        now_nanos()
7764    ));
7765    {
7766        use std::io::Write as _;
7767        let mut file = std::fs::File::create(&tmp)?;
7768        file.write_all(generation.as_bytes())?;
7769        file.write_all(b"\n")?;
7770        file.sync_all()?;
7771    }
7772    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7773        let _ = std::fs::remove_file(&tmp);
7774        return Err(error.into());
7775    }
7776    crate::fs_lock::sync_parent(&pointer);
7777    Ok(())
7778}
7779
7780#[derive(Clone, Debug)]
7781struct GenerationGcCandidate {
7782    name: String,
7783    path: PathBuf,
7784    modified: SystemTime,
7785}
7786
7787/// Best-effort GC of superseded generation files. The current pointer target and
7788/// newest previous generation are always retained. Older generations are removed
7789/// when they have no protected read marker, or after the absolute retention TTL
7790/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
7791/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
7792fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7793    let temp_grace = Duration::from_secs(60);
7794    let now = SystemTime::now();
7795    let pointer_current =
7796        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7797    let gen_prefix = format!("{project_key}.g");
7798    let tmp_prefixes = [
7799        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
7800        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
7801        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
7802    ];
7803    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7804        return;
7805    };
7806    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7807    for entry in entries.flatten() {
7808        let name = entry.file_name();
7809        let name = name.to_string_lossy().to_string();
7810        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7811        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7812
7813        // Orphaned temp files from a crashed build/publish: remove once aged out.
7814        if name.contains(".tmp.") {
7815            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7816                let _ = std::fs::remove_file(entry.path());
7817            }
7818            continue;
7819        }
7820
7821        // Superseded legacy single-file DB: best-effort delete once a generation
7822        // is published (ignored if another process still holds it open).
7823        if name == format!("{project_key}.sqlite") {
7824            remove_sqlite_file_set(&entry.path());
7825            continue;
7826        }
7827
7828        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7829            gens.push(GenerationGcCandidate {
7830                name,
7831                path: entry.path(),
7832                modified: mtime,
7833            });
7834        }
7835    }
7836
7837    let mut superseded = gens
7838        .iter()
7839        .filter(|generation| generation.name != pointer_current)
7840        .collect::<Vec<_>>();
7841    superseded.sort_by(|left, right| {
7842        right
7843            .modified
7844            .cmp(&left.modified)
7845            .then_with(|| right.name.cmp(&left.name))
7846    });
7847    let previous = superseded.first().map(|generation| generation.name.clone());
7848
7849    for generation in gens {
7850        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7851        if generation.name == pointer_current
7852            || Some(generation.name.as_str()) == previous.as_deref()
7853        {
7854            continue;
7855        }
7856
7857        let age = now
7858            .duration_since(generation.modified)
7859            .unwrap_or(Duration::ZERO);
7860        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7861            continue;
7862        }
7863
7864        remove_sqlite_file_set(&generation.path);
7865        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7866        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7867            callgraph_dir,
7868            &generation.name,
7869        ));
7870    }
7871}
7872
7873fn remove_sqlite_file_set(path: &Path) {
7874    let _ = std::fs::remove_file(path);
7875    remove_sqlite_sidecars(path);
7876}
7877
7878fn remove_sqlite_sidecars(path: &Path) {
7879    let path_text = path.to_string_lossy();
7880    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7881    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7882    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7883}
7884
7885#[derive(Clone, Copy, Debug, Default)]
7886struct CallgraphRootSweepSummary {
7887    scanned: usize,
7888    removed: usize,
7889    bytes: u64,
7890    generation_gc: usize,
7891    skipped_memo: usize,
7892    skipped_derived: usize,
7893    skipped_fresh: usize,
7894    skipped_reader: usize,
7895    skipped_lease: usize,
7896    skipped_unreadable: usize,
7897    budget_exhausted: bool,
7898}
7899
7900#[derive(Clone, Copy, Debug, Default)]
7901struct CallgraphRootFileStats {
7902    newest: Option<SystemTime>,
7903    bytes: u64,
7904}
7905
7906enum CallgraphRootWalk {
7907    Complete(CallgraphRootFileStats),
7908    BudgetExceeded,
7909    Failed,
7910}
7911
7912enum CallgraphRootCandidate {
7913    Removed { bytes: u64 },
7914    GenerationGc,
7915    SkippedMemo,
7916    SkippedDerived,
7917    SkippedFresh,
7918    SkippedReader,
7919    SkippedLease,
7920    SkippedUnreadable,
7921    BudgetExceeded,
7922}
7923
7924/// Sweep root-keyed callgraph directories that were detached when cache-key
7925/// eviction forgot a checkout. Generation GC alone only runs while a root
7926/// publishes, so inactive roots otherwise keep every obsolete generation forever.
7927///
7928/// The pass reuses the index-cache liveness boundary and takes each directory's
7929/// writer lease before mutating it. A current memo entry remains eligible only for
7930/// superseded-generation GC; an absent entry is eligible for whole-directory
7931/// deletion after the conservative age threshold.
7932fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
7933    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
7934        return;
7935    };
7936    let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
7937    let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
7938        Ok(keys) => keys,
7939        Err(error) => {
7940            crate::slog_warn!(
7941                "callgraph root sweep root={} scanned=0 removed=0 bytes=0 generation_gc=0 skipped_memo=0 skipped_derived=0 skipped_fresh=0 skipped_reader=0 skipped_lease=0 skipped_unreadable=0 budget_exhausted=false memo_unreadable=true error={}",
7942                root_dir.display(),
7943                error
7944            );
7945            return;
7946        }
7947    };
7948    let derived_keys = crate::search_index::derived_artifact_cache_keys();
7949    let summary = sweep_callgraph_root_dirs_with_limits(
7950        &root_dir,
7951        &memo_keys,
7952        &derived_keys,
7953        CALLGRAPH_ROOT_SWEEP_BUDGET,
7954        CALLGRAPH_ROOT_SWEEP_LIMIT,
7955    );
7956    crate::slog_info!(
7957        "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
7958        root_dir.display(),
7959        summary.scanned,
7960        summary.removed,
7961        summary.bytes,
7962        summary.generation_gc,
7963        summary.skipped_memo,
7964        summary.skipped_derived,
7965        summary.skipped_fresh,
7966        summary.skipped_reader,
7967        summary.skipped_lease,
7968        summary.skipped_unreadable,
7969        summary.budget_exhausted
7970    );
7971}
7972
7973fn sweep_callgraph_root_dirs_with_limits(
7974    root_dir: &Path,
7975    memo_keys: &HashSet<String>,
7976    derived_keys: &HashSet<String>,
7977    wall_clock_budget: Duration,
7978    entry_limit: usize,
7979) -> CallgraphRootSweepSummary {
7980    let started = Instant::now();
7981    let deadline = started + wall_clock_budget;
7982    let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
7983        Ok(boundary) => boundary,
7984        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
7985            return CallgraphRootSweepSummary::default();
7986        }
7987        Err(error) => {
7988            crate::slog_warn!(
7989                "cannot establish filesystem boundary for callgraph root sweep {}: {}",
7990                root_dir.display(),
7991                error
7992            );
7993            return CallgraphRootSweepSummary {
7994                skipped_unreadable: 1,
7995                ..CallgraphRootSweepSummary::default()
7996            };
7997        }
7998    };
7999    let mut entries = match std::fs::read_dir(root_dir) {
8000        Ok(entries) => entries
8001            .filter_map(|entry| entry.ok())
8002            .filter_map(|entry| {
8003                let name = entry.file_name().to_string_lossy().into_owned();
8004                entry
8005                    .file_type()
8006                    .ok()
8007                    .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8008                    .map(|_| (name, entry.path()))
8009            })
8010            .collect::<Vec<_>>(),
8011        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8012        Err(error) => {
8013            crate::slog_warn!(
8014                "cannot read callgraph root sweep directory {}: {}",
8015                root_dir.display(),
8016                error
8017            );
8018            return CallgraphRootSweepSummary {
8019                skipped_unreadable: 1,
8020                ..CallgraphRootSweepSummary::default()
8021            };
8022        }
8023    };
8024    entries.sort_by(|left, right| left.0.cmp(&right.0));
8025
8026    let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8027    let last_name = cursor_store
8028        .lock()
8029        .ok()
8030        .and_then(|cursors| cursors.get(root_dir).cloned());
8031    if let Some(start) = last_name
8032        .as_deref()
8033        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8034    {
8035        entries.rotate_left(start);
8036    }
8037
8038    let mut summary = CallgraphRootSweepSummary::default();
8039    let mut cursor_name = last_name;
8040    for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8041        if processed >= entry_limit || Instant::now() >= deadline {
8042            summary.budget_exhausted = true;
8043            break;
8044        }
8045        summary.scanned += 1;
8046        cursor_name = Some(key.clone());
8047        match callgraph_root_candidate(
8048            &cache_dir,
8049            &key,
8050            memo_keys.contains(&key),
8051            derived_keys.contains(&key),
8052            &boundary,
8053            deadline,
8054        ) {
8055            CallgraphRootCandidate::Removed { bytes } => {
8056                summary.removed += 1;
8057                summary.bytes = summary.bytes.saturating_add(bytes);
8058            }
8059            CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8060            CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8061            CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8062            CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8063            CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8064            CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8065            CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8066            CallgraphRootCandidate::BudgetExceeded => {
8067                summary.budget_exhausted = true;
8068                break;
8069            }
8070        }
8071    }
8072
8073    if let Ok(mut cursors) = cursor_store.lock() {
8074        if summary.budget_exhausted {
8075            if let Some(cursor_name) = cursor_name {
8076                cursors.insert(root_dir.to_path_buf(), cursor_name);
8077            }
8078        } else {
8079            cursors.remove(root_dir);
8080        }
8081    }
8082    if summary.removed > 0 {
8083        crate::fs_lock::sync_parent(root_dir);
8084    }
8085    summary
8086}
8087
8088fn callgraph_root_candidate(
8089    cache_dir: &Path,
8090    project_key: &str,
8091    memo_referenced: bool,
8092    derived_in_process: bool,
8093    boundary: &crate::walk_boundary::DeviceBoundary,
8094    deadline: Instant,
8095) -> CallgraphRootCandidate {
8096    if !boundary.should_descend(cache_dir).unwrap_or(false) {
8097        return CallgraphRootCandidate::SkippedUnreadable;
8098    }
8099    if memo_referenced || derived_in_process {
8100        return sweep_live_callgraph_root_generations(
8101            cache_dir,
8102            project_key,
8103            memo_referenced,
8104            boundary,
8105            deadline,
8106        );
8107    }
8108
8109    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8110        CallgraphRootWalk::Complete(stats) => stats,
8111        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8112        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8113    };
8114    let Some(newest) = stats.newest else {
8115        return CallgraphRootCandidate::SkippedUnreadable;
8116    };
8117    if SystemTime::now()
8118        .duration_since(newest)
8119        .unwrap_or(Duration::ZERO)
8120        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8121    {
8122        return CallgraphRootCandidate::SkippedFresh;
8123    }
8124
8125    // Keep the writer lease held through deletion. A concurrent publisher either
8126    // owns it first (and this pass skips) or starts after this directory is gone.
8127    let _writer_lease = match crate::fs_lock::try_acquire(
8128        &crate::root_cache::writer_lease_path(cache_dir),
8129        Duration::ZERO,
8130    ) {
8131        Ok(lease) => lease,
8132        Err(_) => return CallgraphRootCandidate::SkippedLease,
8133    };
8134    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8135        return CallgraphRootCandidate::SkippedReader;
8136    }
8137
8138    match std::fs::remove_dir_all(cache_dir) {
8139        Ok(()) => {
8140            crate::slog_info!(
8141                "callgraph root sweep reaped dir={} key={} bytes={}",
8142                cache_dir.display(),
8143                project_key,
8144                stats.bytes
8145            );
8146            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8147        }
8148        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8149            crate::slog_info!(
8150                "callgraph root sweep reaped dir={} key={} bytes={}",
8151                cache_dir.display(),
8152                project_key,
8153                stats.bytes
8154            );
8155            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8156        }
8157        Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8158    }
8159}
8160
8161fn sweep_live_callgraph_root_generations(
8162    cache_dir: &Path,
8163    project_key: &str,
8164    memo_referenced: bool,
8165    boundary: &crate::walk_boundary::DeviceBoundary,
8166    deadline: Instant,
8167) -> CallgraphRootCandidate {
8168    if Instant::now() >= deadline {
8169        return CallgraphRootCandidate::BudgetExceeded;
8170    }
8171    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8172        CallgraphRootWalk::Complete(stats) => stats,
8173        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8174        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8175    };
8176    let Some(newest) = stats.newest else {
8177        return CallgraphRootCandidate::SkippedUnreadable;
8178    };
8179    if SystemTime::now()
8180        .duration_since(newest)
8181        .unwrap_or(Duration::ZERO)
8182        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8183    {
8184        return CallgraphRootCandidate::SkippedFresh;
8185    }
8186    let _writer_lease = match crate::fs_lock::try_acquire(
8187        &crate::root_cache::writer_lease_path(cache_dir),
8188        Duration::ZERO,
8189    ) {
8190        Ok(lease) => lease,
8191        Err(_) => return CallgraphRootCandidate::SkippedLease,
8192    };
8193    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8194        return CallgraphRootCandidate::SkippedReader;
8195    }
8196    if let Some(current) = read_pointer(cache_dir, project_key) {
8197        gc_old_generations(cache_dir, project_key, &current);
8198        return CallgraphRootCandidate::GenerationGc;
8199    }
8200    if memo_referenced {
8201        CallgraphRootCandidate::SkippedMemo
8202    } else {
8203        CallgraphRootCandidate::SkippedDerived
8204    }
8205}
8206
8207fn callgraph_root_file_stats(
8208    cache_dir: &Path,
8209    boundary: &crate::walk_boundary::DeviceBoundary,
8210    deadline: Instant,
8211) -> CallgraphRootWalk {
8212    let metadata = match std::fs::metadata(cache_dir) {
8213        Ok(metadata) => metadata,
8214        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8215            return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8216        }
8217        Err(_) => return CallgraphRootWalk::Failed,
8218    };
8219    let mut stats = CallgraphRootFileStats {
8220        newest: metadata.modified().ok(),
8221        bytes: 0,
8222    };
8223    match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8224        Ok(()) => CallgraphRootWalk::Complete(stats),
8225        Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8226        Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8227    }
8228}
8229
8230enum CallgraphRootWalkError {
8231    BudgetExceeded,
8232    Failed,
8233}
8234
8235fn callgraph_root_file_stats_inner(
8236    directory: &Path,
8237    boundary: &crate::walk_boundary::DeviceBoundary,
8238    deadline: Instant,
8239    stats: &mut CallgraphRootFileStats,
8240) -> std::result::Result<(), CallgraphRootWalkError> {
8241    if Instant::now() >= deadline {
8242        return Err(CallgraphRootWalkError::BudgetExceeded);
8243    }
8244    let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8245    for entry in entries {
8246        if Instant::now() >= deadline {
8247            return Err(CallgraphRootWalkError::BudgetExceeded);
8248        }
8249        let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8250        let file_type = entry
8251            .file_type()
8252            .map_err(|_| CallgraphRootWalkError::Failed)?;
8253        if file_type.is_symlink() {
8254            return Err(CallgraphRootWalkError::Failed);
8255        }
8256        let path = entry.path();
8257        if file_type.is_dir() {
8258            if !boundary
8259                .should_descend(&path)
8260                .map_err(|_| CallgraphRootWalkError::Failed)?
8261            {
8262                return Err(CallgraphRootWalkError::Failed);
8263            }
8264            let metadata = entry
8265                .metadata()
8266                .map_err(|_| CallgraphRootWalkError::Failed)?;
8267            merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8268            callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8269            continue;
8270        }
8271        if !file_type.is_file() {
8272            return Err(CallgraphRootWalkError::Failed);
8273        }
8274        let metadata = entry
8275            .metadata()
8276            .map_err(|_| CallgraphRootWalkError::Failed)?;
8277        stats.bytes = stats.bytes.saturating_add(metadata.len());
8278        merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8279    }
8280    Ok(())
8281}
8282
8283fn merge_newest_callgraph_root_mtime(
8284    stats: &mut CallgraphRootFileStats,
8285    modified: Option<SystemTime>,
8286) {
8287    if let Some(modified) = modified {
8288        if stats.newest.is_none_or(|newest| modified > newest) {
8289            stats.newest = Some(modified);
8290        }
8291    }
8292}
8293
8294fn artifact_key_looks_valid(key: &str) -> bool {
8295    key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8296}
8297
8298#[cfg(test)]
8299fn reset_callgraph_root_sweep_cursor_for_test() {
8300    if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8301        cursors.lock().unwrap().clear();
8302    }
8303}
8304
8305/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
8306///
8307/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
8308/// place on success; a build that dies (process kill, crash, host restart) leaves
8309/// the temporary behind. The largest observed cold build finishes well under a
8310/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
8311/// never rename. A live build's temporary is minutes old at most.
8312///
8313/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
8314/// liveness check read false-positive on exactly the oldest files — the ones most
8315/// worth deleting: in production an orphan's embedded pid had been recycled to an
8316/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
8317/// cannot lie that way, so it is the honest orphan predicate.
8318const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8319
8320/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
8321/// same cadence as [`gc_old_generations`] (after a generation is published) but,
8322/// unlike it, is not scoped to the building root: it covers every directory in the
8323/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
8324///
8325/// That last case is the production hole this fixes. The per-root cleanup in
8326/// [`gc_old_generations`] only fires when a root actually builds, so when activity
8327/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
8328/// store's orphans become permanent — gigabytes accumulated in a legacy store
8329/// whose roots no longer built there, while the active store stayed clean. A
8330/// sibling root that still builds triggers this pass and cleans both layouts.
8331fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8332    sweep_orphaned_build_temps(callgraph_dir);
8333    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8334        return;
8335    };
8336    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8337    // A vanished mounted child can make ReadDir::drop panic after closedir
8338    // returns ENXIO, aborting the daemon. Keep the store-wide background sweep
8339    // on the storage root's filesystem before opening child directories.
8340    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8341        crate::slog_warn!(
8342            "cannot establish filesystem boundary for callgraph sweep {}",
8343            storage_root.display()
8344        );
8345        return;
8346    };
8347    let mut skipped_foreign_mounts = 0usize;
8348
8349    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
8350    let root_keyed_dir = storage_root.join(domain);
8351    if root_keyed_dir.is_dir() {
8352        if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8353            if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8354                for entry in entries.flatten() {
8355                    let path = entry.path();
8356                    if path.is_dir() {
8357                        if boundary.should_descend(&path).unwrap_or(false) {
8358                            sweep_orphaned_build_temps(&path);
8359                        } else {
8360                            skipped_foreign_mounts += 1;
8361                        }
8362                    }
8363                }
8364            }
8365        } else {
8366            skipped_foreign_mounts += 1;
8367        }
8368    }
8369
8370    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
8371    if let Ok(entries) = std::fs::read_dir(&storage_root) {
8372        for entry in entries.flatten() {
8373            let harness_dir = entry.path();
8374            if !harness_dir.is_dir() {
8375                continue;
8376            }
8377            if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8378                skipped_foreign_mounts += 1;
8379                continue;
8380            }
8381            let legacy_dir = harness_dir.join(domain);
8382            if legacy_dir.is_dir() {
8383                if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8384                    sweep_orphaned_build_temps(&legacy_dir);
8385                } else {
8386                    skipped_foreign_mounts += 1;
8387                }
8388            }
8389        }
8390    }
8391    if skipped_foreign_mounts > 0 {
8392        crate::slog_warn!(
8393            "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8394            skipped_foreign_mounts,
8395            storage_root.display()
8396        );
8397    }
8398}
8399
8400/// Sweep one callgraph directory, removing build temporaries older than
8401/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
8402fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8403    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8404}
8405
8406/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
8407/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
8408fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8409    let now = SystemTime::now();
8410    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8411        return;
8412    };
8413    let mut removed_any = false;
8414    for entry in entries.flatten() {
8415        let name = entry.file_name().to_string_lossy().to_string();
8416        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
8417        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
8418        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
8419        // without that substring — a completed `.sqlite` generation, a pointer, a
8420        // read-marker dir — is left alone: those belong to generation GC.
8421        if !name.contains(".sqlite.tmp.") {
8422            continue;
8423        }
8424        let mtime = entry
8425            .metadata()
8426            .and_then(|meta| meta.modified())
8427            .unwrap_or(now);
8428        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8429            continue;
8430        }
8431        // Deletion races a concurrent build finishing: that build renames the temp
8432        // into place, so the file is gone by the time we unlink. The 24h age makes
8433        // this overlap practically impossible, but treat a missing file as success
8434        // (the rename won) rather than an error, and never touch a path that does
8435        // not match the temporary shape above.
8436        match std::fs::remove_file(entry.path()) {
8437            Ok(()) => removed_any = true,
8438            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8439            Err(_) => {}
8440        }
8441    }
8442    if removed_any {
8443        crate::fs_lock::sync_parent(callgraph_dir);
8444    }
8445}
8446
8447/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
8448/// the global all-cores rayon pool. The store cold-build is the heaviest
8449/// background pass (parse-dominated) and runs on a separate thread off the
8450/// single-threaded request loop; left unbounded it monopolizes every core and
8451/// starves the bridge so interactive tools time out (the same starvation the
8452/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
8453/// match the main thread, since the extract walks tree-sitter ASTs.
8454fn build_pool_size() -> usize {
8455    std::thread::available_parallelism()
8456        .map(|parallelism| parallelism.get())
8457        .unwrap_or(1)
8458        .div_ceil(2)
8459        .clamp(1, 8)
8460}
8461
8462fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8463    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8464        Ok(extract) => Ok(extract),
8465        Err(error) => {
8466            let abs_path =
8467                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8468            let rel_path = relative_path(project_root, &abs_path);
8469            let freshness = cache_freshness::collect(&abs_path).ok();
8470            log::debug!(
8471                "callgraph store: skipping {} during cold build: {}",
8472                abs_path.display(),
8473                error
8474            );
8475            Err(ExtractFailure {
8476                rel_path,
8477                freshness,
8478            })
8479        }
8480    };
8481
8482    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8483        files.par_iter().map(extract_one).collect()
8484    };
8485
8486    // Run inside a dedicated bounded pool when one builds; fall back to the
8487    // global pool only if the bounded pool can't be constructed.
8488    let results = match rayon::ThreadPoolBuilder::new()
8489        .num_threads(build_pool_size())
8490        .thread_name(|index| format!("aft-callgraph-build-{index}"))
8491        .stack_size(8 * 1024 * 1024)
8492        .build()
8493    {
8494        Ok(pool) => pool.install(run),
8495        Err(error) => {
8496            log::warn!(
8497                "callgraph store: bounded build pool unavailable ({error}); using global pool"
8498            );
8499            run()
8500        }
8501    };
8502
8503    let mut extracts = Vec::new();
8504    let mut failures = Vec::new();
8505    for result in results {
8506        match result {
8507            Ok(extract) => extracts.push(extract),
8508            Err(failure) => failures.push(failure),
8509        }
8510    }
8511    BuildExtractsResult { extracts, failures }
8512}
8513
8514fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8515    let metadata = std::fs::metadata(path)?;
8516    let size = metadata.len();
8517    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8518        cache_freshness::zero_hash()
8519    } else if source.len() as u64 == size {
8520        cache_freshness::hash_bytes(source.as_bytes())
8521    } else {
8522        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
8523    };
8524    Ok(FileFreshness {
8525        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
8526        size,
8527        content_hash,
8528    })
8529}
8530
8531fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
8532    let abs_path = normalize_file_path(project_root, path)?;
8533    let rel_path = relative_path(project_root, &abs_path);
8534    let source = std::fs::read_to_string(&abs_path)?;
8535    let freshness = collect_source_freshness(&abs_path, &source)?;
8536    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
8537    let lang = data.lang;
8538    if lang == LangId::Rust {
8539        extend_rust_imports_with_nested_uses(&source, &mut data);
8540    }
8541    let mut nodes = build_node_records(&rel_path, &source, &data)?;
8542    let node_by_scoped: HashMap<String, String> = nodes
8543        .iter()
8544        .map(|node| (node.scoped_name.clone(), node.id.clone()))
8545        .collect();
8546    let import_dependencies =
8547        import_dependencies(project_root, &abs_path, &data.import_block.imports);
8548    let line_index = LineIndex::new(&source);
8549    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
8550    let rust_reexports = if lang == LangId::Rust {
8551        collect_rust_pub_use_reexport_refs(
8552            project_root,
8553            &abs_path,
8554            &rel_path,
8555            &data.import_block.imports,
8556            &line_index,
8557        )
8558    } else {
8559        ReexportRefs {
8560            raw_refs: Vec::new(),
8561            surface_parts: Vec::new(),
8562        }
8563    };
8564    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
8565    let mut raw_refs = Vec::new();
8566    raw_refs.extend(build_call_refs(
8567        &rel_path,
8568        &data,
8569        &node_by_scoped,
8570        &import_dependencies,
8571    ));
8572    raw_refs.extend(build_value_ref_refs(
8573        &rel_path,
8574        &data,
8575        &node_by_scoped,
8576        &import_dependencies,
8577    ));
8578    raw_refs.extend(build_import_refs(
8579        project_root,
8580        &abs_path,
8581        &rel_path,
8582        &data.import_block.imports,
8583        &line_index,
8584    ));
8585    if lang == LangId::Rust {
8586        raw_refs.extend(build_rust_module_refs(
8587            project_root,
8588            &abs_path,
8589            &rel_path,
8590            &source,
8591        ));
8592    }
8593    let mut surface_parts = reexports.surface_parts;
8594    surface_parts.extend(rust_reexports.surface_parts);
8595    surface_parts.extend(source_less_exports.surface_parts);
8596    raw_refs.extend(reexports.raw_refs);
8597    raw_refs.extend(rust_reexports.raw_refs);
8598    raw_refs.extend(source_less_exports.raw_refs);
8599    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
8600    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
8601
8602    Ok(FileExtract {
8603        rel_path,
8604        freshness,
8605        lang,
8606        data,
8607        nodes,
8608        raw_refs,
8609        dispatch_hints,
8610        surface_fingerprint,
8611    })
8612}
8613
8614fn build_node_records(
8615    rel_path: &str,
8616    source: &str,
8617    data: &FileCallData,
8618) -> Result<Vec<NodeRecord>> {
8619    let mut records = Vec::new();
8620    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
8621    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
8622    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
8623
8624    for (scoped_name, meta) in metadata {
8625        let name = unqualified_name(scoped_name).to_string();
8626        let range = selection_range(source, scoped_name, &name, &meta.range);
8627        let range_key = (
8628            range.start_line,
8629            range.start_col,
8630            range.end_line,
8631            range.end_col,
8632        );
8633        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8634        let range_ordinal = *ordinal;
8635        *ordinal += 1;
8636        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8637        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8638        let is_default_export = data
8639            .default_export_symbol
8640            .as_deref()
8641            .map(|default| default == scoped_name || default == name)
8642            .unwrap_or(false);
8643        records.push(NodeRecord {
8644            id,
8645            file_path: rel_path.to_string(),
8646            name: name.clone(),
8647            scoped_name: scoped_name.clone(),
8648            kind: symbol_kind_label(&meta.kind).to_string(),
8649            range,
8650            range_ordinal,
8651            signature: meta.signature.clone(),
8652            exported,
8653            is_default_export,
8654            is_type_like: is_type_like(&meta.kind),
8655            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8656                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8657        });
8658    }
8659
8660    Ok(records)
8661}
8662
8663fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8664    if scoped_name == TOP_LEVEL_SYMBOL {
8665        return Range {
8666            start_line: 0,
8667            start_col: 0,
8668            end_line: 0,
8669            end_col: 0,
8670        };
8671    }
8672    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8673        return fallback.clone();
8674    };
8675    let start_col = fallback.start_col as usize;
8676    let search_start = start_col.min(line.len());
8677    if let Some(offset) = line[search_start..].find(name) {
8678        let col = search_start + offset;
8679        return Range {
8680            start_line: fallback.start_line,
8681            start_col: col as u32,
8682            end_line: fallback.start_line,
8683            end_col: (col + name.len()) as u32,
8684        };
8685    }
8686    if let Some(offset) = line.find(name) {
8687        return Range {
8688            start_line: fallback.start_line,
8689            start_col: offset as u32,
8690            end_line: fallback.start_line,
8691            end_col: (offset + name.len()) as u32,
8692        };
8693    }
8694    Range {
8695        start_line: fallback.start_line,
8696        start_col: fallback.start_col,
8697        end_line: fallback.start_line,
8698        end_col: fallback.start_col.saturating_add(name.len() as u32),
8699    }
8700}
8701
8702fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8703    if scoped_name == TOP_LEVEL_SYMBOL {
8704        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8705    }
8706    let input = format!(
8707        "{rel_path}:{}:{}:{}:{}:{ordinal}",
8708        range.start_line, range.start_col, range.end_line, range.end_col
8709    );
8710    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8711}
8712
8713fn build_call_refs(
8714    rel_path: &str,
8715    data: &FileCallData,
8716    node_by_scoped: &HashMap<String, String>,
8717    import_dependencies: &BTreeSet<String>,
8718) -> Vec<RawRef> {
8719    build_callable_refs(
8720        rel_path,
8721        &data.calls_by_symbol,
8722        node_by_scoped,
8723        import_dependencies,
8724        "call",
8725    )
8726}
8727
8728fn build_value_ref_refs(
8729    rel_path: &str,
8730    data: &FileCallData,
8731    node_by_scoped: &HashMap<String, String>,
8732    import_dependencies: &BTreeSet<String>,
8733) -> Vec<RawRef> {
8734    build_callable_refs(
8735        rel_path,
8736        &data.value_refs_by_symbol,
8737        node_by_scoped,
8738        import_dependencies,
8739        "value_ref",
8740    )
8741}
8742
8743fn build_callable_refs(
8744    rel_path: &str,
8745    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8746    node_by_scoped: &HashMap<String, String>,
8747    import_dependencies: &BTreeSet<String>,
8748    kind: &str,
8749) -> Vec<RawRef> {
8750    let mut refs = Vec::new();
8751    let mut ordinal = 0usize;
8752    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8753    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8754    for (caller_symbol, call_sites) in symbols {
8755        let caller_node = node_by_scoped.get(caller_symbol).cloned();
8756        for call_site in call_sites {
8757            ordinal += 1;
8758            let ref_id = ref_id(&[
8759                rel_path,
8760                kind,
8761                caller_symbol,
8762                &call_site.line.to_string(),
8763                &call_site.byte_start.to_string(),
8764                &call_site.byte_end.to_string(),
8765                &call_site.full_callee,
8766                &ordinal.to_string(),
8767            ]);
8768            refs.push(RawRef {
8769                ref_id,
8770                caller_node: caller_node.clone(),
8771                caller_symbol: Some(caller_symbol.clone()),
8772                caller_file: rel_path.to_string(),
8773                kind: kind.to_string(),
8774                short_name: Some(call_site.callee_name.clone()),
8775                full_ref: Some(call_site.full_callee.clone()),
8776                module_path: None,
8777                import_kind: None,
8778                local_name: Some(call_site.callee_name.clone()),
8779                requested_name: Some(call_site.callee_name.clone()),
8780                namespace_alias: namespace_alias(&call_site.full_callee),
8781                wildcard: false,
8782                line: call_site.line,
8783                byte_start: call_site.byte_start,
8784                byte_end: call_site.byte_end,
8785                dependencies: import_dependencies.clone(),
8786            });
8787        }
8788    }
8789    refs
8790}
8791
8792fn build_import_refs(
8793    project_root: &Path,
8794    abs_path: &Path,
8795    rel_path: &str,
8796    imports: &[ImportStatement],
8797    line_index: &LineIndex,
8798) -> Vec<RawRef> {
8799    let mut refs = Vec::new();
8800    for (index, import) in imports.iter().enumerate() {
8801        let import_kind = import_kind_label(import.kind).to_string();
8802        let local_name = import_local_names(import).join(",");
8803        let requested_name = import_requested_names(import).join(",");
8804        let ref_id = ref_id(&[
8805            rel_path,
8806            "import",
8807            &import.byte_range.start.to_string(),
8808            &import.byte_range.end.to_string(),
8809            &import.module_path,
8810            &index.to_string(),
8811        ]);
8812        refs.push(RawRef {
8813            ref_id,
8814            caller_node: None,
8815            caller_symbol: None,
8816            caller_file: rel_path.to_string(),
8817            kind: "import".to_string(),
8818            short_name: None,
8819            full_ref: Some(import.raw_text.clone()),
8820            module_path: Some(import.module_path.clone()),
8821            import_kind: Some(import_kind),
8822            local_name: empty_to_none(local_name),
8823            requested_name: empty_to_none(requested_name),
8824            namespace_alias: import.namespace_import.clone(),
8825            wildcard: import_is_wildcard(import),
8826            line: line_index.byte_to_line(import.byte_range.start),
8827            byte_start: import.byte_range.start,
8828            byte_end: import.byte_range.end,
8829            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8830        });
8831    }
8832    refs
8833}
8834
8835fn build_rust_module_refs(
8836    project_root: &Path,
8837    abs_path: &Path,
8838    rel_path: &str,
8839    source: &str,
8840) -> Vec<RawRef> {
8841    let grammar = grammar_for(LangId::Rust);
8842    let mut parser = Parser::new();
8843    if parser.set_language(&grammar).is_err() {
8844        return Vec::new();
8845    }
8846    let Some(tree) = parser.parse(source, None) else {
8847        return Vec::new();
8848    };
8849
8850    let mut refs = Vec::new();
8851    let mut stack = vec![tree.root_node()];
8852    while let Some(node) = stack.pop() {
8853        if node.kind() == "mod_item"
8854            && node
8855                .named_children(&mut node.walk())
8856                .all(|child| child.kind() != "declaration_list")
8857        {
8858            if let Some(name_node) = node.child_by_field_name("name") {
8859                let module_name = node_text(name_node, source).to_string();
8860                let target = rust_external_module_target(abs_path, source, node, &module_name);
8861                let mut dependencies = BTreeSet::new();
8862                if let Some(target) = target {
8863                    dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
8864                }
8865                refs.push(RawRef {
8866                    ref_id: ref_id(&[
8867                        rel_path,
8868                        "module",
8869                        &module_name,
8870                        &node.start_byte().to_string(),
8871                    ]),
8872                    caller_node: None,
8873                    caller_symbol: None,
8874                    caller_file: rel_path.to_string(),
8875                    kind: "module".to_string(),
8876                    short_name: Some(module_name.clone()),
8877                    full_ref: Some(module_name.clone()),
8878                    module_path: Some(module_name.clone()),
8879                    import_kind: Some("module".to_string()),
8880                    local_name: Some(module_name.clone()),
8881                    requested_name: Some(module_name),
8882                    namespace_alias: None,
8883                    wildcard: false,
8884                    line: node.start_position().row as u32 + 1,
8885                    byte_start: node.start_byte(),
8886                    byte_end: node.end_byte(),
8887                    dependencies,
8888                });
8889            }
8890        }
8891
8892        let mut cursor = node.walk();
8893        if cursor.goto_first_child() {
8894            loop {
8895                stack.push(cursor.node());
8896                if !cursor.goto_next_sibling() {
8897                    break;
8898                }
8899            }
8900        }
8901    }
8902    refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
8903    refs
8904}
8905
8906fn rust_declared_module_target(
8907    project_root: &Path,
8908    caller_file: &str,
8909    module_name: &str,
8910) -> Option<String> {
8911    let declaring_file = project_root.join(caller_file);
8912    let source = std::fs::read_to_string(&declaring_file).ok()?;
8913    let grammar = grammar_for(LangId::Rust);
8914    let mut parser = Parser::new();
8915    parser.set_language(&grammar).ok()?;
8916    let tree = parser.parse(&source, None)?;
8917    let mut stack = vec![tree.root_node()];
8918    while let Some(node) = stack.pop() {
8919        if node.kind() == "mod_item"
8920            && node
8921                .child_by_field_name("name")
8922                .is_some_and(|name| node_text(name, &source) == module_name)
8923            && node
8924                .named_children(&mut node.walk())
8925                .all(|child| child.kind() != "declaration_list")
8926        {
8927            let target = rust_external_module_target(&declaring_file, &source, node, module_name)?;
8928            return Some(relative_path(project_root, &canonicalize_path(&target)));
8929        }
8930        let mut cursor = node.walk();
8931        if cursor.goto_first_child() {
8932            loop {
8933                stack.push(cursor.node());
8934                if !cursor.goto_next_sibling() {
8935                    break;
8936                }
8937            }
8938        }
8939    }
8940    None
8941}
8942
8943fn rust_external_module_target(
8944    declaring_file: &Path,
8945    source: &str,
8946    module: Node<'_>,
8947    module_name: &str,
8948) -> Option<PathBuf> {
8949    let parent = declaring_file.parent()?;
8950    let mut previous = module.prev_sibling();
8951    while let Some(attribute) = previous {
8952        if attribute.kind() != "attribute_item" {
8953            break;
8954        }
8955        let text = source.get(attribute.byte_range())?;
8956        if let Some(path) = rust_path_attribute(text) {
8957            let candidate = parent.join(path);
8958            return candidate.is_file().then_some(candidate);
8959        }
8960        previous = attribute.prev_sibling();
8961    }
8962
8963    let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
8964    let module_dir = if matches!(stem, "lib" | "main" | "mod") {
8965        parent.to_path_buf()
8966    } else {
8967        parent.join(stem)
8968    };
8969    [
8970        module_dir.join(format!("{module_name}.rs")),
8971        module_dir.join(module_name).join("mod.rs"),
8972    ]
8973    .into_iter()
8974    .find(|candidate| candidate.is_file())
8975}
8976
8977fn rust_path_attribute(attribute: &str) -> Option<&str> {
8978    let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
8979    let (name, value) = body.split_once('=')?;
8980    (name.trim() == "path")
8981        .then(|| value.trim().trim_matches('"'))
8982        .filter(|path| !path.is_empty())
8983}
8984
8985fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
8986    let grammar = grammar_for(LangId::Rust);
8987    let mut parser = Parser::new();
8988    if parser.set_language(&grammar).is_err() {
8989        return;
8990    }
8991    let Some(tree) = parser.parse(source, None) else {
8992        return;
8993    };
8994
8995    let mut seen = data
8996        .import_block
8997        .imports
8998        .iter()
8999        .map(|import| (import.byte_range.start, import.byte_range.end))
9000        .collect::<HashSet<_>>();
9001    let mut nested_imports = Vec::new();
9002    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9003    if nested_imports.is_empty() {
9004        return;
9005    }
9006
9007    data.import_block.imports.extend(nested_imports);
9008    data.import_block
9009        .imports
9010        .sort_by_key(|import| import.byte_range.start);
9011    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9012}
9013
9014fn collect_rust_use_imports(
9015    source: &str,
9016    node: Node<'_>,
9017    seen: &mut HashSet<(usize, usize)>,
9018    imports: &mut Vec<ImportStatement>,
9019) {
9020    if node.kind() == "use_declaration" {
9021        let range = node.byte_range();
9022        if seen.insert((range.start, range.end)) {
9023            if let Some(import) = rust_import_from_use_node(source, node) {
9024                imports.push(import);
9025            }
9026        }
9027    }
9028
9029    let mut cursor = node.walk();
9030    if !cursor.goto_first_child() {
9031        return;
9032    }
9033    loop {
9034        collect_rust_use_imports(source, cursor.node(), seen, imports);
9035        if !cursor.goto_next_sibling() {
9036            break;
9037        }
9038    }
9039}
9040
9041fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9042    let raw_text = source[node.byte_range()].to_string();
9043    let body = rust_use_body(&raw_text)?.to_string();
9044    let visibility = rust_use_visibility(&raw_text);
9045    let names = rust_use_list_names(&body);
9046    let group = classify_rust_import_group(&body);
9047    let byte_range = node.byte_range();
9048
9049    Some(ImportStatement {
9050        module_path: body,
9051        names: names.clone(),
9052        default_import: visibility.clone(),
9053        namespace_import: None,
9054        kind: ImportKind::Value,
9055        group,
9056        byte_range,
9057        raw_text,
9058        form: ImportForm::RustUse {
9059            visibility,
9060            named: names,
9061        },
9062    })
9063}
9064
9065fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9066    let start = imports.iter().map(|import| import.byte_range.start).min()?;
9067    let end = imports.iter().map(|import| import.byte_range.end).max()?;
9068    Some(start..end)
9069}
9070
9071fn rust_use_visibility(raw_text: &str) -> Option<String> {
9072    let use_pos = raw_text.find("use ")?;
9073    let prefix = raw_text[..use_pos].trim();
9074    if prefix.is_empty() {
9075        None
9076    } else {
9077        Some(prefix.to_string())
9078    }
9079}
9080
9081fn rust_use_body(raw_text: &str) -> Option<&str> {
9082    let use_pos = raw_text.find("use ")?;
9083    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9084}
9085
9086fn rust_use_list_names(body: &str) -> Vec<String> {
9087    let Some(open) = body.find("::{") else {
9088        return Vec::new();
9089    };
9090    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9091        return Vec::new();
9092    };
9093    body[open + 3..close]
9094        .split(',')
9095        .filter_map(|spec| {
9096            let spec = spec.trim();
9097            if spec.is_empty() {
9098                None
9099            } else {
9100                Some(spec.to_string())
9101            }
9102        })
9103        .collect()
9104}
9105
9106fn classify_rust_import_group(body: &str) -> ImportGroup {
9107    let first = body
9108        .split("::")
9109        .next()
9110        .unwrap_or(body)
9111        .split_whitespace()
9112        .next()
9113        .unwrap_or(body);
9114    match first.trim() {
9115        "std" | "core" | "alloc" => ImportGroup::Stdlib,
9116        "crate" | "self" | "super" => ImportGroup::Internal,
9117        _ => ImportGroup::External,
9118    }
9119}
9120
9121#[derive(Debug, Clone)]
9122struct ReexportRefs {
9123    raw_refs: Vec<RawRef>,
9124    surface_parts: Vec<String>,
9125}
9126
9127fn collect_reexport_refs(
9128    project_root: &Path,
9129    abs_path: &Path,
9130    rel_path: &str,
9131    source: &str,
9132) -> ReexportRefs {
9133    let mut raw_refs = Vec::new();
9134    let mut surface_parts = Vec::new();
9135    let mut search_start = 0usize;
9136    let mut ordinal = 0usize;
9137    while let Some(export_offset) = source[search_start..].find("export") {
9138        let start = search_start + export_offset;
9139        let Some(statement_end_offset) = source[start..].find(';') else {
9140            break;
9141        };
9142        let end = start + statement_end_offset + 1;
9143        let statement = &source[start..end];
9144        search_start = end;
9145        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9146            continue;
9147        }
9148        let Some(module_path) = quoted_module_path(statement) else {
9149            continue;
9150        };
9151        ordinal += 1;
9152        let wildcard = statement.contains('*');
9153        let line = source[..start]
9154            .bytes()
9155            .filter(|byte| *byte == b'\n')
9156            .count() as u32
9157            + 1;
9158        let ref_id = ref_id(&[
9159            rel_path,
9160            "reexport",
9161            &start.to_string(),
9162            &end.to_string(),
9163            &module_path,
9164            &ordinal.to_string(),
9165        ]);
9166        surface_parts.push(format!("reexport\t{statement}"));
9167        raw_refs.push(RawRef {
9168            ref_id,
9169            caller_node: None,
9170            caller_symbol: None,
9171            caller_file: rel_path.to_string(),
9172            kind: "reexport".to_string(),
9173            short_name: None,
9174            full_ref: Some(statement.to_string()),
9175            module_path: Some(module_path.clone()),
9176            import_kind: Some("reexport".to_string()),
9177            local_name: None,
9178            requested_name: None,
9179            namespace_alias: None,
9180            wildcard,
9181            line,
9182            byte_start: start,
9183            byte_end: end,
9184            dependencies: module_dependencies(project_root, abs_path, &module_path),
9185        });
9186    }
9187    ReexportRefs {
9188        raw_refs,
9189        surface_parts,
9190    }
9191}
9192
9193fn collect_rust_pub_use_reexport_refs(
9194    project_root: &Path,
9195    abs_path: &Path,
9196    rel_path: &str,
9197    imports: &[ImportStatement],
9198    line_index: &LineIndex,
9199) -> ReexportRefs {
9200    let mut raw_refs = Vec::new();
9201    let mut surface_parts = Vec::new();
9202    let mut ordinal = 0usize;
9203
9204    for import in imports {
9205        let Some(visibility) = &import.default_import else {
9206            continue;
9207        };
9208        if !visibility.starts_with("pub") {
9209            continue;
9210        }
9211        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9212            continue;
9213        };
9214        ordinal += 1;
9215        let ref_id = ref_id(&[
9216            rel_path,
9217            "rust_reexport",
9218            &import.byte_range.start.to_string(),
9219            &import.byte_range.end.to_string(),
9220            &module_path,
9221            &ordinal.to_string(),
9222        ]);
9223        surface_parts.push(format!("reexport\t{}", import.raw_text));
9224        raw_refs.push(RawRef {
9225            ref_id,
9226            caller_node: None,
9227            caller_symbol: None,
9228            caller_file: rel_path.to_string(),
9229            kind: "reexport".to_string(),
9230            short_name: None,
9231            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9232            module_path: Some(module_path.clone()),
9233            import_kind: Some("reexport".to_string()),
9234            local_name: None,
9235            requested_name: None,
9236            namespace_alias: None,
9237            wildcard,
9238            line: line_index.byte_to_line(import.byte_range.start),
9239            byte_start: import.byte_range.start,
9240            byte_end: import.byte_range.end,
9241            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
9242        });
9243    }
9244
9245    ReexportRefs {
9246        raw_refs,
9247        surface_parts,
9248    }
9249}
9250
9251fn rust_pub_use_reexport_parts(
9252    import: &ImportStatement,
9253) -> Option<(String, HashMap<String, String>, bool)> {
9254    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9255    let body = body.trim();
9256    if let Some(module_path) = body.strip_suffix("::*") {
9257        return Some((module_path.trim().to_string(), HashMap::new(), true));
9258    }
9259
9260    if let Some(brace_start) = body.find("::{") {
9261        let module_path = body[..brace_start].trim().to_string();
9262        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9263        if names.is_empty() {
9264            return None;
9265        }
9266        return Some((module_path, names, false));
9267    }
9268
9269    let (module_path, spec) = body.rsplit_once("::")?;
9270    let names = rust_reexport_names_from_specs(spec);
9271    if names.is_empty() {
9272        return None;
9273    }
9274    Some((module_path.trim().to_string(), names, false))
9275}
9276
9277fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9278    let mut names = HashMap::new();
9279    for spec in specs.split(',') {
9280        let spec = spec.trim();
9281        if spec.is_empty() || spec == "self" {
9282            continue;
9283        }
9284        if let Some((source, local)) = spec.split_once(" as ") {
9285            let source = source.trim();
9286            let local = local.trim();
9287            if !source.is_empty() && !local.is_empty() && source != "self" {
9288                names.insert(local.to_string(), source.to_string());
9289            }
9290        } else {
9291            names.insert(spec.to_string(), spec.to_string());
9292        }
9293    }
9294    names
9295}
9296
9297fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9298    if named.is_empty() {
9299        return fallback.to_string();
9300    }
9301    let mut specs = named
9302        .iter()
9303        .map(|(local, source)| {
9304            if local == source {
9305                source.clone()
9306            } else {
9307                format!("{source} as {local}")
9308            }
9309        })
9310        .collect::<Vec<_>>();
9311    specs.sort();
9312    format!("pub use {{{}}};", specs.join(", "))
9313}
9314
9315fn quoted_module_path(statement: &str) -> Option<String> {
9316    let quote = match (statement.find('\''), statement.find('"')) {
9317        (Some(single), Some(double)) if single < double => '\'',
9318        (Some(_), Some(_)) => '"',
9319        (Some(_), None) => '\'',
9320        (None, Some(_)) => '"',
9321        (None, None) => return None,
9322    };
9323    let start = statement.find(quote)? + 1;
9324    let end = statement[start..].find(quote)? + start;
9325    Some(statement[start..end].to_string())
9326}
9327
9328#[derive(Debug, Clone)]
9329struct SourceLessExportRefs {
9330    raw_refs: Vec<RawRef>,
9331    surface_parts: Vec<String>,
9332}
9333
9334fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9335    let mut raw_refs = Vec::new();
9336    let mut surface_parts = Vec::new();
9337    let mut search_start = 0usize;
9338    let mut ordinal = 0usize;
9339    while let Some(export_offset) = source[search_start..].find("export") {
9340        let start = search_start + export_offset;
9341        let Some(statement_end_offset) = source[start..].find(';') else {
9342            break;
9343        };
9344        let end = start + statement_end_offset + 1;
9345        let statement = &source[start..end];
9346        search_start = end;
9347        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9348            continue;
9349        }
9350        let aliases = parse_reexport_names(statement);
9351        if aliases.is_empty() {
9352            continue;
9353        }
9354        let line = source[..start]
9355            .bytes()
9356            .filter(|byte| *byte == b'\n')
9357            .count() as u32
9358            + 1;
9359        for (exported, source_symbol) in aliases {
9360            ordinal += 1;
9361            let ref_id = ref_id(&[
9362                rel_path,
9363                "export_alias",
9364                &start.to_string(),
9365                &end.to_string(),
9366                &exported,
9367                &source_symbol,
9368                &ordinal.to_string(),
9369            ]);
9370            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9371            raw_refs.push(RawRef {
9372                ref_id,
9373                caller_node: None,
9374                caller_symbol: None,
9375                caller_file: rel_path.to_string(),
9376                kind: "export_alias".to_string(),
9377                short_name: None,
9378                full_ref: Some(statement.to_string()),
9379                module_path: None,
9380                import_kind: Some("export_alias".to_string()),
9381                local_name: Some(exported),
9382                requested_name: Some(source_symbol),
9383                namespace_alias: None,
9384                wildcard: false,
9385                line,
9386                byte_start: start,
9387                byte_end: end,
9388                dependencies: BTreeSet::new(),
9389            });
9390        }
9391    }
9392    SourceLessExportRefs {
9393        raw_refs,
9394        surface_parts,
9395    }
9396}
9397
9398fn build_dispatch_hints(
9399    rel_path: &str,
9400    data: &FileCallData,
9401    node_by_scoped: &HashMap<String, String>,
9402) -> Vec<DispatchHint> {
9403    let mut hints = Vec::new();
9404    let mut ordinal = 0usize;
9405    for (caller_symbol, call_sites) in &data.calls_by_symbol {
9406        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9407            continue;
9408        };
9409        for call_site in call_sites {
9410            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9411                continue;
9412            }
9413            ordinal += 1;
9414            hints.push(DispatchHint {
9415                id: ref_id(&[
9416                    rel_path,
9417                    "dispatch",
9418                    caller_symbol,
9419                    &call_site.line.to_string(),
9420                    &call_site.byte_start.to_string(),
9421                    &call_site.byte_end.to_string(),
9422                    &ordinal.to_string(),
9423                ]),
9424                method_name: call_site.callee_name.clone(),
9425                caller_node: caller_node.clone(),
9426                file: rel_path.to_string(),
9427                line: call_site.line,
9428                byte_start: call_site.byte_start,
9429                byte_end: call_site.byte_end,
9430            });
9431        }
9432    }
9433    hints
9434}
9435
9436fn surface_fingerprint(
9437    nodes: &mut [NodeRecord],
9438    data: &FileCallData,
9439    reexport_parts: &[String],
9440) -> String {
9441    nodes.sort_by(|left, right| {
9442        (left.file_path.as_str(), left.scoped_name.as_str())
9443            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
9444    });
9445    let mut parts = Vec::new();
9446    for node in nodes.iter() {
9447        parts.push(format!(
9448            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
9449            node.scoped_name,
9450            node.name,
9451            node.kind,
9452            node.exported,
9453            node.range.start_line,
9454            node.range.start_col,
9455            node.range.end_line,
9456            node.range.end_col,
9457            node.range_ordinal,
9458            node.signature.as_deref().unwrap_or("")
9459        ));
9460    }
9461    let mut exports = data.exported_symbols.clone();
9462    exports.sort();
9463    for export in exports {
9464        parts.push(format!("export\t{export}"));
9465    }
9466    if let Some(default_export) = &data.default_export_symbol {
9467        parts.push(format!("default\t{default_export}"));
9468    }
9469    let mut imports: Vec<String> = data
9470        .import_block
9471        .imports
9472        .iter()
9473        .map(|import| {
9474            format!(
9475                "import\t{}\t{:?}\t{}",
9476                import.module_path, import.form, import.raw_text
9477            )
9478        })
9479        .collect();
9480    imports.sort();
9481    parts.extend(imports);
9482    parts.extend(reexport_parts.iter().cloned());
9483    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
9484}
9485
9486fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
9487    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
9488        return Ok(ResolvedRef {
9489            dependencies: raw.dependencies.clone(),
9490            raw,
9491            status: "unresolved".to_string(),
9492            target_node: None,
9493            target_file: None,
9494            target_symbol: None,
9495            edge: None,
9496        });
9497    }
9498
9499    let caller_file = raw.caller_file.clone();
9500    let caller_data =
9501        index
9502            .caller_data(&caller_file)
9503            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
9504                file: caller_file.clone(),
9505            })?;
9506    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
9507    let short_name = raw.short_name.as_deref().unwrap_or_default();
9508    let mut dependencies = raw.dependencies.clone();
9509
9510    let resolved = match index.lang_for(&caller_file) {
9511        Some(LangId::Rust) => {
9512            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
9513        }
9514        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
9515            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
9516        }
9517        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
9518    };
9519
9520    let Some((status, target_file, target_symbol)) = resolved else {
9521        return Ok(ResolvedRef {
9522            raw,
9523            status: "unresolved".to_string(),
9524            target_node: None,
9525            target_file: None,
9526            target_symbol: None,
9527            dependencies,
9528            edge: None,
9529        });
9530    };
9531
9532    dependencies.insert(target_file.clone());
9533    let target_node = index.node_for_symbol(&target_file, &target_symbol);
9534    if raw.kind == "value_ref"
9535        && !target_node
9536            .as_deref()
9537            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
9538    {
9539        return Ok(ResolvedRef {
9540            raw,
9541            status: "unresolved".to_string(),
9542            target_node: None,
9543            target_file: None,
9544            target_symbol: None,
9545            dependencies,
9546            edge: None,
9547        });
9548    }
9549    let source_node = raw.caller_node.clone();
9550    let edge = if let Some(source_node) = source_node {
9551        if target_file == caller_file
9552            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
9553        {
9554            None
9555        } else {
9556            Some(EdgeRecord {
9557                edge_id: ref_id(&[&raw.ref_id, "edge"]),
9558                source_node,
9559                target_node: target_node.clone(),
9560                target_file: target_file.clone(),
9561                target_symbol: target_symbol.clone(),
9562                kind: raw.kind.clone(),
9563                line: raw.line,
9564            })
9565        }
9566    } else {
9567        None
9568    };
9569
9570    Ok(ResolvedRef {
9571        raw,
9572        status,
9573        target_node,
9574        target_file: Some(target_file),
9575        target_symbol: Some(target_symbol),
9576        dependencies,
9577        edge,
9578    })
9579}
9580
9581fn resolve_js_ts_target<I: ResolverIndex>(
9582    index: &I,
9583    caller_file: &str,
9584    full_ref: &str,
9585    short_name: &str,
9586    caller_data: &FileCallData,
9587) -> Option<(String, String, String)> {
9588    if let Some((namespace, member)) = full_ref.split_once('.') {
9589        for import in &caller_data.import_block.imports {
9590            if import.namespace_import.as_deref() == Some(namespace) {
9591                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9592                    if let Some((file, symbol)) =
9593                        resolve_exported_symbol(index, &target_file, member, 0)
9594                    {
9595                        return Some(("resolved".to_string(), file, symbol));
9596                    }
9597                }
9598            }
9599        }
9600    }
9601
9602    for import in &caller_data.import_block.imports {
9603        for spec in &import.names {
9604            if crate::imports::specifier_local_name(spec) == short_name {
9605                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9606                    let requested = crate::imports::specifier_imported_name(spec);
9607                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
9608                        .unwrap_or_else(|| (target_file, requested.to_string()));
9609                    return Some(("resolved".to_string(), file, symbol));
9610                }
9611            }
9612        }
9613
9614        if import.default_import.as_deref() == Some(short_name) {
9615            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9616                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
9617                    .or_else(|| {
9618                        index
9619                            .default_export(&target_file)
9620                            .map(|symbol| (target_file.clone(), symbol))
9621                    })
9622                    .unwrap_or_else(|| {
9623                        let file_name = Path::new(&target_file)
9624                            .file_name()
9625                            .and_then(|name| name.to_str())
9626                            .unwrap_or("unknown")
9627                            .to_string();
9628                        (target_file, format!("<default:{file_name}>"))
9629                    });
9630                return Some(("resolved".to_string(), file, symbol));
9631            }
9632        }
9633    }
9634
9635    for import in &caller_data.import_block.imports {
9636        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9637            if index.has_export(&target_file, short_name) {
9638                return Some(("resolved".to_string(), target_file, short_name.to_string()));
9639            }
9640        }
9641    }
9642
9643    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9644}
9645
9646fn resolve_exported_symbol<I: ResolverIndex>(
9647    index: &I,
9648    file: &str,
9649    requested: &str,
9650    depth: usize,
9651) -> Option<(String, String)> {
9652    let mut visited = std::collections::HashMap::new();
9653    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
9654}
9655
9656/// Re-export graphs are frequently cyclic (barrel files re-exporting each
9657/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
9658/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
9659/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
9660/// (file, symbol) pair — but only when the earlier visit had at least as much
9661/// remaining depth budget (a shallower re-visit can reach leaves the deeper
9662/// first visit had to cut off at the cap, so plain visited-set pruning would
9663/// lose resolutions the capped walk finds).
9664fn resolve_exported_symbol_inner<I: ResolverIndex>(
9665    index: &I,
9666    file: &str,
9667    requested: &str,
9668    depth: usize,
9669    visited: &mut std::collections::HashMap<(String, String), usize>,
9670) -> Option<(String, String)> {
9671    if depth > 16 {
9672        return None;
9673    }
9674    if requested != "default" {
9675        if let Some(source_symbol) = index.export_alias(file, requested) {
9676            return Some((file.to_string(), source_symbol));
9677        }
9678        if index.has_export(file, requested) {
9679            return Some((file.to_string(), requested.to_string()));
9680        }
9681    } else if let Some(default) = index.default_export(file) {
9682        return Some((file.to_string(), default));
9683    }
9684
9685    // Memo check sits after the local-export fast paths: the common direct
9686    // hit never allocates the key, and a hit through the memo would have
9687    // returned above anyway.
9688    match visited.entry((file.to_string(), requested.to_string())) {
9689        std::collections::hash_map::Entry::Occupied(mut seen) => {
9690            if *seen.get() <= depth {
9691                return None;
9692            }
9693            seen.insert(depth);
9694        }
9695        std::collections::hash_map::Entry::Vacant(slot) => {
9696            slot.insert(depth);
9697        }
9698    }
9699
9700    for reexport in index.reexports_for(file) {
9701        let mut next_requested = requested.to_string();
9702        let matches = if reexport.wildcard {
9703            true
9704        } else if let Some(source_name) = reexport.named.get(requested) {
9705            next_requested = source_name.clone();
9706            true
9707        } else {
9708            false
9709        };
9710        if !matches {
9711            continue;
9712        }
9713        if let Some(target_file) = &reexport.target_file {
9714            if let Some(target) = resolve_exported_symbol_inner(
9715                index,
9716                target_file,
9717                &next_requested,
9718                depth + 1,
9719                visited,
9720            ) {
9721                return Some(target);
9722            }
9723        }
9724    }
9725    None
9726}
9727
9728fn resolve_rust_target<I: ResolverIndex>(
9729    index: &I,
9730    caller_file: &str,
9731    full_ref: &str,
9732    short_name: &str,
9733    caller_data: &FileCallData,
9734    raw: &RawRef,
9735) -> Option<(String, String, String)> {
9736    if full_ref.contains("::") {
9737        if let Some((target_file, target_symbol)) =
9738            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
9739        {
9740            return Some(("resolved".to_string(), target_file, target_symbol));
9741        }
9742    }
9743
9744    for import in &caller_data.import_block.imports {
9745        if let Some((target_file, target_symbol)) =
9746            rust_target_for_use(index, caller_file, import, short_name)
9747        {
9748            return Some(("resolved".to_string(), target_file, target_symbol));
9749        }
9750    }
9751
9752    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9753}
9754
9755fn rust_target_for_qualified<I: ResolverIndex>(
9756    index: &I,
9757    caller_file: &str,
9758    full_ref: &str,
9759    short_name: &str,
9760    caller_data: &FileCallData,
9761    raw: &RawRef,
9762) -> Option<(String, String)> {
9763    let mut segments: Vec<&str> = full_ref.split("::").collect();
9764    if segments.len() < 2 {
9765        return None;
9766    }
9767    segments.pop();
9768    let requested_symbol = rust_target_symbol(full_ref, short_name);
9769
9770    for path in rust_module_path_candidates(&segments, caller_data, raw) {
9771        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
9772        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
9773            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
9774                return Some(rust_resolve_reexport_if_symbol_missing(
9775                    index,
9776                    target_file,
9777                    requested_symbol.clone(),
9778                ));
9779            }
9780        }
9781
9782        let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
9783        if let Some(target) =
9784            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9785        {
9786            return Some(target);
9787        }
9788        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9789            return Some(rust_resolve_reexport_if_symbol_missing(
9790                index,
9791                target_file,
9792                requested_symbol.clone(),
9793            ));
9794        }
9795    }
9796    None
9797}
9798
9799fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9800    full_ref
9801        .rsplit("::")
9802        .next()
9803        .filter(|name| !name.is_empty())
9804        .unwrap_or(short_name)
9805        .to_string()
9806}
9807
9808fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9809    index: &I,
9810    target_file: String,
9811    target_symbol: String,
9812) -> (String, String) {
9813    if index
9814        .node_for_symbol(&target_file, &target_symbol)
9815        .is_some()
9816    {
9817        return (target_file, target_symbol);
9818    }
9819    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9820        resolved
9821    } else {
9822        (target_file, target_symbol)
9823    }
9824}
9825
9826fn rust_module_path_candidates(
9827    segments: &[&str],
9828    caller_data: &FileCallData,
9829    raw: &RawRef,
9830) -> Vec<Vec<String>> {
9831    let mut candidates = Vec::new();
9832    if let Some(first) = segments.first().copied() {
9833        for import in &caller_data.import_block.imports {
9834            if !rust_import_is_visible_to_call(import, raw) {
9835                continue;
9836            }
9837            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9838                continue;
9839            };
9840            if local_name == first {
9841                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9842                rust_push_unique_path_candidate(&mut candidates, path_segments);
9843            }
9844        }
9845    }
9846    rust_push_unique_path_candidate(
9847        &mut candidates,
9848        segments
9849            .iter()
9850            .map(|segment| (*segment).to_string())
9851            .collect(),
9852    );
9853    candidates
9854}
9855
9856fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9857    if !candidates.iter().any(|existing| existing == &candidate) {
9858        candidates.push(candidate);
9859    }
9860}
9861
9862fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9863    import.byte_range.start <= raw.byte_start
9864}
9865
9866fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9867    let path = import.module_path.trim().trim_end_matches(';').trim();
9868    if path.contains("::{") || path.contains('{') || path.contains('*') {
9869        return None;
9870    }
9871    let (path_without_alias, alias) = path
9872        .split_once(" as ")
9873        .map(|(left, right)| (left.trim(), Some(right.trim())))
9874        .unwrap_or((path, None));
9875    let segments = path_without_alias
9876        .split("::")
9877        .map(str::trim)
9878        .filter(|segment| !segment.is_empty())
9879        .collect::<Vec<_>>();
9880    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9881    if local_name.chars().next().is_some_and(char::is_uppercase) {
9882        return None;
9883    }
9884    Some((
9885        local_name,
9886        segments
9887            .into_iter()
9888            .map(|segment| segment.to_string())
9889            .collect(),
9890    ))
9891}
9892
9893fn rust_inline_scoped_target<I: ResolverIndex>(
9894    index: &I,
9895    caller_file: &str,
9896    module_segments: &[String],
9897    short_name: &str,
9898) -> Option<(String, String)> {
9899    index.inline_scoped_target(caller_file, module_segments, short_name)
9900}
9901
9902fn rust_target_for_use<I: ResolverIndex>(
9903    index: &I,
9904    caller_file: &str,
9905    import: &ImportStatement,
9906    short_name: &str,
9907) -> Option<(String, String)> {
9908    let path = import.module_path.trim().trim_end_matches(';');
9909    if let Some(brace_start) = path.find("::{") {
9910        let prefix = &path[..brace_start];
9911        if import.names.iter().any(|name| name == short_name) {
9912            let prefix_segments: Vec<&str> = prefix.split("::").collect();
9913            let module_segments =
9914                rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
9915            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9916            return Some((file, short_name.to_string()));
9917        }
9918        return None;
9919    }
9920
9921    let (path_without_alias, alias) = path
9922        .split_once(" as ")
9923        .map(|(left, right)| (left.trim(), Some(right.trim())))
9924        .unwrap_or((path, None));
9925    let segments: Vec<&str> = path_without_alias.split("::").collect();
9926    let imported = alias.or_else(|| segments.last().copied())?;
9927    if imported != short_name {
9928        return None;
9929    }
9930    if segments.len() < 2 {
9931        return None;
9932    }
9933    let module_segments =
9934        rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
9935    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
9936    Some((file, segments.last().unwrap_or(&short_name).to_string()))
9937}
9938
9939fn rust_workspace_file_for_segments<I: ResolverIndex>(
9940    index: &I,
9941    segments: &[&str],
9942) -> Option<String> {
9943    let crate_name = segments.first().copied()?;
9944    let src_prefix = index.crate_src_prefix(crate_name)?;
9945    let module_segments = segments[1..]
9946        .iter()
9947        .map(|segment| segment.to_string())
9948        .collect::<Vec<_>>();
9949    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
9950}
9951
9952#[cfg(test)]
9953static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
9954    OnceLock::new();
9955
9956#[cfg(test)]
9957fn note_workspace_crate_prefix_build(project_root: &Path) {
9958    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9959        .get_or_init(|| Mutex::new(HashMap::new()))
9960        .lock()
9961        .expect("workspace crate prefix build counts mutex poisoned");
9962    *counts.entry(project_root.to_path_buf()).or_default() += 1;
9963}
9964
9965#[cfg(not(test))]
9966fn note_workspace_crate_prefix_build(_project_root: &Path) {}
9967
9968#[cfg(test)]
9969fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
9970    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9971        .get_or_init(|| Mutex::new(HashMap::new()))
9972        .lock()
9973        .expect("workspace crate prefix build counts mutex poisoned")
9974        .remove(project_root);
9975}
9976
9977#[cfg(test)]
9978fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
9979    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
9980        .get_or_init(|| Mutex::new(HashMap::new()))
9981        .lock()
9982        .expect("workspace crate prefix build counts mutex poisoned")
9983        .get(project_root)
9984        .copied()
9985        .unwrap_or(0)
9986}
9987
9988/// Walk the project tree once and map every Rust crate name (package name with
9989/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
9990/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
9991/// longer re-walks the filesystem once per ref.
9992fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
9993    note_workspace_crate_prefix_build(project_root);
9994    let mut prefixes = HashMap::new();
9995    let mut stack = vec![project_root.to_path_buf()];
9996    while let Some(dir) = stack.pop() {
9997        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
9998        if matches!(name, "target" | "node_modules" | ".git") {
9999            continue;
10000        }
10001        let manifest = dir.join("Cargo.toml");
10002        if manifest.is_file() {
10003            let crate_names = rust_manifest_crate_names(&manifest);
10004            if !crate_names.is_empty() {
10005                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
10006                for crate_name in crate_names {
10007                    prefixes
10008                        .entry(crate_name)
10009                        .or_insert_with(|| src_prefix.clone());
10010                }
10011            }
10012        }
10013        let Ok(entries) = std::fs::read_dir(&dir) else {
10014            continue;
10015        };
10016        for entry in entries.flatten() {
10017            let path = entry.path();
10018            if path.is_dir() {
10019                stack.push(path);
10020            }
10021        }
10022    }
10023    prefixes
10024}
10025
10026/// Extract the crate names a manifest defines: the normalized package name
10027/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
10028/// reachable by either spelling, matching the previous match semantics.
10029fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
10030    let Ok(source) = std::fs::read_to_string(manifest) else {
10031        return Vec::new();
10032    };
10033    let mut in_lib = false;
10034    let mut package_name = None;
10035    let mut lib_name = None;
10036    for line in source.lines() {
10037        let trimmed = line.trim();
10038        if trimmed.starts_with('[') {
10039            in_lib = trimmed == "[lib]";
10040            continue;
10041        }
10042        let Some((key, value)) = trimmed.split_once('=') else {
10043            continue;
10044        };
10045        let key = key.trim();
10046        let value = value.trim().trim_matches('"');
10047        if in_lib && key == "name" {
10048            lib_name = Some(value.to_string());
10049        } else if !in_lib && key == "name" && package_name.is_none() {
10050            package_name = Some(value.to_string());
10051        }
10052    }
10053    let mut names = Vec::new();
10054    if let Some(lib) = lib_name {
10055        names.push(lib);
10056    }
10057    if let Some(package) = package_name {
10058        let normalized = package.replace('-', "_");
10059        if !names.contains(&normalized) {
10060            names.push(normalized);
10061        }
10062    }
10063    names
10064}
10065
10066fn rust_resolve_segments_with_index<I: ResolverIndex>(
10067    index: &I,
10068    caller_file: &str,
10069    segments: &[&str],
10070) -> Option<Vec<String>> {
10071    let caller_segments = rust_registered_module_segments(index, caller_file)
10072        .unwrap_or_else(|| rust_module_segments_for_rel(caller_file));
10073    rust_resolve_segments_from(caller_segments, segments)
10074}
10075
10076fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10077    rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10078}
10079
10080fn rust_resolve_segments_from(
10081    caller_segments: Vec<String>,
10082    segments: &[&str],
10083) -> Option<Vec<String>> {
10084    if segments.is_empty() {
10085        return Some(Vec::new());
10086    }
10087    match segments[0] {
10088        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10089        "self" => {
10090            let mut resolved = caller_segments;
10091            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10092            Some(resolved)
10093        }
10094        "super" => {
10095            let mut resolved = caller_segments;
10096            resolved.pop();
10097            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10098            Some(resolved)
10099        }
10100        _ => {
10101            let mut resolved = caller_segments;
10102            resolved.pop();
10103            resolved.extend(segments.iter().map(|item| item.to_string()));
10104            Some(resolved)
10105        }
10106    }
10107}
10108
10109fn rust_registered_module_segments<I: ResolverIndex>(
10110    index: &I,
10111    caller_file: &str,
10112) -> Option<Vec<String>> {
10113    let mut current = caller_file.to_string();
10114    let mut segments = Vec::new();
10115    let mut seen = HashSet::new();
10116    while seen.insert(current.clone()) {
10117        let Some((parent, module)) = index.module_parent(&current) else {
10118            break;
10119        };
10120        segments.push(module);
10121        current = parent;
10122    }
10123    if segments.is_empty() {
10124        None
10125    } else {
10126        segments.reverse();
10127        Some(segments)
10128    }
10129}
10130
10131fn rust_file_for_segments<I: ResolverIndex>(
10132    index: &I,
10133    caller_file: &str,
10134    segments: &[String],
10135) -> Option<String> {
10136    let src_prefix = rust_src_prefix(caller_file);
10137    if let Some(target) = rust_file_from_module_declarations(index, &src_prefix, segments) {
10138        return Some(target);
10139    }
10140    rust_file_for_src_prefix(index, &src_prefix, segments)
10141}
10142
10143fn rust_file_from_module_declarations<I: ResolverIndex>(
10144    index: &I,
10145    src_prefix: &str,
10146    segments: &[String],
10147) -> Option<String> {
10148    let mut current = [
10149        format!("{src_prefix}/lib.rs"),
10150        format!("{src_prefix}/main.rs"),
10151    ]
10152    .into_iter()
10153    .find(|candidate| index.contains_file(candidate))?;
10154    for segment in segments {
10155        current = index.module_target(&current, segment)?;
10156    }
10157    Some(current)
10158}
10159
10160fn rust_file_for_src_prefix<I: ResolverIndex>(
10161    index: &I,
10162    src_prefix: &str,
10163    segments: &[String],
10164) -> Option<String> {
10165    let candidate = if segments.is_empty() {
10166        [src_prefix, "lib.rs"].join("/")
10167    } else {
10168        format!("{}/{}.rs", src_prefix, segments.join("/"))
10169    };
10170    if index.contains_file(&candidate) {
10171        return Some(candidate);
10172    }
10173    if !segments.is_empty() {
10174        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10175        if index.contains_file(&mod_candidate) {
10176            return Some(mod_candidate);
10177        }
10178    }
10179    None
10180}
10181
10182fn rust_src_prefix(rel_path: &str) -> String {
10183    rel_path
10184        .split_once("/src/")
10185        .map(|(prefix, _)| format!("{prefix}/src"))
10186        .unwrap_or_else(|| "src".to_string())
10187}
10188
10189fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10190    let after_src = rel_path
10191        .split_once("/src/")
10192        .map(|(_, rest)| rest)
10193        .or_else(|| rel_path.strip_prefix("src/"))
10194        .unwrap_or(rel_path);
10195    if matches!(after_src, "lib.rs" | "main.rs") {
10196        return Vec::new();
10197    }
10198    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10199        return prefix.split('/').map(|item| item.to_string()).collect();
10200    }
10201    after_src
10202        .strip_suffix(".rs")
10203        .unwrap_or(after_src)
10204        .split('/')
10205        .map(|item| item.to_string())
10206        .collect()
10207}
10208
10209fn resolve_local_target<I: ResolverIndex>(
10210    _index: &I,
10211    caller_file: &str,
10212    full_ref: &str,
10213    short_name: &str,
10214    caller_data: &FileCallData,
10215) -> Option<(String, String, String)> {
10216    if !callgraph::is_bare_callee(full_ref, short_name) {
10217        return None;
10218    }
10219    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10220        .ok()
10221        .map(|symbol| {
10222            (
10223                "resolved_local".to_string(),
10224                caller_file.to_string(),
10225                symbol,
10226            )
10227        })
10228}
10229
10230impl<'a> ProjectIndex<'a> {
10231    fn from_parts(
10232        project_root: &Path,
10233        files: HashMap<String, DbFileIndex>,
10234        caller_data: HashMap<String, &'a FileCallData>,
10235        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10236    ) -> Self {
10237        Self {
10238            project_root: project_root.to_path_buf(),
10239            files,
10240            caller_data,
10241            workspace_crate_prefixes,
10242        }
10243    }
10244
10245    fn from_db_and_callers(
10246        tx: &Transaction<'_>,
10247        project_root: &Path,
10248        caller_extracts: &'a HashMap<String, FileExtract>,
10249        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10250    ) -> Result<Self> {
10251        let mut files = load_db_file_indexes(tx, project_root)?;
10252        let mut caller_data = HashMap::new();
10253        for (rel_path, extract) in caller_extracts {
10254            files.insert(
10255                rel_path.clone(),
10256                DbFileIndex::from_extract(project_root, extract),
10257            );
10258            caller_data.insert(rel_path.clone(), &extract.data);
10259        }
10260        Ok(Self::from_parts(
10261            project_root,
10262            files,
10263            caller_data,
10264            workspace_crate_prefixes,
10265        ))
10266    }
10267
10268    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10269        self.files.get(rel_path).and_then(|file| file.lang)
10270    }
10271
10272    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10273        self.files
10274            .get(caller_file)
10275            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10276    }
10277
10278    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10279        self.files
10280            .get(rel_path)
10281            .map(|file| file.reexports.as_slice())
10282            .unwrap_or(&[])
10283    }
10284
10285    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10286        self.files.get(rel_path).and_then(|file| {
10287            file.node_by_scoped
10288                .get(symbol)
10289                .cloned()
10290                .or_else(|| file.node_by_bare.get(symbol).cloned())
10291        })
10292    }
10293
10294    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10295        self.files
10296            .get(rel_path)
10297            .and_then(|file| file.node_kind_by_id.get(node_id))
10298            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
10299    }
10300}
10301
10302impl DbFileIndex {
10303    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
10304        let mut node_by_scoped = HashMap::new();
10305        let mut node_by_bare = HashMap::new();
10306        for node in &extract.nodes {
10307            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10308            node_by_bare
10309                .entry(node.name.clone())
10310                .or_insert(node.id.clone());
10311        }
10312        let node_kind_by_id = extract
10313            .nodes
10314            .iter()
10315            .map(|node| (node.id.clone(), node.kind.clone()))
10316            .collect();
10317        let mut export_aliases = HashMap::new();
10318        for raw_ref in &extract.raw_refs {
10319            if raw_ref.kind == "export_alias" {
10320                if let (Some(exported), Some(source_symbol)) =
10321                    (&raw_ref.local_name, &raw_ref.requested_name)
10322                {
10323                    export_aliases.insert(exported.clone(), source_symbol.clone());
10324                }
10325            }
10326        }
10327        let mut module_targets = HashMap::new();
10328        let mut declared_module_targets = HashMap::new();
10329        let mut reexports = Vec::new();
10330        for raw_ref in &extract.raw_refs {
10331            if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10332                continue;
10333            }
10334            let Some(module_path) = &raw_ref.module_path else {
10335                continue;
10336            };
10337            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
10338            module_targets
10339                .entry(module_path.clone())
10340                .or_insert_with(|| target_file.clone());
10341            if raw_ref.kind == "module" {
10342                declared_module_targets
10343                    .entry(module_path.clone())
10344                    .or_insert_with(|| target_file.clone());
10345            }
10346            if raw_ref.kind == "reexport" {
10347                reexports.push(reexport_index_from_raw(raw_ref, target_file));
10348            }
10349        }
10350        Self {
10351            lang: Some(extract.lang),
10352            exports: extract.data.exported_symbols.iter().cloned().collect(),
10353            default_export: extract.data.default_export_symbol.clone(),
10354            export_aliases,
10355            node_by_scoped,
10356            node_by_bare,
10357            node_kind_by_id,
10358            module_targets,
10359            declared_module_targets,
10360            reexports,
10361        }
10362    }
10363}
10364
10365fn load_db_file_indexes(
10366    tx: &Transaction<'_>,
10367    project_root: &Path,
10368) -> Result<HashMap<String, DbFileIndex>> {
10369    let mut files = HashMap::new();
10370    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10371    let rows = stmt.query_map([], |row| {
10372        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10373    })?;
10374    for row in rows {
10375        let (rel_path, lang) = row?;
10376        files.insert(
10377            rel_path.clone(),
10378            DbFileIndex {
10379                lang: lang_from_label(&lang),
10380                exports: HashSet::new(),
10381                default_export: None,
10382                export_aliases: HashMap::new(),
10383                node_by_scoped: HashMap::new(),
10384                node_by_bare: HashMap::new(),
10385                node_kind_by_id: HashMap::new(),
10386                module_targets: HashMap::new(),
10387                declared_module_targets: HashMap::new(),
10388                reexports: Vec::new(),
10389            },
10390        );
10391    }
10392
10393    let mut node_stmt = tx.prepare(
10394        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
10395    )?;
10396    let nodes = node_stmt.query_map([], |row| {
10397        Ok((
10398            row.get::<_, String>(0)?,
10399            row.get::<_, String>(1)?,
10400            row.get::<_, String>(2)?,
10401            row.get::<_, String>(3)?,
10402            row.get::<_, String>(4)?,
10403            row.get::<_, i64>(5)? != 0,
10404            row.get::<_, i64>(6)? != 0,
10405        ))
10406    })?;
10407    for row in nodes {
10408        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
10409        let file = files
10410            .entry(file_path.clone())
10411            .or_insert_with(|| DbFileIndex {
10412                lang: None,
10413                exports: HashSet::new(),
10414                default_export: None,
10415                export_aliases: HashMap::new(),
10416                node_by_scoped: HashMap::new(),
10417                node_by_bare: HashMap::new(),
10418                node_kind_by_id: HashMap::new(),
10419                module_targets: HashMap::new(),
10420                declared_module_targets: HashMap::new(),
10421                reexports: Vec::new(),
10422            });
10423        if exported {
10424            file.exports.insert(name.clone());
10425            file.exports.insert(scoped_name.clone());
10426        }
10427        if is_default_export {
10428            file.default_export = Some(scoped_name.clone());
10429        }
10430        file.node_by_scoped.insert(scoped_name, id.clone());
10431        file.node_by_bare.entry(name).or_insert(id.clone());
10432        file.node_kind_by_id.insert(id, kind);
10433    }
10434    let file_keys: HashSet<String> = files.keys().cloned().collect();
10435    // Persisted caller extracts supply import targets. Only reexports from other
10436    // files need dependency reconstruction, and their caller dependencies are
10437    // loaded once instead of issuing repeated SQLite queries per reference.
10438    let dependencies_by_file = load_file_dependencies_index(tx)?;
10439    let mut ref_stmt = tx.prepare(
10440        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
10441             FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
10442    )?;
10443    let ref_rows = ref_stmt.query_map([], |row| {
10444        Ok((
10445            row.get::<_, String>(0)?,
10446            row.get::<_, String>(1)?,
10447            row.get::<_, String>(2)?,
10448            row.get::<_, Option<String>>(3)?,
10449            row.get::<_, Option<String>>(4)?,
10450            row.get::<_, i64>(5)? != 0,
10451            row.get::<_, Option<String>>(6)?,
10452            row.get::<_, Option<String>>(7)?,
10453        ))
10454    })?;
10455    for row in ref_rows {
10456        let (
10457            ref_id,
10458            caller_file,
10459            kind,
10460            module_path,
10461            full_ref,
10462            wildcard,
10463            local_name,
10464            requested_name,
10465        ) = row?;
10466        if kind == "export_alias" {
10467            if let (Some(exported), Some(source_symbol), Some(file)) =
10468                (local_name, requested_name, files.get_mut(&caller_file))
10469            {
10470                file.export_aliases.insert(exported, source_symbol);
10471            }
10472            continue;
10473        }
10474        let Some(module_path) = module_path else {
10475            continue;
10476        };
10477        let file_deps = dependencies_by_file
10478            .get(&caller_file)
10479            .cloned()
10480            .unwrap_or_default();
10481        let deps = stored_dependencies_for_module(
10482            project_root,
10483            &caller_file,
10484            &module_path,
10485            &file_deps,
10486            &file_keys,
10487        );
10488        let target_file = if kind == "module" {
10489            rust_declared_module_target(project_root, &caller_file, &module_path)
10490        } else {
10491            deps.iter()
10492                .find(|dep| file_keys.contains(*dep))
10493                .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))))
10494        };
10495        if let Some(file) = files.get_mut(&caller_file) {
10496            file.module_targets
10497                .entry(module_path.clone())
10498                .or_insert_with(|| target_file.clone());
10499            if kind == "module" {
10500                file.declared_module_targets
10501                    .entry(module_path.clone())
10502                    .or_insert_with(|| target_file.clone());
10503            }
10504            if kind == "reexport" {
10505                let raw = RawRef {
10506                    ref_id,
10507                    caller_node: None,
10508                    caller_symbol: None,
10509                    caller_file,
10510                    kind,
10511                    short_name: None,
10512                    full_ref,
10513                    module_path: Some(module_path),
10514                    import_kind: Some("reexport".to_string()),
10515                    local_name: None,
10516                    requested_name: None,
10517                    namespace_alias: None,
10518                    wildcard,
10519                    line: 0,
10520                    byte_start: 0,
10521                    byte_end: 0,
10522                    dependencies: deps,
10523                };
10524                file.reexports
10525                    .push(reexport_index_from_raw(&raw, target_file));
10526            }
10527        }
10528    }
10529
10530    Ok(files)
10531}
10532
10533fn stored_dependencies_for_module(
10534    project_root: &Path,
10535    caller_file: &str,
10536    module_path: &str,
10537    caller_dependencies: &BTreeSet<String>,
10538    indexed_files: &HashSet<String>,
10539) -> BTreeSet<String> {
10540    let caller_path = project_root.join(caller_file);
10541    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
10542    if module_path.starts_with('.') {
10543        let caller_dir = caller_path.parent().unwrap_or(project_root);
10544        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
10545            let normalized = if candidate.is_file() {
10546                canonicalize_path(&candidate)
10547            } else {
10548                candidate
10549            };
10550            candidates.insert(relative_path(project_root, &normalized));
10551        }
10552    }
10553    let exact = candidates
10554        .intersection(caller_dependencies)
10555        .filter(|dependency| indexed_files.contains(*dependency))
10556        .cloned()
10557        .collect::<BTreeSet<_>>();
10558    if !exact.is_empty() || module_path.starts_with('.') {
10559        return exact;
10560    }
10561
10562    let module_path = rust_module_path_without_alias_or_use_list(module_path)
10563        .trim_matches(|character| matches!(character, '\'' | '"'));
10564    let package_name = module_path
10565        .split('/')
10566        .next_back()
10567        .unwrap_or(module_path)
10568        .replace('_', "-");
10569    let matched = caller_dependencies
10570        .iter()
10571        .filter(|dependency| indexed_files.contains(*dependency))
10572        .filter(|dependency| {
10573            dependency.as_str() == module_path
10574                || dependency.ends_with(&format!("/{module_path}"))
10575                || Path::new(dependency).components().any(|component| {
10576                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
10577                })
10578        })
10579        .cloned()
10580        .collect::<BTreeSet<_>>();
10581    if matched.len() == 1 {
10582        matched
10583    } else {
10584        BTreeSet::new()
10585    }
10586}
10587
10588fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
10589    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
10590    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
10591    let rows = stmt.query_map([], |row| {
10592        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10593    })?;
10594    for row in rows {
10595        let (file_path, dependency) = row?;
10596        by_file.entry(file_path).or_default().insert(dependency);
10597    }
10598    Ok(by_file)
10599}
10600
10601struct ColdBuildInsertStatements<'stmt> {
10602    file: Statement<'stmt>,
10603    node: Statement<'stmt>,
10604    file_dependency: Statement<'stmt>,
10605    dispatch_hint: Statement<'stmt>,
10606    backend_state: Statement<'stmt>,
10607    reference: Statement<'stmt>,
10608    staging_ref_context: Statement<'stmt>,
10609    edge: Statement<'stmt>,
10610}
10611
10612impl<'stmt> ColdBuildInsertStatements<'stmt> {
10613    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
10614        Ok(Self {
10615            file: tx.prepare(
10616                "INSERT OR REPLACE INTO files(
10617                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10618                    is_public_api, surface_fingerprint, indexed_at
10619                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10620            )?,
10621            node: tx.prepare(
10622                "INSERT OR REPLACE INTO nodes(
10623                    id, file_path, name, scoped_name, kind, start_line, start_col,
10624                    end_line, end_col, range_ordinal, signature, exported,
10625                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
10626                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10627            )?,
10628            file_dependency: tx.prepare(
10629                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10630            )?,
10631            dispatch_hint: tx.prepare(
10632                "INSERT OR REPLACE INTO dispatch_hints(
10633                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10634                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10635            )?,
10636            backend_state: tx.prepare(
10637                "INSERT OR REPLACE INTO backend_file_state(
10638                    backend, workspace_root, file_path, content_hash, status, updated_at
10639                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10640            )?,
10641            reference: tx.prepare(
10642                "INSERT OR REPLACE INTO refs(
10643                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10644                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10645                    byte_start, byte_end, status, target_node, target_file, target_symbol,
10646                    provenance
10647                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10648            )?,
10649            staging_ref_context: tx.prepare(
10650                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
10651            )?,
10652            edge: tx.prepare(
10653                "INSERT OR REPLACE INTO edges(
10654                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10655                    kind, line, provenance
10656                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10657            )?,
10658        })
10659    }
10660}
10661
10662fn insert_file_extract_prepared(
10663    statements: &mut ColdBuildInsertStatements<'_>,
10664    workspace_root: &str,
10665    extract: &FileExtract,
10666) -> Result<()> {
10667    statements.file.execute(params![
10668        extract.rel_path,
10669        hash_to_hex(extract.freshness.content_hash),
10670        system_time_to_ns(extract.freshness.mtime),
10671        extract.freshness.size as i64,
10672        lang_label(extract.lang),
10673        extract.surface_fingerprint,
10674        unix_seconds_now(),
10675    ])?;
10676    for node in &extract.nodes {
10677        statements.node.execute(params![
10678            node.id,
10679            node.file_path,
10680            node.name,
10681            node.scoped_name,
10682            node.kind,
10683            node.range.start_line as i64,
10684            node.range.start_col as i64,
10685            node.range.end_line as i64,
10686            node.range.end_col as i64,
10687            node.range_ordinal as i64,
10688            node.signature,
10689            bool_int(node.exported),
10690            bool_int(node.is_default_export),
10691            bool_int(node.is_type_like),
10692            bool_int(node.is_callgraph_entry_point),
10693            PROVENANCE_TREESITTER,
10694        ])?;
10695    }
10696
10697    let mut dependencies = BTreeSet::new();
10698    for raw_ref in &extract.raw_refs {
10699        dependencies.extend(raw_ref.dependencies.iter().cloned());
10700    }
10701    for dep_file in &dependencies {
10702        statements
10703            .file_dependency
10704            .execute(params![extract.rel_path, dep_file])?;
10705    }
10706
10707    for hint in &extract.dispatch_hints {
10708        statements.dispatch_hint.execute(params![
10709            hint.id,
10710            hint.method_name,
10711            hint.caller_node,
10712            hint.file,
10713            hint.line as i64,
10714            hint.byte_start as i64,
10715            hint.byte_end as i64,
10716            PROVENANCE_TREESITTER,
10717        ])?;
10718    }
10719    insert_backend_state_prepared(
10720        &mut statements.backend_state,
10721        workspace_root,
10722        &extract.rel_path,
10723        Some(&extract.freshness.content_hash),
10724        "fresh",
10725    )?;
10726    Ok(())
10727}
10728
10729fn insert_backend_state_prepared(
10730    stmt: &mut Statement<'_>,
10731    workspace_root: &str,
10732    rel_path: &str,
10733    content_hash: Option<&blake3::Hash>,
10734    status: &str,
10735) -> Result<()> {
10736    let hash = content_hash
10737        .map(|hash| hash_to_hex(*hash))
10738        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10739    stmt.execute(params![
10740        BACKEND_TREESITTER,
10741        workspace_root,
10742        rel_path,
10743        hash,
10744        status,
10745        unix_seconds_now(),
10746    ])?;
10747    Ok(())
10748}
10749
10750fn insert_staged_ref_prepared(
10751    statements: &mut ColdBuildInsertStatements<'_>,
10752    raw: &RawRef,
10753) -> Result<()> {
10754    statements.reference.execute(params![
10755        raw.ref_id,
10756        raw.caller_node,
10757        raw.caller_file,
10758        raw.kind,
10759        raw.short_name,
10760        raw.full_ref,
10761        raw.module_path,
10762        raw.import_kind,
10763        raw.local_name,
10764        raw.requested_name,
10765        raw.namespace_alias,
10766        bool_int(raw.wildcard),
10767        raw.line as i64,
10768        raw.byte_start as i64,
10769        raw.byte_end as i64,
10770        "staged",
10771        Option::<String>::None,
10772        Option::<String>::None,
10773        Option::<String>::None,
10774        ref_provenance(raw),
10775    ])?;
10776    statements
10777        .staging_ref_context
10778        .execute(params![raw.ref_id, raw.caller_symbol])?;
10779    Ok(())
10780}
10781
10782fn insert_resolved_ref_prepared(
10783    statements: &mut ColdBuildInsertStatements<'_>,
10784    resolved: &ResolvedRef,
10785) -> Result<()> {
10786    let raw = &resolved.raw;
10787    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10788    statements.reference.execute(params![
10789        raw.ref_id,
10790        raw.caller_node,
10791        raw.caller_file,
10792        raw.kind,
10793        raw.short_name,
10794        raw.full_ref,
10795        raw.module_path,
10796        raw.import_kind,
10797        raw.local_name,
10798        raw.requested_name,
10799        raw.namespace_alias,
10800        bool_int(raw.wildcard),
10801        raw.line as i64,
10802        raw.byte_start as i64,
10803        raw.byte_end as i64,
10804        resolved.status,
10805        resolved.target_node,
10806        resolved.target_file,
10807        resolved.target_symbol,
10808        ref_provenance(raw),
10809    ])?;
10810    if let Some(edge) = &resolved.edge {
10811        statements.edge.execute(params![
10812            edge.edge_id,
10813            raw.ref_id,
10814            edge.source_node,
10815            edge.target_node,
10816            edge.target_file,
10817            edge.target_symbol,
10818            edge.kind,
10819            edge.line as i64,
10820            ref_provenance(raw),
10821        ])?;
10822    }
10823    Ok(())
10824}
10825
10826#[cfg(test)]
10827fn insert_file_extract(
10828    tx: &Transaction<'_>,
10829    project_root: &Path,
10830    extract: &FileExtract,
10831) -> Result<()> {
10832    tx.execute(
10833        "INSERT OR REPLACE INTO files(
10834            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10835            is_public_api, surface_fingerprint, indexed_at
10836        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10837        params![
10838            extract.rel_path,
10839            hash_to_hex(extract.freshness.content_hash),
10840            system_time_to_ns(extract.freshness.mtime),
10841            extract.freshness.size as i64,
10842            lang_label(extract.lang),
10843            extract.surface_fingerprint,
10844            unix_seconds_now(),
10845        ],
10846    )?;
10847    for node in &extract.nodes {
10848        tx.execute(
10849            "INSERT OR REPLACE INTO nodes(
10850                id, file_path, name, scoped_name, kind, start_line, start_col,
10851                end_line, end_col, range_ordinal, signature, exported,
10852                is_default_export, is_type_like, is_callgraph_entry_point, provenance
10853            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10854            params![
10855                node.id,
10856                node.file_path,
10857                node.name,
10858                node.scoped_name,
10859                node.kind,
10860                node.range.start_line as i64,
10861                node.range.start_col as i64,
10862                node.range.end_line as i64,
10863                node.range.end_col as i64,
10864                node.range_ordinal as i64,
10865                node.signature,
10866                bool_int(node.exported),
10867                bool_int(node.is_default_export),
10868                bool_int(node.is_type_like),
10869                bool_int(node.is_callgraph_entry_point),
10870                PROVENANCE_TREESITTER,
10871            ],
10872        )?;
10873    }
10874    let mut dependencies = BTreeSet::new();
10875    for raw_ref in &extract.raw_refs {
10876        dependencies.extend(raw_ref.dependencies.iter().cloned());
10877    }
10878    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10879
10880    for hint in &extract.dispatch_hints {
10881        tx.execute(
10882            "INSERT OR REPLACE INTO dispatch_hints(
10883                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10884            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10885            params![
10886                hint.id,
10887                hint.method_name,
10888                hint.caller_node,
10889                hint.file,
10890                hint.line as i64,
10891                hint.byte_start as i64,
10892                hint.byte_end as i64,
10893                PROVENANCE_TREESITTER,
10894            ],
10895        )?;
10896    }
10897    mark_backend_state(
10898        tx,
10899        project_root,
10900        &extract.rel_path,
10901        Some(&extract.freshness.content_hash),
10902        "fresh",
10903    )?;
10904    Ok(())
10905}
10906
10907#[cfg(test)]
10908fn insert_file_dependencies(
10909    tx: &Transaction<'_>,
10910    file_path: &str,
10911    dependencies: &BTreeSet<String>,
10912) -> Result<()> {
10913    for dep_file in dependencies {
10914        tx.execute(
10915            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10916            params![file_path, dep_file],
10917        )?;
10918    }
10919    Ok(())
10920}
10921
10922fn ref_provenance(raw: &RawRef) -> &'static str {
10923    if raw.kind == "value_ref" {
10924        PROVENANCE_VALUE_REF
10925    } else {
10926        PROVENANCE_TREESITTER
10927    }
10928}
10929
10930#[cfg(test)]
10931fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
10932    let raw = &resolved.raw;
10933    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10934    tx.execute(
10935        "INSERT OR REPLACE INTO refs(
10936            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10937            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10938            byte_start, byte_end, status, target_node, target_file, target_symbol,
10939            provenance
10940        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10941        params![
10942            raw.ref_id,
10943            raw.caller_node,
10944            raw.caller_file,
10945            raw.kind,
10946            raw.short_name,
10947            raw.full_ref,
10948            raw.module_path,
10949            raw.import_kind,
10950            raw.local_name,
10951            raw.requested_name,
10952            raw.namespace_alias,
10953            bool_int(raw.wildcard),
10954            raw.line as i64,
10955            raw.byte_start as i64,
10956            raw.byte_end as i64,
10957            resolved.status,
10958            resolved.target_node,
10959            resolved.target_file,
10960            resolved.target_symbol,
10961            ref_provenance(raw),
10962        ],
10963    )?;
10964    if let Some(edge) = &resolved.edge {
10965        tx.execute(
10966            "INSERT OR REPLACE INTO edges(
10967                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10968                kind, line, provenance
10969            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10970            params![
10971                edge.edge_id,
10972                raw.ref_id,
10973                edge.source_node,
10974                edge.target_node,
10975                edge.target_file,
10976                edge.target_symbol,
10977                edge.kind,
10978                edge.line as i64,
10979                ref_provenance(raw),
10980            ],
10981        )?;
10982    }
10983    Ok(())
10984}
10985
10986fn insert_method_dispatch_edges(
10987    tx: &Transaction<'_>,
10988    project_root: &Path,
10989    caller_files: Option<&BTreeSet<String>>,
10990) -> Result<usize> {
10991    let references = load_name_match_refs(tx, caller_files)?;
10992    if references.is_empty() {
10993        return Ok(0);
10994    }
10995
10996    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
10997    let mut source_cache: DispatchSourceCache = HashMap::new();
10998    let mut inserted = 0usize;
10999    for reference in references {
11000        let key = (reference.method_name.clone(), reference.lang.clone());
11001        let candidates = match candidates_by_name.entry(key) {
11002            Entry::Occupied(entry) => entry.into_mut(),
11003            Entry::Vacant(entry) => {
11004                let candidates =
11005                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11006                entry.insert(candidates)
11007            }
11008        };
11009
11010        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11011            ReceiverTypeInference::Known(receiver_type) => {
11012                let Some(candidate) =
11013                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11014                else {
11015                    continue;
11016                };
11017                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11018                inserted += 1;
11019                continue;
11020            }
11021            ReceiverTypeInference::RustDirectSelfField {
11022                receiver_type,
11023                declaration_file,
11024                module_scope,
11025            } => {
11026                let Some(candidate) = select_rust_direct_self_field_candidate(
11027                    project_root,
11028                    &reference,
11029                    candidates.as_slice(),
11030                    &receiver_type,
11031                    &declaration_file,
11032                    &module_scope,
11033                    &mut source_cache,
11034                ) else {
11035                    continue;
11036                };
11037                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11038                inserted += 1;
11039                continue;
11040            }
11041            ReceiverTypeInference::KnownButUnresolved => continue,
11042            ReceiverTypeInference::Unknown => {}
11043        }
11044
11045        if method_name_match_denylisted(&reference.method_name) {
11046            continue;
11047        }
11048
11049        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11050            continue;
11051        };
11052        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11053        inserted += 1;
11054    }
11055    Ok(inserted)
11056}
11057
11058fn insert_method_dispatch_edges_chunked(
11059    tx: &Transaction<'_>,
11060    project_root: &Path,
11061    chunk_size: usize,
11062) -> Result<usize> {
11063    let total_files = query_count(
11064        tx,
11065        "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11066    )? as usize;
11067    let mut completed_files = 0usize;
11068    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11069    let mut inserted = 0usize;
11070    let mut after_file = String::new();
11071    loop {
11072        let caller_files = {
11073            let mut statement = tx.prepare(
11074                "SELECT DISTINCT caller_file
11075                 FROM refs
11076                 WHERE caller_file > ?1
11077                 ORDER BY caller_file
11078                 LIMIT ?2",
11079            )?;
11080            let rows = statement
11081                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11082                    row.get::<_, String>(0)
11083                })?;
11084            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11085        };
11086        let Some(last_file) = caller_files.last().cloned() else {
11087            break;
11088        };
11089        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11090        after_file = last_file;
11091        completed_files = completed_files
11092            .saturating_add(caller_files.len())
11093            .min(total_files);
11094        ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11095    }
11096    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11097    Ok(inserted)
11098}
11099
11100fn insert_method_dispatch_edge(
11101    tx: &Transaction<'_>,
11102    reference: &NameMatchRef,
11103    candidate: &NameMatchCandidate,
11104    provenance: &str,
11105) -> Result<()> {
11106    tx.execute(
11107        "INSERT OR REPLACE INTO edges(
11108            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11109            kind, line, provenance
11110        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11111        params![
11112            ref_id(&[&reference.ref_id, provenance, "edge"]),
11113            &reference.ref_id,
11114            &reference.caller_node,
11115            &candidate.node_id,
11116            &candidate.file_path,
11117            &candidate.scoped_name,
11118            reference.line as i64,
11119            provenance,
11120        ],
11121    )?;
11122    Ok(())
11123}
11124
11125fn delete_method_dispatch_edges_for_callers(
11126    tx: &Transaction<'_>,
11127    caller_files: &BTreeSet<String>,
11128) -> Result<()> {
11129    if caller_files.is_empty() {
11130        return Ok(());
11131    }
11132
11133    let mut stmt = tx.prepare(
11134        "DELETE FROM edges
11135         WHERE provenance IN (?1, ?2)
11136           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11137    )?;
11138    for caller_file in caller_files {
11139        stmt.execute(params![
11140            PROVENANCE_NAME_MATCH,
11141            PROVENANCE_TYPE_MATCH,
11142            caller_file
11143        ])?;
11144    }
11145    Ok(())
11146}
11147
11148fn load_name_match_refs(
11149    tx: &Transaction<'_>,
11150    caller_files: Option<&BTreeSet<String>>,
11151) -> Result<Vec<NameMatchRef>> {
11152    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11153                           n.signature, r.short_name, r.full_ref, r.line, f.lang
11154                    FROM refs r
11155                    JOIN files f ON f.path = r.caller_file
11156                    JOIN nodes n ON n.id = r.caller_node
11157                    WHERE r.kind = 'call'
11158                      AND r.status = 'unresolved'
11159                      AND r.caller_node IS NOT NULL
11160                      AND r.full_ref IS NOT NULL
11161                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11162                      AND NOT EXISTS (
11163                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11164                      )";
11165    let mut references = Vec::new();
11166
11167    if let Some(caller_files) = caller_files {
11168        if caller_files.is_empty() {
11169            return Ok(references);
11170        }
11171        let sql = format!(
11172            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11173        );
11174        let mut stmt = tx.prepare(&sql)?;
11175        for caller_file in caller_files {
11176            let rows = stmt.query_map(params![caller_file], |row| {
11177                Ok((
11178                    row.get::<_, String>(0)?,
11179                    row.get::<_, Option<String>>(1)?,
11180                    row.get::<_, String>(2)?,
11181                    row.get::<_, String>(3)?,
11182                    row.get::<_, Option<String>>(4)?,
11183                    row.get::<_, Option<String>>(5)?,
11184                    row.get::<_, Option<String>>(6)?,
11185                    row.get::<_, i64>(7)?,
11186                    row.get::<_, String>(8)?,
11187                ))
11188            })?;
11189            for row in rows {
11190                let (
11191                    ref_id,
11192                    caller_node,
11193                    caller_file,
11194                    caller_symbol,
11195                    caller_signature,
11196                    short_name,
11197                    full_ref,
11198                    line,
11199                    lang,
11200                ) = row?;
11201                if let Some(reference) = name_match_ref_from_parts(
11202                    ref_id,
11203                    caller_node,
11204                    caller_file,
11205                    caller_symbol,
11206                    caller_signature,
11207                    short_name,
11208                    full_ref,
11209                    line,
11210                    lang,
11211                ) {
11212                    references.push(reference);
11213                }
11214            }
11215        }
11216        return Ok(references);
11217    }
11218
11219    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11220    let mut stmt = tx.prepare(&sql)?;
11221    let rows = stmt.query_map([], |row| {
11222        Ok((
11223            row.get::<_, String>(0)?,
11224            row.get::<_, Option<String>>(1)?,
11225            row.get::<_, String>(2)?,
11226            row.get::<_, String>(3)?,
11227            row.get::<_, Option<String>>(4)?,
11228            row.get::<_, Option<String>>(5)?,
11229            row.get::<_, Option<String>>(6)?,
11230            row.get::<_, i64>(7)?,
11231            row.get::<_, String>(8)?,
11232        ))
11233    })?;
11234    for row in rows {
11235        let (
11236            ref_id,
11237            caller_node,
11238            caller_file,
11239            caller_symbol,
11240            caller_signature,
11241            short_name,
11242            full_ref,
11243            line,
11244            lang,
11245        ) = row?;
11246        if let Some(reference) = name_match_ref_from_parts(
11247            ref_id,
11248            caller_node,
11249            caller_file,
11250            caller_symbol,
11251            caller_signature,
11252            short_name,
11253            full_ref,
11254            line,
11255            lang,
11256        ) {
11257            references.push(reference);
11258        }
11259    }
11260    Ok(references)
11261}
11262
11263#[allow(clippy::too_many_arguments)]
11264fn name_match_ref_from_parts(
11265    ref_id: String,
11266    caller_node: Option<String>,
11267    caller_file: String,
11268    caller_symbol: String,
11269    caller_signature: Option<String>,
11270    short_name: Option<String>,
11271    full_ref: Option<String>,
11272    line: i64,
11273    lang: String,
11274) -> Option<NameMatchRef> {
11275    let caller_node = caller_node?;
11276    let full_ref = full_ref?;
11277    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11278    let method_name = if member.is_empty() {
11279        short_name.as_deref()?.to_string()
11280    } else {
11281        member
11282    };
11283    Some(NameMatchRef {
11284        ref_id,
11285        caller_node,
11286        caller_file,
11287        caller_symbol,
11288        caller_signature,
11289        receiver_expression,
11290        receiver,
11291        method_name,
11292        colon_dispatch,
11293        line: line.max(0) as u32,
11294        lang,
11295    })
11296}
11297
11298fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11299    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11300    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11301    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11302    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11303        .into_iter()
11304        .flatten()
11305        .max_by_key(|(index, _, _)| *index)?;
11306    if delimiter == 0 {
11307        return None;
11308    }
11309    let member_start = delimiter + delimiter_len;
11310    if member_start >= full_ref.len() {
11311        return None;
11312    }
11313    let receiver_expression = full_ref[..delimiter].trim();
11314    let receiver = last_name_segment(receiver_expression).trim();
11315    let member = &full_ref[member_start..];
11316    if receiver.is_empty() || member.is_empty() {
11317        return None;
11318    }
11319    Some((
11320        receiver_expression.to_string(),
11321        receiver.to_string(),
11322        member.to_string(),
11323        colon_dispatch,
11324    ))
11325}
11326
11327fn last_name_segment(value: &str) -> &str {
11328    value
11329        .rsplit(['.', ':', '/', '\\', '-', '>'])
11330        .find(|segment| !segment.is_empty())
11331        .unwrap_or(value)
11332}
11333
11334fn load_name_match_candidates(
11335    tx: &Transaction<'_>,
11336    method_name: &str,
11337    lang: &str,
11338) -> Result<Vec<NameMatchCandidate>> {
11339    let mut stmt = tx.prepare(
11340        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11341         FROM nodes n JOIN files f ON f.path = n.file_path
11342         WHERE n.name = ?1
11343           AND f.lang = ?2
11344           AND n.kind IN ('method', 'function')
11345         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11346    )?;
11347    let rows = stmt.query_map(params![method_name, lang], |row| {
11348        Ok(NameMatchCandidate {
11349            node_id: row.get(0)?,
11350            file_path: row.get(1)?,
11351            scoped_name: row.get(2)?,
11352            kind: row.get(3)?,
11353            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11354        })
11355    })?;
11356    rows.collect::<std::result::Result<Vec<_>, _>>()
11357        .map_err(Into::into)
11358}
11359
11360struct ParsedDispatchSource {
11361    source: String,
11362    tree: tree_sitter::Tree,
11363}
11364
11365type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11366
11367#[derive(Debug, Clone, PartialEq, Eq)]
11368enum ReceiverTypeInference {
11369    Unknown,
11370    Known(String),
11371    RustDirectSelfField {
11372        receiver_type: String,
11373        declaration_file: String,
11374        module_scope: Vec<(usize, usize)>,
11375    },
11376    KnownButUnresolved,
11377}
11378
11379#[cfg(test)]
11380fn infer_receiver_type(
11381    project_root: &Path,
11382    reference: &NameMatchRef,
11383    source_cache: &mut DispatchSourceCache,
11384) -> Option<String> {
11385    match infer_receiver_type_state(project_root, reference, source_cache) {
11386        ReceiverTypeInference::Known(receiver_type)
11387        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
11388        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
11389    }
11390}
11391
11392fn infer_receiver_type_state(
11393    project_root: &Path,
11394    reference: &NameMatchRef,
11395    source_cache: &mut DispatchSourceCache,
11396) -> ReceiverTypeInference {
11397    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
11398    match reference.lang.as_str() {
11399        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
11400        "java" => {
11401            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
11402                .map(known)
11403                .unwrap_or(ReceiverTypeInference::Unknown)
11404        }
11405        "kotlin" => {
11406            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
11407                .map(known)
11408                .unwrap_or(ReceiverTypeInference::Unknown)
11409        }
11410        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
11411            .map(known)
11412            .unwrap_or(ReceiverTypeInference::Unknown),
11413        _ => ReceiverTypeInference::Unknown,
11414    }
11415}
11416
11417fn parse_dispatch_source(
11418    project_root: &Path,
11419    caller_file: &str,
11420    lang: LangId,
11421) -> Option<ParsedDispatchSource> {
11422    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
11423    let grammar = crate::parser::grammar_for(lang);
11424    let mut parser = tree_sitter::Parser::new();
11425    parser.set_language(&grammar).ok()?;
11426    let tree = parser.parse(&source, None)?;
11427    Some(ParsedDispatchSource { source, tree })
11428}
11429
11430fn parsed_dispatch_source<'a>(
11431    project_root: &Path,
11432    reference: &NameMatchRef,
11433    lang: LangId,
11434    source_cache: &'a mut DispatchSourceCache,
11435) -> Option<&'a ParsedDispatchSource> {
11436    parsed_dispatch_source_for_file(
11437        project_root,
11438        &reference.caller_file,
11439        &reference.lang,
11440        lang,
11441        source_cache,
11442    )
11443}
11444
11445fn parsed_dispatch_source_for_file<'a>(
11446    project_root: &Path,
11447    file_path: &str,
11448    lang_label: &str,
11449    lang: LangId,
11450    source_cache: &'a mut DispatchSourceCache,
11451) -> Option<&'a ParsedDispatchSource> {
11452    let key = (file_path.to_string(), lang_label.to_string());
11453    source_cache
11454        .entry(key)
11455        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
11456        .as_ref()
11457}
11458
11459fn infer_java_like_receiver_type(
11460    project_root: &Path,
11461    reference: &NameMatchRef,
11462    lang: LangId,
11463    source_cache: &mut DispatchSourceCache,
11464) -> Option<String> {
11465    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11466        return None;
11467    }
11468
11469    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
11470    let root = parsed.tree.root_node();
11471    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
11472
11473    let callable_scope = type_node
11474        .and_then(|node| {
11475            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
11476        })
11477        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
11478
11479    if let Some(callable_scope) = callable_scope {
11480        if let Some(receiver_type) = infer_java_like_local_receiver_type(
11481            callable_scope,
11482            &parsed.source,
11483            &reference.receiver,
11484            reference.line.max(1),
11485            lang,
11486        ) {
11487            return Some(receiver_type);
11488        }
11489    }
11490
11491    type_node.and_then(|node| {
11492        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
11493    })
11494}
11495
11496fn infer_cpp_receiver_type(
11497    project_root: &Path,
11498    reference: &NameMatchRef,
11499    source_cache: &mut DispatchSourceCache,
11500) -> Option<String> {
11501    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11502        return None;
11503    }
11504
11505    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
11506    let root = parsed.tree.root_node();
11507    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
11508    infer_cpp_receiver_type_from_scope(
11509        scope,
11510        &parsed.source,
11511        &reference.receiver,
11512        reference.line.max(1),
11513    )
11514}
11515
11516fn find_enclosing_java_like_type_node<'tree>(
11517    root: tree_sitter::Node<'tree>,
11518    source: &str,
11519    reference: &NameMatchRef,
11520    lang: LangId,
11521) -> Option<tree_sitter::Node<'tree>> {
11522    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
11523        .and_then(|name| simple_type_name(&name));
11524    let line = reference.line.max(1);
11525    let mut best = None;
11526    let mut stack = vec![root];
11527    while let Some(node) = stack.pop() {
11528        if !node_contains_line(node, line) {
11529            continue;
11530        }
11531        if is_java_like_type_kind(node.kind(), lang) {
11532            let name = declaration_name(node, source);
11533            if expected_type
11534                .as_deref()
11535                .is_none_or(|expected| name == Some(expected))
11536            {
11537                best = tighter_node(best, node);
11538            }
11539        }
11540        push_named_children(node, &mut stack);
11541    }
11542    best
11543}
11544
11545fn find_enclosing_java_like_callable_node<'tree>(
11546    root: tree_sitter::Node<'tree>,
11547    source: &str,
11548    reference: &NameMatchRef,
11549    lang: LangId,
11550) -> Option<tree_sitter::Node<'tree>> {
11551    let expected_name = reference.caller_symbol.rsplit("::").next();
11552    let line = reference.line.max(1);
11553    let mut best = None;
11554    let mut stack = vec![root];
11555    while let Some(node) = stack.pop() {
11556        if !node_contains_line(node, line) {
11557            continue;
11558        }
11559        if is_java_like_callable_kind(node.kind(), lang) {
11560            let name = declaration_name(node, source);
11561            if expected_name.is_none_or(|expected| name == Some(expected)) {
11562                best = tighter_node(best, node);
11563            }
11564        }
11565        push_named_children(node, &mut stack);
11566    }
11567    best
11568}
11569
11570fn find_enclosing_cpp_callable_node<'tree>(
11571    root: tree_sitter::Node<'tree>,
11572    _source: &str,
11573    reference: &NameMatchRef,
11574) -> Option<tree_sitter::Node<'tree>> {
11575    let line = reference.line.max(1);
11576    let mut best = None;
11577    let mut stack = vec![root];
11578    while let Some(node) = stack.pop() {
11579        if !node_contains_line(node, line) {
11580            continue;
11581        }
11582        if node.kind() == "function_definition" {
11583            best = tighter_node(best, node);
11584        }
11585        push_named_children(node, &mut stack);
11586    }
11587    best
11588}
11589
11590fn tighter_node<'tree>(
11591    current: Option<tree_sitter::Node<'tree>>,
11592    candidate: tree_sitter::Node<'tree>,
11593) -> Option<tree_sitter::Node<'tree>> {
11594    match current {
11595        Some(current)
11596            if current.start_byte() > candidate.start_byte()
11597                || (current.start_byte() == candidate.start_byte()
11598                    && current.end_byte() <= candidate.end_byte()) =>
11599        {
11600            Some(current)
11601        }
11602        _ => Some(candidate),
11603    }
11604}
11605
11606fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
11607    let start = node.start_position().row as u32 + 1;
11608    let end = node.end_position().row as u32 + 1;
11609    start <= line && line <= end
11610}
11611
11612fn push_named_children<'tree>(
11613    node: tree_sitter::Node<'tree>,
11614    stack: &mut Vec<tree_sitter::Node<'tree>>,
11615) {
11616    for index in 0..node.named_child_count() {
11617        if let Some(child) = node.named_child(index as u32) {
11618            stack.push(child);
11619        }
11620    }
11621}
11622
11623fn declaration_name<'source>(
11624    node: tree_sitter::Node<'_>,
11625    source: &'source str,
11626) -> Option<&'source str> {
11627    node.child_by_field_name("name")
11628        .map(|name| node_text(name, source))
11629        .or_else(|| {
11630            first_named_child_text(
11631                node,
11632                source,
11633                &["identifier", "type_identifier", "simple_identifier"],
11634            )
11635        })
11636}
11637
11638fn first_named_child_text<'source>(
11639    node: tree_sitter::Node<'_>,
11640    source: &'source str,
11641    kinds: &[&str],
11642) -> Option<&'source str> {
11643    for index in 0..node.named_child_count() {
11644        let child = node.named_child(index as u32)?;
11645        if kinds.contains(&child.kind()) {
11646            return Some(node_text(child, source));
11647        }
11648    }
11649    None
11650}
11651
11652fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
11653    &source[node.byte_range()]
11654}
11655
11656fn infer_java_like_field_receiver_type(
11657    type_node: tree_sitter::Node<'_>,
11658    source: &str,
11659    receiver: &str,
11660    lang: LangId,
11661) -> Option<String> {
11662    let mut stack = Vec::new();
11663    push_named_children(type_node, &mut stack);
11664    while let Some(node) = stack.pop() {
11665        if is_java_like_field_kind(node.kind(), lang) {
11666            if let Some(receiver_type) =
11667                extract_java_like_declared_type(node_text(node, source), receiver, lang)
11668            {
11669                return Some(receiver_type);
11670            }
11671        }
11672        if is_java_like_type_kind(node.kind(), lang)
11673            || is_java_like_callable_kind(node.kind(), lang)
11674        {
11675            continue;
11676        }
11677        push_named_children(node, &mut stack);
11678    }
11679    None
11680}
11681
11682fn infer_java_like_local_receiver_type(
11683    callable_node: tree_sitter::Node<'_>,
11684    source: &str,
11685    receiver: &str,
11686    call_line: u32,
11687    lang: LangId,
11688) -> Option<String> {
11689    let mut best: Option<(u32, String)> = None;
11690    let mut stack = Vec::new();
11691    push_named_children(callable_node, &mut stack);
11692    while let Some(node) = stack.pop() {
11693        let start_line = node.start_position().row as u32 + 1;
11694        if start_line > call_line {
11695            continue;
11696        }
11697        if is_java_like_local_kind(node.kind(), lang) {
11698            if let Some(receiver_type) =
11699                extract_java_like_declared_type(node_text(node, source), receiver, lang)
11700            {
11701                if best
11702                    .as_ref()
11703                    .is_none_or(|(best_line, _)| start_line >= *best_line)
11704                {
11705                    best = Some((start_line, receiver_type));
11706                }
11707            }
11708        }
11709        if is_java_like_type_kind(node.kind(), lang)
11710            || is_java_like_callable_kind(node.kind(), lang)
11711        {
11712            continue;
11713        }
11714        push_named_children(node, &mut stack);
11715    }
11716    best.map(|(_, receiver_type)| receiver_type)
11717}
11718
11719fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
11720    match lang {
11721        LangId::Java => matches!(
11722            kind,
11723            "class_declaration"
11724                | "interface_declaration"
11725                | "enum_declaration"
11726                | "record_declaration"
11727                | "annotation_type_declaration"
11728        ),
11729        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
11730        _ => false,
11731    }
11732}
11733
11734fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
11735    match lang {
11736        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
11737        LangId::Kotlin => kind == "function_declaration",
11738        _ => false,
11739    }
11740}
11741
11742fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
11743    match lang {
11744        LangId::Java => kind == "field_declaration",
11745        LangId::Kotlin => kind == "property_declaration",
11746        _ => false,
11747    }
11748}
11749
11750fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
11751    match lang {
11752        LangId::Java => kind == "local_variable_declaration",
11753        LangId::Kotlin => kind == "property_declaration",
11754        _ => false,
11755    }
11756}
11757
11758fn extract_java_like_declared_type(
11759    declaration: &str,
11760    receiver: &str,
11761    lang: LangId,
11762) -> Option<String> {
11763    match lang {
11764        LangId::Java => extract_java_declared_type(declaration, receiver),
11765        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
11766        _ => None,
11767    }
11768}
11769
11770fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11771    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11772    let after = declaration[receiver_start + receiver.len()..].trim_start();
11773    if after
11774        .chars()
11775        .next()
11776        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
11777    {
11778        return None;
11779    }
11780
11781    let before = declaration[..receiver_start].trim_end();
11782    if before.contains(',') {
11783        return None;
11784    }
11785    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
11786}
11787
11788fn strip_java_declaration_prefixes(mut value: &str) -> &str {
11789    loop {
11790        value = value.trim_start();
11791        if let Some(stripped) = strip_leading_java_annotation(value) {
11792            value = stripped;
11793            continue;
11794        }
11795        if let Some(stripped) = strip_leading_java_modifier(value) {
11796            value = stripped;
11797            continue;
11798        }
11799        return value.trim();
11800    }
11801}
11802
11803fn strip_leading_java_annotation(value: &str) -> Option<&str> {
11804    let value = value.trim_start();
11805    let mut chars = value.char_indices();
11806    let (_, first) = chars.next()?;
11807    if first != '@' {
11808        return None;
11809    }
11810    let mut end = first.len_utf8();
11811    for (index, ch) in chars {
11812        if !(is_code_ident_char(ch) || ch == '.') {
11813            end = index;
11814            break;
11815        }
11816        end = index + ch.len_utf8();
11817    }
11818    let rest = value[end..].trim_start();
11819    if let Some(stripped) = rest.strip_prefix('(') {
11820        let mut depth = 1usize;
11821        for (index, ch) in stripped.char_indices() {
11822            match ch {
11823                '(' => depth += 1,
11824                ')' => {
11825                    depth = depth.saturating_sub(1);
11826                    if depth == 0 {
11827                        return Some(stripped[index + ch.len_utf8()..].trim_start());
11828                    }
11829                }
11830                _ => {}
11831            }
11832        }
11833        return Some("");
11834    }
11835    Some(rest)
11836}
11837
11838fn strip_leading_java_modifier(value: &str) -> Option<&str> {
11839    const MODIFIERS: &[&str] = &[
11840        "public",
11841        "protected",
11842        "private",
11843        "abstract",
11844        "static",
11845        "final",
11846        "transient",
11847        "volatile",
11848        "synchronized",
11849        "native",
11850        "strictfp",
11851    ];
11852    MODIFIERS
11853        .iter()
11854        .find_map(|modifier| strip_leading_word(value, modifier))
11855}
11856
11857fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11858    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11859    let before = &declaration[..receiver_start];
11860    if find_identifier_occurrence(before, "val").is_none()
11861        && find_identifier_occurrence(before, "var").is_none()
11862    {
11863        return None;
11864    }
11865
11866    let after = declaration[receiver_start + receiver.len()..].trim_start();
11867    if let Some(type_text) = after.strip_prefix(':') {
11868        return normalize_receiver_type_name(read_type_prefix(type_text));
11869    }
11870    after
11871        .strip_prefix('=')
11872        .and_then(infer_kotlin_constructor_type)
11873}
11874
11875fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11876    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11877    if rest.trim_start().starts_with('(') {
11878        normalize_receiver_type_name(head)
11879    } else {
11880        None
11881    }
11882}
11883
11884fn read_type_prefix(value: &str) -> &str {
11885    let mut angle_depth = 0usize;
11886    for (index, ch) in value.char_indices() {
11887        match ch {
11888            '<' => angle_depth += 1,
11889            '>' => angle_depth = angle_depth.saturating_sub(1),
11890            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11891                return value[..index].trim();
11892            }
11893            _ => {}
11894        }
11895    }
11896    value.trim()
11897}
11898
11899fn infer_cpp_receiver_type_from_scope(
11900    scope: tree_sitter::Node<'_>,
11901    source: &str,
11902    receiver: &str,
11903    call_line: u32,
11904) -> Option<String> {
11905    let lines = source.lines().collect::<Vec<_>>();
11906    if lines.is_empty() {
11907        return None;
11908    }
11909    let scope_start = scope.start_position().row as usize;
11910    let call_index = (call_line as usize)
11911        .saturating_sub(1)
11912        .min(lines.len().saturating_sub(1));
11913    for index in (scope_start..=call_index).rev() {
11914        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
11915            return Some(receiver_type);
11916        }
11917    }
11918    None
11919}
11920
11921fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
11922    for receiver_start in identifier_occurrences(line, receiver) {
11923        let after = line[receiver_start + receiver.len()..].trim_start();
11924        if after
11925            .chars()
11926            .next()
11927            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
11928        {
11929            continue;
11930        }
11931        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
11932        let normalized = normalize_cpp_type_name(type_text)?;
11933        if normalized == "auto" {
11934            if let Some(rhs) = after.strip_prefix('=') {
11935                return infer_cpp_auto_receiver_type(rhs);
11936            }
11937            continue;
11938        }
11939        return Some(normalized);
11940    }
11941    None
11942}
11943
11944fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
11945    let candidate = prefix
11946        .rsplit([';', '{', '}', '('])
11947        .next()
11948        .unwrap_or(prefix)
11949        .trim();
11950    if candidate.is_empty() || candidate.ends_with(',') {
11951        None
11952    } else {
11953        Some(candidate)
11954    }
11955}
11956
11957fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
11958    let without_templates = strip_angle_groups(type_text);
11959    let mut cleaned = String::with_capacity(without_templates.len());
11960    for token in without_templates.split_whitespace() {
11961        if matches!(
11962            token,
11963            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
11964        ) {
11965            continue;
11966        }
11967        if !cleaned.is_empty() {
11968            cleaned.push(' ');
11969        }
11970        cleaned.push_str(token);
11971    }
11972    let token = cleaned
11973        .split_whitespace()
11974        .last()
11975        .unwrap_or(cleaned.trim())
11976        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
11977        .trim_matches(['*', '&']);
11978    let simple = token.rsplit("::").next().unwrap_or(token).trim();
11979    if simple.is_empty() || cpp_non_type_token(simple) {
11980        None
11981    } else {
11982        Some(simple.to_string())
11983    }
11984}
11985
11986fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
11987    let rhs = rhs.trim_start();
11988    if let Some(after_new) = rhs.strip_prefix("new ") {
11989        return infer_cpp_constructor_type(after_new);
11990    }
11991    infer_cpp_make_template_type(rhs)
11992        .or_else(|| infer_cpp_constructor_type(rhs))
11993        .or_else(|| infer_cpp_factory_type(rhs))
11994}
11995
11996fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
11997    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
11998    let normalized = normalize_cpp_type_name(head)?;
11999    if !normalized
12000        .chars()
12001        .next()
12002        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12003    {
12004        return None;
12005    }
12006    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12007        Some(normalized)
12008    } else {
12009        None
12010    }
12011}
12012
12013fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12014    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12015    if !rest.trim_start().starts_with('(') {
12016        return None;
12017    }
12018    let base = head.split('<').next().unwrap_or(head);
12019    let base_simple = base.rsplit("::").next().unwrap_or(base);
12020    if !matches!(base_simple, "make_unique" | "make_shared") {
12021        return None;
12022    }
12023    first_angle_arg(head).and_then(normalize_cpp_type_name)
12024}
12025
12026fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12027    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12028    if !rest.trim_start().starts_with('(') {
12029        return None;
12030    }
12031    let simple = head
12032        .split('<')
12033        .next()
12034        .unwrap_or(head)
12035        .rsplit("::")
12036        .next()
12037        .unwrap_or(head);
12038    for prefix in ["make", "create", "build"] {
12039        if let Some(suffix) = simple.strip_prefix(prefix) {
12040            if suffix
12041                .chars()
12042                .next()
12043                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12044            {
12045                return normalize_cpp_type_name(suffix);
12046            }
12047        }
12048    }
12049    None
12050}
12051
12052#[derive(Debug, Clone, Copy)]
12053enum JavaLikeInvocation {
12054    Kotlin,
12055    Cpp,
12056}
12057
12058fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12059    let value = value.trim_start();
12060    let mut end = 0usize;
12061    for (index, ch) in value.char_indices() {
12062        let allowed_separator = match flavor {
12063            JavaLikeInvocation::Kotlin => ch == '.',
12064            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12065        };
12066        if is_code_ident_char(ch) || allowed_separator {
12067            end = index + ch.len_utf8();
12068            continue;
12069        }
12070        break;
12071    }
12072    if end == 0 {
12073        return None;
12074    }
12075    let mut rest = &value[end..];
12076    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12077        let skipped = skip_balanced_angle(stripped)?;
12078        let rest_start = rest.len() - rest.trim_start().len();
12079        let angle_len = 1 + skipped;
12080        end += rest_start + angle_len;
12081        rest = &value[end..];
12082    }
12083    Some((value[..end].trim(), rest))
12084}
12085
12086fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12087    let mut depth = 1usize;
12088    for (index, ch) in value_after_open.char_indices() {
12089        match ch {
12090            '<' => depth += 1,
12091            '>' => {
12092                depth = depth.saturating_sub(1);
12093                if depth == 0 {
12094                    return Some(index + ch.len_utf8());
12095                }
12096            }
12097            _ => {}
12098        }
12099    }
12100    None
12101}
12102
12103fn first_angle_arg(value: &str) -> Option<&str> {
12104    let open = value.find('<')?;
12105    let inner_len = skip_balanced_angle(&value[open + 1..])?;
12106    let inner = &value[open + 1..open + inner_len];
12107    split_top_level_commas(inner).into_iter().next()
12108}
12109
12110fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12111    let without_generics = strip_angle_groups(type_text);
12112    let cleaned = without_generics
12113        .replace("[]", " ")
12114        .replace("...", " ")
12115        .replace(['?', '&', '*'], " ");
12116    let token = cleaned
12117        .split_whitespace()
12118        .last()
12119        .unwrap_or(cleaned.trim())
12120        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12121    let token = token.rsplit("::").next().unwrap_or(token);
12122    let simple = token.rsplit('.').next().unwrap_or(token).trim();
12123    if simple.is_empty()
12124        || java_like_primitive_type(simple)
12125        || !simple
12126            .chars()
12127            .next()
12128            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12129    {
12130        None
12131    } else {
12132        Some(simple.to_string())
12133    }
12134}
12135
12136fn simple_type_name(scoped_name: &str) -> Option<String> {
12137    scoped_name
12138        .rsplit("::")
12139        .find(|segment| !segment.is_empty())
12140        .and_then(normalize_receiver_type_name)
12141}
12142
12143fn strip_angle_groups(value: &str) -> String {
12144    let mut output = String::with_capacity(value.len());
12145    let mut depth = 0usize;
12146    for ch in value.chars() {
12147        match ch {
12148            '<' => {
12149                if depth == 0 {
12150                    output.push(' ');
12151                }
12152                depth += 1;
12153            }
12154            '>' => depth = depth.saturating_sub(1),
12155            _ if depth == 0 => output.push(ch),
12156            _ => {}
12157        }
12158    }
12159    output
12160}
12161
12162fn java_like_primitive_type(value: &str) -> bool {
12163    matches!(
12164        value,
12165        "boolean"
12166            | "byte"
12167            | "char"
12168            | "double"
12169            | "float"
12170            | "int"
12171            | "long"
12172            | "short"
12173            | "void"
12174            | "Boolean"
12175            | "Byte"
12176            | "Char"
12177            | "Double"
12178            | "Float"
12179            | "Int"
12180            | "Long"
12181            | "Short"
12182            | "Unit"
12183    )
12184}
12185
12186fn cpp_non_type_token(value: &str) -> bool {
12187    matches!(
12188        value,
12189        "return"
12190            | "if"
12191            | "else"
12192            | "for"
12193            | "while"
12194            | "do"
12195            | "switch"
12196            | "case"
12197            | "default"
12198            | "break"
12199            | "continue"
12200            | "goto"
12201            | "throw"
12202            | "new"
12203            | "delete"
12204            | "co_await"
12205            | "co_yield"
12206            | "co_return"
12207            | "static_cast"
12208            | "const_cast"
12209            | "dynamic_cast"
12210            | "reinterpret_cast"
12211            | "sizeof"
12212            | "alignof"
12213            | "typeid"
12214            | "and"
12215            | "or"
12216            | "not"
12217            | "xor"
12218    )
12219}
12220
12221fn receiver_is_bare_identifier(value: &str) -> bool {
12222    let mut chars = value.chars();
12223    let Some(first) = chars.next() else {
12224        return false;
12225    };
12226    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12227}
12228
12229fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12230    identifier_occurrences(value, needle).into_iter().next()
12231}
12232
12233fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12234    value
12235        .match_indices(needle)
12236        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12237        .collect()
12238}
12239
12240fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12241    let before = value[..start].chars().next_back();
12242    let after = value[start + len..].chars().next();
12243    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12244}
12245
12246fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12247    let stripped = value.strip_prefix(word)?;
12248    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12249        Some(stripped.trim_start())
12250    } else {
12251        None
12252    }
12253}
12254
12255fn is_code_ident_char(ch: char) -> bool {
12256    ch == '_' || ch.is_ascii_alphanumeric()
12257}
12258
12259fn infer_rust_receiver_type(
12260    project_root: &Path,
12261    reference: &NameMatchRef,
12262    source_cache: &mut DispatchSourceCache,
12263) -> ReceiverTypeInference {
12264    if matches!(reference.receiver.as_str(), "self" | "Self") {
12265        return enclosing_type_from_scoped_name(&reference.caller_symbol)
12266            .map(ReceiverTypeInference::Known)
12267            .unwrap_or(ReceiverTypeInference::Unknown);
12268    }
12269
12270    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12271        return ReceiverTypeInference::Known(reference.receiver.clone());
12272    }
12273
12274    if let Some(receiver_type) = reference
12275        .caller_signature
12276        .as_deref()
12277        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12278    {
12279        return ReceiverTypeInference::Known(receiver_type);
12280    }
12281
12282    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12283}
12284
12285fn infer_rust_direct_self_field_receiver_type(
12286    project_root: &Path,
12287    reference: &NameMatchRef,
12288    source_cache: &mut DispatchSourceCache,
12289) -> ReceiverTypeInference {
12290    if reference.colon_dispatch {
12291        return ReceiverTypeInference::Unknown;
12292    }
12293    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12294        return ReceiverTypeInference::Unknown;
12295    };
12296    if field_name != reference.receiver {
12297        return ReceiverTypeInference::Unknown;
12298    }
12299
12300    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12301        return ReceiverTypeInference::Unknown;
12302    };
12303    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12304        return ReceiverTypeInference::KnownButUnresolved;
12305    };
12306    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12307    else {
12308        return ReceiverTypeInference::Unknown;
12309    };
12310    let Some(impl_node) =
12311        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12312    else {
12313        return ReceiverTypeInference::Unknown;
12314    };
12315    if impl_node.child_by_field_name("trait").is_some()
12316        || impl_node.child_by_field_name("type_parameters").is_some()
12317    {
12318        return ReceiverTypeInference::KnownButUnresolved;
12319    }
12320    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12321        return ReceiverTypeInference::KnownButUnresolved;
12322    };
12323    if impl_target.kind() != "type_identifier"
12324        || node_text(impl_target, &parsed.source) != impl_type
12325    {
12326        return ReceiverTypeInference::KnownButUnresolved;
12327    }
12328
12329    let module_scope = rust_module_scope(impl_node);
12330    let Some(struct_node) = find_unique_rust_struct(
12331        parsed.tree.root_node(),
12332        &parsed.source,
12333        struct_name,
12334        &module_scope,
12335    ) else {
12336        return ReceiverTypeInference::KnownButUnresolved;
12337    };
12338    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12339    else {
12340        return ReceiverTypeInference::KnownButUnresolved;
12341    };
12342    if field_type.kind() != "type_identifier" {
12343        return ReceiverTypeInference::KnownButUnresolved;
12344    }
12345    let field_type_name = node_text(field_type, &parsed.source);
12346    if find_unique_rust_struct(
12347        parsed.tree.root_node(),
12348        &parsed.source,
12349        field_type_name,
12350        &module_scope,
12351    )
12352    .is_none()
12353    {
12354        return ReceiverTypeInference::KnownButUnresolved;
12355    }
12356
12357    ReceiverTypeInference::RustDirectSelfField {
12358        receiver_type: field_type_name.to_string(),
12359        declaration_file: reference.caller_file.clone(),
12360        module_scope,
12361    }
12362}
12363
12364fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12365    let (base, field) = receiver_expression.split_once('.')?;
12366    let base = base.trim();
12367    let field = field.trim();
12368    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12369}
12370
12371fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12372    let name = value.rsplit("::").next()?.trim();
12373    (!name.is_empty()
12374        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12375        && name.chars().all(is_rust_ident_char))
12376    .then_some(name)
12377}
12378
12379fn find_enclosing_rust_impl_node<'tree>(
12380    root: tree_sitter::Node<'tree>,
12381    line: u32,
12382) -> Option<tree_sitter::Node<'tree>> {
12383    let mut best = None;
12384    let mut stack = vec![root];
12385    while let Some(node) = stack.pop() {
12386        if !node_contains_line(node, line) {
12387            continue;
12388        }
12389        if node.kind() == "impl_item" {
12390            best = tighter_node(best, node);
12391        }
12392        push_named_children(node, &mut stack);
12393    }
12394    best
12395}
12396
12397fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
12398    let mut scope = Vec::new();
12399    let mut current = node.parent();
12400    while let Some(parent) = current {
12401        if parent.kind() == "mod_item" {
12402            scope.push((parent.start_byte(), parent.end_byte()));
12403        }
12404        current = parent.parent();
12405    }
12406    scope.reverse();
12407    scope
12408}
12409
12410fn find_unique_rust_struct<'tree>(
12411    root: tree_sitter::Node<'tree>,
12412    source: &str,
12413    expected_name: &str,
12414    module_scope: &[(usize, usize)],
12415) -> Option<tree_sitter::Node<'tree>> {
12416    let mut found = None;
12417    let mut stack = vec![root];
12418    while let Some(node) = stack.pop() {
12419        if node.kind() == "struct_item"
12420            && rust_module_scope(node) == module_scope
12421            && node.child_by_field_name("type_parameters").is_none()
12422            && declaration_name(node, source) == Some(expected_name)
12423        {
12424            if found.is_some() {
12425                return None;
12426            }
12427            found = Some(node);
12428        }
12429        push_named_children(node, &mut stack);
12430    }
12431    found
12432}
12433
12434fn rust_struct_field_type_node<'tree>(
12435    struct_node: tree_sitter::Node<'tree>,
12436    source: &str,
12437    field_name: &str,
12438) -> Option<tree_sitter::Node<'tree>> {
12439    let fields = struct_node.child_by_field_name("body")?;
12440    if fields.kind() != "field_declaration_list" {
12441        return None;
12442    }
12443    for index in 0..fields.named_child_count() {
12444        let field = fields.named_child(index as u32)?;
12445        if field.kind() != "field_declaration"
12446            || declaration_name(field, source) != Some(field_name)
12447        {
12448            continue;
12449        }
12450        return field.child_by_field_name("type");
12451    }
12452    None
12453}
12454
12455fn rust_receiver_looks_type_like(receiver: &str) -> bool {
12456    receiver
12457        .chars()
12458        .next()
12459        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
12460}
12461
12462fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
12463    scoped_name
12464        .rsplit_once("::")
12465        .map(|(enclosing, _)| enclosing)
12466        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
12467        .map(ToString::to_string)
12468}
12469
12470fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
12471    let params = signature_parameter_text(signature)?;
12472    for param in split_top_level_commas(params) {
12473        let Some((pattern, type_text)) = param.split_once(':') else {
12474            continue;
12475        };
12476        let Some(name) = rust_parameter_name(pattern) else {
12477            continue;
12478        };
12479        if name == receiver {
12480            return normalize_rust_receiver_type(type_text);
12481        }
12482    }
12483    None
12484}
12485
12486fn signature_parameter_text(signature: &str) -> Option<&str> {
12487    let open = signature.find('(')?;
12488    let mut depth = 0usize;
12489    for (offset, ch) in signature[open..].char_indices() {
12490        match ch {
12491            '(' => depth += 1,
12492            ')' => {
12493                depth = depth.saturating_sub(1);
12494                if depth == 0 {
12495                    return Some(&signature[open + 1..open + offset]);
12496                }
12497            }
12498            _ => {}
12499        }
12500    }
12501    None
12502}
12503
12504fn split_top_level_commas(value: &str) -> Vec<&str> {
12505    let mut parts = Vec::new();
12506    let mut start = 0usize;
12507    let mut angle_depth = 0usize;
12508    let mut paren_depth = 0usize;
12509    let mut bracket_depth = 0usize;
12510    for (index, ch) in value.char_indices() {
12511        match ch {
12512            '<' => angle_depth += 1,
12513            '>' => angle_depth = angle_depth.saturating_sub(1),
12514            '(' => paren_depth += 1,
12515            ')' => paren_depth = paren_depth.saturating_sub(1),
12516            '[' => bracket_depth += 1,
12517            ']' => bracket_depth = bracket_depth.saturating_sub(1),
12518            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
12519                let part = value[start..index].trim();
12520                if !part.is_empty() {
12521                    parts.push(part);
12522                }
12523                start = index + ch.len_utf8();
12524            }
12525            _ => {}
12526        }
12527    }
12528    let part = value[start..].trim();
12529    if !part.is_empty() {
12530        parts.push(part);
12531    }
12532    parts
12533}
12534
12535fn rust_parameter_name(pattern: &str) -> Option<&str> {
12536    let mut pattern = pattern.trim();
12537    if let Some(stripped) = pattern.strip_prefix("mut ") {
12538        pattern = stripped.trim_start();
12539    }
12540    pattern
12541        .rsplit(|ch: char| !is_rust_ident_char(ch))
12542        .find(|part| !part.is_empty())
12543}
12544
12545fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
12546    let mut ty = strip_leading_rust_type_modifiers(type_text);
12547    let owned_inner;
12548    if let Some(inner) = single_outer_generic_arg(ty) {
12549        owned_inner = inner.trim().to_string();
12550        ty = strip_leading_rust_type_modifiers(&owned_inner);
12551    }
12552    rust_base_type_ident(ty)
12553}
12554
12555fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
12556    loop {
12557        ty = ty.trim_start();
12558        if let Some(stripped) = ty.strip_prefix('&') {
12559            ty = stripped.trim_start();
12560            if let Some(stripped) = strip_leading_lifetime(ty) {
12561                ty = stripped.trim_start();
12562            }
12563            if let Some(stripped) = ty.strip_prefix("mut ") {
12564                ty = stripped.trim_start();
12565            }
12566            continue;
12567        }
12568        if let Some(stripped) = ty.strip_prefix("mut ") {
12569            ty = stripped.trim_start();
12570            continue;
12571        }
12572        if let Some(stripped) = ty.strip_prefix("dyn ") {
12573            ty = stripped.trim_start();
12574            continue;
12575        }
12576        if let Some(stripped) = ty.strip_prefix("impl ") {
12577            ty = stripped.trim_start();
12578            continue;
12579        }
12580        break ty.trim();
12581    }
12582}
12583
12584fn strip_leading_lifetime(value: &str) -> Option<&str> {
12585    let mut chars = value.char_indices();
12586    let (_, first) = chars.next()?;
12587    if first != '\'' {
12588        return None;
12589    }
12590    for (index, ch) in chars {
12591        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
12592            return Some(&value[index..]);
12593        }
12594    }
12595    Some("")
12596}
12597
12598fn single_outer_generic_arg(ty: &str) -> Option<&str> {
12599    let ty = ty.trim();
12600    let open = ty.find('<')?;
12601    let mut depth = 0usize;
12602    let mut close = None;
12603    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
12604        match ch {
12605            '<' => depth += 1,
12606            '>' => {
12607                depth = depth.saturating_sub(1);
12608                if depth == 0 {
12609                    close = Some(index);
12610                    break;
12611                }
12612            }
12613            _ => {}
12614        }
12615    }
12616    let close = close?;
12617    if !ty[close + 1..].trim().is_empty() {
12618        return None;
12619    }
12620    let inner = &ty[open + 1..close];
12621    let args = split_top_level_commas(inner);
12622    match args.as_slice() {
12623        [arg] => Some(*arg),
12624        _ => None,
12625    }
12626}
12627
12628fn rust_base_type_ident(ty: &str) -> Option<String> {
12629    let ty = ty.trim();
12630    let head = ty
12631        .split([' ', '+', '='])
12632        .find(|part| !part.is_empty())
12633        .unwrap_or(ty);
12634    let head = head.split('<').next().unwrap_or(head).trim();
12635    let ident = head
12636        .rsplit("::")
12637        .next()
12638        .unwrap_or(head)
12639        .trim_matches(|ch: char| !is_rust_ident_char(ch));
12640    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
12641        None
12642    } else {
12643        Some(ident.to_string())
12644    }
12645}
12646
12647fn is_rust_ident_char(ch: char) -> bool {
12648    ch == '_' || ch.is_ascii_alphanumeric()
12649}
12650
12651fn select_rust_direct_self_field_candidate(
12652    project_root: &Path,
12653    reference: &NameMatchRef,
12654    candidates: &[NameMatchCandidate],
12655    receiver_type: &str,
12656    declaration_file: &str,
12657    declaration_scope: &[(usize, usize)],
12658    source_cache: &mut DispatchSourceCache,
12659) -> Option<NameMatchCandidate> {
12660    let eligible = candidates
12661        .iter()
12662        .filter(|candidate| candidate.node_id != reference.caller_node)
12663        .filter(|candidate| {
12664            type_candidate_matches(candidate, receiver_type, &reference.method_name)
12665        })
12666        .filter(|candidate| {
12667            rust_direct_self_field_candidate_matches_scope(
12668                project_root,
12669                candidate,
12670                receiver_type,
12671                declaration_file,
12672                declaration_scope,
12673                source_cache,
12674            )
12675        })
12676        .collect::<Vec<_>>();
12677    match eligible.as_slice() {
12678        [candidate] => Some((**candidate).clone()),
12679        _ => None,
12680    }
12681}
12682
12683fn rust_direct_self_field_candidate_matches_scope(
12684    project_root: &Path,
12685    candidate: &NameMatchCandidate,
12686    receiver_type: &str,
12687    declaration_file: &str,
12688    declaration_scope: &[(usize, usize)],
12689    source_cache: &mut DispatchSourceCache,
12690) -> bool {
12691    if candidate.file_path != declaration_file {
12692        return false;
12693    }
12694    let Some(parsed) = parsed_dispatch_source_for_file(
12695        project_root,
12696        &candidate.file_path,
12697        "rust",
12698        LangId::Rust,
12699        source_cache,
12700    ) else {
12701        return false;
12702    };
12703    let Some(impl_node) =
12704        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
12705    else {
12706        return false;
12707    };
12708    if impl_node.child_by_field_name("trait").is_some()
12709        || impl_node.child_by_field_name("type_parameters").is_some()
12710    {
12711        return false;
12712    }
12713    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12714        return false;
12715    };
12716    impl_target.kind() == "type_identifier"
12717        && node_text(impl_target, &parsed.source) == receiver_type
12718        && rust_module_scope(impl_node) == declaration_scope
12719}
12720
12721fn select_type_match_candidate(
12722    reference: &NameMatchRef,
12723    candidates: &[NameMatchCandidate],
12724    receiver_type: &str,
12725) -> Option<NameMatchCandidate> {
12726    let candidates = candidates
12727        .iter()
12728        .filter(|candidate| candidate.node_id != reference.caller_node)
12729        .filter(|candidate| {
12730            type_candidate_matches(candidate, receiver_type, &reference.method_name)
12731        })
12732        .collect::<Vec<_>>();
12733    match candidates.as_slice() {
12734        [candidate] => Some((**candidate).clone()),
12735        _ => None,
12736    }
12737}
12738
12739fn type_candidate_matches(
12740    candidate: &NameMatchCandidate,
12741    receiver_type: &str,
12742    method_name: &str,
12743) -> bool {
12744    let normalized_type = receiver_type.replace('.', "::");
12745    let suffix = format!("{normalized_type}::{method_name}");
12746    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
12747}
12748
12749fn select_name_match_candidate(
12750    reference: &NameMatchRef,
12751    candidates: &[NameMatchCandidate],
12752) -> Option<NameMatchCandidate> {
12753    let candidates = candidates
12754        .iter()
12755        .filter(|candidate| candidate.node_id != reference.caller_node)
12756        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
12757        .collect::<Vec<_>>();
12758    match candidates.as_slice() {
12759        [] => None,
12760        [candidate] => Some((**candidate).clone()),
12761        _ => select_scored_name_match_candidate(reference, &candidates),
12762    }
12763}
12764
12765fn candidate_allowed_for_reference(
12766    reference: &NameMatchRef,
12767    candidate: &NameMatchCandidate,
12768) -> bool {
12769    if !reference.colon_dispatch {
12770        return true;
12771    }
12772
12773    candidate.kind == "method"
12774        && candidate
12775            .scoped_name
12776            .split("::")
12777            .any(|segment| segment == reference.receiver)
12778}
12779
12780fn select_scored_name_match_candidate(
12781    reference: &NameMatchRef,
12782    candidates: &[&NameMatchCandidate],
12783) -> Option<NameMatchCandidate> {
12784    let receiver_words = split_camel_case(&reference.receiver);
12785    if receiver_words.is_empty() {
12786        return None;
12787    }
12788
12789    let mut best: Option<(&NameMatchCandidate, f64)> = None;
12790    let mut tied_best = false;
12791    for candidate in candidates {
12792        let candidate_words = split_camel_case(&candidate.scoped_name);
12793        let overlap = receiver_words
12794            .iter()
12795            .filter(|receiver_word| {
12796                candidate_words
12797                    .iter()
12798                    .any(|candidate_word| candidate_word == *receiver_word)
12799            })
12800            .count() as f64;
12801        let score =
12802            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
12803        match best {
12804            None => {
12805                best = Some((*candidate, score));
12806                tied_best = false;
12807            }
12808            Some((_, best_score)) if score > best_score => {
12809                best = Some((*candidate, score));
12810                tied_best = false;
12811            }
12812            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
12813                tied_best = true;
12814            }
12815            _ => {}
12816        }
12817    }
12818
12819    let (candidate, score) = best?;
12820    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
12821        Some(candidate.clone())
12822    } else {
12823        None
12824    }
12825}
12826
12827fn method_name_match_denylisted(method_name: &str) -> bool {
12828    matches!(
12829        method_name,
12830        "and_then"
12831            | "as_bytes"
12832            | "as_deref"
12833            | "as_mut"
12834            | "as_ref"
12835            | "as_str"
12836            | "borrow"
12837            | "borrow_mut"
12838            | "clear"
12839            | "clone"
12840            | "collect"
12841            | "contains"
12842            | "contains_key"
12843            | "count"
12844            | "dedup"
12845            | "default"
12846            | "drain"
12847            | "ends_with"
12848            | "entry"
12849            | "err"
12850            | "expect"
12851            | "extend"
12852            | "filter"
12853            | "filter_map"
12854            | "find"
12855            | "from"
12856            | "get"
12857            | "get_mut"
12858            | "insert"
12859            | "into"
12860            | "into_iter"
12861            | "is_empty"
12862            | "is_err"
12863            | "is_none"
12864            | "is_ok"
12865            | "is_some"
12866            | "iter"
12867            | "iter_mut"
12868            | "join"
12869            | "len"
12870            | "lock"
12871            | "map"
12872            | "map_err"
12873            | "max"
12874            | "min"
12875            | "new"
12876            | "next"
12877            | "ok"
12878            | "or_default"
12879            | "or_else"
12880            | "or_insert"
12881            | "or_insert_with"
12882            | "parse"
12883            | "pop"
12884            | "position"
12885            | "push"
12886            | "read"
12887            | "recv"
12888            | "remove"
12889            | "replace"
12890            | "retain"
12891            | "send"
12892            | "sort"
12893            | "sort_by"
12894            | "split"
12895            | "starts_with"
12896            | "sum"
12897            | "take"
12898            | "to_owned"
12899            | "to_string"
12900            | "trim"
12901            | "try_from"
12902            | "try_into"
12903            | "unwrap"
12904            | "unwrap_or"
12905            | "unwrap_or_default"
12906            | "unwrap_or_else"
12907            | "with_capacity"
12908            | "write"
12909    )
12910}
12911
12912fn split_camel_case(value: &str) -> Vec<String> {
12913    let chars = value.chars().collect::<Vec<_>>();
12914    let mut normalized = String::with_capacity(value.len() + 8);
12915    for (index, ch) in chars.iter().enumerate() {
12916        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
12917        let next = chars.get(index + 1);
12918        let is_separator = ch.is_whitespace()
12919            || matches!(
12920                ch,
12921                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
12922            );
12923        if is_separator {
12924            normalized.push(' ');
12925            continue;
12926        }
12927        let camel_boundary = previous.is_some_and(|prev| {
12928            (prev.is_lowercase() && ch.is_uppercase())
12929                || (prev.is_ascii_digit() && ch.is_alphabetic())
12930                || (prev.is_uppercase()
12931                    && ch.is_uppercase()
12932                    && next.is_some_and(|next| next.is_lowercase()))
12933        });
12934        if camel_boundary {
12935            normalized.push(' ');
12936        }
12937        normalized.push(*ch);
12938    }
12939
12940    normalized
12941        .split_whitespace()
12942        .filter(|word| word.len() > 1)
12943        .map(|word| word.to_ascii_lowercase())
12944        .collect()
12945}
12946
12947fn compute_path_proximity(left: &str, right: &str) -> f64 {
12948    let left_dirs = left
12949        .rsplit_once('/')
12950        .map(|(dir, _)| dir)
12951        .unwrap_or_default()
12952        .split('/')
12953        .filter(|part| !part.is_empty());
12954    let right_dirs = right
12955        .rsplit_once('/')
12956        .map(|(dir, _)| dir)
12957        .unwrap_or_default()
12958        .split('/')
12959        .filter(|part| !part.is_empty());
12960
12961    let shared = left_dirs
12962        .zip(right_dirs)
12963        .take_while(|(left, right)| left == right)
12964        .count();
12965    ((shared as f64) * 0.05).min(0.5)
12966}
12967
12968fn mark_backend_state(
12969    tx: &Transaction<'_>,
12970    project_root: &Path,
12971    rel_path: &str,
12972    content_hash: Option<&blake3::Hash>,
12973    status: &str,
12974) -> Result<()> {
12975    clear_backend_state_for_file(tx, project_root, rel_path)?;
12976    let hash = content_hash
12977        .map(|hash| hash_to_hex(*hash))
12978        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
12979    tx.execute(
12980        "INSERT OR REPLACE INTO backend_file_state(
12981            backend, workspace_root, file_path, content_hash, status, updated_at
12982        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
12983        params![
12984            BACKEND_TREESITTER,
12985            project_root.display().to_string(),
12986            rel_path,
12987            hash,
12988            status,
12989            unix_seconds_now(),
12990        ],
12991    )?;
12992    Ok(())
12993}
12994
12995fn clear_backend_state_for_file(
12996    tx: &Transaction<'_>,
12997    project_root: &Path,
12998    rel_path: &str,
12999) -> Result<()> {
13000    tx.execute(
13001        "DELETE FROM backend_file_state
13002         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13003        params![
13004            BACKEND_TREESITTER,
13005            project_root.display().to_string(),
13006            rel_path
13007        ],
13008    )?;
13009    Ok(())
13010}
13011
13012/// Mark a file whose graph bytes were just confirmed current as fresh.
13013///
13014/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
13015/// leftover `status='stale'` row from a failed refresh would keep blocking
13016/// dead-code projection even though the graph still matches disk.
13017fn clear_stale_backend_status_for_file(
13018    tx: &Transaction<'_>,
13019    project_root: &Path,
13020    rel_path: &str,
13021) -> Result<()> {
13022    tx.execute(
13023        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13024         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13025        params![
13026            BACKEND_TREESITTER,
13027            project_root.display().to_string(),
13028            rel_path,
13029            unix_seconds_now(),
13030        ],
13031    )?;
13032    Ok(())
13033}
13034
13035fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13036    conn.query_row(
13037        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13038        params![rel_path],
13039        |row| {
13040            let hash_text: String = row.get(1)?;
13041            Ok(FileRow {
13042                surface_fingerprint: row.get(0)?,
13043                freshness: FileFreshness {
13044                    content_hash: hash_from_hex(&hash_text)
13045                        .unwrap_or_else(cache_freshness::zero_hash),
13046                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13047                    size: row.get::<_, i64>(3)? as u64,
13048                },
13049            })
13050        },
13051    )
13052    .optional()
13053    .map_err(CallGraphStoreError::from)
13054}
13055
13056fn stored_node_ids_match_extract(
13057    tx: &Transaction<'_>,
13058    rel_path: &str,
13059    extract: &FileExtract,
13060) -> Result<bool> {
13061    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13062    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13063    let mut stored = BTreeSet::new();
13064    for row in rows {
13065        stored.insert(row?);
13066    }
13067    let extracted = extract
13068        .nodes
13069        .iter()
13070        .map(|node| node.id.clone())
13071        .collect::<BTreeSet<_>>();
13072    Ok(stored == extracted)
13073}
13074
13075/// Compare every persisted graph row that comes from this file before rewriting it.
13076/// Ranges and reference byte offsets are part of the key because queries expose
13077/// source locations; equal names and edges are not enough after a body shift.
13078fn stored_extract_matches(
13079    tx: &Transaction<'_>,
13080    rel_path: &str,
13081    extract: &FileExtract,
13082    index: &ProjectIndex<'_>,
13083) -> Result<bool> {
13084    let stored_file = tx
13085        .query_row(
13086            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13087            params![rel_path],
13088            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13089        )
13090        .optional()?;
13091    if stored_file
13092        != Some((
13093            lang_label(extract.lang).to_string(),
13094            extract.surface_fingerprint.clone(),
13095        ))
13096    {
13097        return Ok(false);
13098    }
13099
13100    let mut stored_nodes_stmt = tx.prepare(
13101        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13102                end_line, end_col, range_ordinal, signature, exported,
13103                is_default_export, is_type_like, is_callgraph_entry_point, provenance
13104         FROM nodes WHERE file_path = ?1",
13105    )?;
13106    let stored_nodes = stored_nodes_stmt
13107        .query_map(params![rel_path], |row| {
13108            Ok(serde_json::json!([
13109                row.get::<_, String>(0)?,
13110                row.get::<_, String>(1)?,
13111                row.get::<_, String>(2)?,
13112                row.get::<_, String>(3)?,
13113                row.get::<_, String>(4)?,
13114                row.get::<_, i64>(5)?,
13115                row.get::<_, i64>(6)?,
13116                row.get::<_, i64>(7)?,
13117                row.get::<_, i64>(8)?,
13118                row.get::<_, i64>(9)?,
13119                row.get::<_, Option<String>>(10)?,
13120                row.get::<_, i64>(11)?,
13121                row.get::<_, i64>(12)?,
13122                row.get::<_, i64>(13)?,
13123                row.get::<_, i64>(14)?,
13124                row.get::<_, String>(15)?,
13125            ])
13126            .to_string())
13127        })?
13128        .collect::<rusqlite::Result<Vec<_>>>()?;
13129    let expected_nodes = extract
13130        .nodes
13131        .iter()
13132        .map(|node| {
13133            serde_json::json!([
13134                node.id,
13135                node.file_path,
13136                node.name,
13137                node.scoped_name,
13138                node.kind,
13139                node.range.start_line,
13140                node.range.start_col,
13141                node.range.end_line,
13142                node.range.end_col,
13143                node.range_ordinal,
13144                node.signature,
13145                bool_int(node.exported),
13146                bool_int(node.is_default_export),
13147                bool_int(node.is_type_like),
13148                bool_int(node.is_callgraph_entry_point),
13149                PROVENANCE_TREESITTER,
13150            ])
13151            .to_string()
13152        })
13153        .collect::<Vec<_>>();
13154    let mut stored_nodes = stored_nodes;
13155    let mut expected_nodes = expected_nodes;
13156    stored_nodes.sort();
13157    expected_nodes.sort();
13158    if stored_nodes != expected_nodes {
13159        return Ok(false);
13160    }
13161
13162    let resolved_refs = extract
13163        .raw_refs
13164        .iter()
13165        .cloned()
13166        .map(|raw| resolve_ref(raw, index))
13167        .collect::<Result<Vec<_>>>()?;
13168    let mut stored_refs_stmt = tx.prepare(
13169        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13170                module_path, import_kind, local_name, requested_name, namespace_alias,
13171                wildcard, line, byte_start, byte_end, status, target_node,
13172                target_file, target_symbol, provenance
13173         FROM refs WHERE caller_file = ?1",
13174    )?;
13175    let stored_refs = stored_refs_stmt
13176        .query_map(params![rel_path], |row| {
13177            Ok(serde_json::json!([
13178                row.get::<_, String>(0)?,
13179                row.get::<_, Option<String>>(1)?,
13180                row.get::<_, String>(2)?,
13181                row.get::<_, String>(3)?,
13182                row.get::<_, Option<String>>(4)?,
13183                row.get::<_, Option<String>>(5)?,
13184                row.get::<_, Option<String>>(6)?,
13185                row.get::<_, Option<String>>(7)?,
13186                row.get::<_, Option<String>>(8)?,
13187                row.get::<_, Option<String>>(9)?,
13188                row.get::<_, Option<String>>(10)?,
13189                row.get::<_, i64>(11)?,
13190                row.get::<_, i64>(12)?,
13191                row.get::<_, i64>(13)?,
13192                row.get::<_, i64>(14)?,
13193                row.get::<_, String>(15)?,
13194                row.get::<_, Option<String>>(16)?,
13195                row.get::<_, Option<String>>(17)?,
13196                row.get::<_, Option<String>>(18)?,
13197                row.get::<_, String>(19)?,
13198            ])
13199            .to_string())
13200        })?
13201        .collect::<rusqlite::Result<Vec<_>>>()?;
13202    let expected_refs = resolved_refs
13203        .iter()
13204        .map(|resolved| {
13205            let raw = &resolved.raw;
13206            serde_json::json!([
13207                raw.ref_id,
13208                raw.caller_node,
13209                raw.caller_file,
13210                raw.kind,
13211                raw.short_name,
13212                raw.full_ref,
13213                raw.module_path,
13214                raw.import_kind,
13215                raw.local_name,
13216                raw.requested_name,
13217                raw.namespace_alias,
13218                bool_int(raw.wildcard),
13219                raw.line,
13220                raw.byte_start,
13221                raw.byte_end,
13222                resolved.status,
13223                resolved.target_node,
13224                resolved.target_file,
13225                resolved.target_symbol,
13226                PROVENANCE_TREESITTER,
13227            ])
13228            .to_string()
13229        })
13230        .collect::<Vec<_>>();
13231    let mut stored_refs = stored_refs;
13232    let mut expected_refs = expected_refs;
13233    stored_refs.sort();
13234    expected_refs.sort();
13235    if stored_refs != expected_refs {
13236        return Ok(false);
13237    }
13238
13239    let mut stored_edges_stmt = tx.prepare(
13240        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13241                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13242         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13243         WHERE r.caller_file = ?1 AND e.provenance = ?2",
13244    )?;
13245    let stored_edges = stored_edges_stmt
13246        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13247            Ok(serde_json::json!([
13248                row.get::<_, String>(0)?,
13249                row.get::<_, String>(1)?,
13250                row.get::<_, String>(2)?,
13251                row.get::<_, Option<String>>(3)?,
13252                row.get::<_, String>(4)?,
13253                row.get::<_, String>(5)?,
13254                row.get::<_, String>(6)?,
13255                row.get::<_, i64>(7)?,
13256                row.get::<_, String>(8)?,
13257            ])
13258            .to_string())
13259        })?
13260        .collect::<rusqlite::Result<Vec<_>>>()?;
13261    let expected_edges = resolved_refs
13262        .iter()
13263        .filter_map(|resolved| {
13264            resolved.edge.as_ref().map(|edge| {
13265                serde_json::json!([
13266                    edge.edge_id,
13267                    resolved.raw.ref_id,
13268                    edge.source_node,
13269                    edge.target_node,
13270                    edge.target_file,
13271                    edge.target_symbol,
13272                    edge.kind,
13273                    edge.line,
13274                    PROVENANCE_TREESITTER,
13275                ])
13276                .to_string()
13277            })
13278        })
13279        .collect::<Vec<_>>();
13280    let mut stored_edges = stored_edges;
13281    let mut expected_edges = expected_edges;
13282    stored_edges.sort();
13283    expected_edges.sort();
13284    if stored_edges != expected_edges {
13285        return Ok(false);
13286    }
13287
13288    let mut stored_dependencies_stmt =
13289        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13290    let stored_dependencies = stored_dependencies_stmt
13291        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13292        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13293    let expected_dependencies = extract
13294        .raw_refs
13295        .iter()
13296        .flat_map(|raw| raw.dependencies.iter().cloned())
13297        .collect::<BTreeSet<_>>();
13298    if stored_dependencies != expected_dependencies {
13299        return Ok(false);
13300    }
13301
13302    let mut stored_hints_stmt = tx.prepare(
13303        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13304         FROM dispatch_hints WHERE file = ?1",
13305    )?;
13306    let stored_hints = stored_hints_stmt
13307        .query_map(params![rel_path], |row| {
13308            Ok(serde_json::json!([
13309                row.get::<_, String>(0)?,
13310                row.get::<_, String>(1)?,
13311                row.get::<_, String>(2)?,
13312                row.get::<_, String>(3)?,
13313                row.get::<_, i64>(4)?,
13314                row.get::<_, i64>(5)?,
13315                row.get::<_, i64>(6)?,
13316                row.get::<_, String>(7)?,
13317            ])
13318            .to_string())
13319        })?
13320        .collect::<rusqlite::Result<Vec<_>>>()?;
13321    let expected_hints = extract
13322        .dispatch_hints
13323        .iter()
13324        .map(|hint| {
13325            serde_json::json!([
13326                hint.id,
13327                hint.method_name,
13328                hint.caller_node,
13329                hint.file,
13330                hint.line,
13331                hint.byte_start,
13332                hint.byte_end,
13333                PROVENANCE_TREESITTER,
13334            ])
13335            .to_string()
13336        })
13337        .collect::<Vec<_>>();
13338    let mut stored_hints = stored_hints;
13339    let mut expected_hints = expected_hints;
13340    stored_hints.sort();
13341    expected_hints.sort();
13342    Ok(stored_hints == expected_hints)
13343}
13344
13345fn update_file_fresh_metadata(
13346    tx: &Transaction<'_>,
13347    project_root: &Path,
13348    rel_path: &str,
13349    hash: &blake3::Hash,
13350    mtime: SystemTime,
13351    size: u64,
13352) -> Result<()> {
13353    tx.execute(
13354        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13355         WHERE path = ?1",
13356        params![
13357            rel_path,
13358            hash_to_hex(*hash),
13359            system_time_to_ns(mtime),
13360            size as i64,
13361            unix_seconds_now()
13362        ],
13363    )?;
13364    tx.execute(
13365        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13366         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13367        params![
13368            BACKEND_TREESITTER,
13369            rel_path,
13370            hash_to_hex(*hash),
13371            project_root.display().to_string(),
13372            unix_seconds_now(),
13373        ],
13374    )?;
13375    Ok(())
13376}
13377
13378#[derive(Debug, Clone, PartialEq, Eq)]
13379struct DependentRefSelection {
13380    ref_id: String,
13381    caller_file: String,
13382}
13383
13384fn ref_ids_depending_on(
13385    conn: &Connection,
13386    project_root: &Path,
13387    rel_path: &str,
13388) -> Result<Vec<DependentRefSelection>> {
13389    let mut stmt = conn.prepare(
13390        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
13391         FROM refs r
13392         WHERE r.caller_file IN (
13393             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
13394         )
13395            OR r.target_file = ?1
13396         ORDER BY r.ref_id",
13397    )?;
13398    let rows = stmt.query_map(params![rel_path], |row| {
13399        Ok(RefDependencyRow {
13400            ref_id: row.get(0)?,
13401            kind: row.get(1)?,
13402            caller_file: row.get(2)?,
13403            module_path: row.get(3)?,
13404            target_file: row.get(4)?,
13405        })
13406    })?;
13407    let mut ids = Vec::new();
13408    for row in rows {
13409        let row = row?;
13410        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
13411            ids.push(DependentRefSelection {
13412                ref_id: row.ref_id,
13413                caller_file: row.caller_file,
13414            });
13415        }
13416    }
13417    Ok(ids)
13418}
13419
13420fn record_dependent_refs(
13421    selected_ref_ids: &mut BTreeSet<String>,
13422    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
13423    dependent_refs: Vec<DependentRefSelection>,
13424) {
13425    for dependent_ref in dependent_refs {
13426        let DependentRefSelection {
13427            ref_id,
13428            caller_file,
13429        } = dependent_ref;
13430        selected_ref_ids.insert(ref_id.clone());
13431        selected_refs_by_caller
13432            .entry(caller_file)
13433            .or_default()
13434            .insert(ref_id);
13435    }
13436}
13437
13438#[cfg(test)]
13439fn refs_by_caller_for_ref_ids(
13440    tx: &Transaction<'_>,
13441    ref_ids: &BTreeSet<String>,
13442) -> Result<BTreeMap<String, BTreeSet<String>>> {
13443    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13444    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
13445    for ref_id in ref_ids {
13446        if let Some(caller) = stmt
13447            .query_row(params![ref_id], |row| row.get::<_, String>(0))
13448            .optional()?
13449        {
13450            by_caller.entry(caller).or_default().insert(ref_id.clone());
13451        }
13452    }
13453    Ok(by_caller)
13454}
13455
13456fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13457    tx.execute(
13458        "DELETE FROM file_dependencies WHERE file_path = ?1",
13459        params![rel_path],
13460    )?;
13461    delete_refs_for_caller(tx, rel_path)?;
13462    tx.execute(
13463        "DELETE FROM dispatch_hints WHERE file = ?1",
13464        params![rel_path],
13465    )?;
13466    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
13467    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
13468    Ok(())
13469}
13470
13471fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13472    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
13473    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13474    let mut ids = BTreeSet::new();
13475    for row in rows {
13476        ids.insert(row?);
13477    }
13478    delete_ref_ids(tx, &ids)
13479}
13480
13481fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
13482    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
13483    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
13484    for ref_id in ref_ids {
13485        delete_edges.execute(params![ref_id])?;
13486        delete_refs.execute(params![ref_id])?;
13487    }
13488    Ok(())
13489}
13490
13491fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
13492    let mut stmt = conn.prepare(
13493        "SELECT source.file_path, source.scoped_name, edges.target_file,
13494                edges.target_symbol, edges.kind, edges.line
13495         FROM edges
13496         JOIN nodes AS source ON source.id = edges.source_node
13497         ORDER BY source.file_path, source.scoped_name, edges.target_file,
13498                  edges.target_symbol, edges.kind, edges.line",
13499    )?;
13500    let rows = stmt.query_map([], |row| {
13501        Ok(StoredEdge {
13502            source_file: row.get(0)?,
13503            source_symbol: row.get(1)?,
13504            target_file: row.get(2)?,
13505            target_symbol: row.get(3)?,
13506            kind: row.get(4)?,
13507            line: row.get::<_, i64>(5)? as u32,
13508        })
13509    })?;
13510    let mut edges = BTreeSet::new();
13511    for row in rows {
13512        edges.insert(row?);
13513    }
13514    Ok(edges)
13515}
13516
13517fn module_target_from_dependencies(
13518    project_root: &Path,
13519    dependencies: &BTreeSet<String>,
13520) -> Option<String> {
13521    dependencies.iter().find_map(|dep| {
13522        let path = project_root.join(dep);
13523        if path.is_file() {
13524            Some(relative_path(project_root, &canonicalize_path(&path)))
13525        } else {
13526            None
13527        }
13528    })
13529}
13530
13531fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
13532    let mut named = HashMap::new();
13533    if let Some(full_ref) = &raw_ref.full_ref {
13534        named = parse_reexport_names(full_ref);
13535    }
13536    ReexportIndex {
13537        target_file,
13538        named,
13539        wildcard: raw_ref.wildcard,
13540    }
13541}
13542
13543fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
13544    let mut names = HashMap::new();
13545    let Some(open) = statement.find('{') else {
13546        return names;
13547    };
13548    let Some(close) = statement[open + 1..]
13549        .find('}')
13550        .map(|offset| open + 1 + offset)
13551    else {
13552        return names;
13553    };
13554    for spec in statement[open + 1..close].split(',') {
13555        let spec = spec.trim();
13556        if spec.is_empty() {
13557            continue;
13558        }
13559        if let Some((source, local)) = spec.split_once(" as ") {
13560            names.insert(local.trim().to_string(), source.trim().to_string());
13561        } else {
13562            names.insert(spec.to_string(), spec.to_string());
13563        }
13564    }
13565    names
13566}
13567
13568#[derive(Debug)]
13569struct RefDependencyRow {
13570    ref_id: String,
13571    kind: String,
13572    caller_file: String,
13573    module_path: Option<String>,
13574    target_file: Option<String>,
13575}
13576
13577fn ref_dependency_row_depends_on(
13578    project_root: &Path,
13579    row: &RefDependencyRow,
13580    rel_path: &str,
13581) -> bool {
13582    if row.target_file.as_deref() == Some(rel_path) {
13583        return true;
13584    }
13585
13586    match row.kind.as_str() {
13587        "call" => true,
13588        "import" | "reexport" => row
13589            .module_path
13590            .as_deref()
13591            .map(|module_path| {
13592                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
13593                    .contains(rel_path)
13594            })
13595            .unwrap_or(false),
13596        "export_alias" => false,
13597        _ => false,
13598    }
13599}
13600
13601fn module_dependencies_for_ref(
13602    project_root: &Path,
13603    caller_file: &str,
13604    module_path: &str,
13605) -> BTreeSet<String> {
13606    module_dependencies(project_root, &project_root.join(caller_file), module_path)
13607}
13608
13609fn import_dependencies(
13610    project_root: &Path,
13611    abs_path: &Path,
13612    imports: &[ImportStatement],
13613) -> BTreeSet<String> {
13614    let mut deps = BTreeSet::new();
13615    for import in imports {
13616        deps.extend(module_dependencies(
13617            project_root,
13618            abs_path,
13619            &import.module_path,
13620        ));
13621    }
13622    deps
13623}
13624
13625fn module_dependencies(
13626    project_root: &Path,
13627    abs_path: &Path,
13628    module_path: &str,
13629) -> BTreeSet<String> {
13630    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
13631    let caller_dir = abs_path.parent().unwrap_or(project_root);
13632    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
13633        deps.insert(relative_path(project_root, &resolved));
13634    }
13635    if module_path.starts_with('.') {
13636        let base = caller_dir.join(module_path);
13637        for candidate in relative_module_candidates(&base) {
13638            deps.insert(relative_path(project_root, &candidate));
13639        }
13640    }
13641    deps
13642}
13643
13644fn rust_module_dependencies(
13645    project_root: &Path,
13646    abs_path: &Path,
13647    module_path: &str,
13648) -> BTreeSet<String> {
13649    let mut deps = BTreeSet::new();
13650    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
13651    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
13652        return deps;
13653    };
13654    let src_prefix = rust_src_prefix(&rel_path);
13655    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
13656    if !path_segments.is_empty() {
13657        rust_push_module_dependency_candidate(
13658            project_root,
13659            &mut deps,
13660            &src_prefix,
13661            &path_segments[..path_segments.len() - 1],
13662        );
13663    }
13664    deps
13665}
13666
13667fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
13668    let path = rust_module_path_without_alias_or_use_list(module_path);
13669    let segments = path
13670        .split("::")
13671        .map(str::trim)
13672        .filter(|segment| !segment.is_empty())
13673        .collect::<Vec<_>>();
13674    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
13675        return None;
13676    }
13677    rust_resolve_segments(rel_path, &segments)
13678}
13679
13680fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
13681    let path = module_path
13682        .trim()
13683        .trim_end_matches(';')
13684        .split_once(" as ")
13685        .map(|(left, _)| left.trim())
13686        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
13687    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
13688}
13689
13690fn rust_push_module_dependency_candidate(
13691    project_root: &Path,
13692    deps: &mut BTreeSet<String>,
13693    src_prefix: &str,
13694    segments: &[String],
13695) {
13696    let candidates = if segments.is_empty() {
13697        vec![
13698            format!("{src_prefix}/lib.rs"),
13699            format!("{src_prefix}/main.rs"),
13700        ]
13701    } else {
13702        vec![
13703            format!("{}/{}.rs", src_prefix, segments.join("/")),
13704            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
13705        ]
13706    };
13707    for candidate in candidates {
13708        if project_root.join(&candidate).is_file() {
13709            deps.insert(candidate);
13710        }
13711    }
13712}
13713
13714fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
13715    let mut candidates = Vec::new();
13716    if base.extension().is_some() {
13717        candidates.push(base.to_path_buf());
13718        return candidates;
13719    }
13720    for ext in JS_TS_EXTENSIONS {
13721        candidates.push(base.with_extension(ext));
13722    }
13723    for ext in JS_TS_EXTENSIONS {
13724        candidates.push(base.join(format!("index.{ext}")));
13725    }
13726    candidates
13727}
13728
13729fn import_local_names(import: &ImportStatement) -> Vec<String> {
13730    let mut names = Vec::new();
13731    if let Some(default) = &import.default_import {
13732        names.push(default.clone());
13733    }
13734    if let Some(namespace) = &import.namespace_import {
13735        names.push(namespace.clone());
13736    }
13737    for name in &import.names {
13738        names.push(crate::imports::specifier_local_name(name).to_string());
13739    }
13740    names
13741}
13742
13743fn import_requested_names(import: &ImportStatement) -> Vec<String> {
13744    import
13745        .names
13746        .iter()
13747        .map(|name| crate::imports::specifier_imported_name(name).to_string())
13748        .collect()
13749}
13750
13751fn import_is_wildcard(import: &ImportStatement) -> bool {
13752    import.namespace_import.is_some() || import.raw_text.contains('*')
13753}
13754
13755fn namespace_alias(full_ref: &str) -> Option<String> {
13756    full_ref
13757        .split_once('.')
13758        .map(|(namespace, _)| namespace.to_string())
13759}
13760
13761fn import_kind_label(kind: ImportKind) -> &'static str {
13762    match kind {
13763        ImportKind::Value => "value",
13764        ImportKind::Type => "type",
13765        ImportKind::SideEffect => "side_effect",
13766    }
13767}
13768
13769fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
13770    match kind {
13771        SymbolKind::Function => "function",
13772        SymbolKind::Class => "class",
13773        SymbolKind::Method => "method",
13774        SymbolKind::Struct => "struct",
13775        SymbolKind::Interface => "interface",
13776        SymbolKind::Enum => "enum",
13777        SymbolKind::TypeAlias => "type_alias",
13778        SymbolKind::Variable => "variable",
13779        SymbolKind::Heading => "heading",
13780        SymbolKind::FileSummary => "file_summary",
13781    }
13782}
13783
13784fn is_type_like(kind: &SymbolKind) -> bool {
13785    matches!(
13786        kind,
13787        SymbolKind::Class
13788            | SymbolKind::Struct
13789            | SymbolKind::Interface
13790            | SymbolKind::Enum
13791            | SymbolKind::TypeAlias
13792    )
13793}
13794
13795fn lang_label(lang: LangId) -> &'static str {
13796    match lang {
13797        LangId::TypeScript => "typescript",
13798        LangId::Tsx => "tsx",
13799        LangId::JavaScript => "javascript",
13800        LangId::Python => "python",
13801        LangId::Rust => "rust",
13802        LangId::Go => "go",
13803        LangId::C => "c",
13804        LangId::Cpp => "cpp",
13805        LangId::Zig => "zig",
13806        LangId::CSharp => "csharp",
13807        LangId::Bash => "bash",
13808        LangId::Html => "html",
13809        LangId::Markdown => "markdown",
13810        LangId::Solidity => "solidity",
13811        LangId::Scss => "scss",
13812        LangId::Vue => "vue",
13813        LangId::Json => "json",
13814        LangId::Scala => "scala",
13815        LangId::Java => "java",
13816        LangId::Ruby => "ruby",
13817        LangId::Kotlin => "kotlin",
13818        LangId::Swift => "swift",
13819        LangId::Php => "php",
13820        LangId::Lua => "lua",
13821        LangId::Perl => "perl",
13822        LangId::Yaml => "yaml",
13823        LangId::Pascal => "pascal",
13824        LangId::R => "r",
13825        LangId::Groovy => "groovy",
13826        LangId::ObjC => "objc",
13827    }
13828}
13829
13830fn lang_from_label(label: &str) -> Option<LangId> {
13831    match label {
13832        "typescript" => Some(LangId::TypeScript),
13833        "tsx" => Some(LangId::Tsx),
13834        "javascript" => Some(LangId::JavaScript),
13835        "python" => Some(LangId::Python),
13836        "rust" => Some(LangId::Rust),
13837        "go" => Some(LangId::Go),
13838        "c" => Some(LangId::C),
13839        "cpp" => Some(LangId::Cpp),
13840        "zig" => Some(LangId::Zig),
13841        "csharp" => Some(LangId::CSharp),
13842        "bash" => Some(LangId::Bash),
13843        "html" => Some(LangId::Html),
13844        "markdown" => Some(LangId::Markdown),
13845        "solidity" => Some(LangId::Solidity),
13846        "scss" => Some(LangId::Scss),
13847        "vue" => Some(LangId::Vue),
13848        "json" => Some(LangId::Json),
13849        "scala" => Some(LangId::Scala),
13850        "java" => Some(LangId::Java),
13851        "ruby" => Some(LangId::Ruby),
13852        "kotlin" => Some(LangId::Kotlin),
13853        "swift" => Some(LangId::Swift),
13854        "php" => Some(LangId::Php),
13855        "lua" => Some(LangId::Lua),
13856        "perl" => Some(LangId::Perl),
13857        "yaml" => Some(LangId::Yaml),
13858        "pascal" => Some(LangId::Pascal),
13859        "r" => Some(LangId::R),
13860        "groovy" => Some(LangId::Groovy),
13861        "objc" => Some(LangId::ObjC),
13862        _ => None,
13863    }
13864}
13865
13866fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
13867    let mut normalized = if files.is_empty() {
13868        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13869    } else {
13870        files
13871            .iter()
13872            .map(|path| normalize_file_path(project_root, path))
13873            .collect::<Result<Vec<_>>>()?
13874    };
13875    normalized.sort();
13876    normalized.dedup();
13877    Ok(normalized)
13878}
13879
13880fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13881    let full_path = if path.is_relative() {
13882        project_root.join(path)
13883    } else {
13884        path.to_path_buf()
13885    };
13886    Ok(canonicalize_path(&full_path))
13887}
13888
13889/// Normalize a refresh path against the store root before assigning its durable
13890/// relative key. Deleted watcher paths need lenient canonicalization: their
13891/// parent can still reveal an alias such as a symlinked project root.
13892fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13893    let abs_path = normalize_file_path(project_root, path)?;
13894    let rel_path = relative_path(project_root, &abs_path);
13895    if Path::new(&rel_path).is_absolute() {
13896        return Err(CallGraphStoreError::PathIdentityMismatch {
13897            path: path.to_path_buf(),
13898            project_root: project_root.to_path_buf(),
13899        });
13900    }
13901    Ok((abs_path, rel_path))
13902}
13903
13904/// Canonicalize an existing path or the deepest existing ancestor of a deleted
13905/// one. This keeps watcher deletion events in the same identity domain as the
13906/// files indexed before the deletion.
13907fn canonicalize_path(path: &Path) -> PathBuf {
13908    if let Ok(canonical) = std::fs::canonicalize(path) {
13909        return canonical;
13910    }
13911
13912    let mut resolved = PathBuf::new();
13913    let mut missing = Vec::new();
13914    for component in path.components() {
13915        match component {
13916            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
13917                resolved.push(component.as_os_str());
13918                if let Ok(canonical) = std::fs::canonicalize(&resolved) {
13919                    resolved = canonical;
13920                }
13921            }
13922            std::path::Component::CurDir => {}
13923            std::path::Component::ParentDir => {
13924                if missing.pop().is_none() {
13925                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
13926                        return path.to_path_buf();
13927                    }
13928                    resolved.pop();
13929                }
13930            }
13931            std::path::Component::Normal(name) => {
13932                if missing.is_empty() {
13933                    let candidate = resolved.join(name);
13934                    match std::fs::canonicalize(&candidate) {
13935                        Ok(canonical) => resolved = canonical,
13936                        Err(_) => match std::fs::symlink_metadata(&candidate) {
13937                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13938                                missing.push(name.to_owned());
13939                            }
13940                            _ => return path.to_path_buf(),
13941                        },
13942                    }
13943                } else {
13944                    missing.push(name.to_owned());
13945                }
13946            }
13947        }
13948    }
13949    resolved.extend(missing);
13950    resolved
13951}
13952
13953fn relative_path(project_root: &Path, path: &Path) -> String {
13954    if let Ok(stripped) = path.strip_prefix(project_root) {
13955        return stripped.to_string_lossy().replace('\\', "/");
13956    }
13957    let canon_root = canonicalize_path(project_root);
13958    let canon_path = canonicalize_path(path);
13959    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
13960        return stripped.to_string_lossy().replace('\\', "/");
13961    }
13962    canon_path.to_string_lossy().replace('\\', "/")
13963}
13964
13965fn unqualified_name(scoped: &str) -> &str {
13966    if scoped == TOP_LEVEL_SYMBOL {
13967        return scoped;
13968    }
13969    scoped
13970        .rsplit("::")
13971        .next()
13972        .unwrap_or(scoped)
13973        .rsplit('.')
13974        .next()
13975        .unwrap_or(scoped)
13976        .rsplit('#')
13977        .next()
13978        .unwrap_or(scoped)
13979}
13980
13981fn ref_id(parts: &[&str]) -> String {
13982    let joined = parts.join("\0");
13983    hash_to_hex(blake3::hash(joined.as_bytes()))
13984}
13985
13986fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
13987    let mut fingerprint = CorpusFingerprint::default();
13988    for path in callgraph::walk_project_files(project_root) {
13989        fingerprint.add_path(project_root, &path);
13990    }
13991    Ok(fingerprint.finish(project_root))
13992}
13993
13994/// Pre-admission fingerprint over the same source set the staging inventory
13995/// will consume: the walk when no explicit list is supplied, the list
13996/// otherwise. Streaming accumulator - no staging writes, bounded memory.
13997fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
13998    if files.is_empty() {
13999        return callgraph_corpus_fingerprint(project_root);
14000    }
14001    let mut fingerprint = CorpusFingerprint::default();
14002    for path in files {
14003        fingerprint.add_path(project_root, path);
14004    }
14005    Ok(fingerprint.finish(project_root))
14006}
14007
14008#[derive(Default)]
14009struct CorpusFingerprint {
14010    xor: [u8; 32],
14011    sums: [u64; 4],
14012    files: u64,
14013}
14014
14015impl CorpusFingerprint {
14016    fn add_path(&mut self, project_root: &Path, path: &Path) {
14017        let mut record = blake3::Hasher::new();
14018        record.update(relative_path(project_root, path).as_bytes());
14019        record.update(&[0]);
14020        match hash_file_bounded(path) {
14021            Ok(content_hash) => record.update(content_hash.as_bytes()),
14022            // Encoding a missing file as a distinct record changes the corpus
14023            // fingerprint, so breaker state keyed to the previous corpus is not reused.
14024            Err(error) => record.update(format!("missing:{error}").as_bytes()),
14025        };
14026        record.update(&[0]);
14027        let record = record.finalize();
14028        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14029            self.xor[index] ^= byte;
14030        }
14031        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14032            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14033            self.sums[index] = self.sums[index].wrapping_add(value);
14034        }
14035        self.files = self.files.saturating_add(1);
14036    }
14037
14038    fn finish(self, project_root: &Path) -> String {
14039        // Combining both xor and modular sums keeps the digest independent of
14040        // walk order while retaining duplicate sensitivity for generic callers.
14041        let mut hasher = blake3::Hasher::new();
14042        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14043        hasher.update(&self.files.to_le_bytes());
14044        hasher.update(&self.xor);
14045        for sum in self.sums {
14046            hasher.update(&sum.to_le_bytes());
14047        }
14048        let ignore_rules = project_root.join(".gitignore");
14049        if let Ok(contents) = std::fs::read(ignore_rules) {
14050            hasher.update(b".gitignore\0");
14051            hasher.update(blake3::hash(&contents).as_bytes());
14052        }
14053        hash_to_hex(hasher.finalize())
14054    }
14055}
14056
14057fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14058    let mut file = std::fs::File::open(path)?;
14059    let mut hasher = blake3::Hasher::new();
14060    let mut buffer = [0u8; 64 * 1024];
14061    loop {
14062        let read = file.read(&mut buffer)?;
14063        if read == 0 {
14064            break;
14065        }
14066        hasher.update(&buffer[..read]);
14067    }
14068    Ok(hasher.finalize())
14069}
14070
14071#[cfg(test)]
14072pub(crate) fn callgraph_corpus_fingerprint_for_test(
14073    project_root: &Path,
14074    _files: &[PathBuf],
14075) -> Result<String> {
14076    // The streaming fingerprint walks the corpus itself (order-independent
14077    // accumulator, no resident file list); the test seam keeps its historical
14078    // signature so callers need not thread a walk of their own.
14079    callgraph_corpus_fingerprint(project_root)
14080}
14081
14082fn hash_to_hex(hash: blake3::Hash) -> String {
14083    hash.to_hex().to_string()
14084}
14085
14086fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14087    let bytes = hex_to_bytes(value)?;
14088    Some(blake3::Hash::from_bytes(bytes))
14089}
14090
14091fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14092    if value.len() != 64 {
14093        return None;
14094    }
14095    let mut bytes = [0u8; 32];
14096    for (index, slot) in bytes.iter_mut().enumerate() {
14097        let start = index * 2;
14098        let end = start + 2;
14099        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14100    }
14101    Some(bytes)
14102}
14103
14104#[derive(Debug, Clone)]
14105struct LineIndex {
14106    newline_offsets: Vec<usize>,
14107    source_len: usize,
14108}
14109
14110impl LineIndex {
14111    fn new(source: &str) -> Self {
14112        Self {
14113            newline_offsets: source
14114                .bytes()
14115                .enumerate()
14116                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14117                .collect(),
14118            source_len: source.len(),
14119        }
14120    }
14121
14122    fn byte_to_line(&self, byte_offset: usize) -> u32 {
14123        let byte_offset = byte_offset.min(self.source_len);
14124        self.newline_offsets
14125            .partition_point(|offset| *offset < byte_offset) as u32
14126            + 1
14127    }
14128}
14129
14130fn empty_to_none(value: String) -> Option<String> {
14131    if value.is_empty() {
14132        None
14133    } else {
14134        Some(value)
14135    }
14136}
14137
14138fn bool_int(value: bool) -> i64 {
14139    if value {
14140        1
14141    } else {
14142        0
14143    }
14144}
14145
14146fn system_time_to_ns(time: SystemTime) -> i64 {
14147    time.duration_since(UNIX_EPOCH)
14148        .unwrap_or_default()
14149        .as_nanos()
14150        .min(i64::MAX as u128) as i64
14151}
14152
14153fn ns_to_system_time(value: i64) -> SystemTime {
14154    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14155}
14156
14157pub(crate) fn unix_millis_now() -> u64 {
14158    SystemTime::now()
14159        .duration_since(UNIX_EPOCH)
14160        .unwrap_or_default()
14161        .as_millis()
14162        .min(u128::from(u64::MAX)) as u64
14163}
14164
14165fn unix_seconds_now() -> i64 {
14166    SystemTime::now()
14167        .duration_since(UNIX_EPOCH)
14168        .unwrap_or_default()
14169        .as_secs() as i64
14170}
14171
14172/// Serializes every test that drives the process-wide refresh worker
14173/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
14174/// worker down between another test's enqueue and its flush, deferring the
14175/// batch and zeroing that test's seam counts).
14176#[cfg(test)]
14177pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14178
14179#[cfg(test)]
14180mod refresh_worker_tests {
14181    use super::*;
14182    use std::fs;
14183    use tempfile::tempdir;
14184
14185    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14186        let temp = tempdir().unwrap();
14187        let root = temp.path().join("root");
14188        fs::create_dir_all(&root).unwrap();
14189        let artifact_key = crate::search_index::artifact_cache_key(&root);
14190        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14191        let callgraph_dir = temp
14192            .path()
14193            .join("storage")
14194            .join("callgraph")
14195            .join(artifact_key);
14196        let source = root.join("main.rs");
14197        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14198        let (store, _) = CallGraphStore::cold_build_with_lease(
14199            callgraph_dir.clone(),
14200            root.clone(),
14201            std::slice::from_ref(&source),
14202        )
14203        .unwrap();
14204        drop(store);
14205        (temp, root, callgraph_dir, source)
14206    }
14207
14208    fn pending_paths() -> PendingCallGraphStorePaths {
14209        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14210    }
14211
14212    fn wait_for_refresh_calls(root: &Path, expected: usize) {
14213        let deadline = Instant::now() + Duration::from_secs(12);
14214        while callgraph_refresh_worker_test_counts(root).0 < expected {
14215            assert!(
14216                Instant::now() < deadline,
14217                "timed out waiting for {expected} callgraph refresh worker call(s)"
14218            );
14219            std::thread::sleep(Duration::from_millis(5));
14220        }
14221    }
14222
14223    fn wait_for_refresh_worker_idle() {
14224        let deadline = Instant::now() + Duration::from_secs(12);
14225        loop {
14226            let worker = CALLGRAPH_REFRESH_WORKER
14227                .get_or_init(|| Mutex::new(None))
14228                .lock()
14229                .expect("callgraph refresh worker mutex poisoned")
14230                .clone();
14231            let idle = worker.is_none_or(|worker| {
14232                let queue = worker
14233                    .shared
14234                    .queue
14235                    .lock()
14236                    .expect("callgraph refresh queue mutex poisoned");
14237                queue.active.is_none() && queue.order.is_empty()
14238            });
14239            if idle {
14240                return;
14241            }
14242            assert!(
14243                Instant::now() < deadline,
14244                "timed out waiting for callgraph refresh worker to become idle"
14245            );
14246            std::thread::sleep(Duration::from_millis(5));
14247        }
14248    }
14249
14250    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14251        let temp = tempdir().unwrap();
14252        let root = temp.path().join("workspace");
14253        fs::create_dir_all(root.join("app/src")).unwrap();
14254        let artifact_key = crate::search_index::artifact_cache_key(&root);
14255        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14256        let callgraph_dir = temp
14257            .path()
14258            .join("storage")
14259            .join("callgraph")
14260            .join(artifact_key);
14261        fs::write(
14262            root.join("Cargo.toml"),
14263            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14264        )
14265        .unwrap();
14266        fs::write(
14267            root.join("app/Cargo.toml"),
14268            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14269        )
14270        .unwrap();
14271        let caller = root.join("app/src/lib.rs");
14272        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14273        let (store, _) = CallGraphStore::cold_build_with_lease(
14274            callgraph_dir.clone(),
14275            root.clone(),
14276            std::slice::from_ref(&caller),
14277        )
14278        .unwrap();
14279        drop(store);
14280        (temp, root, callgraph_dir, caller)
14281    }
14282
14283    #[test]
14284    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14285        let _guard = REFRESH_WORKER_TEST_LOCK
14286            .lock()
14287            .unwrap_or_else(std::sync::PoisonError::into_inner);
14288        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14289        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14290        reset_workspace_crate_prefix_build_count(&root);
14291        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14292
14293        for revision in ["first", "second"] {
14294            fs::write(
14295                &caller,
14296                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14297            )
14298            .unwrap();
14299            enqueue_callgraph_store_refresh(
14300                callgraph_dir.clone(),
14301                root.clone(),
14302                vec![caller.clone()],
14303                pending_paths(),
14304            );
14305            wait_for_refresh_worker_idle();
14306        }
14307
14308        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14309        assert!(flush_callgraph_store_refreshes_with_budget(
14310            Duration::from_secs(5)
14311        ));
14312        clear_callgraph_refresh_worker_test_seam(&root);
14313    }
14314
14315    #[test]
14316    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14317        let _guard = REFRESH_WORKER_TEST_LOCK
14318            .lock()
14319            .unwrap_or_else(std::sync::PoisonError::into_inner);
14320        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14321        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14322        reset_workspace_crate_prefix_build_count(&root);
14323        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14324
14325        fs::write(
14326            &caller,
14327            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14328        )
14329        .unwrap();
14330        enqueue_callgraph_store_refresh(
14331            callgraph_dir.clone(),
14332            root.clone(),
14333            vec![caller.clone()],
14334            pending_paths(),
14335        );
14336        wait_for_refresh_worker_idle();
14337        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14338
14339        let added_manifest = root.join("added/Cargo.toml");
14340        let added_source = root.join("added/src/lib.rs");
14341        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
14342        fs::write(
14343            root.join("Cargo.toml"),
14344            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
14345        )
14346        .unwrap();
14347        fs::write(
14348            &added_manifest,
14349            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14350        )
14351        .unwrap();
14352        fs::write(&added_source, "pub fn target() {}\n").unwrap();
14353        fs::write(
14354            &caller,
14355            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
14356        )
14357        .unwrap();
14358
14359        enqueue_callgraph_store_refresh(
14360            callgraph_dir.clone(),
14361            root.clone(),
14362            vec![
14363                root.join("Cargo.toml"),
14364                added_manifest,
14365                added_source,
14366                caller,
14367            ],
14368            pending_paths(),
14369        );
14370        assert!(flush_callgraph_store_refreshes_with_budget(
14371            Duration::from_secs(12)
14372        ));
14373
14374        // This is the negative control for a permanently-static cache: without
14375        // manifest invalidation the build count stays at one and the call remains
14376        // unresolved because `added_crate` was absent when the map was primed.
14377        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
14378        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14379            .unwrap()
14380            .expect("refreshed workspace store");
14381        let tree = store
14382            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
14383            .unwrap();
14384        assert_eq!(tree.children.len(), 1);
14385        assert_eq!(tree.children[0].file, "added/src/lib.rs");
14386        assert_eq!(tree.children[0].name, "target");
14387        assert!(tree.children[0].resolved);
14388        clear_callgraph_refresh_worker_test_seam(&root);
14389    }
14390
14391    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
14392        let temp = tempdir().unwrap();
14393        let main = temp.path().join("main");
14394        let worktree = temp.path().join("worktree");
14395        fs::create_dir_all(&main).unwrap();
14396        let mut git = std::process::Command::new("git");
14397        assert!(
14398            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
14399                .status()
14400                .unwrap()
14401                .success()
14402        );
14403        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
14404        for args in [
14405            vec![
14406                "-C",
14407                main.to_str().unwrap(),
14408                "config",
14409                "user.email",
14410                "test@example.com",
14411            ],
14412            vec![
14413                "-C",
14414                main.to_str().unwrap(),
14415                "config",
14416                "user.name",
14417                "AFT Test",
14418            ],
14419            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
14420            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
14421        ] {
14422            let mut command = std::process::Command::new("git");
14423            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
14424                .status()
14425                .unwrap()
14426                .success());
14427        }
14428        let mut add_worktree = std::process::Command::new("git");
14429        assert!(crate::test_env::apply_hermetic_git_env(
14430            add_worktree
14431                .arg("-C")
14432                .arg(&main)
14433                .args(["worktree", "add", "--detach"])
14434                .arg(&worktree),
14435        )
14436        .status()
14437        .unwrap()
14438        .success());
14439        let main = fs::canonicalize(main).unwrap();
14440        let worktree = fs::canonicalize(worktree).unwrap();
14441        let project_key = crate::search_index::artifact_cache_key(&main);
14442        assert_eq!(
14443            crate::search_index::artifact_cache_key(&worktree),
14444            project_key
14445        );
14446        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14447        (temp, main, worktree, project_key, callgraph_dir)
14448    }
14449
14450    #[test]
14451    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
14452        let _git_env = crate::test_env::hermetic_git_env_guard();
14453        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
14454        crate::root_cache::configure_artifact_access(&root, &project_key, true);
14455        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14456        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14457        let publications_for_observer = Arc::clone(&publications);
14458        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14459            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14460        })));
14461        let source = root.join("lib.rs");
14462
14463        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
14464            .expect_err("borrow-only writable open must remain unavailable");
14465        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
14466        assert!(
14467            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
14468                .unwrap()
14469                .is_none()
14470        );
14471        assert!(
14472            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
14473                .unwrap()
14474                .is_none()
14475        );
14476        assert!(matches!(
14477            CallGraphStore::cold_build_with_lease(
14478                callgraph_dir.clone(),
14479                root.clone(),
14480                std::slice::from_ref(&source),
14481            ),
14482            Err(CallGraphStoreError::Unavailable(_))
14483        ));
14484        assert!(matches!(
14485            CallGraphStore::ensure_built_with_lease(
14486                callgraph_dir.clone(),
14487                root.clone(),
14488                std::slice::from_ref(&source),
14489            ),
14490            Err(CallGraphStoreError::Unavailable(_))
14491        ));
14492        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
14493            callgraph_dir.clone(),
14494            root.clone(),
14495            &[source],
14496            1,
14497        )
14498        .expect_err("borrow-only forced rebuild must remain unsatisfied");
14499        set_cold_build_swap_observer(None);
14500
14501        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
14502        assert_eq!(
14503            crate::root_cache::writer_lease_acquisition_count_for_test(
14504                crate::root_cache::RootCacheDomain::Callgraph,
14505                &project_key,
14506                &root,
14507            ),
14508            0
14509        );
14510        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
14511        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
14512    }
14513
14514    #[test]
14515    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
14516        let _git_env = crate::test_env::hermetic_git_env_guard();
14517        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
14518        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
14519        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
14520        let source = owner.join("lib.rs");
14521        let (store, _) = CallGraphStore::cold_build_with_lease(
14522            callgraph_dir.clone(),
14523            owner.clone(),
14524            std::slice::from_ref(&source),
14525        )
14526        .unwrap();
14527        let sqlite_path = store.sqlite_path().to_path_buf();
14528        drop(store);
14529
14530        let conn = Connection::open(&sqlite_path).unwrap();
14531        conn.execute(
14532            "UPDATE backend_file_state SET workspace_root = ?1",
14533            [worktree.display().to_string()],
14534        )
14535        .unwrap();
14536        drop(conn);
14537
14538        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14539        let publications_for_observer = Arc::clone(&publications);
14540        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14541            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14542        })));
14543        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14544
14545        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14546            .unwrap()
14547            .expect("owner should purge the storm-era worktree root");
14548        drop(repaired);
14549        for _ in 0..3 {
14550            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
14551                .unwrap()
14552                .expect("linked worktree should borrow the owner generation");
14553            drop(borrower);
14554            assert!(
14555                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
14556                    .unwrap()
14557                    .is_none()
14558            );
14559            let owner_store =
14560                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14561                    .unwrap()
14562                    .expect("owner generation should remain ready");
14563            drop(owner_store);
14564        }
14565        set_cold_build_swap_observer(None);
14566
14567        assert_eq!(
14568            publications.load(AtomicOrdering::SeqCst),
14569            1,
14570            "the owner performs one expected post-storm purge and alternation stays read-only"
14571        );
14572        assert_eq!(
14573            crate::root_cache::writer_lease_acquisition_count_for_test(
14574                crate::root_cache::RootCacheDomain::Callgraph,
14575                &project_key,
14576                &worktree,
14577            ),
14578            0
14579        );
14580    }
14581
14582    #[test]
14583    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
14584        let temp = tempdir().unwrap();
14585        let root = temp.path().join("owner");
14586        let other_root = temp.path().join("other");
14587        fs::create_dir_all(&root).unwrap();
14588        fs::create_dir_all(&other_root).unwrap();
14589        let source = root.join("lib.rs");
14590        fs::write(&source, "pub fn marker() {}\n").unwrap();
14591        let project_key = crate::search_index::artifact_cache_key(&root);
14592        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14593        crate::root_cache::configure_artifact_access(&root, &project_key, false);
14594        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
14595        rebuild_cooldown_records()
14596            .lock()
14597            .unwrap_or_else(std::sync::PoisonError::into_inner)
14598            .remove(&cooldown_key);
14599        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14600        let stale_epoch = epoch.current();
14601        epoch.next();
14602
14603        let failed = with_publish_epoch(epoch, stale_epoch, || {
14604            CallGraphStore::cold_build_with_lease(
14605                callgraph_dir.clone(),
14606                root.clone(),
14607                std::slice::from_ref(&source),
14608            )
14609        });
14610        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
14611        assert!(
14612            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14613                .is_none()
14614        );
14615
14616        let (store, _) = CallGraphStore::cold_build_with_lease(
14617            callgraph_dir.clone(),
14618            root.clone(),
14619            std::slice::from_ref(&source),
14620        )
14621        .unwrap();
14622        drop(store);
14623        assert!(
14624            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14625                .is_none()
14626        );
14627
14628        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
14629        assert!(
14630            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
14631        );
14632    }
14633
14634    #[test]
14635    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
14636        let _guard = REFRESH_WORKER_TEST_LOCK
14637            .lock()
14638            .unwrap_or_else(std::sync::PoisonError::into_inner);
14639        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14640        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14641        let pending = pending_paths();
14642        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14643
14644        let lifecycle = SubcLifecycleAdmission::default();
14645        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14646        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14647        let ticket = CallgraphRefreshTicket::new(
14648            lifecycle,
14649            Arc::clone(&generation),
14650            7,
14651            publish_epoch.clone(),
14652            publish_epoch.current(),
14653        );
14654        // Supersede before the worker runs: the batch must defer, not commit.
14655        generation.store(8, std::sync::atomic::Ordering::SeqCst);
14656        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14657            .unwrap()
14658            .expect("ready store snapshot");
14659        let refresh_state = CallgraphRefreshState::new(
14660            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
14661            Arc::new(AtomicBool::new(true)),
14662        );
14663
14664        enqueue_callgraph_store_refresh_fenced_with_state(
14665            callgraph_dir,
14666            root.clone(),
14667            vec![source.clone()],
14668            Arc::clone(&pending),
14669            refresh_state,
14670            ticket,
14671        );
14672        assert!(flush_callgraph_store_refreshes_with_budget(
14673            Duration::from_secs(5)
14674        ));
14675        assert_eq!(
14676            callgraph_refresh_worker_test_counts(&root).0,
14677            0,
14678            "superseded batch must not reach refresh_files or self-replay"
14679        );
14680        assert!(
14681            pending.lock().contains(&source),
14682            "superseded batch must defer its paths to the pending sink"
14683        );
14684        clear_callgraph_refresh_worker_test_seam(&root);
14685    }
14686
14687    #[test]
14688    fn superseded_open_failure_defers_without_self_replay() {
14689        let _guard = REFRESH_WORKER_TEST_LOCK
14690            .lock()
14691            .unwrap_or_else(std::sync::PoisonError::into_inner);
14692        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14693        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14694        let pending = pending_paths();
14695        let installed = Arc::new(
14696            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14697                .unwrap()
14698                .expect("ready store snapshot"),
14699        );
14700        let refresh_state = CallgraphRefreshState::new(
14701            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
14702            Arc::new(AtomicBool::new(true)),
14703        );
14704        assert!(!installed.is_legacy_fallback());
14705        assert!(installed.is_current());
14706        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14707        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14708        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
14709        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
14710
14711        let lifecycle = SubcLifecycleAdmission::default();
14712        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14713        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14714        let ticket = CallgraphRefreshTicket::new(
14715            lifecycle,
14716            Arc::clone(&generation),
14717            7,
14718            publish_epoch.clone(),
14719            publish_epoch.current(),
14720        );
14721        enqueue_callgraph_store_refresh_fenced_with_state(
14722            callgraph_dir,
14723            root.clone(),
14724            vec![source.clone()],
14725            Arc::clone(&pending),
14726            refresh_state,
14727            ticket,
14728        );
14729        held_rx
14730            .recv_timeout(Duration::from_secs(12))
14731            .expect("refresh worker must hold after injected open failure");
14732
14733        // Mark the refresh request obsolete after the injected open failure,
14734        // then unblock the worker before its deferred retry can run.
14735        generation.store(8, std::sync::atomic::Ordering::SeqCst);
14736        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
14737        release_tx
14738            .send(())
14739            .expect("release superseded refresh worker");
14740        wait_for_refresh_worker_idle();
14741
14742        assert_eq!(
14743            callgraph_refresh_worker_test_counts(&root).0,
14744            1,
14745            "superseded open-failure batch must not self-replay"
14746        );
14747        assert_eq!(
14748            callgraph_refresh_worker_test_worker_calls(&root),
14749            1,
14750            "superseded open-failure batch must not create another worker call"
14751        );
14752        assert!(
14753            pending.lock().contains(&source),
14754            "superseded open-failure paths must remain in the pending sink"
14755        );
14756        let tree = installed
14757            .call_tree(Path::new("main.rs"), "entry", 1)
14758            .unwrap();
14759        assert_eq!(
14760            tree.children[0].name, "old_leaf",
14761            "superseded open-failure batch must not converge the store"
14762        );
14763        clear_callgraph_refresh_worker_test_seam(&root);
14764    }
14765
14766    #[test]
14767    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
14768        let _guard = REFRESH_WORKER_TEST_LOCK
14769            .lock()
14770            .unwrap_or_else(std::sync::PoisonError::into_inner);
14771        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14772        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14773        let pending = pending_paths();
14774        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14775
14776        let lifecycle = SubcLifecycleAdmission::default();
14777        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
14778        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14779        let expected_epoch = publish_epoch.current();
14780        let ticket = CallgraphRefreshTicket::new(
14781            lifecycle,
14782            generation,
14783            3,
14784            publish_epoch.clone(),
14785            expected_epoch,
14786        );
14787        // A cold build published a replacement generation after enqueue.
14788        publish_epoch.next();
14789
14790        enqueue_callgraph_store_refresh_fenced(
14791            callgraph_dir,
14792            root.clone(),
14793            vec![source.clone()],
14794            Arc::clone(&pending),
14795            ticket,
14796        );
14797        assert!(flush_callgraph_store_refreshes_with_budget(
14798            Duration::from_secs(5)
14799        ));
14800        assert_eq!(
14801            callgraph_refresh_worker_test_counts(&root).0,
14802            0,
14803            "epoch-superseded batch must not reach refresh_files"
14804        );
14805        assert!(
14806            pending.lock().contains(&source),
14807            "epoch-superseded batch must defer its paths to the pending sink"
14808        );
14809        clear_callgraph_refresh_worker_test_seam(&root);
14810    }
14811
14812    #[test]
14813    fn fenced_refresh_with_current_ticket_commits_normally() {
14814        let _guard = REFRESH_WORKER_TEST_LOCK
14815            .lock()
14816            .unwrap_or_else(std::sync::PoisonError::into_inner);
14817        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14818        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14819        let pending = pending_paths();
14820        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14821
14822        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14823
14824        let lifecycle = SubcLifecycleAdmission::default();
14825        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
14826        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14827        let ticket = CallgraphRefreshTicket::new(
14828            lifecycle,
14829            generation,
14830            5,
14831            publish_epoch.clone(),
14832            publish_epoch.current(),
14833        );
14834
14835        enqueue_callgraph_store_refresh_fenced(
14836            callgraph_dir.clone(),
14837            root.clone(),
14838            vec![source.clone()],
14839            Arc::clone(&pending),
14840            ticket,
14841        );
14842        assert!(flush_callgraph_store_refreshes_with_budget(
14843            Duration::from_secs(5)
14844        ));
14845        assert_eq!(
14846            callgraph_refresh_worker_test_counts(&root).0,
14847            1,
14848            "current ticket must run the refresh"
14849        );
14850        assert!(
14851            pending.lock().is_empty(),
14852            "committed batch must not defer paths"
14853        );
14854
14855        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14856            .unwrap()
14857            .expect("published generation must remain readable");
14858        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14859        assert_eq!(
14860            tree.children[0].name, "new_leaf",
14861            "fenced commit must actually persist the refreshed content"
14862        );
14863        clear_callgraph_refresh_worker_test_seam(&root);
14864    }
14865
14866    #[test]
14867    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14868        let _guard = REFRESH_WORKER_TEST_LOCK
14869            .lock()
14870            .unwrap_or_else(std::sync::PoisonError::into_inner);
14871        // Generous pre-drain: the refresh worker is process-wide, so a prior
14872        // test's still-running batch (slow Windows CI) must fully settle
14873        // before this test enqueues, or its wait deadline absorbs the
14874        // leftover work. Idle workers return immediately.
14875        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14876        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14877        let pending = pending_paths();
14878        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14879
14880        enqueue_callgraph_store_refresh(
14881            callgraph_dir.clone(),
14882            root.clone(),
14883            vec![source.clone()],
14884            Arc::clone(&pending),
14885        );
14886        wait_for_refresh_calls(&root, 1);
14887        for _ in 0..3 {
14888            enqueue_callgraph_store_refresh(
14889                callgraph_dir.clone(),
14890                root.clone(),
14891                vec![source.clone()],
14892                Arc::clone(&pending),
14893            );
14894        }
14895
14896        assert!(flush_callgraph_store_refreshes_with_budget(
14897            Duration::from_secs(2)
14898        ));
14899        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
14900        assert!(pending.lock().is_empty());
14901        clear_callgraph_refresh_worker_test_seam(&root);
14902    }
14903
14904    #[test]
14905    fn queued_refresh_opens_generation_published_after_enqueue() {
14906        let _guard = REFRESH_WORKER_TEST_LOCK
14907            .lock()
14908            .unwrap_or_else(std::sync::PoisonError::into_inner);
14909        // Generous pre-drain: the refresh worker is process-wide, so a prior
14910        // test's still-running batch (slow Windows CI) must fully settle
14911        // before this test enqueues, or its wait deadline absorbs the
14912        // leftover work. Idle workers return immediately.
14913        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14914        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
14915        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
14916        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
14917        let (active_held_rx, active_release_tx) =
14918            install_callgraph_refresh_worker_test_gate(active_root.clone());
14919        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
14920        enqueue_callgraph_store_refresh(
14921            active_dir,
14922            active_root.clone(),
14923            vec![active_source],
14924            pending_paths(),
14925        );
14926        active_held_rx
14927            .recv_timeout(Duration::from_secs(12))
14928            .expect("active refresh worker holds the queue");
14929
14930        fs::write(
14931            &target_source,
14932            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14933        )
14934        .unwrap();
14935        enqueue_callgraph_store_refresh(
14936            target_dir.clone(),
14937            target_root.clone(),
14938            vec![target_source.clone()],
14939            pending_paths(),
14940        );
14941        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
14942            target_dir.clone(),
14943            target_root.clone(),
14944            std::slice::from_ref(&target_source),
14945        )
14946        .unwrap();
14947        fs::write(
14948            &target_source,
14949            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
14950        )
14951        .unwrap();
14952        drop(new_generation);
14953
14954        active_release_tx
14955            .send(())
14956            .expect("release active refresh worker");
14957        wait_for_refresh_calls(&target_root, 1);
14958        assert!(flush_callgraph_store_refreshes_with_budget(
14959            Duration::from_secs(12)
14960        ));
14961        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
14962            .unwrap()
14963            .expect("current callgraph generation");
14964        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14965        assert_eq!(tree.children[0].name, "worker_leaf");
14966        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
14967        clear_callgraph_refresh_worker_test_seam(&active_root);
14968        clear_callgraph_refresh_worker_test_seam(&target_root);
14969    }
14970
14971    #[test]
14972    fn refresh_failure_marks_files_stale() {
14973        let _guard = REFRESH_WORKER_TEST_LOCK
14974            .lock()
14975            .unwrap_or_else(std::sync::PoisonError::into_inner);
14976        // Generous pre-drain: the refresh worker is process-wide, so a prior
14977        // test's still-running batch (slow Windows CI) must fully settle
14978        // before this test enqueues, or its wait deadline absorbs the
14979        // leftover work. Idle workers return immediately.
14980        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14981        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14982        let pending = pending_paths();
14983        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
14984
14985        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
14986        assert!(flush_callgraph_store_refreshes_with_budget(
14987            Duration::from_secs(2)
14988        ));
14989
14990        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
14991        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
14992            .unwrap()
14993            .expect("ready callgraph store");
14994        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
14995        clear_callgraph_refresh_worker_test_seam(&root);
14996    }
14997
14998    #[test]
14999    fn idle_refresh_truncates_wal() {
15000        let _guard = REFRESH_WORKER_TEST_LOCK
15001            .lock()
15002            .unwrap_or_else(std::sync::PoisonError::into_inner);
15003        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15004        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15005        let generation = read_pointer(
15006            &callgraph_dir,
15007            &crate::search_index::artifact_cache_key(&root),
15008        )
15009        .expect("fixture publishes a generation");
15010        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15011        let pending = pending_paths();
15012        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15013
15014        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15015        enqueue_callgraph_store_refresh(
15016            callgraph_dir.clone(),
15017            root.clone(),
15018            vec![source.clone()],
15019            Arc::clone(&pending),
15020        );
15021        wait_for_refresh_calls(&root, 1);
15022        wait_for_refresh_worker_idle();
15023        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15024        while fs::metadata(&wal_path)
15025            .map(|metadata| metadata.len())
15026            .unwrap_or(0)
15027            != 0
15028        {
15029            assert!(
15030                Instant::now() < checkpoint_deadline,
15031                "idle checkpoint did not truncate WAL"
15032            );
15033            std::thread::sleep(Duration::from_millis(5));
15034        }
15035        assert_eq!(
15036            fs::metadata(&wal_path)
15037                .map(|metadata| metadata.len())
15038                .unwrap_or(0),
15039            0,
15040            "idle transition truncates the refresh WAL"
15041        );
15042
15043        clear_callgraph_refresh_worker_test_seam(&root);
15044    }
15045
15046    #[test]
15047    fn bounded_shutdown_defers_unprocessed_batches() {
15048        let _guard = REFRESH_WORKER_TEST_LOCK
15049            .lock()
15050            .unwrap_or_else(std::sync::PoisonError::into_inner);
15051        // Generous pre-drain: the refresh worker is process-wide, so a prior
15052        // test's still-running batch (slow Windows CI) must fully settle
15053        // before this test enqueues, or its wait deadline absorbs the
15054        // leftover work. Idle workers return immediately.
15055        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15056        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15057        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15058        let active_pending = pending_paths();
15059        let queued_pending = pending_paths();
15060        set_callgraph_refresh_worker_test_seam(
15061            active_root.clone(),
15062            Duration::from_millis(300),
15063            false,
15064        );
15065
15066        enqueue_callgraph_store_refresh(
15067            active_dir,
15068            active_root.clone(),
15069            vec![active_source.clone()],
15070            Arc::clone(&active_pending),
15071        );
15072        wait_for_refresh_calls(&active_root, 1);
15073        enqueue_callgraph_store_refresh(
15074            queued_dir,
15075            queued_root.clone(),
15076            vec![queued_source.clone()],
15077            Arc::clone(&queued_pending),
15078        );
15079
15080        assert!(!flush_callgraph_store_refreshes_with_budget(
15081            Duration::from_millis(20)
15082        ));
15083        assert!(active_pending.lock().contains(&active_source));
15084        assert!(queued_pending.lock().contains(&queued_source));
15085        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15086        clear_callgraph_refresh_worker_test_seam(&active_root);
15087    }
15088}
15089
15090#[cfg(test)]
15091mod cold_build_insert_tests {
15092    use super::*;
15093    use crate::imports::ImportBlock;
15094    use std::cell::Cell;
15095    use std::fs;
15096    use std::path::{Path, PathBuf};
15097    use tempfile::tempdir;
15098
15099    thread_local! {
15100        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15101        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15102        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15103    }
15104
15105    fn count_caller_traversal_selects(sql: &str) {
15106        let sql = sql.trim_start();
15107        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15108            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15109        }
15110        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15111            && sql.contains("e.target_file =")
15112        {
15113            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15114        }
15115        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15116            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15117        }
15118    }
15119
15120    #[test]
15121    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15122        let dir = tempdir().unwrap();
15123        let previous_root = dir.path().join("previous-root");
15124        let current_root = dir.path().join("current-root");
15125        fs::create_dir_all(&previous_root).unwrap();
15126        fs::create_dir_all(&current_root).unwrap();
15127        fs::remove_dir(&previous_root).unwrap();
15128        let mut conn = Connection::open_in_memory().unwrap();
15129        initialize_schema(&conn).unwrap();
15130        conn.execute(
15131            "INSERT INTO backend_file_state(
15132                backend, workspace_root, file_path, content_hash, status, updated_at
15133             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15134            params![previous_root.display().to_string()],
15135        )
15136        .unwrap();
15137
15138        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
15139
15140        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15141        assert_eq!(
15142            stored_workspace_roots(&conn).unwrap(),
15143            vec![previous_root.display().to_string()]
15144        );
15145    }
15146
15147    #[test]
15148    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15149        assert_eq!(
15150            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15151            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15152        );
15153    }
15154
15155    #[test]
15156    fn legacy_migration_completion_log_has_operator_fields() {
15157        assert_eq!(
15158            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15159            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15160        );
15161    }
15162
15163    fn write_generation_with_age(
15164        dir: &Path,
15165        project_key: &str,
15166        ordinal: u64,
15167        age: Duration,
15168    ) -> String {
15169        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15170        let path = dir.join(&generation);
15171        fs::write(&path, b"sqlite placeholder").unwrap();
15172        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15173        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15174        generation
15175    }
15176
15177    #[test]
15178    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15179        let dir = tempfile::tempdir().unwrap();
15180        let project_key = "project";
15181        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15182        let previous =
15183            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15184        let pinned =
15185            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15186        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15187
15188        gc_old_generations(dir.path(), project_key, &current);
15189
15190        assert!(dir.path().join(&previous).is_file());
15191        assert!(dir.path().join(&pinned).is_file());
15192
15193        drop(marker);
15194        gc_old_generations(dir.path(), project_key, &current);
15195
15196        assert!(dir.path().join(&previous).is_file());
15197        assert!(!dir.path().join(&pinned).exists());
15198    }
15199
15200    #[test]
15201    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15202        let dir = tempfile::tempdir().unwrap();
15203        let project_key = "project";
15204        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15205        let _previous =
15206            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15207        let pinned =
15208            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15209        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15210        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15211
15212        gc_old_generations(dir.path(), project_key, &current);
15213
15214        assert!(dir.path().join(&pinned).is_file());
15215    }
15216
15217    #[test]
15218    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15219        let dir = tempfile::tempdir().unwrap();
15220        let project_key = "project";
15221        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15222        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15223        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15224        let old = write_generation_with_age(
15225            dir.path(),
15226            project_key,
15227            200,
15228            expired + Duration::from_secs(60),
15229        );
15230        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15231
15232        gc_old_generations(dir.path(), project_key, &current);
15233
15234        assert!(dir.path().join(&current).is_file());
15235        assert!(dir.path().join(&previous).is_file());
15236        assert!(!dir.path().join(&old).exists());
15237    }
15238
15239    fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15240        let cache_dir = callgraph_root.join(key);
15241        fs::create_dir_all(cache_dir.join("nested")).unwrap();
15242        fs::write(
15243            cache_dir.join("nested").join("payload.sqlite"),
15244            b"old cache payload",
15245        )
15246        .unwrap();
15247        age_callgraph_root_tree(&cache_dir);
15248        cache_dir
15249    }
15250
15251    fn age_callgraph_root_tree(path: &Path) {
15252        let old = SystemTime::now()
15253            .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15254            .unwrap_or(UNIX_EPOCH);
15255        let entries = fs::read_dir(path)
15256            .unwrap()
15257            .collect::<std::io::Result<Vec<_>>>()
15258            .unwrap();
15259        for entry in entries {
15260            let child = entry.path();
15261            if entry.file_type().unwrap().is_dir() {
15262                age_callgraph_root_tree(&child);
15263            } else {
15264                filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15265                    .unwrap();
15266            }
15267        }
15268        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15269    }
15270
15271    #[test]
15272    fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15273        reset_callgraph_root_sweep_cursor_for_test();
15274        let storage = tempdir().unwrap();
15275        let callgraph_root = storage.path().join("callgraph");
15276        let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15277        let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15278        let fresh = callgraph_root.join("d1c2b3a495867768");
15279        fs::create_dir_all(&fresh).unwrap();
15280        fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15281        let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15282
15283        let writer_lease = crate::fs_lock::try_acquire(
15284            &crate::root_cache::writer_lease_path(&leased),
15285            Duration::ZERO,
15286        )
15287        .unwrap();
15288        age_callgraph_root_tree(&leased);
15289        let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15290        // Same-host marker protection is PID-authoritative, so this old mtime
15291        // proves the reader guard instead of accidentally relying on freshness.
15292        age_callgraph_root_tree(&marked);
15293
15294        let first = sweep_callgraph_root_dirs_with_limits(
15295            &callgraph_root,
15296            &HashSet::new(),
15297            &HashSet::new(),
15298            CALLGRAPH_ROOT_SWEEP_BUDGET,
15299            usize::MAX,
15300        );
15301
15302        assert_eq!(first.removed, 1);
15303        assert!(first.bytes > 0, "the reaped byte count must be reported");
15304        assert!(!dead.exists(), "an aged dead root must be reaped");
15305        assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15306        assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15307        assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15308        assert!(leased.is_dir(), "the leased root must survive");
15309        assert!(marked.is_dir(), "the reader-marked root must survive");
15310        assert!(fresh.is_dir(), "the recent root must survive");
15311
15312        drop(writer_lease);
15313        drop(marker);
15314        // Mutation controls: removing each guard and aging each payload makes
15315        // every initially protected decoy eligible for the next pass.
15316        for cache_dir in [&leased, &marked, &fresh] {
15317            age_callgraph_root_tree(cache_dir);
15318        }
15319        let second = sweep_callgraph_root_dirs_with_limits(
15320            &callgraph_root,
15321            &HashSet::new(),
15322            &HashSet::new(),
15323            CALLGRAPH_ROOT_SWEEP_BUDGET,
15324            usize::MAX,
15325        );
15326
15327        assert_eq!(second.removed, 3);
15328        for cache_dir in [&leased, &marked, &fresh] {
15329            assert!(
15330                !cache_dir.exists(),
15331                "the decoy must be reaped after its guard or freshness changes"
15332            );
15333        }
15334        reset_callgraph_root_sweep_cursor_for_test();
15335    }
15336
15337    #[test]
15338    fn callgraph_root_sweep_resumes_after_entry_budget() {
15339        reset_callgraph_root_sweep_cursor_for_test();
15340        let storage = tempdir().unwrap();
15341        let callgraph_root = storage.path().join("callgraph");
15342        let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
15343        let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
15344        let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
15345
15346        let first_pass = sweep_callgraph_root_dirs_with_limits(
15347            &callgraph_root,
15348            &HashSet::new(),
15349            &HashSet::new(),
15350            CALLGRAPH_ROOT_SWEEP_BUDGET,
15351            1,
15352        );
15353        assert!(first_pass.budget_exhausted);
15354        assert_eq!(first_pass.scanned, 1);
15355        assert!(!first.exists());
15356        assert!(second.exists());
15357        assert!(third.exists());
15358
15359        let second_pass = sweep_callgraph_root_dirs_with_limits(
15360            &callgraph_root,
15361            &HashSet::new(),
15362            &HashSet::new(),
15363            CALLGRAPH_ROOT_SWEEP_BUDGET,
15364            1,
15365        );
15366        assert!(second_pass.budget_exhausted);
15367        assert!(!second.exists());
15368        assert!(third.exists());
15369
15370        let third_pass = sweep_callgraph_root_dirs_with_limits(
15371            &callgraph_root,
15372            &HashSet::new(),
15373            &HashSet::new(),
15374            CALLGRAPH_ROOT_SWEEP_BUDGET,
15375            1,
15376        );
15377        assert!(!third_pass.budget_exhausted);
15378        assert!(!third.exists());
15379        reset_callgraph_root_sweep_cursor_for_test();
15380    }
15381
15382    #[test]
15383    fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
15384        reset_callgraph_root_sweep_cursor_for_test();
15385        let storage = tempdir().unwrap();
15386        let callgraph_root = storage.path().join("callgraph");
15387        let key = "a1b2c3d4e5f60718";
15388        let cache_dir = callgraph_root.join(key);
15389        fs::create_dir_all(&cache_dir).unwrap();
15390        let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
15391        let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
15392        let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
15393        publish_pointer(&cache_dir, key, &current).unwrap();
15394        age_callgraph_root_tree(&cache_dir);
15395        let memo_keys = HashSet::from([key.to_string()]);
15396
15397        let summary = sweep_callgraph_root_dirs_with_limits(
15398            &callgraph_root,
15399            &memo_keys,
15400            &HashSet::new(),
15401            CALLGRAPH_ROOT_SWEEP_BUDGET,
15402            usize::MAX,
15403        );
15404
15405        assert_eq!(summary.generation_gc, 1);
15406        assert!(cache_dir.join(&current).is_file());
15407        assert!(cache_dir.join(&previous).is_file());
15408        assert!(
15409            !cache_dir.join(&obsolete).exists(),
15410            "the store-wide sweep must collect an inactive live root's obsolete generation"
15411        );
15412        reset_callgraph_root_sweep_cursor_for_test();
15413    }
15414
15415    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
15416        let path = dir.join(name);
15417        fs::write(&path, b"temp placeholder").unwrap();
15418        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15419        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15420        path
15421    }
15422
15423    #[test]
15424    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
15425        let dir = tempdir().unwrap();
15426        // One directory holds both an aged orphan (with its journal sidecar) and a
15427        // fresh temporary, so this proves the sweep SELECTS by age rather than
15428        // deleting everything in the directory.
15429        let aged = "project.g100.1.sqlite.tmp.1.200";
15430        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
15431        let fresh = "project.g300.1.sqlite.tmp.1.400";
15432        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
15433        write_build_temp_with_age(dir.path(), aged, aged_age);
15434        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
15435        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15436
15437        sweep_orphaned_build_temps(dir.path());
15438
15439        assert!(
15440            !dir.path().join(aged).exists(),
15441            "aged orphan must be removed"
15442        );
15443        assert!(
15444            !dir.path().join(aged_journal).exists(),
15445            "aged journal sidecar must be removed"
15446        );
15447        assert!(
15448            dir.path().join(fresh).is_file(),
15449            "fresh temporary must survive"
15450        );
15451    }
15452
15453    #[test]
15454    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
15455        let storage = tempdir().unwrap();
15456        let storage_root = storage.path();
15457        // The production shape: a legacy per-harness store whose root no longer
15458        // builds there — no `.current` pointer, no running build — so the per-root
15459        // cleanup never fires for it. A sibling root still building in the
15460        // root-keyed store triggers the store-wide sweep, which must reach into the
15461        // legacy directory and reclaim the orphan.
15462        let legacy_dir = storage_root.join("opencode").join("callgraph");
15463        fs::create_dir_all(&legacy_dir).unwrap();
15464        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
15465        write_build_temp_with_age(
15466            &legacy_dir,
15467            orphan,
15468            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15469        );
15470        assert!(
15471            !legacy_dir.join("deadbeef.current").exists(),
15472            "the dead root has no current pointer"
15473        );
15474
15475        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
15476        fs::create_dir_all(&root_keyed_dir).unwrap();
15477
15478        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
15479
15480        assert!(
15481            !legacy_dir.join(orphan).exists(),
15482            "legacy orphan must be reclaimed by the store-wide sweep"
15483        );
15484    }
15485
15486    #[test]
15487    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
15488        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
15489        // everything (min_age = 0) removes the fresh temporary that the real 24h
15490        // threshold spares in the test above. If a mutation to the age check leaves
15491        // the fresh file in place here, the predicate is no longer doing the
15492        // selection work the fresh-survives assertion relies on.
15493        let dir = tempdir().unwrap();
15494        let fresh = "project.g300.1.sqlite.tmp.1.400";
15495        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15496
15497        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
15498
15499        assert!(
15500            !dir.path().join(fresh).exists(),
15501            "with the age predicate forced open, the fresh temporary is removed"
15502        );
15503    }
15504
15505    #[test]
15506    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
15507        let dir = tempdir().unwrap();
15508        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
15509        // to be swept, plus a live read marker, is generation GC's jurisdiction.
15510        // The orphan sweep must not intersect it.
15511        let generation = write_generation_with_age(
15512            dir.path(),
15513            "project",
15514            400,
15515            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15516        );
15517        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15518
15519        sweep_orphaned_build_temps(dir.path());
15520
15521        assert!(
15522            dir.path().join(&generation).is_file(),
15523            "completed generation must survive the orphan sweep"
15524        );
15525        assert!(
15526            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
15527            "read marker must survive the orphan sweep"
15528        );
15529    }
15530
15531    #[test]
15532    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
15533        let dir = tempfile::tempdir().unwrap();
15534        let project_key = "project".to_string();
15535        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
15536        let sqlite_path = dir.path().join(&generation);
15537        fs::remove_file(&sqlite_path).unwrap();
15538        let conn = Connection::open(&sqlite_path).unwrap();
15539        let store = CallGraphStore::from_connection(
15540            dir.path().to_path_buf(),
15541            project_key,
15542            sqlite_path,
15543            dir.path().to_path_buf(),
15544            false,
15545            Some(generation.clone()),
15546            None,
15547            None,
15548            conn,
15549        );
15550
15551        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15552        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
15553
15554        drop(marker);
15555        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
15556    }
15557
15558    #[test]
15559    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
15560        let dir = tempdir().expect("temp dir");
15561        let file = dir.path().join("main.ts");
15562        fs::write(&file, "export function main() {}\n").expect("write fixture");
15563        let store = CallGraphStore::open(
15564            dir.path().join(".store-readiness-cache"),
15565            dir.path().to_path_buf(),
15566        )
15567        .expect("open store");
15568        {
15569            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15570            conn.trace(Some(count_caller_traversal_selects));
15571        }
15572
15573        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15574        assert!(store.indexed_file_count().is_err());
15575        assert!(store.indexed_file_count().is_err());
15576        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15577
15578        store
15579            .cold_build(std::slice::from_ref(&file))
15580            .expect("cold build");
15581        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15582        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
15583        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
15584        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
15585
15586        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15587        conn.trace(None);
15588    }
15589
15590    #[test]
15591    fn direct_caller_frontier_chunks_sqlite_selects() {
15592        let dir = tempdir().expect("temp dir");
15593        let file = dir.path().join("main.ts");
15594        fs::write(
15595            &file,
15596            "export function caller() { target(); }\nexport function target() {}\n",
15597        )
15598        .expect("write fixture");
15599        let store = CallGraphStore::open(
15600            dir.path().join(".store-caller-frontier-query"),
15601            dir.path().to_path_buf(),
15602        )
15603        .expect("open store");
15604        store
15605            .cold_build(std::slice::from_ref(&file))
15606            .expect("cold build");
15607        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
15608        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
15609
15610        CALLER_QUERY_SELECTS.with(|count| count.set(0));
15611        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15612        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15613        {
15614            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15615            conn.trace(Some(count_caller_traversal_selects));
15616        }
15617        let callers = store
15618            .direct_callers_for_symbols(&targets)
15619            .expect("batched callers");
15620        {
15621            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15622            conn.trace(None);
15623        }
15624
15625        assert_eq!(callers.len(), 1_000);
15626        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
15627        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
15628        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
15629        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15630    }
15631
15632    #[test]
15633    fn callers_depth_boundary_batches_sqlite_counts() {
15634        const CALLER_COUNT: usize = 1_000;
15635
15636        let dir = tempdir().expect("temp dir");
15637        let file = dir.path().join("main.ts");
15638        let mut source = String::from("export function sharedHotHelper() {}\n");
15639        for index in 0..CALLER_COUNT {
15640            source.push_str(&format!(
15641                "export function caller{index}() {{ sharedHotHelper(); }}\n"
15642            ));
15643        }
15644        fs::write(&file, source).expect("write fixture");
15645
15646        let store = CallGraphStore::open(
15647            dir.path().join(".store-callers-query-fanout"),
15648            dir.path().to_path_buf(),
15649        )
15650        .expect("open store");
15651        store
15652            .cold_build(std::slice::from_ref(&file))
15653            .expect("cold build");
15654
15655        CALLER_QUERY_SELECTS.with(|count| count.set(0));
15656        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15657        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15658        {
15659            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15660            conn.trace(Some(count_caller_traversal_selects));
15661        }
15662
15663        let started = Instant::now();
15664        let result = crate::commands::callgraph_store_adapter::callers_result(
15665            &store,
15666            Path::new("main.ts"),
15667            "sharedHotHelper",
15668            1,
15669            true,
15670        )
15671        .expect("callers result");
15672        let elapsed = started.elapsed();
15673
15674        {
15675            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15676            conn.trace(None);
15677        }
15678        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
15679        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
15680        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
15681        eprintln!(
15682            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
15683            result.total_callers,
15684            caller_queries,
15685            boundary_queries,
15686            total_selects,
15687            elapsed.as_secs_f64() * 1_000.0
15688        );
15689
15690        assert_eq!(result.total_callers, CALLER_COUNT);
15691        assert_eq!(caller_queries, 1);
15692        assert_eq!(boundary_queries, 3);
15693        assert_eq!(total_selects, 9);
15694    }
15695
15696    #[test]
15697    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
15698        let dir = tempdir().expect("temp dir");
15699        let file = dir.path().join("main.ts");
15700        fs::write(
15701            &file,
15702            r#"export function topA() {
15703  root();
15704}
15705
15706export function topB() {
15707  root();
15708}
15709
15710export function root() {
15711  leaf();
15712  missing();
15713}
15714
15715export function leaf() {}
15716"#,
15717        )
15718        .expect("write fixture");
15719
15720        let store = CallGraphStore::open(
15721            dir.path().join(".store-depth-boundary-counts"),
15722            dir.path().to_path_buf(),
15723        )
15724        .expect("open store");
15725        store
15726            .cold_build(std::slice::from_ref(&file))
15727            .expect("cold build");
15728
15729        let root = store
15730            .node_for(Path::new("main.ts"), "root")
15731            .expect("root node");
15732        let leaf = store
15733            .node_for(Path::new("main.ts"), "leaf")
15734            .expect("leaf node");
15735
15736        let (full_forward_len, full_direct_len) = {
15737            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15738            conn.execute(
15739                "INSERT INTO edges (
15740                    edge_id, ref_id, source_node, target_node, target_file,
15741                    target_symbol, kind, line, provenance
15742                 ) VALUES (
15743                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
15744                    ?2, ?3, 'call', 98, ?4
15745                 )",
15746                rusqlite::params![
15747                    &root.node_id,
15748                    &leaf.file,
15749                    &leaf.symbol,
15750                    PROVENANCE_TREESITTER
15751                ],
15752            )
15753            .expect("insert dangling forward edge");
15754            conn.execute(
15755                "INSERT INTO edges (
15756                    edge_id, ref_id, source_node, target_node, target_file,
15757                    target_symbol, kind, line, provenance
15758                 ) VALUES (
15759                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
15760                    ?1, ?2, ?3, 'call', 99, ?4
15761                 )",
15762                rusqlite::params![
15763                    &root.node_id,
15764                    &root.file,
15765                    &root.symbol,
15766                    PROVENANCE_TREESITTER
15767                ],
15768            )
15769            .expect("insert dangling direct-caller edge");
15770
15771            let full_forward_len = forward_calls_for_node(&conn, &root)
15772                .expect("full forward calls")
15773                .len();
15774            let counted_forward_len =
15775                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
15776            assert_eq!(
15777                counted_forward_len, full_forward_len,
15778                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
15779            );
15780
15781            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
15782                .expect("full direct callers");
15783            let full_direct_len = full_direct.len();
15784            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
15785                .expect("counted direct callers");
15786            assert_eq!(
15787                counted_direct_len, full_direct_len,
15788                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
15789            );
15790
15791            let distinct_direct_len = full_direct
15792                .iter()
15793                .map(|site| {
15794                    (
15795                        site.caller.file.clone(),
15796                        site.line,
15797                        site.target_file.clone(),
15798                        site.target_symbol.clone(),
15799                    )
15800                })
15801                .collect::<BTreeSet<_>>()
15802                .len();
15803            let batch_counts = direct_caller_counts_for_tuples(
15804                &conn,
15805                &[
15806                    (root.file.clone(), root.symbol.clone()),
15807                    (root.file.clone(), root.symbol.clone()),
15808                    (leaf.file.clone(), leaf.symbol.clone()),
15809                ],
15810            )
15811            .expect("batched direct-caller counts");
15812            assert_eq!(batch_counts.len(), 2);
15813            assert_eq!(
15814                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
15815                Some(&distinct_direct_len)
15816            );
15817
15818            (full_forward_len, full_direct_len)
15819        };
15820
15821        assert_eq!(
15822            full_forward_len, 2,
15823            "fixture root should have one resolved and one unresolved outgoing call"
15824        );
15825        assert_eq!(
15826            full_direct_len, 2,
15827            "fixture root should have two real direct callers"
15828        );
15829
15830        let tree = store
15831            .call_tree(Path::new("main.ts"), "root", 0)
15832            .expect("call tree");
15833        assert!(tree.depth_limited);
15834        assert_eq!(tree.children.len(), 0);
15835        assert_eq!(
15836            tree.truncated, full_forward_len,
15837            "call_tree depth boundary must report the full forward-call list length"
15838        );
15839
15840        let callers = store
15841            .callers_of(Path::new("main.ts"), "leaf", 0)
15842            .expect("callers");
15843        assert!(callers.depth_limited);
15844        assert_eq!(callers.callers.len(), 1);
15845        assert_eq!(callers.callers[0].caller.symbol, "root");
15846        assert_eq!(
15847            callers.truncated, full_direct_len,
15848            "callers depth boundary must report the full direct-caller list length"
15849        );
15850    }
15851
15852    #[test]
15853    fn source_freshness_matches_cache_collect_for_same_bytes() {
15854        let dir = tempdir().expect("temp dir");
15855        let path = dir.path().join("fixture.ts");
15856        let source = "export function main() { return helper(); }\n";
15857        fs::write(&path, source).expect("write fixture");
15858
15859        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
15860        let actual =
15861            collect_source_freshness(&path, source).expect("collect freshness from source");
15862
15863        assert_eq!(actual, expected);
15864    }
15865
15866    #[test]
15867    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
15868        let root = tempfile::tempdir().unwrap();
15869        let callgraph_dir = tempfile::tempdir().unwrap();
15870        let source_dir = root.path().join("src");
15871        std::fs::create_dir_all(&source_dir).unwrap();
15872        let source = source_dir.join("lib.rs");
15873        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
15874        let files = vec![source.clone()];
15875        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15876        let old_epoch = epoch.next();
15877        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
15878        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
15879        let old_epoch_flag = epoch.clone();
15880        let old_dir = callgraph_dir.path().to_path_buf();
15881        let old_root = root.path().to_path_buf();
15882        let old_files = files.clone();
15883        let old = std::thread::spawn(move || {
15884            set_cold_build_before_publish_observer(Some(Arc::new(move || {
15885                reached_tx.send(()).unwrap();
15886                release_rx.recv().unwrap();
15887            })));
15888            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
15889                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
15890            });
15891            set_cold_build_before_publish_observer(None);
15892            result
15893        });
15894        // Positive wait: the older build runs a real cold build (git probe +
15895        // SQLite schema init) before the barrier, which can exceed 5s on a
15896        // contended Windows CI runner. Only negative waits stay short.
15897        reached_rx
15898            .recv_timeout(Duration::from_secs(30))
15899            .expect("older build did not reach its publication barrier");
15900
15901        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
15902        let new_epoch = epoch.next();
15903        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
15904            CallGraphStore::cold_build_with_lease(
15905                callgraph_dir.path().to_path_buf(),
15906                root.path().to_path_buf(),
15907                &files,
15908            )
15909        })
15910        .expect("newer build should publish");
15911        drop(new_store);
15912
15913        release_tx.send(()).unwrap();
15914        assert!(matches!(
15915            old.join().unwrap(),
15916            Err(CallGraphStoreError::Superseded)
15917        ));
15918
15919        let current = CallGraphStore::open_readonly(
15920            callgraph_dir.path().to_path_buf(),
15921            root.path().to_path_buf(),
15922        )
15923        .unwrap()
15924        .expect("current callgraph generation");
15925        assert_eq!(
15926            current
15927                .nodes_matching("new_generation_marker")
15928                .unwrap()
15929                .len(),
15930            1
15931        );
15932        assert!(current
15933            .nodes_matching("old_generation_marker")
15934            .unwrap()
15935            .is_empty());
15936    }
15937
15938    #[test]
15939    fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
15940        let root = tempfile::tempdir().unwrap();
15941        let callgraph_dir = tempfile::tempdir().unwrap();
15942        let source = root.path().join("lib.rs");
15943        std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
15944        let files = vec![source];
15945        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15946        let old_epoch = epoch.next();
15947        let epoch_for_observer = epoch.clone();
15948        set_cold_build_before_publish_observer(Some(Arc::new(move || {
15949            epoch_for_observer.next();
15950        })));
15951        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
15952            CallGraphStore::cold_build_with_lease_chunked(
15953                callgraph_dir.path().to_path_buf(),
15954                root.path().to_path_buf(),
15955                &files,
15956                1,
15957            )
15958        });
15959        set_cold_build_before_publish_observer(None);
15960        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
15961
15962        let project_key = crate::search_index::artifact_cache_key(root.path());
15963        let staging = callgraph_dir
15964            .path()
15965            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
15966        let staged = Connection::open(&staging).unwrap();
15967        assert_eq!(
15968            staged_build_phase(&staged).unwrap().as_deref(),
15969            Some("ready")
15970        );
15971        drop(staged);
15972
15973        let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15974        let extracted_for_observer = Arc::clone(&extracted);
15975        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
15976            extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
15977        })));
15978        let successor_epoch = epoch.next();
15979        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
15980            CallGraphStore::cold_build_with_lease_chunked(
15981                callgraph_dir.path().to_path_buf(),
15982                root.path().to_path_buf(),
15983                &files,
15984                1,
15985            )
15986        })
15987        .expect("completed same-corpus staging publishes without rebuilding");
15988        set_cold_build_extract_observer(None);
15989
15990        assert_eq!(stats.files, 1);
15991        assert_eq!(
15992            extracted.load(AtomicOrdering::SeqCst),
15993            0,
15994            "completed staging must not repeat extraction"
15995        );
15996        drop(store);
15997    }
15998
15999    #[test]
16000    fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16001        let root = tempfile::tempdir().unwrap();
16002        let callgraph_dir = tempfile::tempdir().unwrap();
16003        let files = ["a.rs", "b.rs", "c.rs"]
16004            .into_iter()
16005            .map(|name| {
16006                let path = root.path().join(name);
16007                std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16008                    .unwrap();
16009                path
16010            })
16011            .collect::<Vec<_>>();
16012        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16013        let old_epoch = epoch.next();
16014        let superseded = Arc::new(AtomicBool::new(false));
16015        let epoch_for_observer = epoch.clone();
16016        let superseded_for_observer = Arc::clone(&superseded);
16017        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16018            if stage == "extraction"
16019                && completed == 1
16020                && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16021            {
16022                epoch_for_observer.next();
16023            }
16024        })));
16025
16026        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16027            CallGraphStore::cold_build_with_lease_chunked(
16028                callgraph_dir.path().to_path_buf(),
16029                root.path().to_path_buf(),
16030                &files,
16031                1,
16032            )
16033        });
16034        set_cold_build_slice_observer(None);
16035        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16036        assert!(superseded.load(AtomicOrdering::SeqCst));
16037
16038        let project_key = crate::search_index::artifact_cache_key(root.path());
16039        let staging = callgraph_dir
16040            .path()
16041            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16042        assert!(staging.exists(), "supersession must retain durable staging");
16043        let staged = Connection::open(&staging).unwrap();
16044        assert_eq!(
16045            staged_build_phase(&staged).unwrap().as_deref(),
16046            Some("extracting")
16047        );
16048        assert_eq!(
16049            query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16050            1
16051        );
16052        drop(staged);
16053
16054        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16055        let extracted_for_observer = Arc::clone(&extracted);
16056        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16057            extracted_for_observer
16058                .lock()
16059                .unwrap()
16060                .extend(paths.iter().filter_map(|path| {
16061                    path.file_name()
16062                        .map(|name| name.to_string_lossy().into_owned())
16063                }));
16064        })));
16065        let successor_epoch = epoch.next();
16066        let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16067            CallGraphStore::cold_build_with_lease_chunked(
16068                callgraph_dir.path().to_path_buf(),
16069                root.path().to_path_buf(),
16070                &files,
16071                1,
16072            )
16073        })
16074        .expect("same-corpus successor resumes and publishes");
16075        set_cold_build_extract_observer(None);
16076
16077        assert_eq!(stats.files, 3);
16078        assert_eq!(
16079            *extracted.lock().unwrap(),
16080            vec!["b.rs".to_string(), "c.rs".to_string()],
16081            "the successor must not repeat the committed first slice"
16082        );
16083        drop(store);
16084        assert!(
16085            !staging.exists(),
16086            "published staging moves to its generation"
16087        );
16088    }
16089
16090    #[test]
16091    fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16092        let root = tempfile::tempdir().unwrap();
16093        let callgraph_dir = tempfile::tempdir().unwrap();
16094        let first = root.path().join("a.rs");
16095        let second = root.path().join("b.rs");
16096        std::fs::write(&first, "pub fn a() {}\n").unwrap();
16097        std::fs::write(&second, "pub fn b() {}\n").unwrap();
16098        let mut files = vec![first.clone(), second.clone()];
16099        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16100        let old_epoch = epoch.next();
16101        let advanced = Arc::new(AtomicBool::new(false));
16102        let epoch_for_observer = epoch.clone();
16103        let advanced_for_observer = Arc::clone(&advanced);
16104        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16105            if stage == "extraction"
16106                && completed == 1
16107                && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16108            {
16109                epoch_for_observer.next();
16110            }
16111        })));
16112        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16113            CallGraphStore::cold_build_with_lease_chunked(
16114                callgraph_dir.path().to_path_buf(),
16115                root.path().to_path_buf(),
16116                &files,
16117                1,
16118            )
16119        });
16120        set_cold_build_slice_observer(None);
16121        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16122
16123        std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16124        let third = root.path().join("c.rs");
16125        std::fs::write(&third, "pub fn c() {}\n").unwrap();
16126        files.push(third);
16127        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16128        let extracted_for_observer = Arc::clone(&extracted);
16129        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16130            extracted_for_observer
16131                .lock()
16132                .unwrap()
16133                .extend(paths.iter().filter_map(|path| {
16134                    path.file_name()
16135                        .map(|name| name.to_string_lossy().into_owned())
16136                }));
16137        })));
16138        let successor_epoch = epoch.next();
16139        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16140            CallGraphStore::cold_build_with_lease_chunked(
16141                callgraph_dir.path().to_path_buf(),
16142                root.path().to_path_buf(),
16143                &files,
16144                1,
16145            )
16146        })
16147        .expect("changed-corpus successor restarts and publishes");
16148        set_cold_build_extract_observer(None);
16149
16150        assert_eq!(stats.files, 3);
16151        assert_eq!(
16152            *extracted.lock().unwrap(),
16153            vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16154            "fingerprint mismatch must invalidate every old extraction slice"
16155        );
16156        drop(store);
16157    }
16158
16159    #[test]
16160    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16161        let dir = tempdir().expect("temp dir");
16162        let project_root = dir.path();
16163        let extract = fixture_extract(project_root);
16164        let resolved = fixture_resolved(&extract);
16165
16166        let reference = build_reference_connection(project_root, &extract, &resolved);
16167        let optimized = build_optimized_connection(project_root, &extract, &resolved);
16168
16169        for table in [
16170            "files",
16171            "nodes",
16172            "file_dependencies",
16173            "dispatch_hints",
16174            "refs",
16175            "edges",
16176        ] {
16177            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
16178            // the reference and optimized builds run sequentially and can straddle a
16179            // one-second tick under load, so it is legitimately allowed to differ.
16180            // This mirrors the existing exclusions of `backend_file_state.updated_at`
16181            // and the chunked-vs-unchunked sibling test. The check is for structural
16182            // row equivalence of the optimized bulk insert, not wall-clock equality.
16183            let excluded: &[&str] = if table == "files" {
16184                &["indexed_at"]
16185            } else {
16186                &[]
16187            };
16188            assert_eq!(
16189                table_rows_without(&reference, table, excluded),
16190                table_rows_without(&optimized, table, excluded),
16191                "table `{table}` rows must match apart from wall-clock columns"
16192            );
16193        }
16194        assert_eq!(
16195            backend_state_rows(&reference),
16196            backend_state_rows(&optimized),
16197            "backend freshness rows must match apart from updated_at"
16198        );
16199        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16200    }
16201
16202    #[test]
16203    fn cold_build_chunked_matches_unchunked_logical_rows() {
16204        let dir = tempdir().expect("temp dir");
16205        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16206        write_chunked_equivalence_fixture(&project_root);
16207        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16208        assert!(
16209            files.len() > 6,
16210            "fixture should be large enough to split into multiple chunks"
16211        );
16212
16213        let unchunked = CallGraphStore::open(
16214            project_root.join(".store-unchunked"),
16215            project_root.to_path_buf(),
16216        )
16217        .expect("open unchunked store");
16218        let unchunked_stats = unchunked
16219            .cold_build_chunked(&files, 0)
16220            .expect("unchunked cold build");
16221
16222        let chunked = CallGraphStore::open(
16223            project_root.join(".store-chunked"),
16224            project_root.to_path_buf(),
16225        )
16226        .expect("open chunked store");
16227        let chunked_stats = chunked
16228            .cold_build_chunked(&files, 3)
16229            .expect("chunked cold build");
16230
16231        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16232        assert_eq!(
16233            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16234            chunked.edge_snapshot().expect("chunked edge snapshot"),
16235            "public edge snapshots must match"
16236        );
16237
16238        let dispatch_edges = {
16239            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16240            conn.query_row(
16241                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16242                [],
16243                |row| row.get::<_, i64>(0),
16244            )
16245            .expect("count dispatch edges")
16246        };
16247        assert!(
16248            dispatch_edges > 0,
16249            "fixture must exercise method-dispatch edge insertion"
16250        );
16251
16252        for table in [
16253            "edges",
16254            "refs",
16255            "nodes",
16256            "file_dependencies",
16257            "dispatch_hints",
16258        ] {
16259            assert_eq!(
16260                graph_table_rows(&unchunked, table),
16261                graph_table_rows(&chunked, table),
16262                "chunked cold build must match unchunked rows for {table}"
16263            );
16264        }
16265        assert_eq!(
16266            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16267            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16268            "files rows must match apart from indexed_at"
16269        );
16270        assert_eq!(
16271            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16272            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16273            "backend freshness rows must match apart from updated_at"
16274        );
16275
16276        let published_dir = project_root.join(".store-published");
16277        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16278            published_dir.clone(),
16279            project_root.to_path_buf(),
16280            &files,
16281            0,
16282        )
16283        .expect("published unchunked cold build");
16284        assert!(
16285            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16286                .expect("needs_cold_build after publish"),
16287            "published store should be ready"
16288        );
16289        drop(_published);
16290        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16291            published_dir,
16292            project_root.to_path_buf(),
16293            &files,
16294            3,
16295        )
16296        .expect("ensure with a different chunk size");
16297        assert!(
16298            rebuild_stats.is_none(),
16299            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16300        );
16301    }
16302
16303    #[test]
16304    fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16305        let dir = tempdir().expect("temp dir");
16306        let project_root = dir.path().join("project");
16307        fs::create_dir_all(&project_root).expect("create project root");
16308        let project_root = fs::canonicalize(project_root).expect("canonical project root");
16309        let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16310        let resolve_window = 19;
16311
16312        callgraph::clear_workspace_package_cache();
16313        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16314        let uncached = CallGraphStore::open(
16315            dir.path().join("store-uncached"),
16316            project_root.to_path_buf(),
16317        )
16318        .expect("open uncached store");
16319        let uncached_stats = uncached
16320            .cold_build_chunked_with_resolution_memo_for_test(
16321                &files,
16322                7,
16323                resolve_window,
16324                &uncached_memo,
16325            )
16326            .expect("uncached comparison build");
16327        assert!(
16328            uncached_stats.refs > resolve_window * 2,
16329            "fixture must cross several staged reference windows"
16330        );
16331
16332        callgraph::clear_workspace_package_cache();
16333        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16334        let cached =
16335            CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
16336                .expect("open cached store");
16337        let cached_stats = cached
16338            .cold_build_chunked_with_resolution_memo_for_test(
16339                &files,
16340                7,
16341                resolve_window,
16342                &cached_memo,
16343            )
16344            .expect("cached build");
16345
16346        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16347        for table in [
16348            "nodes",
16349            "refs",
16350            "file_dependencies",
16351            "edges",
16352            "dispatch_hints",
16353            "type_ref_names",
16354            "meta",
16355            "staging_file_inventory",
16356            "staging_ref_context",
16357        ] {
16358            assert_eq!(
16359                graph_table_rows(&uncached, table),
16360                graph_table_rows(&cached, table),
16361                "memoized and uncached cold builds must produce identical {table} rows"
16362            );
16363        }
16364        assert_eq!(
16365            graph_table_rows_without(&uncached, "files", &["indexed_at"]),
16366            graph_table_rows_without(&cached, "files", &["indexed_at"]),
16367            "files rows must match apart from indexed_at"
16368        );
16369        assert_eq!(
16370            graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
16371            graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
16372            "backend rows must match apart from updated_at"
16373        );
16374
16375        let cached_module_computations = cached_memo.module_computations_for_test();
16376        assert!(
16377            !cached_module_computations.is_empty(),
16378            "fixture must exercise module resolution"
16379        );
16380        assert!(
16381            cached_module_computations.values().all(|count| *count == 1),
16382            "each importing-directory/specifier pair must reach the filesystem once"
16383        );
16384        let uncached_module_computations = uncached_memo.module_computations_for_test();
16385        assert!(
16386            uncached_module_computations
16387                .values()
16388                .copied()
16389                .max()
16390                .unwrap_or_default()
16391                > 16,
16392            "mutation control: disabling the memo must recompute a hot module target"
16393        );
16394
16395        let cached_package_probes = cached_memo
16396            .json_probes_for_test()
16397            .into_iter()
16398            .filter(|(path, _)| {
16399                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16400            })
16401            .collect::<HashMap<_, _>>();
16402        assert!(
16403            !cached_package_probes.is_empty(),
16404            "fixture must exercise package.json lookup"
16405        );
16406        assert!(
16407            cached_package_probes.values().all(|count| *count == 1),
16408            "every package.json path must be probed at most once per cold build"
16409        );
16410        let uncached_package_probes = uncached_memo
16411            .json_probes_for_test()
16412            .into_iter()
16413            .filter(|(path, _)| {
16414                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16415            })
16416            .collect::<HashMap<_, _>>();
16417        let cached_probe_total: usize = cached_package_probes.values().sum();
16418        let uncached_probe_total: usize = uncached_package_probes.values().sum();
16419        assert!(
16420            uncached_probe_total > cached_probe_total * 20,
16421            "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
16422        );
16423    }
16424
16425    // Benchmark the cold resolver with and without memoization. The generated
16426    // workspace has hundreds of TypeScript files below a deep package-manifest
16427    // ladder and enough imported calls for filesystem resolution to dominate
16428    // the uncached run.
16429    #[test]
16430    #[ignore]
16431    fn bench_cold_build_resolution_memo() {
16432        let dir = tempdir().expect("temp dir");
16433        let project_root = dir.path().join("project");
16434        fs::create_dir_all(&project_root).expect("create benchmark root");
16435        let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
16436        let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
16437        assert!(
16438            files.len() > 250,
16439            "benchmark fixture must contain hundreds of files"
16440        );
16441
16442        for enabled in [false, true] {
16443            callgraph::clear_workspace_package_cache();
16444            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
16445            let store = CallGraphStore::open(
16446                dir.path().join(if enabled {
16447                    "store-cached"
16448                } else {
16449                    "store-uncached"
16450                }),
16451                project_root.to_path_buf(),
16452            )
16453            .expect("open benchmark store");
16454            let cpu_started = process_cpu_time();
16455            let wall_started = Instant::now();
16456            let stats = store
16457                .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
16458                .expect("benchmark cold build");
16459            let wall_ms = wall_started.elapsed().as_millis();
16460            let cpu_ms = process_cpu_time()
16461                .checked_sub(cpu_started)
16462                .unwrap_or_default()
16463                .as_millis();
16464            println!(
16465                "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
16466                if enabled { "on" } else { "off" },
16467                stats.files,
16468                stats.refs,
16469                stats.edges,
16470                wall_ms,
16471                cpu_ms
16472            );
16473        }
16474    }
16475
16476    // Perf A/B bench (not a gate): measures cold_build wall time at a given
16477    // chunk size against a real repo. Driven by env so the same binary can A/B
16478    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
16479    // memory work. Run:
16480    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
16481    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
16482    #[test]
16483    #[ignore]
16484    fn bench_cold_build_chunk() {
16485        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
16486        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
16487            .expect("AFT_PERF_CHUNK")
16488            .parse()
16489            .expect("AFT_PERF_CHUNK must be a non-negative integer");
16490        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
16491        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16492        let dir = tempdir().expect("temp dir");
16493        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
16494            .expect("open store");
16495        let started = Instant::now();
16496        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
16497        let ms = started.elapsed().as_millis();
16498        println!(
16499            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
16500            stats.files, stats.nodes, stats.refs, stats.edges
16501        );
16502    }
16503
16504    #[test]
16505    fn persisted_workspace_reexport_selects_its_package_dependency() {
16506        let root = tempdir().expect("temp dir");
16507        let dependencies = BTreeSet::from([
16508            "packages/aft-bridge/src/index.ts".to_string(),
16509            "packages/opencode-plugin/src/types.ts".to_string(),
16510        ]);
16511        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
16512
16513        assert_eq!(
16514            stored_dependencies_for_module(
16515                root.path(),
16516                "packages/opencode-plugin/src/shared/bash-hints.ts",
16517                "@cortexkit/aft-bridge",
16518                &dependencies,
16519                &indexed_files,
16520            ),
16521            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
16522        );
16523    }
16524
16525    #[test]
16526    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
16527        let dir = tempdir().expect("temp dir");
16528        let project_root = dir.path();
16529        let files =
16530            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
16531        let index_path = project_root.join("src/index.ts");
16532
16533        let store = CallGraphStore::open(
16534            project_root.join(".store-incremental-barrel"),
16535            project_root.to_path_buf(),
16536        )
16537        .expect("open incremental store");
16538        store.cold_build(&files).expect("initial cold build");
16539
16540        {
16541            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16542            let tx = conn.transaction().expect("dependency transaction");
16543            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
16544                .expect("dependent refs for barrel");
16545            let selected_ref_ids = dependent_refs
16546                .iter()
16547                .map(|dependent_ref| dependent_ref.ref_id.clone())
16548                .collect::<BTreeSet<_>>();
16549            let mut threaded_ref_ids = BTreeSet::new();
16550            let mut threaded_by_caller = BTreeMap::new();
16551            record_dependent_refs(
16552                &mut threaded_ref_ids,
16553                &mut threaded_by_caller,
16554                dependent_refs,
16555            );
16556            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
16557                .expect("old per-ref caller lookup");
16558
16559            assert_eq!(threaded_ref_ids, selected_ref_ids);
16560            assert_eq!(threaded_by_caller, old_by_caller);
16561            for consumer in [
16562                "src/consumer_a.ts",
16563                "src/consumer_b.ts",
16564                "src/consumer_c.ts",
16565            ] {
16566                assert!(
16567                    threaded_by_caller.contains_key(consumer),
16568                    "barrel edit should select dependent refs from {consumer}"
16569                );
16570            }
16571        }
16572
16573        fs::write(
16574            &index_path,
16575            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
16576        )
16577        .expect("edit barrel");
16578        let stats = store
16579            .refresh_files(std::slice::from_ref(&index_path))
16580            .expect("incremental refresh");
16581        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
16582        assert!(
16583            stats.dependency_selected_refs > 0,
16584            "barrel surface edit should select dependent refs"
16585        );
16586
16587        let cold_store = CallGraphStore::open(
16588            project_root.join(".store-cold-barrel"),
16589            project_root.to_path_buf(),
16590        )
16591        .expect("open cold rebuild store");
16592        cold_store
16593            .cold_build(&files)
16594            .expect("comparison cold build");
16595
16596        for table in [
16597            "nodes",
16598            "refs",
16599            "file_dependencies",
16600            "edges",
16601            "dispatch_hints",
16602        ] {
16603            assert_eq!(
16604                graph_table_rows(&store, table),
16605                graph_table_rows(&cold_store, table),
16606                "incremental refresh {table} rows must match cold rebuild"
16607            );
16608        }
16609
16610        let consumer_path = project_root.join("src/consumer_a.ts");
16611        fs::write(
16612            &consumer_path,
16613            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
16614        )
16615        .expect("edit barrel consumer");
16616        store
16617            .refresh_files(std::slice::from_ref(&consumer_path))
16618            .expect("refresh consumer through unchanged barrel");
16619        cold_store
16620            .cold_build(&files)
16621            .expect("comparison cold rebuild after consumer refresh");
16622        for table in [
16623            "nodes",
16624            "refs",
16625            "file_dependencies",
16626            "edges",
16627            "dispatch_hints",
16628        ] {
16629            assert_eq!(
16630                graph_table_rows(&store, table),
16631                graph_table_rows(&cold_store, table),
16632                "refresh through a persisted barrel must preserve cold-build {table} rows"
16633            );
16634        }
16635    }
16636
16637    fn build_reference_connection(
16638        project_root: &Path,
16639        extract: &FileExtract,
16640        resolved: &ResolvedRef,
16641    ) -> Connection {
16642        let mut conn = Connection::open_in_memory().expect("open reference db");
16643        configure_build_connection(&conn).expect("configure reference db");
16644        initialize_schema(&conn).expect("initialize reference schema");
16645        {
16646            let tx = conn.transaction().expect("reference transaction");
16647            clear_tables(&tx).expect("reference clear");
16648            insert_meta(&tx).expect("reference meta");
16649            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
16650            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
16651            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
16652                .expect("reference dispatch edges");
16653            assert_eq!(supplemental, 0);
16654            tx.commit().expect("reference commit");
16655        }
16656        conn
16657    }
16658
16659    fn build_optimized_connection(
16660        project_root: &Path,
16661        extract: &FileExtract,
16662        resolved: &ResolvedRef,
16663    ) -> Connection {
16664        let mut conn = Connection::open_in_memory().expect("open optimized db");
16665        configure_build_connection(&conn).expect("configure optimized db");
16666        initialize_schema(&conn).expect("initialize optimized schema");
16667        {
16668            let tx = conn.transaction().expect("optimized transaction");
16669            clear_tables(&tx).expect("optimized clear");
16670            insert_meta(&tx).expect("optimized meta");
16671            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
16672            {
16673                let workspace_root = project_root.display().to_string();
16674                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
16675                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
16676                    .expect("optimized file extract");
16677                insert_resolved_ref_prepared(&mut inserts, resolved)
16678                    .expect("optimized resolved ref");
16679            }
16680            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
16681            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
16682                .expect("optimized dispatch edges");
16683            assert_eq!(supplemental, 0);
16684            tx.commit().expect("optimized commit");
16685        }
16686        conn
16687    }
16688
16689    fn fixture_extract(_project_root: &Path) -> FileExtract {
16690        let rel_path = "src/main.ts".to_string();
16691        let target_path = "src/helper.ts".to_string();
16692        let node = NodeRecord {
16693            id: "node-main".to_string(),
16694            file_path: rel_path.clone(),
16695            name: "main".to_string(),
16696            scoped_name: "main".to_string(),
16697            kind: "function".to_string(),
16698            range: Range {
16699                start_line: 0,
16700                start_col: 0,
16701                end_line: 0,
16702                end_col: 32,
16703            },
16704            range_ordinal: 0,
16705            signature: Some("export function main()".to_string()),
16706            exported: true,
16707            is_default_export: false,
16708            is_type_like: false,
16709            is_callgraph_entry_point: true,
16710        };
16711        let mut dependencies = BTreeSet::new();
16712        dependencies.insert(target_path.clone());
16713        let raw_ref = RawRef {
16714            ref_id: "ref-main-helper".to_string(),
16715            caller_node: Some(node.id.clone()),
16716            caller_symbol: Some(node.scoped_name.clone()),
16717            caller_file: rel_path.clone(),
16718            kind: "call".to_string(),
16719            short_name: Some("helper".to_string()),
16720            full_ref: Some("helper".to_string()),
16721            module_path: None,
16722            import_kind: None,
16723            local_name: Some("helper".to_string()),
16724            requested_name: Some("helper".to_string()),
16725            namespace_alias: None,
16726            wildcard: false,
16727            line: 1,
16728            byte_start: 24,
16729            byte_end: 32,
16730            dependencies,
16731        };
16732        FileExtract {
16733            rel_path,
16734            freshness: FileFreshness {
16735                mtime: UNIX_EPOCH + Duration::from_secs(123),
16736                size: 40,
16737                content_hash: cache_freshness::hash_bytes(b"fixture source"),
16738            },
16739            lang: LangId::TypeScript,
16740            data: FileCallData {
16741                calls_by_symbol: HashMap::new(),
16742                value_refs_by_symbol: HashMap::new(),
16743                exported_symbols: Vec::new(),
16744                symbol_metadata: HashMap::new(),
16745                default_export_symbol: None,
16746                import_block: ImportBlock::empty(),
16747                lang: LangId::TypeScript,
16748            },
16749            nodes: vec![node.clone()],
16750            raw_refs: vec![raw_ref],
16751            dispatch_hints: vec![DispatchHint {
16752                id: "dispatch-main-helper".to_string(),
16753                method_name: "helper".to_string(),
16754                caller_node: node.id,
16755                file: "src/main.ts".to_string(),
16756                line: 1,
16757                byte_start: 24,
16758                byte_end: 32,
16759            }],
16760            surface_fingerprint: "surface".to_string(),
16761        }
16762    }
16763
16764    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
16765        let raw = extract.raw_refs[0].clone();
16766        let mut dependencies = raw.dependencies.clone();
16767        dependencies.insert("src/helper.ts".to_string());
16768        ResolvedRef {
16769            edge: Some(EdgeRecord {
16770                edge_id: "edge-main-helper".to_string(),
16771                source_node: raw.caller_node.clone().expect("caller node"),
16772                target_node: Some("node-helper".to_string()),
16773                target_file: "src/helper.ts".to_string(),
16774                target_symbol: "helper".to_string(),
16775                kind: "call".to_string(),
16776                line: raw.line,
16777            }),
16778            raw,
16779            status: "resolved".to_string(),
16780            target_node: Some("node-helper".to_string()),
16781            target_file: Some("src/helper.ts".to_string()),
16782            target_symbol: Some("helper".to_string()),
16783            dependencies,
16784        }
16785    }
16786
16787    fn write_ts_resolution_memo_fixture(
16788        project_root: &Path,
16789        package_count: usize,
16790        files_per_package: usize,
16791        calls_per_file: usize,
16792    ) -> Vec<PathBuf> {
16793        fs::create_dir_all(project_root).expect("create memo fixture root");
16794        fs::write(
16795            project_root.join("package.json"),
16796            r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
16797        )
16798        .expect("write workspace package manifest");
16799        fs::write(
16800            project_root.join("tsconfig.json"),
16801            r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
16802        )
16803        .expect("write fixture tsconfig");
16804
16805        let shared_root = project_root.join("packages/shared");
16806        let shared_source = shared_root.join("src/index.ts");
16807        fs::create_dir_all(shared_source.parent().expect("shared source parent"))
16808            .expect("create shared package");
16809        fs::write(
16810            shared_root.join("package.json"),
16811            r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
16812        )
16813        .expect("write shared package manifest");
16814        fs::write(
16815            &shared_source,
16816            "export function shared(value: number) { return value + 1; }\n",
16817        )
16818        .expect("write shared source");
16819        let mut files = vec![shared_source];
16820
16821        for package in 0..package_count {
16822            let package_root = project_root.join(format!("packages/app-{package:02}"));
16823            fs::create_dir_all(&package_root).expect("create app package");
16824            fs::write(
16825                package_root.join("package.json"),
16826                format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
16827            )
16828            .expect("write app package manifest");
16829            let source_dir = package_root.join("src/features/deep/nested/leaf");
16830            fs::create_dir_all(&source_dir).expect("create deep app source dir");
16831
16832            for file in 0..files_per_package {
16833                let source_path = source_dir.join(format!("caller_{file:03}.ts"));
16834                let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
16835                for call in 0..calls_per_file {
16836                    source.push_str(&format!(
16837                        "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
16838                    ));
16839                }
16840                fs::write(&source_path, source).expect("write app source");
16841                files.push(source_path);
16842            }
16843        }
16844
16845        files
16846    }
16847
16848    #[cfg(unix)]
16849    fn process_cpu_time() -> Duration {
16850        let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
16851        let result =
16852            unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
16853        if result != 0 {
16854            return Duration::ZERO;
16855        }
16856        let value = unsafe { value.assume_init() };
16857        Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
16858    }
16859
16860    #[cfg(not(unix))]
16861    fn process_cpu_time() -> Duration {
16862        Duration::ZERO
16863    }
16864
16865    fn write_chunked_equivalence_fixture(project_root: &Path) {
16866        let ts_dir = project_root.join("ts");
16867        fs::create_dir_all(&ts_dir).expect("create ts dir");
16868        fs::write(
16869            ts_dir.join("leaf.ts"),
16870            "export function leaf(value: number) {\n  return value + 1;\n}\n",
16871        )
16872        .expect("write ts leaf");
16873        fs::write(
16874            ts_dir.join("mid.ts"),
16875            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
16876        )
16877        .expect("write ts mid");
16878        fs::write(
16879            ts_dir.join("entry.ts"),
16880            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
16881        )
16882        .expect("write ts entry");
16883        fs::write(
16884            ts_dir.join("worker.ts"),
16885            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
16886        )
16887        .expect("write ts worker");
16888        for idx in 0..4 {
16889            fs::write(
16890                ts_dir.join(format!("extra_{idx}.ts")),
16891                format!(
16892                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
16893                ),
16894            )
16895            .expect("write ts extra");
16896        }
16897
16898        let rust_dir = project_root.join("src");
16899        let commands_dir = rust_dir.join("commands");
16900        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
16901        fs::write(
16902            rust_dir.join("context.rs"),
16903            r#"pub struct AppContext;
16904
16905impl AppContext {
16906    pub fn callgraph_store_for_ops(&self) -> usize {
16907        1
16908    }
16909}
16910"#,
16911        )
16912        .expect("write rust context");
16913        fs::write(
16914            rust_dir.join("lib.rs"),
16915            "pub mod context;\npub mod commands;\n",
16916        )
16917        .expect("write rust lib");
16918        fs::write(
16919            commands_dir.join("mod.rs"),
16920            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
16921        )
16922        .expect("write rust commands mod");
16923        for name in ["callers", "impact", "trace_to"] {
16924            fs::write(
16925                commands_dir.join(format!("{name}.rs")),
16926                format!(
16927                    r#"use crate::context::AppContext;
16928
16929pub fn handle_{name}(ctx: &AppContext) -> usize {{
16930    ctx.callgraph_store_for_ops()
16931}}
16932"#
16933                ),
16934            )
16935            .expect("write rust command");
16936        }
16937    }
16938
16939    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
16940        let src_dir = project_root.join("src");
16941        fs::create_dir_all(&src_dir).expect("create src dir");
16942
16943        let target_path = src_dir.join("target.ts");
16944        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
16945            .expect("write target");
16946
16947        let index_path = src_dir.join("index.ts");
16948        fs::write(&index_path, barrel_source).expect("write barrel");
16949
16950        let mut files = vec![target_path, index_path];
16951        for (file_name, function_name) in [
16952            ("consumer_a.ts", "consumerA"),
16953            ("consumer_b.ts", "consumerB"),
16954            ("consumer_c.ts", "consumerC"),
16955        ] {
16956            let path = src_dir.join(file_name);
16957            fs::write(
16958                &path,
16959                format!(
16960                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
16961                ),
16962            )
16963            .expect("write consumer");
16964            files.push(path);
16965        }
16966        files
16967    }
16968
16969    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
16970        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16971        table_rows(&conn, table)
16972    }
16973
16974    fn graph_table_rows_without(
16975        store: &CallGraphStore,
16976        table: &str,
16977        excluded_columns: &[&str],
16978    ) -> Vec<String> {
16979        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16980        table_rows_without(&conn, table, excluded_columns)
16981    }
16982
16983    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
16984        table_rows_without(conn, table, &[])
16985    }
16986
16987    fn table_rows_without(
16988        conn: &Connection,
16989        table: &str,
16990        excluded_columns: &[&str],
16991    ) -> Vec<String> {
16992        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
16993        let columns: Vec<String> = conn
16994            .prepare(&format!("PRAGMA table_info({table})"))
16995            .expect("prepare table_info")
16996            .query_map([], |row| row.get::<_, String>(1))
16997            .expect("query table_info")
16998            .collect::<std::result::Result<Vec<String>, _>>()
16999            .expect("collect columns")
17000            .into_iter()
17001            .filter(|column| !excluded_columns.contains(column.as_str()))
17002            .collect();
17003        let sql = format!(
17004            "SELECT {} FROM {table} ORDER BY {}",
17005            columns.join(", "),
17006            columns.join(", ")
17007        );
17008        conn.prepare(&sql)
17009            .expect("prepare table rows")
17010            .query_map([], |row| row_to_strings(row, columns.len()))
17011            .expect("query table rows")
17012            .collect::<std::result::Result<_, _>>()
17013            .expect("collect table rows")
17014    }
17015
17016    fn assert_cold_build_stats_match_except_elapsed(
17017        expected: &ColdBuildStats,
17018        actual: &ColdBuildStats,
17019    ) {
17020        assert_eq!(actual.files, expected.files, "file counts must match");
17021        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
17022        assert_eq!(actual.refs, expected.refs, "ref counts must match");
17023        assert_eq!(actual.edges, expected.edges, "edge counts must match");
17024        assert_eq!(
17025            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
17026            expected
17027                .failed_files
17028                .iter()
17029                .cloned()
17030                .collect::<BTreeSet<_>>(),
17031            "failed file sets must match"
17032        );
17033    }
17034
17035    fn backend_state_rows(conn: &Connection) -> Vec<String> {
17036        conn.prepare(
17037            "SELECT backend, workspace_root, file_path, content_hash, status
17038             FROM backend_file_state
17039             ORDER BY backend, workspace_root, file_path, content_hash, status",
17040        )
17041        .expect("prepare backend rows")
17042        .query_map([], |row| row_to_strings(row, 5))
17043        .expect("query backend rows")
17044        .collect::<std::result::Result<_, _>>()
17045        .expect("collect backend rows")
17046    }
17047
17048    fn secondary_indexes(conn: &Connection) -> Vec<String> {
17049        let mut indexes = Vec::new();
17050        for table in [
17051            "files",
17052            "nodes",
17053            "refs",
17054            "file_dependencies",
17055            "edges",
17056            "dispatch_hints",
17057            "type_ref_names",
17058            "backend_file_state",
17059            "meta",
17060        ] {
17061            let sql = format!("PRAGMA index_list({table})");
17062            let mut stmt = conn.prepare(&sql).expect("prepare index list");
17063            let rows = stmt
17064                .query_map([], |row| row.get::<_, String>(1))
17065                .expect("query index list");
17066            for name in rows {
17067                let name = name.expect("index name");
17068                if name.starts_with("idx_") {
17069                    indexes.push(format!("{table}:{name}"));
17070                }
17071            }
17072        }
17073        indexes.sort();
17074        indexes
17075    }
17076
17077    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
17078        let mut values = Vec::with_capacity(len);
17079        for index in 0..len {
17080            let value = row.get_ref(index)?;
17081            values.push(match value {
17082                rusqlite::types::ValueRef::Null => "NULL".to_string(),
17083                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
17084                rusqlite::types::ValueRef::Real(value) => value.to_string(),
17085                rusqlite::types::ValueRef::Text(value) => {
17086                    String::from_utf8_lossy(value).into_owned()
17087                }
17088                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
17089            });
17090        }
17091        Ok(values.join("\u{1f}"))
17092    }
17093}
17094
17095#[cfg(test)]
17096mod rust_resolution_tests {
17097    use super::*;
17098    use crate::inspect::job::CallgraphSnapshot;
17099    use std::fs;
17100    use tempfile::tempdir;
17101
17102    #[test]
17103    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
17104        let dir = tempdir().expect("tempdir");
17105        let root = dir.path();
17106        write_rust_manifest(root, "scoped-alias-fixture");
17107        write_file(
17108            root,
17109            "src/lib.rs",
17110            r#"pub mod finalization_contract;
17111
17112pub fn run_alias() {
17113    use crate::finalization_contract as fc;
17114    fc::check_mason_contract();
17115}
17116"#,
17117        );
17118        write_file(
17119            root,
17120            "src/finalization_contract.rs",
17121            r#"pub fn check_mason_contract() {}
17122fn planted_dead() {}
17123"#,
17124        );
17125
17126        let (store, snapshot) = cold_build_twice(root);
17127        assert_direct_caller(
17128            &store,
17129            "src/finalization_contract.rs",
17130            "check_mason_contract",
17131            "src/lib.rs",
17132            "run_alias",
17133        );
17134        assert_projected_call(
17135            root,
17136            &snapshot,
17137            "src/finalization_contract.rs",
17138            "check_mason_contract",
17139        );
17140        assert_no_projected_call(
17141            root,
17142            &snapshot,
17143            "src/finalization_contract.rs",
17144            "planted_dead",
17145        );
17146        assert!(
17147            store
17148                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
17149                .expect("planted dead callers")
17150                .is_empty(),
17151            "planted-dead guard should stay without callers"
17152        );
17153    }
17154
17155    #[test]
17156    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
17157        let dir = tempdir().expect("tempdir");
17158        let root = dir.path();
17159        write_rust_manifest(root, "inline-module-fixture");
17160        write_file(
17161            root,
17162            "src/lib.rs",
17163            r#"mod work_graph { fn operations() {} }
17164mod manifest { fn operations() {} }
17165mod audit { fn operations() {} }
17166mod dispatch { fn operations() {} }
17167mod finalization { fn operations() {} }
17168
17169pub fn run_inline_operations() {
17170    work_graph::operations();
17171    manifest::operations();
17172    audit::operations();
17173    dispatch::operations();
17174    finalization::operations();
17175}
17176
17177fn planted_dead() {}
17178"#,
17179        );
17180
17181        let (store, snapshot) = cold_build_twice(root);
17182        for module in [
17183            "work_graph",
17184            "manifest",
17185            "audit",
17186            "dispatch",
17187            "finalization",
17188        ] {
17189            assert_direct_caller(
17190                &store,
17191                "src/lib.rs",
17192                &format!("{module}::operations"),
17193                "src/lib.rs",
17194                "run_inline_operations",
17195            );
17196        }
17197        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
17198        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17199    }
17200
17201    #[test]
17202    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
17203        let dir = tempdir().expect("tempdir");
17204        let root = dir.path();
17205        fs::write(
17206            root.join("Cargo.toml"),
17207            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
17208        )
17209        .expect("write workspace manifest");
17210        write_file(
17211            root,
17212            "crates/but-action/Cargo.toml",
17213            r#"[package]
17214name = "but-action"
17215version = "0.1.0"
17216edition = "2021"
17217"#,
17218        );
17219        write_file(
17220            root,
17221            "crates/but-action/src/lib.rs",
17222            "mod action;\npub use action::{list_actions};\n",
17223        );
17224        write_file(
17225            root,
17226            "crates/but-action/src/action.rs",
17227            "pub fn list_actions() {}\nfn planted_dead() {}\n",
17228        );
17229        write_file(
17230            root,
17231            "crates/app/Cargo.toml",
17232            r#"[package]
17233name = "app"
17234version = "0.1.0"
17235edition = "2021"
17236"#,
17237        );
17238        write_file(
17239            root,
17240            "crates/app/src/lib.rs",
17241            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
17242        );
17243
17244        let (store, snapshot) = cold_build_twice(root);
17245        assert_direct_caller(
17246            &store,
17247            "crates/but-action/src/action.rs",
17248            "list_actions",
17249            "crates/app/src/lib.rs",
17250            "run_actions",
17251        );
17252        assert!(
17253            store
17254                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
17255                .expect("lib reexport callers")
17256                .is_empty(),
17257            "call should target the reexported source function, not lib.rs"
17258        );
17259        assert_projected_call(
17260            root,
17261            &snapshot,
17262            "crates/but-action/src/action.rs",
17263            "list_actions",
17264        );
17265        assert_no_projected_call(
17266            root,
17267            &snapshot,
17268            "crates/but-action/src/action.rs",
17269            "planted_dead",
17270        );
17271    }
17272
17273    #[test]
17274    fn rust_cfg_attributed_module_resolves_outgoing_calls() {
17275        let dir = tempdir().expect("tempdir");
17276        let root = dir.path();
17277        write_rust_manifest(root, "cfg-module-outgoing-fixture");
17278        write_file(
17279            root,
17280            "src/lib.rs",
17281            "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
17282        );
17283        for module in ["conformance", "ordinary"] {
17284            write_file(
17285                root,
17286                &format!("src/{module}.rs"),
17287                "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n    local_target();\n    project_range();\n}\n",
17288            );
17289        }
17290
17291        let (store, _) = cold_build_twice(root);
17292        for module in ["conformance", "ordinary"] {
17293            assert_direct_caller(
17294                &store,
17295                &format!("src/{module}.rs"),
17296                "local_target",
17297                &format!("src/{module}.rs"),
17298                "run",
17299            );
17300            assert_direct_caller(
17301                &store,
17302                "src/lib.rs",
17303                "project_range",
17304                &format!("src/{module}.rs"),
17305                "run",
17306            );
17307        }
17308    }
17309
17310    #[test]
17311    fn rust_registered_modules_preserve_import_alias_resolution() {
17312        let dir = tempdir().expect("tempdir");
17313        let root = dir.path();
17314        write_rust_manifest(root, "registered-module-import-control");
17315        write_file(
17316            root,
17317            "src/main.rs",
17318            "mod commands;\nmod db;\nfn main() {}\n",
17319        );
17320        write_file(
17321            root,
17322            "src/commands.rs",
17323            "use crate::db;\n\npub fn run() {\n    db::helper();\n}\n",
17324        );
17325        write_file(root, "src/db.rs", "pub fn helper() {}\n");
17326
17327        let main_extract =
17328            build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
17329        let commands_extract =
17330            build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
17331        let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
17332        let files = [&main_extract, &commands_extract, &db_extract]
17333            .into_iter()
17334            .map(|extract| {
17335                (
17336                    extract.rel_path.clone(),
17337                    DbFileIndex::from_extract(root, extract),
17338                )
17339            })
17340            .collect::<HashMap<_, _>>();
17341        let caller_data = [&main_extract, &commands_extract, &db_extract]
17342            .into_iter()
17343            .map(|extract| (extract.rel_path.clone(), &extract.data))
17344            .collect::<HashMap<_, _>>();
17345        let index = ProjectIndex::from_parts(
17346            root,
17347            files,
17348            caller_data,
17349            WorkspaceCratePrefixCache::default(),
17350        );
17351        assert_eq!(
17352            index.module_parent("src/commands.rs"),
17353            Some(("src/main.rs".to_string(), "commands".to_string()))
17354        );
17355        assert_eq!(
17356            index.module_target("src/main.rs", "db").as_deref(),
17357            Some("src/db.rs")
17358        );
17359        let call = commands_extract
17360            .raw_refs
17361            .iter()
17362            .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
17363            .expect("db helper call")
17364            .clone();
17365        let resolved = resolve_ref(call, &index).expect("resolve db helper");
17366        assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
17367        assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
17368
17369        let (store, _) = cold_build_twice(root);
17370        assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
17371    }
17372
17373    #[test]
17374    fn rust_path_attributed_module_uses_declared_logical_parent() {
17375        let dir = tempdir().expect("tempdir");
17376        let root = dir.path();
17377        write_rust_manifest(root, "path-module-outgoing-fixture");
17378        write_file(
17379            root,
17380            "src/lib.rs",
17381            "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
17382        );
17383        write_file(
17384            root,
17385            "src/alternate/custom.rs",
17386            "pub fn run() {\n    super::project_range();\n}\n",
17387        );
17388
17389        let (store, _) = cold_build_twice(root);
17390        assert_direct_caller(
17391            &store,
17392            "src/lib.rs",
17393            "project_range",
17394            "src/alternate/custom.rs",
17395            "run",
17396        );
17397    }
17398
17399    #[test]
17400    fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
17401        let dir = tempdir().expect("tempdir");
17402        let root = dir.path();
17403        write_rust_manifest(root, "same-file-test-module-fixture");
17404        write_file(
17405            root,
17406            "src/lib.rs",
17407            r#"pub struct Index(u32);
17408
17409impl Index {
17410    pub fn shares_index_with(&self, other: &Self) -> bool {
17411        self.0 == other.0
17412    }
17413}
17414
17415#[cfg(test)]
17416mod tests {
17417    use super::Index;
17418
17419    #[test]
17420    fn compares_indexes() {
17421        let before = Index(1);
17422        let after = Index(1);
17423        assert!(before.shares_index_with(&after));
17424    }
17425}
17426"#,
17427        );
17428
17429        let (store, snapshot) = cold_build_twice(root);
17430        assert_direct_caller(
17431            &store,
17432            "src/lib.rs",
17433            "Index::shares_index_with",
17434            "src/lib.rs",
17435            "tests::compares_indexes",
17436        );
17437        assert!(
17438            snapshot.outbound_calls.iter().any(|call| {
17439                call.caller_symbol == "compares_indexes"
17440                    && call.line == 17
17441                    && call.target.starts_with(&format!(
17442                        "shares_index_with{}before.shares_index_with",
17443                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17444                    ))
17445            }),
17446            "expected projected macro receiver call; calls: {:#?}",
17447            snapshot.outbound_calls
17448        );
17449    }
17450
17451    #[test]
17452    fn rust_generic_self_turbofish_method_dispatch_resolves() {
17453        let dir = tempdir().expect("tempdir");
17454        let root = dir.path();
17455        write_rust_manifest(root, "generic-self-fixture");
17456        write_file(
17457            root,
17458            "src/lib.rs",
17459            r#"pub struct Matcher;
17460
17461impl Matcher {
17462    pub fn run(&self) -> bool {
17463        self.fuzzy_match_optimal::<usize>("needle")
17464    }
17465
17466    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
17467        let _ = std::marker::PhantomData::<T>;
17468        true
17469    }
17470
17471    fn planted_dead(&self) {}
17472}
17473
17474pub fn entry() -> bool {
17475    let matcher = Matcher;
17476    matcher.run()
17477}
17478"#,
17479        );
17480
17481        let (store, snapshot) = cold_build_twice(root);
17482        assert_direct_caller(
17483            &store,
17484            "src/lib.rs",
17485            "Matcher::fuzzy_match_optimal",
17486            "src/lib.rs",
17487            "Matcher::run",
17488        );
17489        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
17490        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17491    }
17492
17493    #[test]
17494    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
17495        let dir = tempdir().expect("tempdir");
17496        let root = dir.path();
17497        write_rust_manifest(root, "manifest-operations-fixture");
17498        write_file(
17499            root,
17500            "src/main.rs",
17501            r#"mod dispatch;
17502use dispatch::{manifest_operations};
17503
17504fn main() {
17505    manifest_operations();
17506}
17507"#,
17508        );
17509        write_file(
17510            root,
17511            "src/dispatch.rs",
17512            r#"mod work_graph { fn operations() {} }
17513mod manifest { fn operations() {} }
17514mod audit { fn operations() {} }
17515mod descriptor { fn operations() {} }
17516mod writer { fn operations() {} }
17517
17518pub fn manifest_operations() {
17519    manifest::operations();
17520}
17521
17522pub fn work_graph_operations() {
17523    work_graph::operations();
17524}
17525
17526pub fn audit_operations() {
17527    audit::operations();
17528}
17529
17530pub fn descriptor_operations() {
17531    descriptor::operations();
17532}
17533
17534pub fn writer_operations() {
17535    writer::operations();
17536}
17537
17538fn planted_dead() {}
17539"#,
17540        );
17541
17542        let (store, snapshot) = cold_build_twice(root);
17543        assert_direct_caller(
17544            &store,
17545            "src/dispatch.rs",
17546            "manifest_operations",
17547            "src/main.rs",
17548            "main",
17549        );
17550        assert_direct_caller(
17551            &store,
17552            "src/dispatch.rs",
17553            "manifest::operations",
17554            "src/dispatch.rs",
17555            "manifest_operations",
17556        );
17557        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
17558        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
17559        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
17560    }
17561
17562    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
17563        let files = rust_files(root);
17564        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
17565            .expect("open first store");
17566        first.cold_build(&files).expect("first cold build");
17567        let first_snapshot =
17568            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
17569
17570        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
17571            .expect("open second store");
17572        second.cold_build(&files).expect("second cold build");
17573        let second_snapshot =
17574            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
17575
17576        assert_eq!(
17577            projection_rows(&first_snapshot),
17578            projection_rows(&second_snapshot),
17579            "cold-build projection should be deterministic"
17580        );
17581        (first, first_snapshot)
17582    }
17583
17584    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
17585        let mut rows = Vec::new();
17586        for export in &snapshot.exported_symbols {
17587            rows.push(format!(
17588                "export\t{}\t{}\t{}\t{}",
17589                export.file.display(),
17590                export.symbol,
17591                export.kind,
17592                export.line
17593            ));
17594        }
17595        for call in &snapshot.outbound_calls {
17596            rows.push(format!(
17597                "call\t{}\t{}\t{}\t{}\t{}",
17598                call.caller_file.display(),
17599                call.caller_symbol,
17600                call.target,
17601                call.line,
17602                call.provenance
17603            ));
17604        }
17605        for file in &snapshot.entry_points {
17606            rows.push(format!("entry_file\t{}", file.display()));
17607        }
17608        for (file, symbols) in &snapshot.entry_point_symbols {
17609            for symbol in symbols {
17610                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
17611            }
17612        }
17613        rows.sort();
17614        rows
17615    }
17616
17617    fn assert_direct_caller(
17618        store: &CallGraphStore,
17619        target_rel: &str,
17620        target_symbol: &str,
17621        caller_rel: &str,
17622        caller_symbol: &str,
17623    ) {
17624        let callers = store
17625            .direct_callers_of(Path::new(target_rel), target_symbol)
17626            .unwrap_or_else(|error| {
17627                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
17628            });
17629        assert!(
17630            callers.iter().any(|site| {
17631                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
17632            }),
17633            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
17634        );
17635    }
17636
17637    fn assert_projected_call(
17638        root: &Path,
17639        snapshot: &CallgraphSnapshot,
17640        target_rel: &str,
17641        symbol: &str,
17642    ) {
17643        let target = projected_target(root, target_rel, symbol);
17644        assert!(
17645            snapshot.outbound_calls.iter().any(|call| {
17646                call.target == target
17647                    || call.target.starts_with(&format!(
17648                        "{target}{}",
17649                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17650                    ))
17651            }),
17652            "expected projected call to {target}; calls: {:#?}",
17653            snapshot.outbound_calls
17654        );
17655    }
17656
17657    fn assert_no_projected_call(
17658        root: &Path,
17659        snapshot: &CallgraphSnapshot,
17660        target_rel: &str,
17661        symbol: &str,
17662    ) {
17663        let target = projected_target(root, target_rel, symbol);
17664        assert!(
17665            snapshot.outbound_calls.iter().all(|call| {
17666                call.target != target
17667                    && !call.target.starts_with(&format!(
17668                        "{target}{}",
17669                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17670                    ))
17671            }),
17672            "did not expect projected call to {target}; calls: {:#?}",
17673            snapshot.outbound_calls
17674        );
17675    }
17676
17677    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
17678        // Projection targets carry the normalized (verbatim-stripped)
17679        // canonical form; bare fs::canonicalize diverges on Windows.
17680        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
17681        format!("{}::{symbol}", path.display())
17682    }
17683
17684    fn write_rust_manifest(root: &Path, name: &str) {
17685        write_file(
17686            root,
17687            "Cargo.toml",
17688            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
17689        );
17690    }
17691
17692    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
17693        let path = root.join(rel_path);
17694        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
17695        fs::write(&path, source).expect("write fixture file");
17696        path
17697    }
17698
17699    fn rust_files(root: &Path) -> Vec<PathBuf> {
17700        let mut files = Vec::new();
17701        collect_rust_files(root, &mut files);
17702        files.sort();
17703        files
17704    }
17705
17706    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
17707        for entry in fs::read_dir(dir).expect("read fixture dir") {
17708            let entry = entry.expect("read fixture entry");
17709            let path = entry.path();
17710            if path.is_dir() {
17711                let name = path
17712                    .file_name()
17713                    .and_then(|name| name.to_str())
17714                    .unwrap_or("");
17715                if !name.starts_with(".store") {
17716                    collect_rust_files(&path, files);
17717                }
17718            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
17719                files.push(path);
17720            }
17721        }
17722    }
17723}
17724
17725#[cfg(test)]
17726mod build_pool_tests {
17727    use super::build_pool_size;
17728
17729    #[test]
17730    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
17731        let size = build_pool_size();
17732        // Never zero, never the full core count, never above the 8 cap — this is
17733        // the starvation guard for the cold-build's all-cores tree-sitter pass.
17734        assert!(size >= 1, "pool size must be at least 1");
17735        assert!(size <= 8, "pool size must be capped at 8, got {size}");
17736
17737        let cores = std::thread::available_parallelism()
17738            .map(|p| p.get())
17739            .unwrap_or(1);
17740        let expected = cores.div_ceil(2).clamp(1, 8);
17741        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
17742    }
17743}
17744
17745#[cfg(test)]
17746mod reexport_resolution_tests {
17747    use super::*;
17748
17749    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
17750        ProjectIndex {
17751            project_root: PathBuf::from("/fixture"),
17752            files: files.into_iter().collect(),
17753            caller_data: HashMap::new(),
17754            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
17755        }
17756    }
17757
17758    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
17759        DbFileIndex {
17760            lang: None,
17761            exports: HashSet::new(),
17762            default_export: None,
17763            export_aliases: HashMap::new(),
17764            node_by_scoped: HashMap::new(),
17765            node_by_bare: HashMap::new(),
17766            node_kind_by_id: HashMap::new(),
17767            module_targets: HashMap::new(),
17768            declared_module_targets: HashMap::new(),
17769            reexports: reexport_targets
17770                .iter()
17771                .map(|target| ReexportIndex {
17772                    target_file: Some((*target).to_string()),
17773                    named: HashMap::new(),
17774                    wildcard: true,
17775                })
17776                .collect(),
17777        }
17778    }
17779
17780    /// A dense wildcard re-export cycle (barrel files re-exporting each
17781    /// other) must resolve in O(files), not O(branching^depth). Without the
17782    /// resolver's memoization, resolving a MISSING symbol through this
17783    /// 12-file complete digraph explores ~11^16 paths and this test never
17784    /// finishes: the depth cap bounds path length, not path count, and one
17785    /// such resolution can pin a worker thread at 100% CPU indefinitely.
17786    #[test]
17787    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
17788        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
17789        let files = names
17790            .iter()
17791            .map(|name| {
17792                let targets: Vec<&str> = names
17793                    .iter()
17794                    .filter(|other| *other != name)
17795                    .map(String::as_str)
17796                    .collect();
17797                (name.clone(), barrel_file(&targets))
17798            })
17799            .collect();
17800        let index = barrel_index(files);
17801
17802        assert_eq!(
17803            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
17804            None
17805        );
17806    }
17807
17808    /// Depth-dominance counterexample: the walk first reaches `shared` down a
17809    /// 16-hop chain (no budget left for its leaf), then reaches it again
17810    /// directly at depth 1. Plain visited-set pruning would skip the second
17811    /// visit and lose a resolution the capped resolver finds; the
17812    /// depth-dominance memo revisits because the second arrival is shallower.
17813    #[test]
17814    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
17815        let mut leaf = barrel_file(&[]);
17816        leaf.exports.insert("deep_symbol".to_string());
17817        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
17818        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
17819        // entry's SECOND reexport goes straight to shared.
17820        files.push((
17821            "src/entry.ts".to_string(),
17822            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
17823        ));
17824        for i in 0..15 {
17825            let next = if i == 14 {
17826                "src/shared.ts".to_string()
17827            } else {
17828                format!("src/chain{}.ts", i + 1)
17829            };
17830            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
17831        }
17832        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
17833        files.push(("src/leaf.ts".to_string(), leaf));
17834        let index = barrel_index(files);
17835
17836        assert_eq!(
17837            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
17838            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
17839            "a shallower re-visit must not be pruned by a deeper capped visit"
17840        );
17841    }
17842
17843    #[test]
17844    fn symbol_reachable_through_reexport_cycle_still_resolves() {
17845        let mut leaf = barrel_file(&[]);
17846        leaf.exports.insert("real_symbol".to_string());
17847        let index = barrel_index(vec![
17848            (
17849                "src/a.ts".to_string(),
17850                barrel_file(&["src/b.ts", "src/a.ts"]),
17851            ),
17852            (
17853                "src/b.ts".to_string(),
17854                barrel_file(&["src/a.ts", "src/leaf.ts"]),
17855            ),
17856            ("src/leaf.ts".to_string(), leaf),
17857        ]);
17858
17859        assert_eq!(
17860            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
17861            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
17862        );
17863    }
17864}
17865
17866#[cfg(test)]
17867mod method_dispatch_inference_tests {
17868    use super::*;
17869    use std::fs;
17870    use tempfile::tempdir;
17871
17872    #[test]
17873    fn java_field_receiver_type_selects_declared_class_method() {
17874        let source = r#"class EntryPoint {
17875    private UserService userService;
17876
17877    void handle() {
17878        userService.find();
17879    }
17880}
17881
17882class UserService {
17883    void find() {}
17884}
17885
17886class AuditService {
17887    void find() {}
17888}
17889"#;
17890        let dir = tempdir().expect("temp dir");
17891        let root = dir.path();
17892        write_fixture(root, "src/EntryPoint.java", source);
17893        let reference = reference(
17894            "java",
17895            "src/EntryPoint.java",
17896            "EntryPoint::handle",
17897            "userService",
17898            "find",
17899            line_of(source, "userService.find()"),
17900        );
17901        let mut cache = DispatchSourceCache::new();
17902
17903        let receiver_type =
17904            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
17905        assert_eq!(receiver_type, "UserService");
17906
17907        let candidates = vec![
17908            method_candidate("audit", "AuditService::find"),
17909            method_candidate("user", "UserService::find"),
17910        ];
17911        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
17912            .expect("type candidate");
17913        assert_eq!(selected.scoped_name, "UserService::find");
17914
17915        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
17916        assert!(
17917            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
17918        );
17919    }
17920
17921    #[test]
17922    fn kotlin_property_and_local_value_types_are_inferred() {
17923        let source = r#"class Handler {
17924    private val auditService: AuditService = AuditService()
17925
17926    fun handle() {
17927        auditService.find()
17928        val userService: UserService = UserService()
17929        userService.find()
17930        val billingService = BillingService()
17931        billingService.find()
17932    }
17933}
17934
17935class UserService { fun find() {} }
17936class AuditService { fun find() {} }
17937class BillingService { fun find() {} }
17938"#;
17939        let dir = tempdir().expect("temp dir");
17940        let root = dir.path();
17941        write_fixture(root, "src/Handler.kt", source);
17942        let mut cache = DispatchSourceCache::new();
17943
17944        let audit_ref = reference(
17945            "kotlin",
17946            "src/Handler.kt",
17947            "Handler::handle",
17948            "auditService",
17949            "find",
17950            line_of(source, "auditService.find()"),
17951        );
17952        assert_eq!(
17953            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
17954            Some("AuditService")
17955        );
17956
17957        let user_ref = reference(
17958            "kotlin",
17959            "src/Handler.kt",
17960            "Handler::handle",
17961            "userService",
17962            "find",
17963            line_of(source, "userService.find()"),
17964        );
17965        assert_eq!(
17966            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
17967            Some("UserService")
17968        );
17969
17970        let billing_ref = reference(
17971            "kotlin",
17972            "src/Handler.kt",
17973            "Handler::handle",
17974            "billingService",
17975            "find",
17976            line_of(source, "billingService.find()"),
17977        );
17978        assert_eq!(
17979            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
17980            Some("BillingService")
17981        );
17982    }
17983
17984    #[test]
17985    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
17986        let source = r#"struct Foo { void run(); };
17987struct PointerFoo { void run(); };
17988struct FactoryFoo { void run(); };
17989FactoryFoo makeFactoryFoo();
17990
17991void handle() {
17992    Foo foo;
17993    foo.run();
17994    PointerFoo* pointerFoo = nullptr;
17995    pointerFoo->run();
17996    auto factoryFoo = makeFactoryFoo();
17997    factoryFoo.run();
17998}
17999"#;
18000        let dir = tempdir().expect("temp dir");
18001        let root = dir.path();
18002        write_fixture(root, "src/fixture.cpp", source);
18003        let mut cache = DispatchSourceCache::new();
18004
18005        let foo_ref = reference(
18006            "cpp",
18007            "src/fixture.cpp",
18008            "handle",
18009            "foo",
18010            "run",
18011            line_of(source, "foo.run()"),
18012        );
18013        assert_eq!(
18014            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
18015            Some("Foo")
18016        );
18017
18018        let pointer_ref = reference(
18019            "cpp",
18020            "src/fixture.cpp",
18021            "handle",
18022            "pointerFoo",
18023            "run",
18024            line_of(source, "pointerFoo->run()"),
18025        );
18026        assert_eq!(
18027            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
18028            Some("PointerFoo")
18029        );
18030
18031        let factory_ref = reference(
18032            "cpp",
18033            "src/fixture.cpp",
18034            "handle",
18035            "factoryFoo",
18036            "run",
18037            line_of(source, "factoryFoo.run()"),
18038        );
18039        assert_eq!(
18040            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
18041            Some("FactoryFoo")
18042        );
18043    }
18044
18045    #[test]
18046    fn rust_direct_self_field_name_trims_separator_whitespace() {
18047        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
18048            assert_eq!(
18049                rust_direct_self_field_name(receiver_expression),
18050                Some("engine")
18051            );
18052        }
18053    }
18054
18055    #[test]
18056    fn rust_direct_self_field_receiver_type_is_conservative() {
18057        let source = r#"struct Engine;
18058
18059struct Car {
18060    engine: Engine,
18061}
18062
18063impl Car {
18064    fn run(&self) {
18065        self.engine.start();
18066    }
18067}
18068
18069struct NestedCar {
18070    engine: Engine,
18071}
18072
18073impl NestedCar {
18074    fn run(&self) {
18075        self.inner.engine.start();
18076    }
18077}
18078
18079struct WrappedCar {
18080    engine: Option<Engine>,
18081}
18082
18083impl WrappedCar {
18084    fn run(&self) {
18085        self.engine.start(); // wrapped
18086    }
18087}
18088
18089struct GenericCar<T> {
18090    engine: T,
18091}
18092
18093impl<T> GenericCar<T> {
18094    fn run(&self) {
18095        self.engine.start(); // generic
18096    }
18097}
18098
18099type EngineAlias = Engine;
18100
18101struct AliasCar {
18102    engine: EngineAlias,
18103}
18104
18105impl AliasCar {
18106    fn run(&self) {
18107        self.engine.start(); // alias
18108    }
18109}
18110"#;
18111        let dir = tempdir().expect("temp dir");
18112        let root = dir.path();
18113        write_fixture(root, "src/lib.rs", source);
18114        let mut cache = DispatchSourceCache::new();
18115
18116        let mut direct = reference(
18117            "rust",
18118            "src/lib.rs",
18119            "Car::run",
18120            "engine",
18121            "start",
18122            line_of(source, "self.engine.start()"),
18123        );
18124        direct.receiver_expression = "self.engine".to_string();
18125        assert_eq!(
18126            infer_receiver_type(root, &direct, &mut cache).as_deref(),
18127            Some("Engine")
18128        );
18129
18130        let mut mismatched_impl_target = direct.clone();
18131        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
18132        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
18133
18134        let mut nested = reference(
18135            "rust",
18136            "src/lib.rs",
18137            "NestedCar::run",
18138            "engine",
18139            "start",
18140            line_of(source, "self.inner.engine.start()"),
18141        );
18142        nested.receiver_expression = "self.inner.engine".to_string();
18143        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
18144
18145        let mut wrapped = reference(
18146            "rust",
18147            "src/lib.rs",
18148            "WrappedCar::run",
18149            "engine",
18150            "start",
18151            line_of(source, "self.engine.start(); // wrapped"),
18152        );
18153        wrapped.receiver_expression = "self.engine".to_string();
18154        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
18155
18156        let mut generic = reference(
18157            "rust",
18158            "src/lib.rs",
18159            "GenericCar::run",
18160            "engine",
18161            "start",
18162            line_of(source, "self.engine.start(); // generic"),
18163        );
18164        generic.receiver_expression = "self.engine".to_string();
18165        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
18166
18167        let mut alias = reference(
18168            "rust",
18169            "src/lib.rs",
18170            "AliasCar::run",
18171            "engine",
18172            "start",
18173            line_of(source, "self.engine.start(); // alias"),
18174        );
18175        alias.receiver_expression = "self.engine".to_string();
18176        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
18177    }
18178
18179    #[test]
18180    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
18181        let source = r#"struct Engine;
18182
18183struct Car {
18184    engine: &'static Engine,
18185}
18186
18187impl Car {
18188    fn run(&self) {
18189        self.engine.start();
18190    }
18191}
18192"#;
18193        let dir = tempdir().expect("temp dir");
18194        let root = dir.path();
18195        write_fixture(root, "src/lib.rs", source);
18196        let mut cache = DispatchSourceCache::new();
18197        let mut reference = reference(
18198            "rust",
18199            "src/lib.rs",
18200            "Car::run",
18201            "engine",
18202            "start",
18203            line_of(source, "self.engine.start()"),
18204        );
18205        reference.receiver_expression = "self.engine".to_string();
18206
18207        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18208    }
18209
18210    #[test]
18211    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
18212        let source = r#"trait Drive {
18213    fn run(&self);
18214}
18215
18216struct Engine;
18217
18218struct Car {
18219    engine: Engine,
18220}
18221
18222impl Drive for Car {
18223    fn run(&self) {
18224        self.engine.start();
18225    }
18226}
18227"#;
18228        let dir = tempdir().expect("temp dir");
18229        let root = dir.path();
18230        write_fixture(root, "src/lib.rs", source);
18231        let mut cache = DispatchSourceCache::new();
18232        let mut reference = reference(
18233            "rust",
18234            "src/lib.rs",
18235            "Car::run",
18236            "engine",
18237            "start",
18238            line_of(source, "self.engine.start()"),
18239        );
18240        reference.receiver_expression = "self.engine".to_string();
18241
18242        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18243    }
18244
18245    #[test]
18246    fn rust_self_field_does_not_bind_struct_from_another_module() {
18247        let source = r#"struct Engine;
18248
18249mod unrelated {
18250    struct Car {
18251        engine: Engine,
18252    }
18253}
18254
18255impl Car {
18256    fn run(&self) {
18257        self.engine.start();
18258    }
18259}
18260"#;
18261        let dir = tempdir().expect("temp dir");
18262        let root = dir.path();
18263        write_fixture(root, "src/lib.rs", source);
18264        let mut cache = DispatchSourceCache::new();
18265        let mut reference = reference(
18266            "rust",
18267            "src/lib.rs",
18268            "Car::run",
18269            "engine",
18270            "start",
18271            line_of(source, "self.engine.start()"),
18272        );
18273        reference.receiver_expression = "self.engine".to_string();
18274
18275        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18276    }
18277
18278    #[test]
18279    fn unknown_java_receiver_still_uses_name_match_fallback() {
18280        let source = r#"class EntryPoint {
18281    void handle() {
18282        service.runSpecial();
18283    }
18284}
18285
18286class OnlyService {
18287    void runSpecial() {}
18288}
18289"#;
18290        let dir = tempdir().expect("temp dir");
18291        let root = dir.path();
18292        write_fixture(root, "src/EntryPoint.java", source);
18293        let reference = reference(
18294            "java",
18295            "src/EntryPoint.java",
18296            "EntryPoint::handle",
18297            "service",
18298            "runSpecial",
18299            line_of(source, "service.runSpecial()"),
18300        );
18301        let mut cache = DispatchSourceCache::new();
18302
18303        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18304        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
18305        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
18306        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
18307    }
18308
18309    fn reference(
18310        lang: &str,
18311        caller_file: &str,
18312        caller_symbol: &str,
18313        receiver: &str,
18314        method_name: &str,
18315        line: u32,
18316    ) -> NameMatchRef {
18317        NameMatchRef {
18318            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
18319            caller_node: format!("{caller_symbol}:node"),
18320            caller_file: caller_file.to_string(),
18321            caller_symbol: caller_symbol.to_string(),
18322            caller_signature: None,
18323            receiver_expression: receiver.to_string(),
18324            receiver: receiver.to_string(),
18325            method_name: method_name.to_string(),
18326            colon_dispatch: false,
18327            line,
18328            lang: lang.to_string(),
18329        }
18330    }
18331
18332    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
18333        NameMatchCandidate {
18334            node_id: node_id.to_string(),
18335            file_path: "src/targets.fixture".to_string(),
18336            scoped_name: scoped_name.to_string(),
18337            kind: "method".to_string(),
18338            start_line: 1,
18339        }
18340    }
18341
18342    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
18343        let path = root.join(rel_path);
18344        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
18345        fs::write(path, source).expect("write fixture");
18346    }
18347
18348    fn line_of(source: &str, needle: &str) -> u32 {
18349        source
18350            .lines()
18351            .position(|line| line.contains(needle))
18352            .map(|index| index as u32 + 1)
18353            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
18354    }
18355}
18356
18357#[cfg(test)]
18358mod bounded_build_breaker_tests {
18359    use super::*;
18360    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
18361    use tempfile::tempdir;
18362
18363    #[test]
18364    fn staged_inventory_drives_ordered_bounded_file_batches() {
18365        let temp = tempdir().unwrap();
18366        let root = temp.path().join("root");
18367        std::fs::create_dir_all(&root).unwrap();
18368        let first = root.join("a.ts");
18369        let second = root.join("b.ts");
18370        let third = root.join("c.ts");
18371        for path in [&first, &second, &third] {
18372            std::fs::write(path, "export function item() {}\n").unwrap();
18373        }
18374        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
18375            .unwrap()
18376            .expect("test root may write its private staging database");
18377        let store = CallGraphStore::open_at_path(
18378            root.clone(),
18379            "inventory-key".to_string(),
18380            temp.path().join("inventory.sqlite"),
18381            None,
18382            true,
18383            Some(writer_lease),
18384            None,
18385        )
18386        .unwrap()
18387        .store;
18388        let fingerprint = store
18389            .stage_cold_build_file_inventory(&[
18390                third.clone(),
18391                first.clone(),
18392                second.clone(),
18393                first.clone(),
18394            ])
18395            .unwrap();
18396
18397        let conn = store.conn.lock().unwrap();
18398        assert_eq!(
18399            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
18400            3,
18401            "the primary key deduplicates caller-supplied paths on disk"
18402        );
18403        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
18404            .unwrap()
18405            .expect("first batch");
18406        assert_eq!(first_batch.paths, vec![first.clone(), second]);
18407        let second_batch =
18408            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
18409                .unwrap()
18410                .expect("second batch");
18411        assert_eq!(second_batch.paths, vec![third]);
18412        assert_eq!(
18413            fingerprint,
18414            callgraph_corpus_fingerprint(&root).unwrap(),
18415            "staged and direct streaming fingerprints agree without walk-order dependence"
18416        );
18417    }
18418
18419    #[test]
18420    fn resumed_stage_preserves_committed_batch_and_counter() {
18421        let temp = tempdir().unwrap();
18422        let root = temp.path().join("root");
18423        std::fs::create_dir_all(&root).unwrap();
18424        let first = root.join("first.ts");
18425        let second = root.join("second.ts");
18426        std::fs::write(&first, "export function first() {}\n").unwrap();
18427        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
18428        let staging = temp.path().join("stage.sqlite");
18429        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
18430            .unwrap()
18431            .expect("test root may write its private staging database");
18432        let store = CallGraphStore::open_at_path(
18433            root.clone(),
18434            "test-key".to_string(),
18435            staging,
18436            None,
18437            true,
18438            Some(writer_lease),
18439            None,
18440        )
18441        .unwrap()
18442        .store;
18443        let corpus_fingerprint = store
18444            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
18445            .unwrap();
18446        let first_extract = build_file_extract(&root, &first).unwrap();
18447        let first_bytes = first_extract.freshness.size;
18448        {
18449            let mut conn = store.conn.lock().unwrap();
18450            let tx = conn.transaction().unwrap();
18451            clear_tables(&tx).unwrap();
18452            insert_meta(&tx).unwrap();
18453            drop_cold_build_secondary_indexes(&tx).unwrap();
18454            set_meta_ready(&tx, false).unwrap();
18455            set_staged_build_phase(&tx, "extracting").unwrap();
18456            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
18457            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
18458            {
18459                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
18460                insert_file_extract_prepared(
18461                    &mut inserts,
18462                    &root.display().to_string(),
18463                    &first_extract,
18464                )
18465                .unwrap();
18466                for raw in &first_extract.raw_refs {
18467                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
18468                }
18469            }
18470            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
18471            tx.commit().unwrap();
18472        }
18473
18474        store
18475            .cold_build_chunked(&[first.clone(), second.clone()], 1)
18476            .unwrap();
18477        let conn = store.conn.lock().unwrap();
18478        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
18479        assert_eq!(
18480            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18481            first_bytes + std::fs::metadata(second).unwrap().len(),
18482            "the already committed batch and its credit survive adoption; only the new batch increments credit"
18483        );
18484        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
18485    }
18486
18487    const SPECIMEN_CHILD_TEST: &str =
18488        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
18489    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
18490    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
18491    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
18492    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
18493
18494    fn wait_for_child_barrier(path: &Path) {
18495        let deadline = Instant::now() + Duration::from_secs(10);
18496        while !path.exists() {
18497            assert!(
18498                Instant::now() < deadline,
18499                "callgraph child did not reach barrier {}",
18500                path.display()
18501            );
18502            std::thread::sleep(Duration::from_millis(5));
18503        }
18504    }
18505
18506    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
18507        let signal = store.join("specimen-child.reached");
18508        let _ = std::fs::remove_file(&signal);
18509        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
18510        command
18511            .arg("--exact")
18512            .arg(SPECIMEN_CHILD_TEST)
18513            .arg("--nocapture")
18514            .arg("--test-threads=1")
18515            .env(SPECIMEN_CHILD_ROOT, root)
18516            .env(SPECIMEN_CHILD_STORE, store)
18517            .env(SPECIMEN_CHILD_SIGNAL, &signal)
18518            .stdout(std::process::Stdio::null())
18519            .stderr(std::process::Stdio::null());
18520        if let Some(phase) = phase {
18521            command.env(SPECIMEN_CHILD_PHASE, phase);
18522        }
18523        command.spawn().unwrap()
18524    }
18525
18526    fn staging_path(root: &Path, store: &Path) -> PathBuf {
18527        let project_key = crate::search_index::artifact_cache_key(root);
18528        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
18529    }
18530
18531    fn durable_staging_state(path: &Path) -> (u64, u64) {
18532        if !path.exists() {
18533            return (0, 0);
18534        }
18535        let conn = Connection::open(path).unwrap();
18536        (
18537            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
18538            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18539        )
18540    }
18541
18542    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
18543        wait_for_child_barrier(signal);
18544        child.kill().unwrap();
18545        let _ = child.wait().unwrap();
18546    }
18547
18548    #[test]
18549    fn respawn_loop_build_child() {
18550        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
18551            return;
18552        };
18553        let root = PathBuf::from(root);
18554        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
18555        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
18556            let phase = phase.to_string_lossy().into_owned();
18557            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
18558            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
18559                if observed == phase {
18560                    std::fs::write(&signal, observed.as_bytes()).unwrap();
18561                    std::thread::sleep(Duration::from_secs(30));
18562                }
18563            })));
18564        }
18565        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
18566        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
18567    }
18568
18569    #[test]
18570    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
18571        let temp = tempdir().unwrap();
18572        let root = temp.path().join("resumable-root");
18573        let store = temp.path().join("resumable-store");
18574        std::fs::create_dir_all(&root).unwrap();
18575        std::fs::create_dir_all(&store).unwrap();
18576        for index in 0..3 {
18577            std::fs::write(
18578                root.join(format!("file-{index}.ts")),
18579                format!("export function specimen{index}() {{ return {index}; }}\n"),
18580            )
18581            .unwrap();
18582        }
18583        let stage = staging_path(&root, &store);
18584        let signal = store.join("specimen-child.reached");
18585
18586        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
18587        kill_barrier_child(&mut first, &signal);
18588        let (first_rows, first_bytes) = durable_staging_state(&stage);
18589        assert_eq!(first_rows, 1);
18590        assert!(first_bytes > 0);
18591
18592        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
18593        kill_barrier_child(&mut second, &signal);
18594        let (second_rows, second_bytes) = durable_staging_state(&stage);
18595        assert_eq!(second_rows, 2);
18596        assert!(
18597            second_bytes > first_bytes,
18598            "a replacement process must adopt committed bytes instead of restarting from zero"
18599        );
18600
18601        let status = spawn_build_child(&root, &store, None).wait().unwrap();
18602        assert!(status.success(), "uninterrupted replacement build failed");
18603        assert!(!stage.exists(), "published staging file must be renamed");
18604        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
18605            .unwrap()
18606            .expect("replacement attempts must converge to a published graph");
18607        assert_eq!(ready.indexed_file_count().unwrap(), 3);
18608
18609        let fast_root = temp.path().join("zero-credit-root");
18610        let fast_store = temp.path().join("zero-credit-store");
18611        std::fs::create_dir_all(&fast_root).unwrap();
18612        std::fs::create_dir_all(&fast_store).unwrap();
18613        std::fs::write(
18614            fast_root.join("main.ts"),
18615            "export function neverCommitted() {}\n",
18616        )
18617        .unwrap();
18618        let fast_stage = staging_path(&fast_root, &fast_store);
18619        let fast_signal = fast_store.join("specimen-child.reached");
18620        let breaker_path = fast_store.join("build-breaker.sqlite");
18621        let now = unix_millis_now();
18622
18623        for death in 0..3 {
18624            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
18625            wait_for_child_barrier(&fast_signal);
18626            let attempt_id = Connection::open(&breaker_path)
18627                .unwrap()
18628                .query_row(
18629                    "SELECT attempt_id FROM breaker_attempts
18630                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
18631                    [],
18632                    |row| row.get::<_, String>(0),
18633                )
18634                .unwrap();
18635            let (_, committed_bytes) = durable_staging_state(&fast_stage);
18636            assert_eq!(
18637                committed_bytes, 0,
18638                "the fast-kill schedule must not cross an extraction commit"
18639            );
18640            child.kill().unwrap();
18641            let _ = child.wait().unwrap();
18642
18643            let key = BreakerKey::new(
18644                fast_root.display().to_string(),
18645                BuildDomain::CallgraphCold,
18646                callgraph_corpus_fingerprint(&fast_root).unwrap(),
18647            );
18648            BuildDeathBreaker::open(&breaker_path)
18649                .unwrap()
18650                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
18651                .unwrap();
18652        }
18653
18654        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
18655        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
18656            .unwrap()
18657            .expect("three zero-credit process deaths must suspend the root");
18658        assert_eq!(suspension.reason, "zero_credit_death_limit");
18659        assert_eq!(suspension.death_count, 3);
18660        let response = crate::commands::callgraph_store_adapter::suspended_response(
18661            "specimen",
18662            "callers",
18663            &suspension,
18664        );
18665        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
18666        let message = response.data["message"].as_str().unwrap();
18667        assert!(
18668            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
18669        );
18670        assert!(message.ends_with(
18671            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
18672        ));
18673        let refused =
18674            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
18675                .expect_err("a suspended root must not report a perpetually building worker");
18676        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
18677    }
18678
18679    #[test]
18680    fn published_callgraph_build_respects_durable_domain_suspension() {
18681        let temp = tempdir().unwrap();
18682        let root = temp.path().join("root");
18683        let store_dir = temp.path().join("store");
18684        std::fs::create_dir_all(&root).unwrap();
18685        let source = root.join("main.ts");
18686        std::fs::write(&source, "export function marker() {}\n").unwrap();
18687        let files = vec![source];
18688        let key = BreakerKey::new(
18689            root.display().to_string(),
18690            BuildDomain::CallgraphCold,
18691            callgraph_corpus_fingerprint(&root).unwrap(),
18692        );
18693        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
18694        for _ in 0..3 {
18695            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
18696                panic!("unexpected early suspension");
18697            };
18698            breaker
18699                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
18700                .unwrap();
18701        }
18702
18703        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
18704            .expect_err("durably tripped callgraph domain must refuse a new cold build");
18705        assert!(matches!(
18706            error,
18707            CallGraphStoreError::Suspended(ref suspension)
18708                if suspension.domain == BuildDomain::CallgraphCold
18709                    && suspension.death_count == 3
18710        ));
18711    }
18712}